Watch
0
0
Fork
You've already forked hyperhive
0

subagents, ACP: stop with SIGINT only, so claude ends its own tool commands

claude's Bash tool starts every command in a new session, so a signal to
claude's process group never reaches it. claude stops those commands
itself when SIGINT makes it shut down. A SIGKILL leaves them running,
re-parented to init. Per the ruling on #4999, every stop is SIGINT and
nothing escalates.

- Subagent `interrupt` always sends SIGINT. The `force` argument is gone
  from the MCP tool and from `session::interrupt`. A caller that still
  sends `force: true` gets SIGINT; the argument is ignored.
- `interrupt` used to free the session name before it signalled, so a
  `continue` could start a second claude on the session while the first
  was still exiting. The `running` map now holds a three-state `Slot`
  (starting, running, stopping). An interrupted claude turn is
  `Stopping` until the turn task's own `finish_turn` clears it. `status`
  says "stopping", and `start`/`continue` on that name are refused.
  `interrupt` still returns at once. The ACP arm is unchanged: its name
  is freed when `session/cancel` is sent.
- ACP `Connection` drop sends its process group SIGINT, not SIGKILL, and
  the spawn no longer sets tokio's `kill_on_drop`, which SIGKILLed the
  leader after the `Drop` body. The test fixture's background grandchild
  resets SIGINT to default, since `&` starts it ignored.

Tests: a fake claude that records SIGINT, interrupted, fails with
`cancel(true)` (the old `force: true` path). An interrupted fake claude
that keeps running keeps its name claimed and `continue` is refused,
which fails when the slot is freed before the signal. A fake ACP agent
that records SIGINT, dropped, fails with SIGKILL in `Drop`, and also with
SIGINT in `Drop` but `kill_on_drop(true)` left in place.

Not changed: hive-claude's idle watchdog (still SIGKILL, separate
hive-claude change), the units' `KillSignal`, `machinectl kill`, and the
login helper.
This commit is contained in:
atlas 2026-10-10 19:52:25 +02:00 • committed by mara
commit 43e69d345e
7 changed files with 337 additions and 167 deletions

View file

@ -166,10 +166,15 @@ 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.
On `claude`, the signal goes to claude's whole process group, so the
commands the subagent started (a `nix build`, a test run) get it too. The
signal doesn't reach a command that moved itself out of the group
(`setsid`).
On `claude`, the signal is always SIGINT, and it goes to claude's whole
process group. The signal doesn't reach a command that moved itself out of
the group (`setsid`), and claude's Bash tool starts every command that way:
claude stops those commands itself when it shuts down on SIGINT. A SIGKILL
would leave them running, so nothing escalates to one. `interrupt` returns
at once, and the name stays claimed until claude has exited: `status` says
**stopping**, and the daemon refuses `start` and `continue` on that name
until then. A claude that ignores the SIGINT stays stopping until someone kills
it by hand or the unit stops.
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
@ -345,7 +350,7 @@ page applies as written.
On `acp`:
- each run spawns its own copy of the parent's ACP agent. The end of the
run kills it and every command it started. The unit loads
run sends SIGINT to it and every command in its process group. The unit loads
`backendEnvironmentFile` for it. On the `opencode` preset, the daemon
reads a provider key that file leaves unset from the swarm secret store
at `swarm/agents/<agent>/acp-provider`, as the harness does.
@ -354,9 +359,10 @@ On `acp`:
agent process, and a `continue` with none recorded fails straight away.
- a role goes in front of the session's first prompt, since ACP has no
system prompt.
- `interrupt` sends `session/cancel`, and `force` changes nothing. The
runtime kills an agent that ignores the cancel for 10s, with every
command it started.
- `interrupt` sends `session/cancel`. The runtime sends SIGINT to the
process group of an agent that ignores the cancel for 10s, and when the
run ends. Nothing escalates past that SIGINT. The name is free as soon as
`interrupt` sends the cancel.
- the runtime sets `model` and `effort` on the session when the agent
offers that value for its `model` or `thought_level` config option. Any
other value does nothing (`hive_runtime::AcpRuntime`).