//! 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` 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`: `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>>, dirs: Mutex>, killed: Mutex>, 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 { 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 { 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 { 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 { 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=` /// attribute, so every token/cost/tool-call data point this subagent's own /// claude process emits carries it alongside the parent's `agent=` /// 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, 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 { 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, name: &str, model: Option, prompt_file: &str, trigger: String, dir: Option<&str>, ) -> anyhow::Result { 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, name: &str, model: Option, prompt_file: &str, trigger: String, dir: Option<&str>, ) -> anyhow::Result { 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, name: &str, prompt: String, model: Option, dir: Option<&str>, ) -> anyhow::Result { 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) -> 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, name: &str, prompt: String, model: Option, dir: Option<&str>, ) -> anyhow::Result { 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, name: &str, config: &Config, attach: &Attach, prompt: String, ) -> anyhow::Result { 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 { 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, killed: Option, session_exists: bool, ) -> anyhow::Result { 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 { 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); } }