hyperhive/hive-subagent-mcp/src/session.rs
atlas ae84174e3a subagent daemon: explain the status when returning it, not up front
The `status` tool description enumerated all five states it can report, so
every caller paid for four answers it didn't get and read the explanation
in the wrong place. The description now states only what the tool is for
and that it costs nothing to call; each answer it returns carries its own
meaning and the caller's next move instead — running, starting and idle
were terse, the not-found error terser still, and they had been leaning on
the enumeration to be legible. The killed answer and the end-of-turn todo
are unchanged. `continue`'s description keeps its killed-resume sentence:
that describes what the tool does, not a state it might hand back.

Refs #4326
2026-09-13 15:23:25 +02:00

1063 lines
44 KiB
Rust

//! The claude-facing half of this daemon: spawn a subagent turn, track it
//! only while it's alive, and push exactly one todo when it ends — saying
//! whether it finished or was killed.
//!
//! **No task files, no restart recovery.** The daemon's only state is an
//! in-memory `name -> Option<Cancel>` map (see `State`'s own doc for what
//! the `None`/`Some` split is for) plus a `name -> dir` memory so `start`'s
//! `dir` doesn't have to be repeated on every later `continue`/`status`
//! (`State::resolve_dir`) — both live for exactly as long as the process
//! is — a daemon restart means whatever was running gets killed with it
//! (`tokio`'s own child-process drop semantics), not adopted, and the
//! remembered `dir` is gone too. The durable
//! record of a subagent's existence is `hive_claude::SessionStore` — claude's
//! own on-disk session, found again by name. `continue` is how a caller
//! reattaches to it, whether that's "give it a new turn" or "the daemon
//! restarted and I want to pick this back up" — after a restart, re-supply
//! `dir` once if the session isn't in the daemon's own default directory.
//! **A killed turn is not a finished turn.** A child that died on a signal
//! arrives as a `hive_claude::Error::Exit` carrying its `ExitStatus`, so the
//! "how" is there to be read: `classify_end` takes the signal out of it and
//! `State::finish_turn` remembers it against the name, which is what lets
//! `status`, the end-of-turn todo and `continue`'s own reply all say the
//! session was killed rather than let it read as finished. See
//! `docs/tools/subagent.md`.
//!
//! **No mid-turn compaction.** Building on `hive_claude::Claude::spawn` +
//! `RunningClaude::wait` directly (not `InfiniteSession::run`) is what makes
//! `interrupt` possible at all — `InfiniteSession` has no cancel handle to
//! reach in from the outside, only `RunningClaude::cancel_handle` does. The
//! trade: this daemon doesn't get `InfiniteSession`'s reactive-compact-on-
//! overflow or proactive-checkpoint-compact for free: a turn that overflows
//! the context window surfaces as a plain `Error::PromptTooLong` to the
//! caller instead of self-healing. Subagents are meant to be bounded,
//! single-batch work (see the `base:claude-subagents` skill), not sessions
//! long-lived enough to need in-place compaction — a real follow-up if that
//! assumption stops holding, not shipped here.
use std::collections::HashMap;
use std::os::unix::process::ExitStatusExt as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, PoisonError};
use hive_claude::{Attach, Cancel, Claude, Config, NoopSink, SessionStore};
/// How a subagent's turn ended, as far as the daemon can tell from what
/// [`hive_claude::RunningClaude::wait`] returned.
///
/// The distinction that matters is `Killed` vs everything else: a child that
/// died on a signal was cut off mid-turn by something outside this daemon
/// (the kernel's OOM killer, a `systemctl stop`, an operator's `kill`) or by
/// an `interrupt` call here — either way its work stopped wherever it got
/// to, which is not what `Complete` means.
#[derive(Debug, Clone, PartialEq, Eq)]
enum TurnEnd {
/// The turn ran to completion.
Complete,
/// The child was terminated by `signal` instead of exiting on its own.
Killed { signal: i32 },
/// claude exited on its own but did not complete the turn — a
/// recognized sentinel (rate limit, prompt too long, …) or a plain
/// non-zero exit. Carries the message shown to the caller.
Failed(String),
}
/// Name a signal number for a human: `9` reads as `SIGKILL (signal 9)`. Only
/// the three that end a subagent in practice are named — anything else keeps
/// the number, which is still enough to look up.
fn describe_signal(signal: i32) -> String {
match signal {
libc::SIGKILL => "SIGKILL (signal 9)".to_owned(),
libc::SIGTERM => "SIGTERM (signal 15)".to_owned(),
libc::SIGINT => "SIGINT (signal 2)".to_owned(),
other => format!("signal {other}"),
}
}
/// Read how the turn ended out of what the driver returned. A signalled
/// child surfaces as [`hive_claude::Error::Exit`] whose `ExitStatus` has a
/// `signal()` — the driver's own docs point at exactly this check — so the
/// "how" is preserved by the time it reaches here rather than having to be
/// recovered.
fn classify_end(outcome: hive_claude::Result<()>) -> TurnEnd {
let Err(error) = outcome else {
return TurnEnd::Complete;
};
if let hive_claude::Error::Exit { status, .. } = &error
&& let Some(signal) = status.signal()
{
return TurnEnd::Killed { signal };
}
TurnEnd::Failed(format!("claude error: {error}"))
}
/// This daemon's whole state: which names currently have a live process or
/// a reservation in flight, and where to push the completion todo.
/// `Arc`-wrapped so the background task that drives a turn to completion
/// can outlive the tool call that started it.
///
/// The map value is `Option<Cancel>`: `None` means `name` is reserved for
/// an in-flight `start`/`continue` that hasn't reached a confirmed
/// `Claude::spawn` yet; `Some(cancel)` means a real process is tracked and
/// interruptible. The `None` state exists to close a real TOCTOU window a
/// reviewer caught in the original check-then-insert version: checking "is
/// `name` free" and committing to it are two different lock acquisitions
/// unless the check *is* the reservation — see `reserve`.
///
/// `killed` is the other half of that map's story: `running` says what is in
/// flight *now*, `killed` remembers the names whose last turn ended on a
/// signal rather than on its own, keyed to the signal number. Without it a
/// killed session is indistinguishable from a finished one the moment its
/// entry leaves `running` — the whole point of this record.
///
/// ⚠️ This concurrency guard is keyed by `name` alone — a `start`/`continue`
/// for `name` with a *different* `dir` than one already in flight under
/// that name is refused as "already running," even though the two would
/// resolve to entirely separate on-disk sessions. Deliberate: `name` is the
/// caller's one chosen identity for a subagent, not `(name, dir)` — reuse a
/// name across directories at your own risk, the tool doesn't disambiguate
/// it (flagged in review when `dir` was added).
pub struct State {
running: Mutex<HashMap<String, Option<Cancel>>>,
dirs: Mutex<HashMap<String, String>>,
killed: Mutex<HashMap<String, i32>>,
socket: PathBuf,
}
impl State {
#[must_use]
pub fn new(socket: PathBuf) -> Self {
Self {
running: Mutex::new(HashMap::new()),
dirs: Mutex::new(HashMap::new()),
killed: Mutex::new(HashMap::new()),
socket,
}
}
/// `Some(true)` — a live process is tracked, interruptible. `Some(false)`
/// — reserved for an in-flight start/continue, not yet a confirmed
/// spawn. `None` — nothing tracked under `name` at all.
fn occupancy(&self, name: &str) -> Option<bool> {
self.running
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(name)
.map(Option::is_some)
}
/// Atomically claim `name` for an in-flight start/continue: check
/// "is anything tracked under this name" and "commit to this call
/// owning it" in the *same* lock acquisition, so two calls racing the
/// same name can't both pass a check before either commits (the exact
/// same-name concurrent-run hazard this module's doc warns about).
/// Returns `false` (reserving nothing) if `name` is already reserved
/// or running.
fn reserve(&self, name: &str) -> bool {
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
if running.contains_key(name) {
return false;
}
running.insert(name.to_owned(), None);
true
}
/// Release a reservation that never made it to a real spawn (an error
/// on the slow path between `reserve` and `Claude::spawn` succeeding).
/// A no-op if the entry was already upgraded to `Some` — this only ever
/// clears a still-`None` placeholder, never a live process.
fn release_reservation(&self, name: &str) {
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
if matches!(running.get(name), Some(None)) {
running.remove(name);
}
}
/// Retire the finished turn's tracking for `name` and remember how it
/// ended: a `Killed` end is recorded so `status`, the end-of-turn todo
/// and a later `continue` can all say so; any other end clears a stale
/// record from an earlier turn.
///
/// The kill is recorded *before* the `running` entry goes away, so there
/// is no instant in which `name` is neither running nor known-killed —
/// a `status` landing between the two would otherwise read the session
/// as plainly idle, which is the exact confusion this record exists to
/// remove.
fn finish_turn(&self, name: &str, end: &TurnEnd) {
let mut killed = self.killed.lock().unwrap_or_else(PoisonError::into_inner);
match end {
TurnEnd::Killed { signal } => killed.insert(name.to_owned(), *signal),
TurnEnd::Complete | TurnEnd::Failed(_) => killed.remove(name),
};
drop(killed);
self.running
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(name);
}
/// The signal `name`'s last turn died on, if it died on one.
fn killed_by(&self, name: &str) -> Option<i32> {
self.killed
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(name)
.copied()
}
/// Forget that `name`'s last turn was killed — called once a new turn is
/// confirmed spawned, since the record describes the turn before it and
/// would otherwise keep flagging a session that has since run again.
fn clear_kill(&self, name: &str) {
self.killed
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(name);
}
/// An explicit `dir` is remembered against `name` and returned as-is; an
/// omitted one (`None`) falls back to whatever was last remembered for
/// `name`, so a `start` that gave a `dir` doesn't force every later
/// `continue`/`status` against the same name to repeat it. Explicit
/// always wins and updates the memory — there's no way to say "forget
/// it, use the default" once something's been remembered short of
/// giving a genuinely different `dir`, a deliberate simplicity trade,
/// not an oversight. In-memory only, same durability envelope as
/// `running`: a daemon restart forgets it same as it forgets everything
/// else here — the caller re-supplies `dir` once, same as it always
/// could.
///
/// ⚠️ **Callers must only call this once the call is known to proceed**
/// (i.e. after `reserve` has already succeeded in `start`/`continue_`) —
/// see `peek_dir` for the non-committing variant a call that might still
/// be refused (or that shouldn't persist its `dir` at all, like
/// `status`) needs instead. This one argus caught in review: calling it
/// unconditionally before `reserve`'s check meant a *rejected* concurrent
/// call still overwrote the remembered `dir` for the name it was refused
/// against.
fn resolve_dir(&self, name: &str, dir: Option<&str>) -> Option<String> {
let mut dirs = self.dirs.lock().unwrap_or_else(PoisonError::into_inner);
match dir {
Some(d) => {
dirs.insert(name.to_owned(), d.to_owned());
Some(d.to_owned())
}
None => dirs.get(name).cloned(),
}
}
/// Same resolution as `resolve_dir` (explicit wins, omitted falls back
/// to the remembered value) but never writes — for a call that must not
/// change what a later bare `continue`/`status` resolves to. `status`'s
/// own doc offers `dir` as a one-off "check a different directory"
/// knob; if that call committed the same way `start`/`continue_` do, the
/// one-off peek would silently become the new remembered default (the
/// second bug argus flagged in the same review).
fn peek_dir(&self, name: &str, dir: Option<&str>) -> Option<String> {
match dir {
Some(d) => Some(d.to_owned()),
None => self
.dirs
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(name)
.cloned(),
}
}
}
/// A caller-chosen name, validated the same way `hive-bash-mcp`'s task ids
/// are: a single safe [`hive_types::Ident`] segment, which doubles as
/// claude's own `--name`/`--resume` session title.
fn validate_name(name: &str) -> anyhow::Result<()> {
hive_types::Ident::parse(name)
.map(|_| ())
.map_err(|e| anyhow::anyhow!("invalid subagent name {name:?}: {e}"))
}
/// Extend the ambient `OTEL_RESOURCE_ATTRIBUTES` with a `subagent=<name>`
/// attribute, so every token/cost/tool-call data point this subagent's own
/// claude process emits carries it alongside the parent's `agent=<name>`
/// label. `Config.env` applies after the inherited environment, so this one
/// entry overriding the ambient value is the intended shape, not a
/// wholesale replacement.
fn subagent_otel_attrs(name: &str) -> String {
match std::env::var("OTEL_RESOURCE_ATTRIBUTES") {
Ok(existing) if !existing.is_empty() => format!("{existing},subagent={name}"),
_ => format!("subagent={name}"),
}
}
/// Build the `Config` one subagent turn runs against. `prompt_file`, when
/// given, becomes `--append-system-prompt-file` — the subagent's task
/// instructions. `dir`, when given, becomes `Config::cwd` (e.g. a worktree
/// the caller already prepared); `None` inherits this daemon's own working
/// directory, same as before this field existed. Always
/// `--dangerously-skip-permissions --strict-mcp-config` — the safety
/// property is `strict_mcp_config: true` with no ambient MCP discovery, not
/// an unconditional absence of `--mcp-config`: a subagent gets exactly the
/// `hyperhive.extraMcpServers` entries an operator has explicitly opted in
/// via `availableToSubagents = true` (`crate::mcp_config::build`), nothing
/// implicit and nothing more. With no entry opted in — the default — that
/// resolves to `None` and the invocation is unchanged from before this
/// toggle existed: zero MCP servers, full stop.
fn build_config(
name: &str,
model: Option<String>,
prompt_file: Option<&str>,
dir: Option<&str>,
) -> Config {
let mut extra_args = vec!["--dangerously-skip-permissions".to_owned()];
if let Some(path) = prompt_file {
extra_args.push("--append-system-prompt-file".to_owned());
extra_args.push(path.to_owned());
}
Config {
model,
cwd: dir.map(PathBuf::from),
mcp_config: crate::mcp_config::build(),
strict_mcp_config: true,
extra_args,
env: vec![(
"OTEL_RESOURCE_ATTRIBUTES".to_owned(),
subagent_otel_attrs(name),
)],
..Default::default()
}
}
/// The [`SessionStore`] a subagent's turn actually runs against — same
/// resolution `hive_claude::Claude` itself uses, so a lookup here can't
/// disagree with what the driver does a moment later.
fn build_store(config: &Config) -> std::io::Result<SessionStore> {
Ok(SessionStore::new(
config.resolved_claude_home()?,
config.resolved_cwd()?,
))
}
/// Start a fresh subagent under `name`. A prior *finished* session under
/// the same name is archived first (so this is a real fresh start, not a
/// silent resume of old history) — a *currently running* one is refused
/// outright, since `hive_claude::InfiniteSession`'s own docs warn that two
/// concurrent runs against the same name corrupt both.
///
/// # Errors
///
/// A name already running, an invalid name, an archive failure, or the
/// underlying `Claude::spawn` failing (binary missing, etc.) — the last
/// case is the only one that can happen *after* commit-to-run, and it's
/// exactly why nothing is registered in `running` until spawn actually
/// succeeds.
pub fn start(
state: &Arc<State>,
name: &str,
model: Option<String>,
prompt_file: &str,
trigger: String,
dir: Option<&str>,
) -> anyhow::Result<String> {
validate_name(name)?;
if !state.reserve(name) {
anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`");
}
// Only commit the remembered `dir` now that `reserve` has actually
// claimed `name` — see `resolve_dir`'s doc for why the order matters.
let dir = state.resolve_dir(name, dir);
let result = start_reserved(state, name, model, prompt_file, trigger, dir.as_deref());
if result.is_err() {
state.release_reservation(name);
}
result
}
/// The slow, fallible part of `start`, run only after `reserve` has
/// already closed the TOCTOU window — split out so `start` can release the
/// reservation on any error path here without duplicating that logic per
/// failure site.
fn start_reserved(
state: &Arc<State>,
name: &str,
model: Option<String>,
prompt_file: &str,
trigger: String,
dir: Option<&str>,
) -> anyhow::Result<String> {
let config = build_config(name, model, Some(prompt_file), dir);
let store = build_store(&config)?;
if store.find_by_title(name).is_some() {
tracing::info!(
name,
"start: archiving a finished prior session for a fresh start"
);
store
.archive_by_title(name)
.map_err(|e| anyhow::anyhow!("archiving the prior `{name}` session failed: {e}"))?;
}
spawn_and_track(
state,
name,
&config,
&Attach::Create(name.to_owned()),
trigger,
)
}
/// Give an existing named session a new turn — resuming it whether that
/// means "the previous turn finished, here's the next instruction" or "the
/// daemon restarted, reattaching." Refuses a name with no session on disk
/// at all (nothing to continue) or one already running (same
/// concurrent-run hazard as `start`).
///
/// Resuming a session whose last turn was *killed* is allowed — that is
/// often exactly what the caller wants — but never silent: the reply says
/// so, since a caller that never ran `status` and missed the todo would
/// otherwise carry on from cut-off work believing it was finished work.
///
/// # Errors
///
/// No session under `name`, an invalid name, one already running, or
/// `Claude::spawn` failing.
pub fn continue_(
state: &Arc<State>,
name: &str,
prompt: String,
model: Option<String>,
dir: Option<&str>,
) -> anyhow::Result<String> {
validate_name(name)?;
if !state.reserve(name) {
anyhow::bail!(
"subagent `{name}` is already running — use `interrupt` first if you meant to redirect it"
);
}
// Read before the spawn, which clears the record as soon as the new turn
// is confirmed — this reply is the last chance to mention it.
let killed = state.killed_by(name);
// Only commit the remembered `dir` now that `reserve` has actually
// claimed `name` — see `resolve_dir`'s doc for why the order matters.
let dir = state.resolve_dir(name, dir);
let result = continue_reserved(state, name, prompt, model, dir.as_deref());
if result.is_err() {
state.release_reservation(name);
}
result.map(|msg| note_resumed_after_kill(&msg, killed))
}
/// Append the "you are resuming a killed session" note to `continue`'s reply
/// when its previous turn was signalled; pass the reply through unchanged
/// otherwise.
fn note_resumed_after_kill(msg: &str, killed: Option<i32>) -> String {
match killed {
None => msg.to_owned(),
Some(signal) => format!(
"{msg} — note: its previous turn was killed ({}) rather than finishing, so this turn \
resumes from work that was cut off mid-way",
describe_signal(signal)
),
}
}
/// The slow, fallible part of `continue_`, run only after `reserve` has
/// already closed the TOCTOU window — same split rationale as
/// `start_reserved`.
fn continue_reserved(
state: &Arc<State>,
name: &str,
prompt: String,
model: Option<String>,
dir: Option<&str>,
) -> anyhow::Result<String> {
let config = build_config(name, model, None, dir);
let store = build_store(&config)?;
if store.find_by_title(name).is_none() {
anyhow::bail!(
"no session named `{name}` exists — `continue` resumes an existing subagent, `start` \
creates one"
);
}
spawn_and_track(
state,
name,
&config,
&Attach::Resume(name.to_owned()),
prompt,
)
}
/// Spawn the child (synchronous — returns with a real pid the instant the
/// process exists, which *is* "confirmed running": there is no stronger
/// signal to wait for without slowing every call down for no reason), track
/// it in `running`, and hand the actual turn off to a background task so
/// the caller returns immediately instead of blocking on the whole turn.
/// Not `async` itself — `tokio::spawn` needs an active runtime to spawn
/// *onto*, not an `async` caller to spawn *from*.
fn spawn_and_track(
state: &Arc<State>,
name: &str,
config: &Config,
attach: &Attach,
prompt: String,
) -> anyhow::Result<String> {
let running = Claude::spawn(config, attach)
.map_err(|e| anyhow::anyhow!("starting the subagent process failed: {e}"))?;
// Upgrades the `None` reservation `reserve` already placed here to a
// real cancel handle — same key, so there's no window where `name`
// reads as unoccupied between the reservation and this insert.
state
.running
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(name.to_owned(), Some(running.cancel_handle()));
// 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);
let state = Arc::clone(state);
let task_name = name.to_owned();
tokio::spawn(async move {
let end = classify_end(running.wait(&prompt, &NoopSink).await);
match &end {
TurnEnd::Complete => {}
TurnEnd::Killed { signal } => {
tracing::warn!(
name = %task_name,
signal,
"subagent: turn killed — the child died on a signal, it did not finish"
);
}
TurnEnd::Failed(e) => {
tracing::warn!(name = %task_name, error = %e, "subagent: turn failed");
}
}
state.finish_turn(&task_name, &end);
push_turn_end_todo(&state.socket, &task_name, &end).await;
});
Ok(format!("subagent `{name}` started"))
}
/// Report whether `name` is currently running — a zero-cost check that
/// never launches a process, unlike `continue`. Distinguishes five states:
/// running, starting (reserved, not yet a confirmed spawn — see `State`'s
/// doc), killed (its last turn died on a signal), idle (a session exists,
/// its last turn finished, nothing is in flight), and no such session at
/// all.
///
/// # Errors
///
/// An invalid name, or no session — running, killed or on disk — under
/// `name`.
pub fn status(state: &State, name: &str, dir: Option<&str>) -> anyhow::Result<String> {
validate_name(name)?;
// Read-only: an explicit `dir` here is a one-off "check this other
// directory's session" per this fn's own doc, not a new remembered
// default — `peek_dir` resolves the same way but never writes.
let dir = state.peek_dir(name, dir);
let occupancy = state.occupancy(name);
let killed = state.killed_by(name);
// Nothing on disk to look for while something is tracked in memory —
// those states answer on their own, and the store read is the only
// expensive part of this call.
let session_exists = if occupancy.is_none() && killed.is_none() {
let config = build_config(name, None, None, dir.as_deref());
build_store(&config)?.find_by_title(name).is_some()
} else {
false
};
describe_status(name, occupancy, killed, session_exists)
}
/// Render `status`'s answer from the three facts it gathers. Split out from
/// the gathering so the killed-versus-idle distinction is exercisable
/// without a real spawn, a real signal and a real on-disk claude session.
///
/// A recorded kill outranks the on-disk session: the session file exists
/// either way, so "a session is there" is precisely the fact that cannot
/// tell the two apart.
///
/// Every answer is self-contained — it names the one state the caller got
/// and what to do next — because the tool description deliberately doesn't
/// enumerate the state space (the operator's ruling on this surface:
/// describe the tool, explain the state when returning it). Keep the split:
/// a terser answer here has nowhere left to be explained from.
fn describe_status(
name: &str,
occupancy: Option<bool>,
killed: Option<i32>,
session_exists: bool,
) -> anyhow::Result<String> {
match occupancy {
Some(true) => {
return Ok(format!(
"subagent `{name}` is running — its turn is still in flight, so there's nothing \
to do but let it work: the daemon pushes a todo when the turn ends, or \
`interrupt` it if you want it stopped early."
));
}
Some(false) => {
return Ok(format!(
"subagent `{name}` is starting — a `start`/`continue` has claimed the name but \
its process isn't confirmed spawned yet, which is normally over in well under a \
second: check again shortly rather than starting anything else under this name."
));
}
None => {}
}
if let Some(signal) = killed {
return Ok(format!(
"subagent `{name}` was killed — its last turn died on {}, so its work stopped \
wherever it had got to rather than finishing. `continue` still resumes it, but \
whatever it was told to do is unfinished: check what it actually left behind before \
trusting it.",
describe_signal(signal)
));
}
if session_exists {
Ok(format!(
"subagent `{name}` is idle — its session exists, its last turn ended on its own \
rather than being cut off, and nothing is in flight: that turn's own todo says how \
it went, and `continue` gives it another."
))
} else {
anyhow::bail!(
"no subagent named `{name}` exists — nothing is running under that name and there's \
no session on disk to resume, so `start` is what creates one (check the name if you \
expected something here)."
)
}
}
/// Signal `name`'s running process — `force` picks SIGKILL over SIGINT (see
/// `hive_claude::Cancel::cancel`). Refuses a name with nothing running: no
/// entry at all, or one still in the brief `reserve`d-but-not-yet-spawned
/// window (nothing to signal yet — the reservation is put back so a
/// concurrent `start`/`continue` for the same name still gets refused).
///
/// # Errors
///
/// An invalid name, nothing tracked under `name`, or `name` is still
/// starting (reserved, not yet a confirmed spawn).
pub fn interrupt(state: &State, name: &str, force: bool) -> anyhow::Result<String> {
validate_name(name)?;
let mut running = state.running.lock().unwrap_or_else(PoisonError::into_inner);
match running.remove(name) {
None => anyhow::bail!("no subagent named `{name}` is currently running"),
Some(None) => {
running.insert(name.to_owned(), None);
anyhow::bail!(
"subagent `{name}` is still starting — not yet confirmed running, try again \
shortly"
);
}
Some(Some(cancel)) => {
drop(running);
cancel.cancel(force);
Ok(format!("interrupt sent to subagent `{name}`"))
}
}
}
/// Push `name`'s one-shot end-of-turn todo. Best-effort: a connect/write
/// failure is logged and swallowed, matching every other in-agent-socket
/// producer in this codebase — there's no retry queue to fall back to, and
/// the caller has already moved on by the time this fires.
async fn push_turn_end_todo(socket: &std::path::Path, name: &str, end: &TurnEnd) {
let req = hive_agent_sock::Request::UpsertTodo {
subsystem: "subagent".to_owned(),
key: Some(name.to_owned()),
summary: turn_end_summary(name, end),
source: None,
reopen_if_acked: false,
};
if let Err(e) = hive_sock_client::notify(socket, &req, hive_sock_client::Retry::None).await {
tracing::warn!(name, error = ?e, "subagent: end-of-turn todo push failed");
}
}
/// The todo text for a turn that has ended. A killed turn deliberately does
/// not use the "finished" wording the other two share: this todo is the only
/// thing the owning agent is shown without asking, so it has to read as the
/// interruption it is rather than as one more completed subagent.
fn turn_end_summary(name: &str, end: &TurnEnd) -> String {
match end {
TurnEnd::Complete => format!("subagent `{name}` finished: turn complete"),
TurnEnd::Failed(e) => format!("subagent `{name}` finished: {e}"),
TurnEnd::Killed { signal } => format!(
"subagent `{name}` was KILLED mid-turn ({}) — it did not finish, and its work stopped \
wherever it had got to. Check what it left behind before you act on it; `continue` \
resumes the session if you want it carried on.",
describe_signal(*signal)
),
}
}
#[cfg(test)]
mod tests {
use super::*;
// `Cancel` can only be constructed from a real spawned process (no test
// fixture in `hive_claude` for it), so these exercise the reservation
// half of `State` directly — the actual TOCTOU-closure logic — rather
// than the full start/spawn path.
#[test]
fn reserve_is_exclusive_for_the_same_name() {
let state = State::new(PathBuf::from("/dev/null"));
assert!(state.reserve("dup"), "first reservation should succeed");
assert!(
!state.reserve("dup"),
"a second reservation for the same name must be refused — this is the exact race \
argus found: two calls both passing a check before either commits"
);
}
#[test]
fn reserve_does_not_cross_block_different_names() {
let state = State::new(PathBuf::from("/dev/null"));
assert!(state.reserve("a"));
assert!(
state.reserve("b"),
"unrelated names must not block each other"
);
}
#[test]
fn release_reservation_frees_the_name_for_reuse() {
let state = State::new(PathBuf::from("/dev/null"));
assert!(state.reserve("n"));
state.release_reservation("n");
assert!(
state.reserve("n"),
"releasing a still-`None` reservation must free the name again"
);
}
#[test]
fn occupancy_reflects_the_reserved_but_not_running_state() {
let state = State::new(PathBuf::from("/dev/null"));
assert_eq!(state.occupancy("never-reserved"), None);
state.reserve("n");
assert_eq!(
state.occupancy("n"),
Some(false),
"reserved-but-not-yet-spawned must read as occupied-but-not-running"
);
}
// One test, not two: `subagent_otel_attrs` reads the real process-wide
// `OTEL_RESOURCE_ATTRIBUTES` env var, and cargo runs tests in parallel
// threads by default — two separate tests each mutating that global
// raced each other (and, in this container, lost to the agent's own
// real ambient value). Sequencing both assertions in one test removes
// the race instead of papering over it with a mutex.
#[test]
fn otel_attrs_append_ambient_value_or_stand_alone() {
// SAFETY: test-only env mutation; sequenced within this one test so
// no other test's concurrent read/write of the same var can race it.
unsafe {
std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
}
assert_eq!(subagent_otel_attrs("batch-1"), "subagent=batch-1");
unsafe {
std::env::set_var("OTEL_RESOURCE_ATTRIBUTES", "agent=damocles");
}
assert_eq!(
subagent_otel_attrs("batch-1"),
"agent=damocles,subagent=batch-1"
);
unsafe {
std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
}
}
#[test]
fn build_config_only_appends_system_prompt_when_given() {
let with = build_config("n", None, Some("/tmp/p.md"), None);
assert!(
with.extra_args
.contains(&"--append-system-prompt-file".to_owned())
);
assert!(with.extra_args.contains(&"/tmp/p.md".to_owned()));
let without = build_config("n", None, None, None);
assert!(
!without
.extra_args
.contains(&"--append-system-prompt-file".to_owned())
);
}
#[test]
fn resolve_dir_remembers_an_explicit_dir_and_falls_back_to_it_when_omitted() {
let state = State::new(PathBuf::from("/dev/null"));
assert_eq!(
state.resolve_dir("n", None),
None,
"nothing remembered yet — omitted dir has nothing to fall back to"
);
assert_eq!(
state.resolve_dir("n", Some("/tmp/worktree")),
Some("/tmp/worktree".to_owned()),
"an explicit dir is returned as-is"
);
assert_eq!(
state.resolve_dir("n", None),
Some("/tmp/worktree".to_owned()),
"a later omitted dir falls back to what was just remembered"
);
assert_eq!(
state.resolve_dir("n", Some("/tmp/other")),
Some("/tmp/other".to_owned()),
"a later explicit dir overrides the memory, not just reads it"
);
assert_eq!(
state.resolve_dir("n", None),
Some("/tmp/other".to_owned()),
"the fallback now reflects the override"
);
}
#[test]
fn resolve_dir_does_not_cross_names() {
let state = State::new(PathBuf::from("/dev/null"));
state.resolve_dir("a", Some("/tmp/a"));
assert_eq!(
state.resolve_dir("b", None),
None,
"an unrelated name's memory must stay empty"
);
}
#[test]
fn peek_dir_resolves_like_resolve_dir_but_never_writes() {
let state = State::new(PathBuf::from("/dev/null"));
state.resolve_dir("n", Some("/tmp/remembered"));
assert_eq!(
state.peek_dir("n", Some("/tmp/one-off")),
Some("/tmp/one-off".to_owned()),
"an explicit dir still resolves as given"
);
assert_eq!(
state.resolve_dir("n", None),
Some("/tmp/remembered".to_owned()),
"but the one-off peek must not have overwritten the remembered value"
);
}
#[test]
fn start_does_not_corrupt_the_remembered_dir_when_the_name_is_already_running() {
// The exact scenario argus caught in review: a rejected concurrent
// `start` used to commit its `dir` before the reservation check
// refused it, corrupting what a later bare `continue` would resolve
// to. Reordering `reserve` before `resolve_dir` in `start` closes it
// — a name that's already reserved must never reach `resolve_dir` at
// all, so `dirs` stays exactly as a caller left it.
let state = Arc::new(State::new(PathBuf::from("/dev/null")));
state.resolve_dir("dup", Some("/tmp/original"));
assert!(
state.reserve("dup"),
"simulate an in-flight start/continue already owning the name"
);
let result = start(
&state,
"dup",
None,
"/tmp/prompt.md",
"trigger".to_owned(),
Some("/tmp/rejected"),
);
assert!(
result.is_err(),
"a name already reserved must be refused, not spawned"
);
assert_eq!(
state.resolve_dir("dup", None),
Some("/tmp/original".to_owned()),
"the rejected call's dir must not have overwritten the remembered one"
);
}
/// The error the driver hands back for a child that died on `signal` —
/// `ExitStatus::from_raw` takes a raw `wait(2)` status, whose low seven
/// bits are the terminating signal, so this is the same value
/// `RunningClaude::wait` would have produced from a real kill.
fn signalled_exit(signal: i32) -> hive_claude::Error {
hive_claude::Error::Exit {
status: std::process::ExitStatus::from_raw(signal),
stderr_tail: String::new(),
}
}
#[test]
fn classify_end_separates_a_signalled_child_from_a_clean_one() {
assert_eq!(classify_end(Ok(())), TurnEnd::Complete);
assert_eq!(
classify_end(Err(signalled_exit(libc::SIGKILL))),
TurnEnd::Killed { signal: 9 },
"a SIGKILLed child must not read as a turn that ended on its own"
);
assert_eq!(
classify_end(Err(signalled_exit(libc::SIGTERM))),
TurnEnd::Killed { signal: 15 }
);
assert!(
matches!(
classify_end(Err(hive_claude::Error::PromptTooLong)),
TurnEnd::Failed(_)
),
"claude exiting on its own is a failure, not a kill"
);
}
#[test]
fn status_reports_killed_not_idle_for_a_signalled_session() {
// Both sessions exist on disk and neither is running: the only thing
// that can tell them apart is the recorded signal.
let killed = describe_status("n", None, Some(libc::SIGKILL), true).expect("killed status");
let idle = describe_status("n", None, None, true).expect("idle status");
assert!(
killed.contains("killed") && killed.contains("SIGKILL (signal 9)"),
"a killed session must say so, and name the signal: {killed}"
);
assert!(
!killed.contains("idle"),
"a killed session must not also read as idle: {killed}"
);
assert!(idle.contains("idle"), "a finished turn still reads idle");
assert_ne!(
killed, idle,
"collapsing the two back together is the bug this reports"
);
}
#[test]
fn every_status_answer_names_its_state_and_the_next_move() {
// The tool description no longer lists the states, so each answer
// has to carry its own explanation — checked one state at a time,
// which is all a caller ever gets back.
let running = describe_status("n", Some(true), None, false).expect("running status");
assert!(
running.contains("running") && running.contains("`interrupt`"),
"a running answer must say the turn is in flight and how to stop it: {running}"
);
let starting = describe_status("n", Some(false), None, false).expect("starting status");
assert!(
starting.contains("starting") && starting.contains("check again"),
"a starting answer must say the spawn isn't confirmed yet and to retry: {starting}"
);
let idle = describe_status("n", None, None, true).expect("idle status");
assert!(
idle.contains("idle") && idle.contains("`continue`"),
"an idle answer must say the last turn ended on its own and how to give it another: \
{idle}"
);
let killed = describe_status("n", None, Some(libc::SIGKILL), true).expect("killed status");
assert!(
killed.contains("killed") && killed.contains("`continue`"),
"a killed answer must name the kill and say resuming is still possible: {killed}"
);
let missing = describe_status("n", None, None, false)
.expect_err("nothing tracked and nothing on disk is an error, not a state");
assert!(
missing.to_string().contains("`start`"),
"the no-such-session answer must point at what creates one: {missing}"
);
}
#[test]
fn a_killed_turn_and_a_clean_one_do_not_land_in_the_same_state() {
// The whole path a real turn takes, minus the process: what the
// driver returned -> what the daemon records -> what `status` says.
let state = State::new(PathBuf::from("/dev/null"));
for (name, outcome) in [
("gone", Err(signalled_exit(libc::SIGKILL))),
("done", Ok(())),
] {
assert!(state.reserve(name));
state.finish_turn(name, &classify_end(outcome));
assert_eq!(
state.occupancy(name),
None,
"the turn is over either way — nothing stays tracked as running"
);
}
assert_eq!(state.killed_by("gone"), Some(9));
assert_eq!(state.killed_by("done"), None);
let gone = describe_status("gone", None, state.killed_by("gone"), true).expect("status");
let done = describe_status("done", None, state.killed_by("done"), true).expect("status");
assert!(gone.contains("killed"), "{gone}");
assert!(done.contains("idle"), "{done}");
}
#[test]
fn a_new_turn_clears_the_previous_turn_s_kill() {
let state = State::new(PathBuf::from("/dev/null"));
state.finish_turn("n", &TurnEnd::Killed { signal: 9 });
assert_eq!(state.killed_by("n"), Some(9));
// What `spawn_and_track` does once the next turn is confirmed
// spawned, and then what its own clean end does.
state.clear_kill("n");
assert_eq!(state.killed_by("n"), None);
state.finish_turn("n", &TurnEnd::Complete);
assert_eq!(
state.killed_by("n"),
None,
"a turn that finished must not leave the old kill standing"
);
}
#[test]
fn the_killed_todo_does_not_read_like_a_completion() {
let complete = turn_end_summary("n", &TurnEnd::Complete);
let killed = turn_end_summary("n", &TurnEnd::Killed { signal: 9 });
assert_eq!(
complete, "subagent `n` finished: turn complete",
"the ordinary completion todo is unchanged"
);
assert!(
killed.contains("KILLED mid-turn") && killed.contains("SIGKILL (signal 9)"),
"the killed todo must state the kill and the signal: {killed}"
);
assert!(
!killed.contains("finished"),
"the one notification pushed without being asked must not read as a finished turn: \
{killed}"
);
assert_ne!(complete, killed);
}
#[test]
fn continue_tells_the_caller_it_is_resuming_a_killed_session() {
let plain = note_resumed_after_kill("subagent `n` started", None);
assert_eq!(
plain, "subagent `n` started",
"an ordinary resume is unchanged"
);
let after_kill = note_resumed_after_kill("subagent `n` started", Some(libc::SIGKILL));
assert!(
after_kill.contains("killed") && after_kill.contains("SIGKILL (signal 9)"),
"resuming a killed session is allowed, but the caller has to be told: {after_kill}"
);
}
#[test]
fn describe_signal_names_the_ones_that_end_a_subagent() {
assert_eq!(describe_signal(9), "SIGKILL (signal 9)");
assert_eq!(describe_signal(15), "SIGTERM (signal 15)");
assert_eq!(describe_signal(2), "SIGINT (signal 2)");
assert_eq!(describe_signal(7), "signal 7");
}
#[test]
fn build_config_sets_cwd_only_when_a_dir_is_given() {
let with = build_config("n", None, None, Some("/tmp/some-worktree"));
assert_eq!(with.cwd, Some(PathBuf::from("/tmp/some-worktree")));
let without = build_config("n", None, None, None);
assert_eq!(without.cwd, None);
}
}