subagent: make interrupt cancel a goal run, not just its turn

`interrupt` killed the child and trusted that to end the run. It didn't:
the default signal is SIGINT, and `claude --print` handles SIGINT by
writing its terminal `result` event and exiting **zero**. A zero exit is
`TurnEnd::Complete`, so `spawn_and_track`'s killed-turn early return —
the code that was supposed to stop the run — never fired, and the loop
went straight on to spawn the next goal turn. An interrupted run carried
on to completion; the interrupt changed nothing about the outcome.

Measured, not inferred: `claude --print --verbose --output-format
stream-json`, SIGINT'd mid-turn, exits 0 on every run.

So record the reason instead of inferring it. `interrupt` writes
`StopReason::Cancelled` — the same path `goal_reached`/`need_help`
already use — while it still holds the `running` lock, so there is no
instant in which the name has lost its cancel handle but not yet gained
its stop reason. `plan_after_turn` checks stop reasons ahead of the goal,
so the run stops whichever way the child ends up exiting. `force`'s
SIGKILL still produces a `TurnEnd::Killed`, and `status` still prefers
the kill record it already had.

`continue`'s budget reset is untouched: it clears the stop reason and
hands back a fresh allowance, which is what makes a cancel a pause an
operator can undo.

The test spawns the real continuation loop against a fake claude that
exits 0 on SIGINT, interrupts it, and asserts turn two never starts —
it reports `left: 2` without the fix.
This commit is contained in:
atlas 2026-09-21 22:31:44 +02:00 committed by mara
commit 33da51382e
3 changed files with 217 additions and 24 deletions

View file

@ -136,7 +136,7 @@ reaches. Read alongside the last-event age below, it's what separates a
subagent that's working from one that's wedged from one that's out of
turns — without `ps` and without opening a file.
Four things stop a run; the daemon records each distinctly, reports it via
Five things stop a run; the daemon records each distinctly, reports it via
`status`, and appends it to the one todo it pushes when the run ends:
- **the turn ended and there was no goal** — the single-turn case;
@ -144,11 +144,22 @@ Four things stop a run; the daemon records each distinctly, reports it via
- **`need_help`**, likewise;
- **the turn cap**, which says so rather than stopping quietly: the todo
states that the run hit the harness limit and the goal was never
reported reached, so the work stopped where it had got to.
reported reached, so the work stopped where it had got to;
- **`interrupt`**, which cancels the whole run and not merely the turn
that was in flight.
A killed or failed turn ends the run too, and keeps the records it already
had — see [A killed turn](#a-killed-turn). `interrupt` therefore stops a
whole goal run, not just the turn in flight.
had — see [A killed turn](#a-killed-turn).
`interrupt` being on that list is load-bearing, not bookkeeping. Killing
the process isn't by itself a cancel: `interrupt` sends SIGINT by
default, and `claude --print` handles SIGINT by writing its terminal
result event and exiting **zero**, which is indistinguishable from a turn
that finished. `interrupt` therefore records the cancel before it
signals, and the continuation loop reads that record rather than guessing
from how the child exited. `continue` clears it and hands the session a
fresh turn allowance, which is what makes an interrupt a pause you can
undo rather than a session you have to abandon.
When the session knows where its report goes — `start`'s `report_file`,
or the path the subagent names when it signals — the daemon appends the