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

@ -1029,7 +1029,7 @@ mod tests {
use hive_claude::{Config, NoopSink, PercentPolicy, Sink};
use super::{AcpError, AcpRuntime, Choice, ReportedCost, SessionChoices, Spend, TurnKind};
use crate::{AcpCommand, Error, Runtime};
use crate::{AcpCommand, Error, Runtime, TurnStop};
/// An ACP agent in plain `sh`. It answers `initialize`, numbers its
/// sessions `s1`, `s2`, …, appends every method it is sent to
@ -1422,6 +1422,24 @@ done
assert!(!canceller.cancel(), "the turn is over");
}
#[tokio::test]
async fn a_turn_stop_cancels_only_a_turn_in_flight() {
let dir = tempfile::tempdir().unwrap();
let runtime = runtime(dir.path(), "silent");
let stop = TurnStop::from(runtime.canceller().unwrap());
assert!(!stop.stop(), "no turn is in flight yet");
let config = config(dir.path());
let (done, was_in_flight) = tokio::join!(runtime.run(&config, "one", &NoopSink), async {
prompt_sent(dir.path()).await;
stop.stop()
});
assert!(was_in_flight);
assert!(done.is_ok(), "{done:?}");
assert_eq!(count(dir.path(), "session/cancel"), 1);
assert!(!stop.stop(), "the turn is over");
}
#[tokio::test]
async fn an_agent_ignoring_a_cancel_is_killed() {
let dir = tempfile::tempdir().unwrap();

View file

@ -20,6 +20,7 @@ use std::path::PathBuf;
mod acp;
mod claude;
mod spec;
mod stop;
pub use acp::{
AcpError, AcpRuntime, Canceller, Choice, Choices, PermissionAsk, PermissionPolicy,
@ -33,6 +34,7 @@ pub use spec::{
ACP_API_KEY_ENV_ENV, ACP_ARGS_ENV, ACP_COMMAND_ENV, ACP_ENV_ENV, AcpCommand, RUNTIME_ENV,
RuntimeSpec, SpecError,
};
pub use stop::TurnStop;
/// The operations every runtime backend offers.
///

101
hive-runtime/src/stop.rs Normal file
View file

@ -0,0 +1,101 @@
//! [`TurnStop`]: the handle that stops a turn from outside the call driving it.
use hive_claude::Cancel;
use crate::Canceller;
/// Stops the turn a claude process or an ACP agent has in flight. Cheap to
/// clone.
///
/// Every stop is SIGINT, directly (claude) or after `session/cancel` went
/// unanswered (ACP): claude shuts down the commands it started when it gets
/// SIGINT, and nothing here escalates past it or waits for the turn to end.
#[derive(Clone)]
pub enum TurnStop {
/// Signals the turn's `claude` process group.
Claude(Cancel),
/// Sends the ACP agent `session/cancel`; the runtime sends SIGINT to one
/// that ignores it for its own grace period.
Acp(Canceller),
}
impl From<Cancel> for TurnStop {
fn from(cancel: Cancel) -> Self {
Self::Claude(cancel)
}
}
impl From<Canceller> for TurnStop {
fn from(canceller: Canceller) -> Self {
Self::Acp(canceller)
}
}
impl TurnStop {
/// Ask the turn to stop, without waiting for it to. Returns `false` when
/// no turn was in flight and nothing was signalled. A claude handle exists
/// only for a spawned process, so it always returns `true`.
#[must_use]
pub fn stop(&self) -> bool {
match self {
Self::Claude(cancel) => {
cancel.cancel(false);
true
}
Self::Acp(canceller) => canceller.cancel(),
}
}
}
#[cfg(all(test, unix))]
mod tests {
use std::path::Path;
use std::time::Duration;
use hive_claude::{Attach, Claude, Config};
use super::TurnStop;
/// A `claude` that records its SIGINT in `$MARK` and exits 0, and says it
/// is listening for one in `$READY`.
const FAKE_CLAUDE: &str = "#!/bin/sh\n\
trap ': > \"$MARK\"; exit 0' INT\n\
: > \"$READY\"\n\
sleep 30 >/dev/null 2>&1 &\n\
wait\n";
async fn exists(path: &Path) {
for _ in 0..250 {
if path.exists() {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("{} never appeared", path.display());
}
#[tokio::test]
async fn a_claude_stop_is_a_sigint_to_the_process() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let program = dir.path().join("fake-claude");
std::fs::write(&program, FAKE_CLAUDE).unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
let (ready, mark) = (dir.path().join("ready"), dir.path().join("int"));
let config = Config {
program: Some(program),
env: vec![
("READY".to_owned(), ready.display().to_string()),
("MARK".to_owned(), mark.display().to_string()),
],
..Config::default()
};
let running = Claude::spawn(&config, &Attach::Create("n".to_owned())).unwrap();
let stop = TurnStop::from(running.cancel_handle());
exists(&ready).await;
assert!(stop.stop());
exists(&mark).await;
}
}