Watch
0
0
Fork
You've already forked hyperhive
0

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

@ -268,11 +268,13 @@ impl SubagentMcp {
}
#[tool(
description = "Signal a currently-running subagent session to stop. Only works once \
it's actually running — a `start`/`continue` still in its brief window before the \
process is confirmed spawned refuses interrupt too (nothing to signal yet; retry \
shortly), same as a name with nothing tracked at all. `force: true` for SIGKILL, \
otherwise SIGINT."
description = "Cancel a currently-running subagent session. This stops the whole run, \
not just the turn in flight: a session started with a `goal` will not be \
re-prompted toward it afterwards, and `continue` is what restarts it. Only works \
once it's actually running — a `start`/`continue` still in its brief window before \
the process is confirmed spawned refuses interrupt too (nothing to signal yet; \
retry shortly), same as a name with nothing tracked at all. `force: true` for \
SIGKILL, otherwise SIGINT."
)]
fn interrupt(&self, Parameters(args): Parameters<InterruptArgs>) -> String {
match session::interrupt(&self.state, &args.name, args.force) {