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:
parent
11ec050ca8
commit
33da51382e
3 changed files with 217 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue