Stop stops the machine: an interrupted turn's CLI is really gone #76

Merged
mph merged 25 commits from cc-chat into main 2026-09-09 07:14:45 +00:00
Owner

Goal

I press Stop on a runaway turn, the CLI is gone, and my next message continues the conversation.

Fit test (the close gate, passed on Michal's read)

Ask Gladys for something long on the chat page, press Stop part-way, confirm from outside the page
that the CLI is gone, then send the next message and get an answer with the conversation intact.
Taken on the procedures offline harness (profiles/gladys-offline/start-offline.sh, an in-memory
graph, no Telegram, no email, driving the real CLI).

What was wrong

Not one fault but three, at the same seam:

  1. The blocking stdout read sat on the consuming fiber, so that fiber never unwound and no
    finalizer ever ran.
  2. Teardown awaited the forked pipe readers. ZStream.fromInputStream reads through
    ZIO.attemptBlockingInterrupt, whose interrupt path spins thread.interrupt() until the read
    returns — and a pipe read ignores thread interruption. Only the process dying ends it, so
    waiting for a reader before the kill is a deadlock.
  3. A window between Command.run and ZIO.addFinalizer(kill) left a spawned CLI with nothing
    registered to end it.

And, found in review: the kill ended the CLI but not the tools it had started, so a Stop during
a tool call orphaned the tool and left it working.

What changed

  • ProcessManager.spawnUnderKill — spawn and arm the kill as one uninterruptible step, and kill the
    whole process tree.
  • ProcessManager.forkPipeReader — read each pipe on a daemon fiber whose teardown only signals.
  • Both are used by ProcessManager.executeProcess and SessionProcess.start: the card settled one
    mechanism for both call sites, and it is now literally one.
  • ClaudeCodeTranscript reads AssistantLogEntry.model: a reply Claude Code wrote itself
    (<synthetic>) renders as the activity row "Injected reply" instead of as the agent's words.

Tests

  • ProcessTeardownIntegrationTest — interrupting a query, interrupting a session, closing a session
    scope normally, and interrupting a query that is running a tool. Every process is resolved by the
    exact pid the stand-in CLI recorded for itself, never by a name pattern.
  • StopAndKeepTalkingE2ETest — against the real CLI: kill a turn mid tool call, resume, get a
    non-error result.
  • ClaudeCodeTranscriptTest — a <synthetic> reply is activity, never the agent's.

Measured: the kill lands 130–220 ms after the interrupt, 5 runs of 5. Before, the process outlived
the interrupt entirely.

One limit, stated rather than glossed: neither session test fails when the reader fork is
reverted (six runs). The mechanism is pinned on the query path — red before the fix, green after —
and both call sites now run that same code.

Review

review-slice-20260908-205148.md, six skills, one round: 1 critical, 4 warnings, 5 suggestions.
Critical and all warnings fixed in-loop; one suggestion rejected with reasoning (wrapping a fork in
ZIO.uninterruptible would make the forked fiber inherit it); two parked.

Follow-ups

  • procedures records the CLI session id only from the end-of-turn result, so a Stop on the first
    turn of a fresh conversation loses it. Does not affect an established conversation.
  • Claude Code's own transcript repair after a kill still reaches the model as history on the
    following turn. Nothing this SDK writes; nothing it can prevent.
  • See project-management/efforts/cc-chat/FOLLOW-UPS.md.

Not in this PR

Cutting 0.7.0 and moving procedures onto it, which happens after merge on Michal's approval.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x

## Goal I press Stop on a runaway turn, the CLI is gone, and my next message continues the conversation. ## Fit test (the close gate, passed on Michal's read) Ask Gladys for something long on the chat page, press Stop part-way, confirm from outside the page that the CLI is gone, then send the next message and get an answer with the conversation intact. Taken on the `procedures` offline harness (`profiles/gladys-offline/start-offline.sh`, an in-memory graph, no Telegram, no email, driving the real CLI). ## What was wrong Not one fault but three, at the same seam: 1. The blocking stdout read sat on the **consuming** fiber, so that fiber never unwound and no finalizer ever ran. 2. Teardown **awaited** the forked pipe readers. `ZStream.fromInputStream` reads through `ZIO.attemptBlockingInterrupt`, whose interrupt path spins `thread.interrupt()` until the read returns — and a pipe read ignores thread interruption. Only the process dying ends it, so waiting for a reader before the kill is a deadlock. 3. A window between `Command.run` and `ZIO.addFinalizer(kill)` left a spawned CLI with nothing registered to end it. And, found in review: the kill ended the CLI but **not the tools it had started**, so a Stop during a tool call orphaned the tool and left it working. ## What changed - `ProcessManager.spawnUnderKill` — spawn and arm the kill as one uninterruptible step, and kill the whole process tree. - `ProcessManager.forkPipeReader` — read each pipe on a daemon fiber whose teardown only *signals*. - Both are used by `ProcessManager.executeProcess` and `SessionProcess.start`: the card settled one mechanism for both call sites, and it is now literally one. - `ClaudeCodeTranscript` reads `AssistantLogEntry.model`: a reply Claude Code wrote itself (`<synthetic>`) renders as the activity row "Injected reply" instead of as the agent's words. ## Tests - `ProcessTeardownIntegrationTest` — interrupting a query, interrupting a session, closing a session scope normally, and interrupting a query that is running a tool. Every process is resolved by the exact pid the stand-in CLI recorded for itself, never by a name pattern. - `StopAndKeepTalkingE2ETest` — against the **real** CLI: kill a turn mid tool call, resume, get a non-error result. - `ClaudeCodeTranscriptTest` — a `<synthetic>` reply is activity, never the agent's. Measured: the kill lands 130–220 ms after the interrupt, 5 runs of 5. Before, the process outlived the interrupt entirely. **One limit, stated rather than glossed:** neither session test fails when the reader fork is reverted (six runs). The mechanism is pinned on the query path — red before the fix, green after — and both call sites now run that same code. ## Review `review-slice-20260908-205148.md`, six skills, one round: 1 critical, 4 warnings, 5 suggestions. Critical and all warnings fixed in-loop; one suggestion rejected with reasoning (wrapping a fork in `ZIO.uninterruptible` would make the forked fiber inherit it); two parked. ## Follow-ups - `procedures` records the CLI session id only from the end-of-turn result, so a Stop on the *first* turn of a fresh conversation loses it. Does not affect an established conversation. - Claude Code's own transcript repair after a kill still reaches the model as history on the following turn. Nothing this SDK writes; nothing it can prevent. - See `project-management/efforts/cc-chat/FOLLOW-UPS.md`. ## Not in this PR Cutting `0.7.0` and moving `procedures` onto it, which happens after merge on Michal's approval. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
mph added 24 commits 2026-09-08 21:26:41 +00:00
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Michal separated what the card was conflating: interrupting a running
session, stopping a one-off, and Gladys being shaped as the second while
she is really the first. The card stands; killing is the correct meaning
of stop for a one-off, and the defect is only that the kill never runs.
Gladys' shape is procedures#701.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Three faults at one seam kept an interrupted query's CLI alive. The
blocking stdout read sat on the consuming fiber, so that fiber never
unwound and no finalizer ran. Teardown then awaited the forked pipe
readers, and a pipe read answers neither fiber nor thread interruption —
only the process dying ends it. And an interrupt landing between the
spawn and the kill's registration left a CLI with nothing to end it.

Both pipes are now read on their own fibers, teardown signals them
without waiting, and the spawn and its kill are one uninterruptible
step. The kill lands ~150 ms after the interrupt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
The session path already read its pipes on their own fibers, so it did
not hold the query path's first fault. It held the other two: teardown
awaited readers that only the kill could release, and the spawn was not
atomic with registering that kill. Removing the kill finalizer makes the
new session teardown test hang rather than fail, which is that first
fault in the open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
An end-to-end test against the real CLI kills a turn while a tool call is
open and then resumes that session id. The transcript is left holding a
tool_use with no tool_result, and the next turn still answers with a
non-error result. That was the open question behind procedures#701.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
The reaction surface is not standing. Starting it showed that a side-car
on non-production ports still boots the Telegram bot, email polling and
the schedulers against the live data directory, because those are gated
on configuration rather than on the port. Isolating it is a choice about
how fit is taken, so it waits for Michal.

Also records the incident this stretch caused: a pattern kill aimed at
the side-car matched the live system's launcher and stopped it. Restored
from the canonical checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Michal asked why a side-car was needed when the offline harness exists.
It was the right question: the harness pins its own data dir, runs an
in-memory graph, sources no secrets, and still drives the real CLI.

On it, the first half of the fit test passes — a turn spawned a real CLI
child of the harness JVM, the interrupt answered 202, and the pid was
gone in a second. The second half fails: procedures records the CLI
session id only from the end-of-turn result, so a stopped turn leaves no
id and the next message opens a new conversation. The killed session's
transcript survives on disk and is resumable, and neither turn carried
--resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Michal stopped a turn and kept talking. The context held — the earlier
measurement was wrong, because that probe had stopped the first turn of
a fresh conversation, before any session id existed.

What his transcript shows instead: after a Stop the CLI repairs the
killed transcript with a meta user turn and a <synthetic> assistant
turn. The render site discards the model field, so the synthetic line is
drawn as Gladys' own answer, and it reaches her as context — she
apologised for words she never produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Claude Code repairs a transcript whose turn was stopped mid tool call by
writing a meta prompt of its own and answering it, marking the answer
<synthetic>. The meta prompt already read as injected context; the reply
was drawn as the agent's own words, which put sentences in her mouth.

It now renders beside the prompt as an injected reply: still on the
page, no longer hers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Reproduced with the bare CLI, no SDK and no procedures: a killed run
leaves an orphaned user turn and nothing else, and the resume writes the
meta prompt and the synthetic reply itself, before the new message. It
needs no open tool call.

So the pair arrives before any of our code runs and cannot be prevented
here — only rendered honestly, which it now is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
The kill ended the CLI but not the tools it had started, so a Stop during
a tool call orphaned the tool and left it working — the shape of Michal's
own fit act. It now ends the tree, with a test that was red first.

The teardown mechanism the card settled as one is now one:
spawnUnderKill and forkPipeReader on ProcessManager, called by both the
query and the session path.

Also from review: a session teardown test that goes through an
interrupted fiber rather than a clean scope exit, a shared textFileBusy,
a pattern in place of a guard, and the keep-talking E2E no longer racing
its own teardown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Drop 2 · Follow-up 1 · Already fixed elsewhere 0 · Decide 3

Approved:
- ZIO.uninterruptible around a scoped stream's builder → kanon
  iterative-works/dev-docs #244 (code-review-zio check)
- mill's test filter is a task, not a flag → CLAUDE.md corrected on this
  branch, plus kanon iterative-works/dev-docs #245 for
  guide/mill_operations.md

Rejected:
- a blocking pipe read cannot be interrupted: fixed on this branch
- resuming a killed CLI session writes a turn of its own: fixed on this branch
- a pattern kill took down the live system: belongs to procedures, already
  present there in some form
- the procedures side-car is not inert: work in procedures, reduced to one
  line in FOLLOW-UPS.md

The CLAUDE.md correction is the substantive one: it documented
`./mill direct.test --test-only '*ClassName*'`, which does not filter —
10 test classes ran against 1 for `direct.test.testOnly '*ClassName'`.
An unrecognised flag on `test` is forwarded to the test framework and
silently runs the whole suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
Note a flaky archive-follow test under load
All checks were successful
CI / Check Formatting (pull_request) Successful in 1m16s
CI / Compile (pull_request) Successful in 1m53s
CI / Check Linting (pull_request) Successful in 1m28s
CI / Run Tests (pull_request) Successful in 2m14s
CI / Run Integration Tests (pull_request) Successful in 1m42s
CI / Check Bundle Size (pull_request) Successful in 1m2s
fdb50f245b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
workflow(sl): close cc-chat/07-stop-stops-the-machine
All checks were successful
CI / Check Formatting (pull_request) Successful in 45s
CI / Compile (pull_request) Successful in 1m2s
CI / Check Linting (pull_request) Successful in 1m4s
CI / Run Tests (pull_request) Successful in 1m30s
CI / Run Integration Tests (pull_request) Successful in 1m27s
CI / Check Bundle Size (pull_request) Successful in 44s
99b31f7c19
Fit passed on the offline harness. Tracker closed, seven parked items
flushed to the effort map, the map item marked done, and a new candidate
filed for the procedures-side session-id gap a Stop on a first turn
exposes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTrxcxBQiNjXh79dN8Sh5x
mph merged commit af3b55d876 into main 2026-09-09 07:14:45 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/claude-code-query!76
No description provided.