Watch
0
0
Fork
You've already forked hyperhive
0

hive-runtime: TurnStop replaces the subagent daemon's Stopper

TurnStop is the enum over hive-claude's Cancel and the ACP Canceller, with
stop() -> bool (false when no turn was in flight). It signals SIGINT only and
never waits. The subagent daemon tracks a TurnStop per turn, so interrupt has
one stop path; whether the name stays claimed afterwards (claude) or is freed
(ACP) still follows the runtime.
This commit is contained in:
atlas 2026-10-11 02:54:00 +02:00 • committed by mara
commit 496f85c8c6
5 changed files with 147 additions and 36 deletions

View file

@ -92,9 +92,9 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use hive_claude::{Attach, Cancel, Claude, Config, SessionStore};
use hive_claude::{Attach, Claude, Config, SessionStore};
use hive_runtime::{
AcpCommand, AcpRuntime, Canceller, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec,
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
};
use hive_sh4re::permissions::ToolGroup;
use tokio::sync::oneshot;
@ -226,22 +226,13 @@ enum SubagentRuntime {
},
}
/// How `interrupt` reaches a tracked turn, per runtime.
enum Stopper {
/// Signals the turn's `claude` process.
Claude(Cancel),
/// Sends the ACP agent `session/cancel`; the runtime sends SIGINT to one
/// that ignores it for its own grace period.
Acp(Canceller),
}
/// What `running` holds for a claimed name.
enum Slot {
/// Claimed, with no process confirmed under it: a `start`/`continue`
/// that hasn't spawned yet, or a goal run between turns.
Starting,
/// A turn is in flight, and this is how `interrupt` reaches it.
Running(Stopper),
Running(TurnStop),
/// `interrupt` has signalled a claude turn and its process hasn't exited
/// yet. Nothing escalates past that SIGINT, so a claude that ignores it
/// stays here until it exits; the turn's own `finish_turn` frees the
@ -767,11 +758,11 @@ impl State {
/// Upgrade `name`'s `Starting` reservation to a real, interruptible
/// process. Same key as the reservation, so there is no window in which
/// `name` reads as unoccupied between the two.
fn track(&self, name: &str, stopper: Stopper) {
fn track(&self, name: &str, stop: TurnStop) {
self.running
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(name.to_owned(), Slot::Running(stopper));
.insert(name.to_owned(), Slot::Running(stop));
}
/// Drop `name`'s cancel handle but keep the name claimed — the moment
@ -1747,7 +1738,7 @@ fn spawn_and_track(
) -> anyhow::Result<String> {
let running = Claude::spawn(config, attach)
.map_err(|e| anyhow::anyhow!("starting the subagent process failed: {e}"))?;
state.track(name, Stopper::Claude(running.cancel_handle()));
state.track(name, running.cancel_handle().into());
// This turn supersedes whatever the previous one did, including having
// been killed — the record is about the turn before this one.
state.clear_kill(name);
@ -1786,7 +1777,7 @@ fn spawn_and_track(
.and_then(|id| Ok(Claude::spawn(&config, &Attach::Resume(id))?))
{
Ok(next_running) => {
state.track(&task_name, Stopper::Claude(next_running.cancel_handle()));
state.track(&task_name, next_running.cancel_handle().into());
state.note_event(&task_name);
running = next_running;
prompt = next;
@ -1874,7 +1865,7 @@ fn spawn_and_track_acp(
let canceller = runtime
.canceller()
.ok_or_else(|| anyhow::anyhow!("the ACP runtime offers no cancel handle"))?;
state.track(name, Stopper::Acp(canceller.clone()));
state.track(name, canceller.clone().into());
state.clear_kill(name);
state.note_event(name);
@ -1895,7 +1886,7 @@ fn spawn_and_track_acp(
let Some(next) = after_turn(&state, &task_name, end, verdict.as_ref()).await else {
return;
};
state.track(&task_name, Stopper::Acp(canceller.clone()));
state.track(&task_name, canceller.clone().into());
state.note_event(&task_name);
prompt = next;
}
@ -2400,31 +2391,29 @@ pub fn interrupt(state: &State, name: &str) -> anyhow::Result<String> {
`status` says when it has"
);
}
Some(Slot::Running(Stopper::Claude(cancel))) => {
state.record_stop(name, StopReason::Cancelled);
running.insert(name.to_owned(), Slot::Stopping);
drop(running);
cancel.cancel(false);
claimed = " The name stays claimed until claude has exited; `status` says `stopping` \
until then.";
}
// `cancel()` is `false` when no turn is in flight (the agent not
// prompted yet, or a turn just ended), and then nothing was
// cancelled. The stop goes in before the cancel and comes out under
// the same `stops` lock, so `plan_after_turn` never reads a cancel
// that didn't happen.
Some(Slot::Running(Stopper::Acp(canceller))) => {
// `stop()` is `false` when no turn is in flight (an ACP agent not
// prompted yet, or a turn just ended), and then nothing was stopped.
// The stop goes in before the signal and comes out under the same
// `stops` lock, so `plan_after_turn` never reads a cancel that
// didn't happen.
Some(Slot::Running(stop)) => {
let mut stops = state.stops.lock().unwrap_or_else(PoisonError::into_inner);
let prior = stops.insert(name.to_owned(), StopReason::Cancelled);
if !canceller.cancel() {
if !stop.stop() {
match prior {
Some(prior) => stops.insert(name.to_owned(), prior),
None => stops.remove(name),
};
drop(stops);
running.insert(name.to_owned(), Slot::Running(Stopper::Acp(canceller)));
running.insert(name.to_owned(), Slot::Running(stop));
return Err(still_starting(name));
}
drop(stops);
if matches!(state.runtime, SubagentRuntime::Claude) {
running.insert(name.to_owned(), Slot::Stopping);
claimed = " The name stays claimed until claude has exited; `status` says \
`stopping` until then.";
}
}
}
Ok(format!(