//! 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 finishes. //! //! **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), 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. 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." //! //! **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::path::PathBuf; use std::sync::{Arc, Mutex, PoisonError}; use hive_claude::{Attach, Cancel, Claude, Config, NoopSink, SessionStore}; /// 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`. pub struct State { running: Mutex>>, socket: PathBuf, } impl State { #[must_use] pub fn new(socket: PathBuf) -> Self { Self { running: 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); } } } /// 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. Always `--dangerously-skip-permissions --strict-mcp-config` /// (no `--mcp-config` override — a safety property, not a knob). fn build_config(name: &str, model: Option, prompt_file: 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, 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, ) -> anyhow::Result { validate_name(name)?; if !state.reserve(name) { anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`"); } let result = start_reserved(state, name, model, prompt_file, trigger); 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, ) -> anyhow::Result { let config = build_config(name, model, Some(prompt_file)); 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`). /// /// # 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, ) -> 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" ); } let result = continue_reserved(state, name, prompt, model); if result.is_err() { state.release_reservation(name); } result } /// 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, ) -> anyhow::Result { let config = build_config(name, model, None); 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())); let state = Arc::clone(state); let task_name = name.to_owned(); tokio::spawn(async move { let outcome = running.wait(&prompt, &NoopSink).await; state .running .lock() .unwrap_or_else(PoisonError::into_inner) .remove(&task_name); let summary = match outcome { Ok(()) => "turn complete".to_owned(), Err(e) => { tracing::warn!(name = %task_name, error = %e, "subagent: turn failed"); format!("claude error: {e}") } }; push_completion_todo(&state.socket, &task_name, &summary).await; }); Ok(format!("subagent `{name}` started")) } /// Report whether `name` is currently running — a zero-cost check that /// never launches a process, unlike `continue`. Distinguishes four states: /// running, starting (reserved, not yet a confirmed spawn — see `State`'s /// doc), idle (a session exists but nothing is in flight), and no such /// session at all. /// /// # Errors /// /// An invalid name, or no session — running or on disk — under `name`. pub fn status(state: &State, name: &str) -> anyhow::Result { validate_name(name)?; match state.occupancy(name) { Some(true) => return Ok(format!("subagent `{name}` is running")), Some(false) => { return Ok(format!( "subagent `{name}` is starting — not yet confirmed running" )); } None => {} } let config = build_config(name, None, None); let store = build_store(&config)?; if store.find_by_title(name).is_some() { Ok(format!( "subagent `{name}` is idle — its last turn finished; `continue` to give it another" )) } else { anyhow::bail!("no subagent named `{name}` exists — `start` creates one") } } /// 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 completion 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_completion_todo(socket: &std::path::Path, name: &str, summary: &str) { let req = hive_agent_sock::Request::UpsertTodo { subsystem: "subagent".to_owned(), key: Some(name.to_owned()), summary: format!("subagent `{name}` finished: {summary}"), 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: completion todo push failed"); } } #[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" ); } #[test] fn otel_attrs_appends_when_ambient_var_is_set() { // SAFETY: test-only env mutation, single-threaded within this fn's // scope (no other test in this crate touches this var — checked). 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 otel_attrs_stands_alone_when_ambient_var_is_unset() { unsafe { std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES"); } assert_eq!(subagent_otel_attrs("batch-1"), "subagent=batch-1"); } #[test] fn build_config_only_appends_system_prompt_when_given() { let with = build_config("n", None, Some("/tmp/p.md")); 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); assert!( !without .extra_args .contains(&"--append-system-prompt-file".to_owned()) ); } }