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:
parent
028c283ff9
commit
43e69d345e
7 changed files with 337 additions and 167 deletions
|
|
@ -35,13 +35,15 @@ The crate depends on no hyperhive binary crate, so `hive-agent` and
|
|||
A turn is stopped with `session/cancel`: by the `Canceller` handle, or by the
|
||||
idle watchdog once no `session/update` has arrived for
|
||||
`Config::idle_timeout`. An agent that has not answered the prompt 10s later
|
||||
is killed, and the next turn respawns it. A watchdog stop fails the turn
|
||||
with `AcpError::IdleTimeout` (`IdleKilled` if the agent was killed). This is
|
||||
is sent SIGINT, and the next turn respawns it. A watchdog stop fails the turn
|
||||
with `AcpError::IdleTimeout` (`IdleKilled` if the agent was sent SIGINT). This is
|
||||
the only way a provider error the agent retries without reporting it, such as
|
||||
an HTTP 429, ends the turn.
|
||||
|
||||
The agent leads its own process group. Killing it, and dropping the runtime,
|
||||
`SIGKILL`s that group, so the commands the agent started die with it.
|
||||
The agent leads its own process group. Stopping it, and dropping the runtime,
|
||||
sends that group SIGINT, so the commands the agent started get it too.
|
||||
Nothing escalates to SIGKILL: an agent that ignores SIGINT keeps running,
|
||||
and the next turn starts a new one beside it.
|
||||
|
||||
## ACP backend: compaction
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const SETTLE_QUIET: Duration = Duration::from_millis(500);
|
|||
const SETTLE_MAX: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How long the agent has to end its turn after `session/cancel` before it is
|
||||
/// killed.
|
||||
/// sent SIGINT.
|
||||
const CANCEL_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long a `compact` command may send nothing before it is cancelled and
|
||||
|
|
@ -120,10 +120,12 @@ pub enum AcpError {
|
|||
EmptyEndTurn,
|
||||
#[error(
|
||||
"ACP agent sent nothing for {silent_secs}s and did not stop within {grace_secs}s of \
|
||||
`session/cancel`, so it was killed"
|
||||
`session/cancel`, so it was sent SIGINT"
|
||||
)]
|
||||
IdleKilled { silent_secs: u64, grace_secs: u64 },
|
||||
#[error("ACP agent did not stop within {grace_secs}s of `session/cancel`, so it was killed")]
|
||||
#[error(
|
||||
"ACP agent did not stop within {grace_secs}s of `session/cancel`, so it was sent SIGINT"
|
||||
)]
|
||||
CancelIgnored { grace_secs: u64 },
|
||||
}
|
||||
|
||||
|
|
@ -141,8 +143,8 @@ struct CancelState {
|
|||
impl Canceller {
|
||||
/// Ask the turn in flight to stop. The agent is sent `session/cancel`,
|
||||
/// and the turn then ends normally with what it produced so far; an
|
||||
/// agent that has not ended it within `CANCEL_GRACE` is killed, and the
|
||||
/// turn fails with [`AcpError::CancelIgnored`]. Returns whether a turn
|
||||
/// agent that has not ended it within `CANCEL_GRACE` is sent SIGINT, and
|
||||
/// the turn fails with [`AcpError::CancelIgnored`]. Returns whether a turn
|
||||
/// was in flight; with none, this does nothing.
|
||||
#[must_use]
|
||||
pub fn cancel(&self) -> bool {
|
||||
|
|
@ -1062,9 +1064,14 @@ mod tests {
|
|||
const AGENT: &str = r#"
|
||||
n=0 prompt= model=m/think effort=low
|
||||
printf 'start\n' >> "$1.methods"
|
||||
# With TRAP_INT set: SIGINT creates <log>.int and exits 0.
|
||||
if [ -n "$TRAP_INT" ]; then
|
||||
trap ': > "$1.int"; exit 0' INT
|
||||
fi
|
||||
# With GRANDCHILD set: a background `sleep`, its pid in <log>.grandchild.
|
||||
# `&` starts it with SIGINT ignored; `env` puts the default back.
|
||||
if [ -n "$GRANDCHILD" ]; then
|
||||
sh -c 'echo $$ >"$1.grandchild"; exec sleep 300' sh "$1" >/dev/null 2>&1 &
|
||||
sh -c 'echo $$ >"$1.grandchild"; exec env --default-signal=INT sleep 300' sh "$1" >/dev/null 2>&1 &
|
||||
fi
|
||||
update() {
|
||||
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"%s","update":%s}}\n' "$1" "$2"
|
||||
|
|
@ -1529,6 +1536,33 @@ done
|
|||
assert_gone(grandchild).await;
|
||||
}
|
||||
|
||||
/// SIGINT rather than SIGKILL: an agent only stops the commands it
|
||||
/// started itself if it gets to shut down.
|
||||
#[tokio::test]
|
||||
async fn dropping_the_runtime_lets_its_agent_shut_down_on_sigint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let runtime = agent(
|
||||
dir.path(),
|
||||
"ok",
|
||||
&[("TRAP_INT", "1")],
|
||||
PercentPolicy::default(),
|
||||
);
|
||||
runtime
|
||||
.run(&config(dir.path()), "one", &NoopSink)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
drop(runtime);
|
||||
let marker = dir.path().join("prompts.int");
|
||||
for _ in 0..250 {
|
||||
if marker.exists() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(marker.exists(), "the agent never got SIGINT");
|
||||
}
|
||||
|
||||
fn sent(session: &str, text: &str) -> (String, String) {
|
||||
(session.to_owned(), text.to_owned())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ type Reply = Result<Value, AcpError>;
|
|||
type Pending = Arc<Mutex<HashMap<u64, (&'static str, oneshot::Sender<Reply>)>>>;
|
||||
|
||||
/// The agent runs as the leader of its own process group; dropping the
|
||||
/// connection `SIGKILL`s that group, so the commands the agent started die
|
||||
/// with it. A descendant that left the group (`setsid`, `setpgid`) is not
|
||||
/// reached.
|
||||
/// connection sends that group SIGINT, so the agent can stop the commands
|
||||
/// it started, and they get the signal too. Nothing escalates: an agent
|
||||
/// that ignores SIGINT keeps running. A descendant that left the group
|
||||
/// (`setsid`, `setpgid`) is not reached.
|
||||
pub(super) struct Connection {
|
||||
child: Child,
|
||||
/// The agent's pid, which is its group's id. Read at spawn: a reaped
|
||||
|
|
@ -66,7 +67,6 @@ impl Connection {
|
|||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.process_group(0)
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|source| AcpError::Spawn {
|
||||
program: command.command.clone(),
|
||||
|
|
@ -182,7 +182,7 @@ impl Drop for Connection {
|
|||
// SAFETY: plain `kill(2)`; `-pgid` targets the agent's own
|
||||
// process group, which `process_group(0)` made it lead.
|
||||
unsafe {
|
||||
libc::kill(-pgid, libc::SIGKILL);
|
||||
libc::kill(-pgid, libc::SIGINT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue