hyperhive/hive-subagent-mcp/src/session.rs
atlas 34129d776c subagent: give each run its own signal URL, and drop the name argument
`goal_reached`/`need_help` took the session name as a tool argument, so
identity was an assertion by the caller and the only guard on it was
`occupancy()` — "does that name have a turn in flight", which two
concurrently running siblings both satisfy for each other. A subagent
could stop its sibling's run by naming it.

Identity moves into the URL. Each spawned run is minted an unguessable
token (`Uuid::new_v4`, the OS CSPRNG), the URL carrying it goes into that
one subagent's own `--mcp-config`, and the route resolves it back to a
session before dispatching to a handler bound to that session. Neither
tool takes a `name` any more: a subagent has no field in which to name a
sibling, and a sibling's name — which a brief may well mention — is not a
token.

One route with a path parameter, not a route per session: the `Router` is
built once at startup and subagents come and go for the daemon's whole
life. An unminted or revoked token gets a bare 404, the same answer either
way, so nothing enumerates. A run's token is revoked when the run ends
(`finish_turn`) or when a call never reached a spawn.

Two things fall out of that:

- the config file becomes one per session. A single shared path was
  already a race between two `start`s; with a per-session URL in it, the
  loser would read the winner's identity.
- `occupancy()` stops being the identity guard and is gone from the signal
  path entirely rather than kept "just in case" — a revoked token can't
  reach it, and it never answered the question it was standing in for.
  It still backs `status`, which is what it was always actually for.

Refs #4403
Refs #4413
2026-09-14 22:24:51 +02:00

3200 lines
138 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The claude-facing half of this daemon: spawn a subagent turn, keep giving
//! it turns until it says it's done or runs out of them, track it only while
//! it's alive, and push exactly one todo when the whole run stops — saying
//! whether it finished, was killed, or stopped for one of the four reasons
//! the continuation loop records.
//!
//! **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 the
//! rest of the maps and for what the `None`/`Some` split is for) — all of
//! it living 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 running turn is not necessarily a working turn.** A wedged child
//! satisfies "running" as fully as a busy one, so every line of the child's
//! streams bumps `last_event_at` (`LivenessSink`) and `status` reports its
//! age — the timestamp says the child is alive, never what it said.
//!
//! **`continue` doesn't pre-check the session's existence — it waits for
//! the answer instead.** claude's own `--resume` is the authority, so
//! `continue` holds its tool call open for up to `RESUME_GRACE` and reports
//! a missed resume as its own error, naming the directory searched
//! (`classify_end`). See `docs/tools/subagent.md`.
//! **One `start` can be more than one turn.** With a `goal` set, a turn that
//! ends without a stop signal is followed by another re-prompting the
//! subagent toward it, up to `max_turns` (default five). The loop lives in
//! `spawn_and_track`'s background task, so both tool-call contracts are
//! unchanged and only one todo is pushed, when the *run* stops. No goal
//! means what it always did: one turn, one todo.
//!
//! **A subagent's own stop signals are labels, not gates.** `goal_reached`
//! and `need_help` stop the loop and extend the done message; neither
//! verifies anything. `goal_reached` is self-reported by a subagent that has
//! just been re-prompted with "you have not reached the goal", which is
//! exactly the incentive to claim it — the same failure class as a build
//! report asserting "done, tests pass". Everything here treats it as a claim
//! about the work, never as the work, and says so. See
//! `docs/tools/subagent.md`.
//! **A subagent cannot say who it is.** Neither signal tool takes a session
//! name. Each run is minted an unguessable token at spawn
//! (`State::mint_signal_url`), the URL carrying it is written into that one
//! subagent's own `--mcp-config`, and the route resolves it back to a session
//! before dispatching — so the identity of a signal is a property of the
//! endpoint it arrived on, not a field its sender filled in. An unminted or
//! revoked token is a 404. The `occupancy()` liveness check that used to
//! stand here instead was a guard on an assertion: two siblings running
//! concurrently could each satisfy it for the other's name.
//! **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 std::time::{Duration, Instant};
use hive_claude::{Attach, Cancel, Claude, Config, SessionStore};
use tokio::sync::oneshot;
/// How long a `continue` holds its tool call open waiting to find out
/// whether the resume landed. A cap, not a delay: both real outcomes settle
/// it well inside this, and it is only ever reached by a child that neither
/// speaks nor exits.
///
/// Measured rather than guessed, on this box, running the driver's own
/// invocation (`--print --verbose --output-format stream-json --resume
/// <missing>`) against a session name that matches nothing: 14 runs,
/// 5501087 ms wall from spawn to exit. A healthy turn's first stream event
/// lands at roughly 500 ms, so the two paths finish within ~100 ms of each
/// other and neither waits on this bound. Five seconds is ~4.6× the slowest
/// miss observed — headroom for a loaded box starting node far slower than
/// any of those runs, while still bounding the one case that reaches it.
const RESUME_GRACE: Duration = Duration::from_secs(5);
/// How many turns a goal-continued session gets before the harness stops it
/// itself. Five is the number the feature was specified with, not one tuned
/// here: enough for a bounded batch to converge, short enough that a
/// subagent which has misunderstood its goal can't re-attempt it forever on
/// someone else's budget. `start`'s `max_turns` overrides it per session,
/// which is where a caller that genuinely needs a longer leash says so.
const DEFAULT_MAX_TURNS: u32 = 5;
/// Why a session's turn continuation stopped — recorded per name, reported
/// by `status`, appended to the end-of-turn todo, and written into the
/// session's report file when it has one.
///
/// Only the four ends of the *continuation loop* live here. A turn that was
/// killed or that failed outright never reaches the loop's decision at all:
/// those keep the records they already had (`State::killed`, the todo's own
/// killed wording), and giving them a second home here would have handed
/// `status` two rival answers for one fact.
#[derive(Debug, Clone, PartialEq, Eq)]
enum StopReason {
/// The turn ended and there was no goal to continue toward — the
/// single-turn shape every session had before continuation existed.
Done,
/// The subagent called `goal_reached`. **Self-reported.** It records
/// that the subagent claimed the goal, never that the goal was met.
GoalReached(Option<String>),
/// The subagent called `need_help`: it can't proceed, and says why.
NeedHelp(String),
/// `turns` turns ran and the goal was never reported reached.
TurnCap { turns: u32 },
}
impl StopReason {
/// The one sentence that says why, shared by the todo extension and the
/// report-file line so a parent reading either sees the same words.
///
/// `GoalReached` carries its "self-reported" caveat in the sentence
/// itself rather than leaving it to whichever surface renders it: the
/// caveat is the load-bearing half of that claim, and a surface that
/// forgot to add it would read as verification.
fn sentence(&self) -> String {
match self {
Self::Done => "its turn ended and there was no goal to continue toward".to_owned(),
Self::GoalReached(msg) => {
let said = msg
.as_deref()
.map_or_else(String::new, |m| format!(": {m}"));
format!(
"the subagent reported its goal reached{said} — self-reported, not verified, \
so read what it actually changed before acting on it"
)
}
Self::NeedHelp(msg) => {
format!("the subagent called `need_help` and can't proceed: {msg}")
}
Self::TurnCap { turns } => format!(
"the harness turn limit was reached ({turns} turns) without the goal ever being \
reported reached, so the work stopped where it had got to"
),
}
}
}
/// 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),
}
/// What settled a resumed turn's bounded wait — the one thing `continue`
/// blocks on before it answers.
enum ResumeVerdict {
/// The child emitted a stream event that isn't the turn's own terminal
/// `result` — which a resume that matched nothing never gets as far as
/// emitting. The turn is genuinely running; there is nothing left to
/// wait for.
Underway,
/// The turn ended before that happened. A `Failed` end here is the
/// resume miss `continue` exists to report.
Ended(TurnEnd),
}
/// Write half of [`ResumeVerdict`]'s channel, shared between the turn's sink
/// (which reports `Underway`) and its background task (which reports
/// `Ended`). `Option` because a `oneshot::Sender` is consumed by its single
/// send: whichever side gets there first takes it, and the loser finds
/// nothing left to send on — exactly the "first answer wins" the wait wants.
/// The whole thing is `None` on a `start`, which has no resume to miss.
type VerdictTx = Arc<Mutex<Option<oneshot::Sender<ResumeVerdict>>>>;
/// Report `verdict`, if nothing has been reported yet.
///
/// Returns `true` only when this call both won that race *and* found a
/// receiver still listening — i.e. the verdict genuinely reached a
/// `continue` that is about to act on it. The background task reads that
/// answer to decide whether its end-of-turn todo would be a second
/// notification for something the caller has already been told to its face.
fn settle(tx: Option<&VerdictTx>, verdict: ResumeVerdict) -> bool {
let Some(tx) = tx else { return false };
let taken = tx.lock().unwrap_or_else(PoisonError::into_inner).take();
taken.is_some_and(|tx| tx.send(verdict).is_ok())
}
/// 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.
///
/// `searched` is the resume lookup's location, present only for a turn that
/// attached with [`Attach::Resume`] (see `searched_location`). A missed
/// resume arrives as [`hive_claude::Error::SessionNotFound`], whose message
/// names the *value* that matched nothing but not the *directory* it was
/// looked for in — and "the session lives in another directory" is the way
/// this actually fails in practice, so the caller gets told where the daemon
/// looked rather than left to conclude the session is gone.
fn classify_end(outcome: hive_claude::Result<()>, searched: Option<&str>) -> 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 };
}
if matches!(error, hive_claude::Error::SessionNotFound)
&& let Some(searched) = searched
{
return TurnEnd::Failed(format!("claude error: {error} {searched}"));
}
TurnEnd::Failed(format!("claude error: {error}"))
}
/// Where a `--resume` against `config` would have looked for the session,
/// phrased to append to claude's own not-found message. Both halves come
/// from the same resolution `build_store` and the driver itself use, so this
/// names the directory that was genuinely searched rather than a guess at
/// it. `None` when either half fails to resolve (no `HOME`, a `cwd` that
/// doesn't exist) — an unresolvable location is worse than none, and the
/// underlying error still reaches the caller unadorned.
fn searched_location(config: &Config) -> Option<String> {
let home = config.resolved_claude_home().ok()?;
let cwd = config.resolved_cwd().ok()?;
Some(format!(
"(searched {} for cwd {}; if it was started elsewhere, pass the `dir` it was started in)",
home.display(),
cwd.display()
))
}
/// This daemon's whole state: which names have a live process or a
/// reservation in flight, what each is working toward and how it stopped,
/// and where to push the completion todo. `Arc`-wrapped so the background
/// task driving a run outlives 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.
///
/// `last_event` splits `running` a second way — *in flight* against
/// *making progress*, since a wedged child is tracked exactly like a busy
/// one (`note_event` / `last_event_age`).
///
/// ⚠️ 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>>,
last_event: Mutex<HashMap<String, Instant>>,
/// Turn continuation's three records. Unlike the four maps above they
/// deliberately **outlive the turn** — a stop reason that vanished with
/// the process it described would be unreadable by the time anyone
/// asked — so they're cleared by the next `start` under the same name,
/// not by a turn ending.
goals: Mutex<HashMap<String, GoalState>>,
stops: Mutex<HashMap<String, StopReason>>,
reports: Mutex<HashMap<String, PathBuf>>,
socket: PathBuf,
/// The prefix of every subagent's signal URL — this daemon's own `--http`
/// address with [`crate::mcp::SIGNAL_PATH`] on the end, and *not* a
/// reachable route by itself. It lives here because the daemon can only
/// learn it from its own `--http` argument — deriving it from a
/// convention would be the same inference the report path is careful not
/// to make.
signal_base: String,
signal_tokens: Mutex<SignalTokens>,
}
/// Which opaque URL segment belongs to which session — the whole of a
/// subagent's identity, as far as `goal_reached`/`need_help` are concerned.
///
/// A subagent is told one URL, in its own `--mcp-config`, and that URL is
/// what says who it is: it has no field to name a session in and no second
/// session's URL to reach for. Both directions live under one lock because a
/// half-updated pair is exactly the state in which a token could resolve to
/// a session that has since minted another one.
#[derive(Default)]
struct SignalTokens {
/// The token currently minted for a session, so a re-mint can retire it.
by_name: HashMap<String, String>,
/// The resolution the route does: token -> the session it speaks for.
by_token: HashMap<String, String>,
}
/// What a session is being continued toward, and how far through its turn
/// budget it is. Present only for a session `start`ed with a `goal`; its
/// absence is what makes a session single-turn.
struct GoalState {
/// Verbatim from `start` — re-prompted at the subagent each turn rather
/// than paraphrased, since the caller wrote it for the subagent to read.
goal: String,
/// The cap this session runs under (`start`'s `max_turns`).
max_turns: u32,
/// Turns started so far, counting the first — so the very first turn is
/// `1 of max_turns`, not `0`.
turn: u32,
}
impl State {
/// `signal_base` is where the streamable-http endpoint a subagent's own
/// claude reaches `goal_reached`/`need_help` on *starts* — this daemon's
/// `--http` address with the signal route appended (see
/// `crate::mcp::serve_http`). Each session's actual URL is that plus its
/// own token; see `State::mint_signal_url`.
#[must_use]
pub fn new(socket: PathBuf, signal_base: String) -> Self {
Self {
running: Mutex::new(HashMap::new()),
dirs: Mutex::new(HashMap::new()),
killed: Mutex::new(HashMap::new()),
last_event: Mutex::new(HashMap::new()),
goals: Mutex::new(HashMap::new()),
stops: Mutex::new(HashMap::new()),
reports: Mutex::new(HashMap::new()),
socket,
signal_base,
signal_tokens: Mutex::new(SignalTokens::default()),
}
}
/// Mint `name` a fresh signal URL: an unguessable token appended to
/// [`State::signal_base`], resolvable back to this one session and to no
/// other. Called once per spawned run, and the result goes into exactly
/// one place — that subagent's own `--mcp-config` (`crate::mcp_config`).
///
/// A v4 UUID's 122 bits come from the OS CSPRNG, so the segment is not
/// derived from the name, the port or anything else a sibling subagent
/// could compute; a subagent that wants to signal as somebody else has
/// nothing to guess *from*. Minting replaces any token the name held
/// before, which is what stops a name's old URL surviving the run it was
/// issued for.
pub(crate) fn mint_signal_url(&self, name: &str) -> String {
let token = uuid::Uuid::new_v4().simple().to_string();
let mut tokens = self
.signal_tokens
.lock()
.unwrap_or_else(PoisonError::into_inner);
if let Some(previous) = tokens.by_name.insert(name.to_owned(), token.clone()) {
tokens.by_token.remove(&previous);
}
tokens.by_token.insert(token.clone(), name.to_owned());
drop(tokens);
format!("{}/{token}", self.signal_base)
}
/// Which session an incoming signal request speaks for, or `None` for a
/// token this daemon never minted or has since revoked — which the route
/// answers with a bare 404 (see `crate::mcp::serve_http`). `None` is the
/// only failure shape there is: nothing about the answer distinguishes
/// "never existed" from "that run is over", so a caller holding a wrong
/// token learns nothing from being refused.
pub(crate) fn session_for_signal_token(&self, token: &str) -> Option<String> {
self.signal_tokens
.lock()
.unwrap_or_else(PoisonError::into_inner)
.by_token
.get(token)
.cloned()
}
/// Retire `name`'s signal URL — its run is over (or never started), so
/// the route it was handed stops resolving and answers 404 from here on.
/// This is the expiry half of "unknown or expired token ⇒ 404": without
/// it a finished subagent's config file would still name a live route.
fn revoke_signal_token(&self, name: &str) {
let mut tokens = self
.signal_tokens
.lock()
.unwrap_or_else(PoisonError::into_inner);
if let Some(token) = tokens.by_name.remove(name) {
tokens.by_token.remove(&token);
}
}
/// `Some(true)` — a live process is tracked, interruptible. `Some(false)`
/// — the name is claimed but no process is confirmed under it: an
/// in-flight start/continue that hasn't spawned yet, or the gap between
/// one continued turn's child exiting and the next one spawning (see
/// `between_turns`). `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.
///
/// The signal token the failed call minted goes with it: no process ever
/// read that URL, and a token outliving the call that minted it is the
/// one way a route could resolve to a session that isn't there.
fn release_reservation(&self, name: &str) {
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
let released = matches!(running.get(name), Some(None));
if released {
running.remove(name);
}
drop(running);
// Only alongside a reservation this actually released: a call that
// found a live process left it running, and revoking that run's URL
// would cut off a subagent which is still using it.
if released {
self.revoke_signal_token(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.
///
/// The liveness clock goes with the `running` entry, for the same reason
/// it's kept at all: it answers "is this turn still making progress",
/// and a turn that has ended has no progress left to make.
///
/// So does the session's signal token. Every caller of this is a point
/// where the *run* stops — a continued run's own turn boundary goes
/// through `between_turns` instead, and keeps its URL because the next
/// turn is the same subagent against the same rendered config. Revoking
/// here is what makes a signal for a session that already ended a 404
/// rather than a late stop reason recorded against it.
fn finish_turn(&self, name: &str, end: &TurnEnd) {
self.revoke_signal_token(name);
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);
self.last_event
.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);
}
/// `name`'s turn just produced output — record *when*, and nothing else.
/// Called from `LivenessSink` for every line of every stream, so it runs
/// far more often than anything else in this type and stays a single
/// map write for that reason.
///
/// A monotonic [`Instant`], not a wall clock: what's ever read back out
/// is an elapsed age, which a clock adjustment must not be able to
/// distort into a stall that never happened.
///
/// Also called by `spawn_and_track` the moment each turn's child exists,
/// so the clock starts at the spawn rather than at the first line.
/// Without that seed a subagent that wedged before emitting anything at
/// all would report no age forever — the one case where an age is most
/// worth having. The value therefore reads as "how long since the daemon
/// last heard anything from this child", counting the spawn itself as
/// the first thing it heard. A continued run re-seeds it per turn, for
/// the same reason: the age describes the turn in flight, not the run.
fn note_event(&self, name: &str) {
self.last_event
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(name.to_owned(), Instant::now());
}
/// How long since `name`'s turn last produced output. `None` once the
/// turn is over (`finish_turn` drops the entry) or for a name that never
/// reached a spawn — in both cases there's no *running* turn whose
/// progress the age would describe, and a leftover age from a turn that
/// has already ended would read as a stall that isn't one.
fn last_event_age(&self, name: &str) -> Option<Duration> {
self.last_event
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(name)
.map(Instant::elapsed)
}
/// 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(),
}
}
/// Upgrade `name`'s `None` reservation to a real, interruptible process.
/// Same key as the reservation, so there is no window in which `name`
/// reads as unoccupied between the two.
fn track(&self, name: &str, cancel: Cancel) {
self.running
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(name.to_owned(), Some(cancel));
}
/// Drop `name`'s cancel handle but keep the name claimed — the moment
/// between a continued turn's child exiting and its successor being
/// spawned. Without it the name would read as free mid-loop and a
/// concurrent `start` could take it out from under the continuation;
/// with it, `status` says "starting" and `interrupt` says "still
/// starting, retry shortly", both of which are true of the sub-second
/// gap it covers.
fn between_turns(&self, name: &str) {
self.running
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(name.to_owned(), None);
}
/// Set (or clear) `name`'s goal and start its turn budget over at turn
/// one. Called by `start` only: the goal is a property of the session
/// being created, and a later `continue` re-prompts toward whatever
/// `start` set rather than redefining it.
fn set_goal(&self, name: &str, goal: Option<String>, max_turns: u32) {
let mut goals = self.goals.lock().unwrap_or_else(PoisonError::into_inner);
match goal {
Some(goal) => {
goals.insert(
name.to_owned(),
GoalState {
goal,
max_turns,
turn: 1,
},
);
}
None => {
goals.remove(name);
}
}
}
/// Put `name` back at turn one, keeping whatever goal `start` set. A
/// `continue` is the parent's own deliberate turn, and the cap exists to
/// bound *unattended* continuation — so its allowance starts over rather
/// than a capped session being permanently un-continuable. The parent
/// was always the authority on whether more turns are worth spending.
fn restart_turns(&self, name: &str) {
if let Some(goal) = self
.goals
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_mut(name)
{
goal.turn = 1;
}
}
/// `(turn, max_turns)` for `name`. `None` for a session with no goal,
/// which has no turn budget to be partway through — reporting `1 of 1`
/// there would invent a cap that isn't enforced.
fn turns(&self, name: &str) -> Option<(u32, u32)> {
self.goals
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(name)
.map(|g| (g.turn, g.max_turns))
}
/// Record why `name` stopped. Idempotent by design: the subagent's own
/// signal lands here mid-turn and the loop re-records the same reason at
/// the turn's end, so both paths can write without checking.
fn record_stop(&self, name: &str, stop: StopReason) {
self.stops
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(name.to_owned(), stop);
}
fn stop_reason(&self, name: &str) -> Option<StopReason> {
self.stops
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(name)
.cloned()
}
/// Forget why `name` last stopped — called when a new turn is starting,
/// since the record describes the run before it and would otherwise make
/// a session that has since been given another turn still read as
/// blocked or out of turns.
fn clear_stop(&self, name: &str) {
self.stops
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(name);
}
/// Remember where `name` writes its report, so a stop reason can be
/// appended to the artifact a parent already reads rather than living
/// only in a todo. `None` leaves whatever was remembered alone, so a
/// signal tool that doesn't name a path doesn't erase the one `start`
/// gave.
///
/// Never inferred: `start` carries what the brief named, and the signal
/// tools carry where the subagent says it actually wrote. A daemon that
/// derived this from a path convention would be guessing about someone
/// else's layout.
fn set_report_file(&self, name: &str, path: Option<&str>) {
if let Some(path) = path {
self.reports
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(name.to_owned(), PathBuf::from(path));
}
}
/// Forget `name`'s report path — a fresh `start` under the same name is
/// a different piece of work, and inheriting the last one's artifact
/// path would append its stop reason to a file this run never wrote.
fn clear_report_file(&self, name: &str) {
self.reports
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(name);
}
fn report_file(&self, name: &str) -> Option<PathBuf> {
self.reports
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(name)
.cloned()
}
/// What the continuation loop does after a turn that ran to completion:
/// stop with a reason, or spend another turn re-prompting toward the
/// goal. Advances the turn counter itself, since deciding to continue
/// and consuming a turn are the same act.
///
/// A signal the subagent raised mid-turn is already in `stops` and wins
/// outright — that is what "both stop goal continues" means, and it's
/// checked before the goal so a `goal_reached` on the last allowed turn
/// reads as reached rather than as capped.
fn plan_after_turn(&self, name: &str) -> Continuation {
if let Some(stop) = self.stop_reason(name) {
return Continuation::Stop(stop);
}
let mut goals = self.goals.lock().unwrap_or_else(PoisonError::into_inner);
let Some(state) = goals.get_mut(name) else {
return Continuation::Stop(StopReason::Done);
};
if state.turn >= state.max_turns {
return Continuation::Stop(StopReason::TurnCap { turns: state.turn });
}
state.turn += 1;
Continuation::Continue {
prompt: continuation_prompt(name, &state.goal, state.turn, state.max_turns),
}
}
}
/// `plan_after_turn`'s answer: the loop either stops with a reason to
/// report, or has the next turn's prompt ready to spawn against.
#[derive(Debug, PartialEq, Eq)]
enum Continuation {
Stop(StopReason),
Continue { prompt: String },
}
/// 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. `model` maps straight
/// onto `Config::model` — `--model`, omitted when `None` so claude falls
/// back to its own default. `effort` does not: an omitted `effort` defaults
/// to `"medium"` here rather than falling through to claude's own default
/// (`high` on most models) — a deliberate hive policy for subagent work
/// specifically, not a reflection of Anthropic's own recommendation, on the
/// same "cheaper than you" cost-consciousness the `base:claude-subagents`
/// skill already asks of `model`. `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`), plus this
/// daemon's own two-tool signal surface when `signal_url` is given — nothing
/// implicit and nothing more.
///
/// `signal_url` is what makes `goal_reached`/`need_help` callable at all: a
/// subagent reaches them over the same streamable-http listener its parent
/// uses, on a route that serves those two tools and nothing else, so being
/// able to say "I'm done" never carries the ability to spawn a subagent of
/// its own. It is also *this* session's own URL — minted per run by
/// [`State::mint_signal_url`] and written only into this session's config
/// file — which is where the signal tools get the identity they no longer
/// ask the caller for. `None` — which only `status` passes, building a
/// config purely to resolve the session store — leaves the surface out
/// entirely.
fn build_config(
name: &str,
model: Option<String>,
effort: Option<String>,
prompt_file: Option<&str>,
dir: Option<&str>,
signal_url: 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,
effort: Some(effort.unwrap_or_else(|| "medium".to_owned())),
cwd: dir.map(PathBuf::from),
mcp_config: crate::mcp_config::build(name, signal_url),
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()?,
))
}
/// Everything a `start` needs, as one struct rather than a parameter list:
/// the call already carried six mostly-optional values before goals were
/// added, and nine positional arguments is both unreadable at the call site
/// and a lint.
pub struct StartRequest {
/// Session name — the tracking key and claude's own session title.
pub name: String,
pub model: Option<String>,
pub effort: Option<String>,
/// File holding the subagent's task instructions.
pub prompt_file: String,
/// The first turn's prompt. A goal, when given, is appended to it.
pub trigger: String,
/// Working directory for the session; `None` inherits the daemon's.
pub dir: Option<String>,
/// What this session is being continued *toward*. `None` keeps the
/// pre-continuation shape: one turn, one todo, no re-prompting.
pub goal: Option<String>,
/// Turn cap for the continuation, defaulting to `DEFAULT_MAX_TURNS`.
/// Ignored without a `goal`, which is what continuation continues
/// toward — there is nothing to re-prompt against otherwise.
pub max_turns: Option<u32>,
/// Where this session's brief told it to write its report, so the stop
/// reason can be appended to that artifact. Never inferred — see
/// `State::set_report_file`.
pub report_file: Option<String>,
}
/// 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.
///
/// With a `goal` set this starts a whole *run*, not a single turn: see
/// `spawn_and_track` for the continuation loop. The return is unchanged
/// either way — it reports the first turn's spawn, not the run's outcome.
///
/// # 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>, req: StartRequest) -> anyhow::Result<String> {
let name = req.name.as_str();
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,
// and the same reasoning governs the three records below it.
let dir = state.resolve_dir(name, req.dir.as_deref());
// A fresh start owns none of the previous run's records: its goal, its
// turn budget, why it stopped and where it wrote are all about work this
// call is deliberately replacing.
state.clear_stop(name);
state.clear_report_file(name);
state.set_report_file(name, req.report_file.as_deref());
let max_turns = req.max_turns.unwrap_or(DEFAULT_MAX_TURNS).max(1);
state.set_goal(name, req.goal.clone(), max_turns);
let trigger = match req.goal.as_deref() {
None => req.trigger,
Some(goal) => format!("{}{}", req.trigger, goal_briefing(name, goal, max_turns)),
};
let result = start_reserved(
state,
name,
req.model,
req.effort,
&req.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>,
effort: Option<String>,
prompt_file: &str,
trigger: String,
dir: Option<&str>,
) -> anyhow::Result<String> {
let signal_url = state.mint_signal_url(name);
let config = build_config(
name,
model,
effort,
Some(prompt_file),
dir,
Some(&signal_url),
);
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}"))?;
}
// No verdict channel: a `start` creates its session, so there is no
// resume to miss and nothing for the caller to wait on past the spawn —
// see `spawn_and_track`'s doc.
spawn_and_track(
state,
name,
&config,
&Attach::Create(name.to_owned()),
trigger,
None,
)
}
/// 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 already running (same
/// concurrent-run hazard as `start`).
///
/// A name with **no** session to resume is still not *pre-checked* here:
/// claude's own `--resume` answers that, and the pre-check that used to live
/// here could only repeat the lookup the driver was about to do anyway,
/// while telling the caller a session didn't exist when the true answer was
/// almost always that it exists somewhere else. What changed is that
/// `continue` now waits for the driver's answer instead of returning ahead
/// of it: a missed resume comes back as this call's own `Err`, carrying
/// claude's message and the directory that was searched (`classify_end`),
/// rather than only as an end-of-turn todo the caller had already stopped
/// looking for. A `continue` that reports "started" therefore means the turn
/// started. See `await_resume` for the bound, and `spawn_and_track`'s doc for
/// why `start` keeps the older, unconditional contract.
///
/// 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.
///
/// A goal session re-enters its continuation loop here with a fresh turn
/// budget — the cap bounds *unattended* re-prompting, not the parent's own.
///
/// # Errors
///
/// An invalid name, one already running, `Claude::spawn` failing, or the
/// resumed turn failing within `RESUME_GRACE` — in practice a missed resume.
pub async fn continue_(
state: &Arc<State>,
name: &str,
prompt: String,
model: Option<String>,
effort: 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);
// A new turn supersedes why the last run stopped, and gives a
// goal-continued session its allowance back — see `restart_turns`.
state.clear_stop(name);
state.restart_turns(name);
let (tx, rx) = oneshot::channel();
let verdict: VerdictTx = Arc::new(Mutex::new(Some(tx)));
let started = continue_reserved(state, name, prompt, model, effort, dir.as_deref(), &verdict);
if started.is_err() {
state.release_reservation(name);
return started;
}
// Nothing to release on this path either way: a turn that ended inside
// the grace has already been through `finish_turn`, which clears the
// tracking the spawn put there, and one that a goal carried into another
// turn still owns the name via `between_turns`.
await_resume(rx).await?;
started.map(|msg| note_resumed_after_kill(&msg, killed))
}
/// Hold a `continue` open until its resume is known to have landed — or
/// until [`RESUME_GRACE`] says that waiting any longer costs more than the
/// answer is worth.
///
/// Returns as soon as *either* side of the race reports, so a successful
/// `continue` pays no fixed delay: its first stream event settles the wait
/// at roughly the same moment a miss's exit would have. The timeout is the
/// floor under a child that does neither, and a turn that reaches it is
/// reported as started — which it is, with the end-of-turn todo left to say
/// how it goes.
///
/// The third case is the channel closing with nothing ever sent — both the
/// sink and the background task dropping their `Arc` without calling
/// `settle`, which only a panic in the task can produce. That reads here as
/// "started", the same as `Underway`, and the fail-open is deliberate: the
/// one thing already known is that `Claude::spawn` returned a live pid, so
/// answering "the resume missed" would be a claim about the session that
/// nothing observed. The turn's real end still reaches the caller as a todo.
///
/// # Errors
///
/// The turn's own failure message, verbatim: the caller asked claude to
/// resume a session and claude said why it couldn't, which is a better
/// answer than anything this daemon could paraphrase it into.
async fn await_resume(rx: oneshot::Receiver<ResumeVerdict>) -> anyhow::Result<()> {
match tokio::time::timeout(RESUME_GRACE, rx).await {
Ok(Ok(ResumeVerdict::Ended(TurnEnd::Failed(e)))) => anyhow::bail!("{e}"),
// Everything else is a turn that started: it spoke (`Underway`), or
// it ended on its own terms within the grace — `Complete`, or a
// `Killed` that some concurrent `interrupt` asked for and whose todo
// says so — or it is still going when the grace runs out, or nobody
// ever sent at all, which only a panicked task produces and which
// this deliberately fails open on. See the doc above.
_ => Ok(()),
}
}
/// 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>,
effort: Option<String>,
dir: Option<&str>,
verdict: &VerdictTx,
) -> anyhow::Result<String> {
// A fresh URL for the resumed run, not the one the last run was handed:
// a token is per run, and this is a new one.
let signal_url = state.mint_signal_url(name);
let config = build_config(name, model, effort, None, dir, Some(&signal_url));
// No existence pre-check: claude's own `--resume` is the authority on
// whether the session is there, and it errors rather than quietly
// starting a fresh one. `verdict` is how that answer gets back to the
// caller in time to be its error. See this module's doc.
spawn_and_track(
state,
name,
&config,
&Attach::Resume(name.to_owned()),
prompt,
Some(Arc::clone(verdict)),
)
}
/// Bumps `name`'s liveness clock on every line of the turn's output, and
/// does nothing else with it. Replaces the `NoopSink` this daemon used to
/// run turns against, which discarded the stream wholesale and left `status`
/// unable to tell a working child from a wedged one.
///
/// **All three callbacks, deliberately.** A stderr line or a stdout line
/// that didn't parse as JSON is proof the child is alive every bit as much
/// as a stream-json event is, and the failure that matters here is reporting
/// a live subagent as wedged — so anything the child says counts, and what
/// it said is never read: classifying *what* the subagent is doing is a
/// separate question from whether it's doing anything.
///
/// Sink methods are called synchronously from the driver's stream readers as
/// lines arrive, so the body has to stay cheap — one uncontended map write
/// is, and forwarding to a channel to do the same write elsewhere would cost
/// more than it saved. `settle` adds a second uncontended lock on a
/// `resume`d turn only, and finds an already-emptied slot after the first
/// event — strictly less work than the map write next to it.
///
/// **Liveness counts every callback; "the turn is underway" does not.** A
/// resume that matched nothing is not silent: claude writes the reason to
/// stderr *and* emits a terminal stream-json `result` event before exiting,
/// so treating any callback at all as proof the turn began would report
/// every missed resume as a successful start. The narrowest fact that
/// separates the two is the event's own kind — a `result` is stream-json's
/// end-of-turn marker, so an event that isn't one is a turn still in
/// progress. That's the envelope, not the content: nothing here reads what
/// the subagent said.
struct LivenessSink {
state: Arc<State>,
name: String,
/// Where to report the first non-terminal event, on a `resume` whose
/// caller is still waiting to hear whether it landed. `None` on a
/// `start`, which has nobody waiting.
verdict: Option<VerdictTx>,
}
impl hive_claude::Sink for LivenessSink {
fn on_event(&self, event: &serde_json::Value) {
self.state.note_event(&self.name);
if event.get("type").and_then(serde_json::Value::as_str) != Some("result") {
settle(self.verdict.as_ref(), ResumeVerdict::Underway);
}
}
fn on_stdout_line(&self, _line: &str) {
self.state.note_event(&self.name);
}
fn on_stderr_line(&self, _line: &str) {
self.state.note_event(&self.name);
}
}
/// Spawn the child (synchronous — `Claude::spawn` returns with a real pid
/// the instant the process exists), track it in `running`, and hand the
/// actual turn off to a background task so nobody blocks on the whole turn.
///
/// **A pid is "confirmed running" for a `start`, and only for a `start`.**
/// A `start` creates its session, so the only thing that can go wrong at
/// attach time is the spawn itself, which has already either succeeded or
/// returned here as an error — there is no stronger signal to wait for, and
/// waiting would slow every call down for no reason. A `resume` breaks that
/// reasoning: the session it names may not be there, and claude only says so
/// a fraction of a second *after* the process exists, so for a `continue` a
/// pid is not proof the turn began. That's what `verdict` is for — `Some`
/// only on the resume path, carrying the first real answer back to a caller
/// that is waiting for it (`await_resume`); `None` on a `start`, which keeps
/// the immediate-return contract unchanged.
///
/// **The turn *continuation* loop lives in that background task, and only
/// there.** A session `start`ed with a goal runs turn after turn until
/// something stops it, and every one of those turns is a fresh
/// `Claude::spawn` against `Attach::Resume` — the driver's `wait` consumes
/// its child, so there is no other shape it could take. Keeping the loop
/// behind the same `tokio::spawn` is what leaves both tool-call contracts
/// untouched: `start` still returns at the first spawn, `continue` still
/// returns when its own turn is underway, and neither waits on turns two
/// through five.
///
/// 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,
verdict: Option<VerdictTx>,
) -> anyhow::Result<String> {
let running = Claude::spawn(config, attach)
.map_err(|e| anyhow::anyhow!("starting the subagent process failed: {e}"))?;
state.track(name, 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);
// Start the liveness clock at the spawn, so the age is already an answer
// before the child's first line — see `note_event`.
state.note_event(name);
// Resolved here, on the calling thread, while the config is still to
// hand: only a resume can miss, and only `classify_end` finding a
// `SessionNotFound` ever uses it. Every continuation turn is a resume,
// so this is worth having even when the *first* attach is a `Create`.
let resume_searched = searched_location(config);
let mut searched = if matches!(attach, Attach::Resume(_)) {
resume_searched.clone()
} else {
None
};
let config = config.clone();
let state = Arc::clone(state);
let task_name = name.to_owned();
tokio::spawn(async move {
let mut running = running;
let mut prompt = prompt;
loop {
let sink = LivenessSink {
state: Arc::clone(&state),
name: task_name.clone(),
verdict: verdict.clone(),
};
let end = classify_end(running.wait(&prompt, &sink).await, searched.as_deref());
log_turn_end(&task_name, &end);
// The todo is how a turn's end reaches an agent that is no
// longer looking — so it is pushed for every end *except* the
// one the caller is being handed as a tool-call error right now.
// `settle` saying the verdict was delivered is what makes that
// certain: a `continue` whose grace had already run out gets
// `false` here and its todo, same as before. Only the first turn
// can ever win this — the sender is consumed — which is right,
// since only the first turn is one a caller is still waiting on.
let reported = settle(verdict.as_ref(), ResumeVerdict::Ended(end.clone()));
if !matches!(end, TurnEnd::Complete) {
// A killed or failed turn ends the run, goal or not: there is
// nothing to re-prompt a child that isn't there any more, and
// these two ends already have records of their own.
state.finish_turn(&task_name, &end);
if !(matches!(end, TurnEnd::Failed(_)) && reported) {
push_turn_end_todo(&state.socket, &task_name, &end, None).await;
}
return;
}
match state.plan_after_turn(&task_name) {
Continuation::Stop(stop) => {
state.record_stop(&task_name, stop.clone());
state.finish_turn(&task_name, &end);
write_stop_to_report(state.report_file(&task_name), &task_name, &stop).await;
push_turn_end_todo(&state.socket, &task_name, &end, Some(&stop)).await;
return;
}
Continuation::Continue { prompt: next } => {
// The name stays claimed across the gap — see
// `between_turns` for what a concurrent `start` would
// otherwise be able to do with it.
state.between_turns(&task_name);
match Claude::spawn(&config, &Attach::Resume(task_name.clone())) {
Ok(next_running) => {
state.track(&task_name, next_running.cancel_handle());
state.note_event(&task_name);
running = next_running;
prompt = next;
searched = resume_searched.clone();
}
Err(e) => {
let end = TurnEnd::Failed(format!(
"claude error: starting the next goal turn failed: {e}"
));
log_turn_end(&task_name, &end);
state.finish_turn(&task_name, &end);
push_turn_end_todo(&state.socket, &task_name, &end, None).await;
return;
}
}
}
}
}
});
Ok(format!("subagent `{name}` started"))
}
/// Log a turn's end at the level its severity deserves: a completion is
/// unremarkable, the other two are not.
fn log_turn_end(name: &str, end: &TurnEnd) {
match end {
TurnEnd::Complete => {}
TurnEnd::Killed { signal } => {
tracing::warn!(
name = %name,
signal,
"subagent: turn killed — the child died on a signal, it did not finish"
);
}
TurnEnd::Failed(e) => {
tracing::warn!(name = %name, error = %e, "subagent: turn failed");
}
}
}
/// Append the stop reason to the session's own report file, so the artifact
/// a parent already reads is where the run's ending is recorded too — rather
/// than the parent having to correlate a todo against a file.
///
/// Best-effort and appended, never rewritten: the subagent wrote that file,
/// and this adds a line under what it wrote instead of taking a position on
/// the rest of it. A path this daemon can't write to is logged and dropped —
/// the todo still carries the same sentence, so nothing is only here.
async fn write_stop_to_report(path: Option<PathBuf>, name: &str, stop: &StopReason) {
let Some(path) = path else { return };
let line = format!("\n**Subagent `{name}` stopped:** {}\n", stop.sentence());
let appended = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await;
let result = match appended {
Ok(mut file) => {
use tokio::io::AsyncWriteExt as _;
// `flush`, not just `write_all`: a `tokio::fs::File` buffers, and
// dropping one discards whatever hasn't been handed to the
// blocking pool — so without this the line is written to nothing
// and the failure is silent. A unit test caught exactly that.
match file.write_all(line.as_bytes()).await {
Ok(()) => file.flush().await,
Err(e) => Err(e),
}
}
Err(e) => Err(e),
};
if let Err(e) = result {
tracing::warn!(
name,
path = %path.display(),
error = ?e,
"subagent: could not record the stop reason in the session's report file",
);
}
}
/// The goal contract appended to a `start`'s first prompt when a goal was
/// given. Spelled out to the subagent rather than left implicit: it is about
/// to be re-prompted by something it can't see, and the two tools that stop
/// that are the only way it has to say "done" or "stuck".
fn goal_briefing(name: &str, goal: &str, max_turns: u32) -> String {
format!(
"\n\nYou are the subagent session `{name}`.\n\nYour goal for this session: {goal}\n\nYou \
have up to {max_turns} turns to reach it. When a turn of yours ends and you haven't \
reported the goal reached, the harness starts another turn re-prompting you toward it. \
Call the `goal_reached` tool once you've genuinely reached it, or `need_help` with what \
is blocking you if you can't proceed — either one stops the re-prompting. Neither takes \
a session name: the endpoint you call them on is yours alone, so they always apply to \
this session and can't be aimed at another one. Running out of turns stops it too, with \
the work left wherever it had got to."
)
}
/// The prompt a continuation turn opens with. It states the one fact the
/// subagent can't observe for itself — that its last turn ended without the
/// goal being reported reached — and says plainly that claiming the goal
/// isn't the same as reaching it, since being re-prompted is precisely the
/// pressure to claim it.
fn continuation_prompt(name: &str, goal: &str, turn: u32, max_turns: u32) -> String {
format!(
"Your previous turn ended and you have not reported the goal reached.\n\nYou are the \
subagent session `{name}`.\n\nGoal: {goal}\n\nThis is turn {turn} of {max_turns}. Carry \
on toward the goal. If you have in fact reached it, call `goal_reached`; if you are \
blocked, call `need_help` with what is blocking you. Neither is a substitute for \
the work: whoever spawned you reads what you actually changed, not what you claim about \
it."
)
}
/// Record the subagent's own "I have reached the goal" signal and stop its
/// turn continuation. Called by the subagent, from inside its own turn, over
/// the signal route this daemon hands it (see `build_config`).
///
/// **`name` is not a parameter of the tool.** It is whatever the route's
/// token resolved to (`State::session_for_signal_token`), so a subagent
/// records a stop against its own session because that is the only session
/// its URL can reach — not because it addressed the right one.
///
/// **This verifies nothing**, and the answer it returns says so to the
/// subagent's face. It stops the loop and extends the done message; whether
/// the goal was actually reached is a question about the diff and the gate
/// output, which the parent reads for itself.
///
/// `report_file` is the subagent saying where it wrote its report, which is
/// the only reason this daemon ever knows that path — see
/// `State::set_report_file`.
pub fn goal_reached(
state: &State,
name: &str,
msg: Option<String>,
report_file: Option<&str>,
) -> String {
signal_stop(state, name, StopReason::GoalReached(msg), report_file);
format!(
"noted — `{name}`'s goal is recorded as reported reached, so this turn finishes and no \
further goal turn is started. It is recorded as your claim, not as verification: whoever \
spawned you still reads what you changed."
)
}
/// Record that the subagent can't proceed, and stop its turn continuation.
/// The blocking signal the run has otherwise no way to raise: without it a
/// stuck subagent would be re-prompted toward a goal it has already told
/// nobody it can't reach, until the turn cap.
///
/// `msg` is required, unlike `goal_reached`'s — "I'm stuck" with no reason
/// gives the parent nothing to act on, and acting on it is the entire point.
///
/// `name` comes from the route's token, exactly as in [`goal_reached`].
pub fn need_help(state: &State, name: &str, msg: String, report_file: Option<&str>) -> String {
signal_stop(state, name, StopReason::NeedHelp(msg), report_file);
format!(
"noted — `{name}` is recorded as blocked, so this turn finishes and no further goal turn \
is started. Write down what you have done so far where your brief told you to; whoever \
spawned you sees the block in `status` and in this run's todo."
)
}
/// The half [`goal_reached`] and [`need_help`] share: remember where the
/// subagent says it wrote, and record the stop.
///
/// It checks nothing, and has nothing left to check. `name` reached it by
/// being resolved from the route's own token, so "is this signal about the
/// session it claims" is answered before the request is dispatched at all —
/// a token that names no live session never reaches this function, it gets a
/// 404. What used to stand here was an `occupancy()` liveness check standing
/// in for identity, which two concurrently-running siblings could each
/// satisfy for the other's name.
fn signal_stop(state: &State, name: &str, stop: StopReason, report_file: Option<&str>) {
state.set_report_file(name, report_file);
state.record_stop(name, stop);
}
/// Report whether `name` is currently running — a zero-cost check that
/// never launches a process, unlike `continue`. Distinguishes running,
/// starting (reserved, not yet a confirmed spawn — see `State`'s doc),
/// killed (its last turn died on a signal), each of the four ways a goal
/// run stops, idle (a session exists, its last turn finished, nothing is in
/// flight), and no such session at all.
///
/// A *running* answer also carries how long since that turn last produced
/// output, which is the part of this answer a caller can act on: "running"
/// describes a wedged child and a busy one identically, and the age
/// separates them (see `State`'s `last_event` doc).
///
/// A goal-continued session carries `turn N of M` alongside that, in every
/// state. With the age, it is what lets a caller tell *working* from
/// *wedged* from *out of turns* off one answer, without reaching for `ps`
/// or reading any file.
///
/// # Errors
///
/// An invalid name, or no session — running, killed, stopped 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 facts = StatusFacts {
occupancy: state.occupancy(name),
killed: state.killed_by(name),
last_event_age: state.last_event_age(name),
turns: state.turns(name),
stop: state.stop_reason(name),
..StatusFacts::new(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. A recorded stop reason counts: it is
// proof this daemon ran the session, which is what the lookup asks.
let session_exists =
if facts.occupancy.is_none() && facts.killed.is_none() && facts.stop.is_none() {
// No signal surface in this config: it exists only to resolve the
// session store, and rendering a subagent's MCP config for a
// read-only status check would be writing a file for nobody.
let config = build_config(name, None, None, None, dir.as_deref(), None);
build_store(&config)?.find_by_title(name).is_some()
} else {
false
};
describe_status(&StatusFacts {
session_exists,
..facts
})
}
/// The liveness sentence appended to a *running* answer, and the whole point
/// of recording the timestamp: an age in seconds a caller can read a verdict
/// off directly, rather than one it has to go and measure itself with `ps`
/// and CPU-time deltas.
///
/// Empty when there's no age to report — a turn that ended between `status`
/// reading `running` and reading the clock. Saying nothing is right there:
/// the alternative is an age that describes a turn which has already
/// finished, which reads as a stall that never happened.
fn describe_liveness(age: Option<Duration>) -> String {
match age {
None => String::new(),
Some(age) => format!(
" Last event {}s ago — how long since this turn's claude process produced any output \
at all, whatever it was: a few seconds means it's working, an age that keeps \
climbing into the minutes means it's wedged. It resets at each turn's spawn, so on a \
goal run it describes the turn in flight, not the run.",
age.as_secs()
),
}
}
/// The progress sentence a goal-continued session carries in every state:
/// which turn of its budget it is on. Empty for a session with no goal,
/// which has no budget to be partway through.
///
/// On a *running* answer this is the turn in flight; on a stopped one it is
/// the turn it stopped on — the same number either way, since the counter
/// only advances when the loop decides to spend another turn.
fn describe_turns(turns: Option<(u32, u32)>) -> String {
match turns {
None => String::new(),
Some((turn, max_turns)) => format!(" Turn {turn} of {max_turns}."),
}
}
/// Everything `status` gathers, as one value — seven separate parameters
/// read as noise at both the call site and the test sites, and every one of
/// them is a fact about the same session.
struct StatusFacts<'a> {
name: &'a str,
occupancy: Option<bool>,
killed: Option<i32>,
session_exists: bool,
last_event_age: Option<Duration>,
turns: Option<(u32, u32)>,
stop: Option<StopReason>,
}
impl<'a> StatusFacts<'a> {
/// The nothing-known baseline for `name`: no process, no records, no
/// session. Both the real gathering in `status` and the tests build on
/// it, so neither has to spell out the fields it isn't exercising.
fn new(name: &'a str) -> Self {
Self {
name,
occupancy: None,
killed: None,
session_exists: false,
last_event_age: None,
turns: None,
stop: None,
}
}
}
/// Render `status`'s answer from the facts it gathers. Split out from the
/// gathering so the killed-versus-idle distinction — and the liveness age,
/// the turn counter and each stop reason — are exercisable without a real
/// spawn, a real signal, a real stream of events and a real on-disk claude
/// session.
///
/// A recorded kill outranks both the stop reason and the on-disk session:
/// the session file exists either way and a stop reason may be left over
/// from the signal a subagent raised just before something killed it, so
/// neither can tell a killed turn from a finished one. The kill can.
///
/// 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(facts: &StatusFacts<'_>) -> anyhow::Result<String> {
let name = facts.name;
let turns = describe_turns(facts.turns);
match facts.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 run ends, or \
`interrupt` it if you want it stopped early.{turns}{}{}",
describe_liveness(facts.last_event_age),
describe_pending_signal(facts.stop.as_ref()),
));
}
Some(false) => {
return Ok(format!(
"subagent `{name}` is starting — the name is claimed but no process is confirmed \
under it yet, either because a `start`/`continue` hasn't spawned one or because \
a goal run is between turns. Normally over in well under a second: check again \
shortly rather than starting anything else under this name.{turns}"
));
}
None => {}
}
if let Some(signal) = facts.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.{turns}",
describe_signal(signal)
));
}
if let Some(stop) = &facts.stop {
return Ok(describe_stopped(name, stop, &turns));
}
if facts.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.{turns}"
))
} 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)."
)
}
}
/// The note a *running* answer carries when the subagent has already raised
/// a stop signal for the turn still in flight. Without it a parent polling
/// `status` would read plain "running" for the whole stretch between the
/// subagent saying it is blocked and its turn actually ending — the one
/// stretch where "it's working, leave it alone" is the wrong conclusion.
fn describe_pending_signal(stop: Option<&StopReason>) -> String {
match stop {
None | Some(StopReason::Done | StopReason::TurnCap { .. }) => String::new(),
Some(stop) => format!(
" It has already signalled how this run ends — {} — so this is its last turn.",
stop.sentence()
),
}
}
/// The answer for a session whose run has stopped, one per [`StopReason`].
/// Each names the state, why the continuation stopped, and what `continue`
/// would do about it — and the `GoalReached` one is deliberately the least
/// reassuring of the four, because it is the one a caller is most likely to
/// read as "finished successfully" when it means "said so".
fn describe_stopped(name: &str, stop: &StopReason, turns: &str) -> String {
match stop {
StopReason::Done => 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.{turns}"
),
StopReason::GoalReached(msg) => format!(
"subagent `{name}` stopped: it reported its goal reached{}. Nothing is in flight and \
no further goal turn will start.{turns} That report is the subagent's own claim, not \
a verification of anything — read the diff and whatever gate the work was supposed \
to pass before you treat the goal as met, exactly as you would a build report saying \
the tests passed.",
msg.as_deref()
.map_or_else(String::new, |m| format!(": {m}"))
),
StopReason::NeedHelp(msg) => format!(
"subagent `{name}` is BLOCKED and needs help: {msg}. It called `need_help`, which \
stopped its goal continuation, and nothing is in flight — it stays blocked until you \
answer it.{turns} `continue` is how you answer: give it what it asked for as the \
next turn's prompt."
),
StopReason::TurnCap { turns: spent } => format!(
"subagent `{name}` ran out of turns — the harness limit of {spent} was reached and it \
never reported its goal reached, so the work stopped wherever it had got to rather \
than finishing.{turns} Check what it actually left behind; `continue` gives it a \
fresh allowance if carrying on is worth it."
),
}
}
/// 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 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).
///
/// This stops a goal run, not just the turn in it: the signalled child ends
/// as `TurnEnd::Killed`, which the continuation loop treats as the end of
/// the whole run rather than something to re-prompt past. That falls out of
/// there being no child left to continue, and it is the answer you want —
/// `interrupt` would be useless if the harness immediately started turn
/// three of five.
///
/// # Errors
///
/// An invalid name, nothing tracked under `name`, or `name` has no confirmed
/// process right now (a spawn in flight, or a goal run between turns).
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,
stop: Option<&StopReason>,
) {
let req = hive_agent_sock::Request::UpsertTodo {
subsystem: "subagent".to_owned(),
key: Some(name.to_owned()),
summary: turn_end_summary(name, end, stop),
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 run 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.
///
/// A `stop` **extends** that message, it never replaces it. Both halves are
/// load-bearing and neither substitutes for the other: the turn's own end is
/// what the daemon observed, the stop reason is why the run went no further
/// — and for `GoalReached` that second half is a claim, which would read as
/// a verdict if it were allowed to stand where the observed end belongs.
/// `StopReason::Done` adds nothing, since "there was no goal" is exactly
/// what the unextended message already describes.
fn turn_end_summary(name: &str, end: &TurnEnd, stop: Option<&StopReason>) -> String {
let base = 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)
),
};
match stop {
None | Some(StopReason::Done) => base,
Some(stop) => format!("{base} — and the run stopped there: {}", stop.sentence()),
}
}
#[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.
/// A stand-in for the signal route a real daemon would hand its
/// subagents — the *prefix*, as `State::new` takes it. Nothing in these
/// tests dials it: what `State` does with it is append a minted token and
/// carry the result into `build_config`, both asserted on directly.
fn signal_url() -> String {
"http://127.0.0.1:1/signal/mcp".to_owned()
}
/// The token out of a minted URL — what the route would have parsed out
/// of the path before resolving it.
fn token_of(url: &str) -> String {
url.rsplit('/')
.next()
.expect("a minted URL always has a last segment")
.to_owned()
}
/// A `StartRequest` with only the fields a test cares about set — the
/// other six are the same "nothing asked for" every time.
fn start_request(name: &str) -> StartRequest {
StartRequest {
name: name.to_owned(),
model: None,
effort: None,
prompt_file: "/tmp/prompt.md".to_owned(),
trigger: "trigger".to_owned(),
dir: None,
goal: None,
max_turns: None,
report_file: None,
}
}
#[test]
fn reserve_is_exclusive_for_the_same_name() {
let state = State::new(PathBuf::from("/dev/null"), signal_url());
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"), signal_url());
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"), signal_url());
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"), signal_url());
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, None, Some("/tmp/p.md"), None, 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, 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"), signal_url());
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"), signal_url());
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"), signal_url());
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"), signal_url()));
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,
StartRequest {
dir: Some("/tmp/rejected".to_owned()),
..start_request("dup")
},
);
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(()), None), TurnEnd::Complete);
assert_eq!(
classify_end(Err(signalled_exit(libc::SIGKILL)), None),
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)), None),
TurnEnd::Killed { signal: 15 }
);
assert!(
matches!(
classify_end(Err(hive_claude::Error::PromptTooLong), None),
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(&StatusFacts {
killed: Some(libc::SIGKILL),
session_exists: true,
..StatusFacts::new("n")
})
.expect("killed status");
let idle = describe_status(&StatusFacts {
session_exists: true,
..StatusFacts::new("n")
})
.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(&StatusFacts {
occupancy: Some(true),
..StatusFacts::new("n")
})
.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(&StatusFacts {
occupancy: Some(false),
..StatusFacts::new("n")
})
.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(&StatusFacts {
session_exists: true,
..StatusFacts::new("n")
})
.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(&StatusFacts {
killed: Some(libc::SIGKILL),
session_exists: true,
..StatusFacts::new("n")
})
.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(&StatusFacts::new("n"))
.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"), signal_url());
for (name, outcome) in [
("gone", Err(signalled_exit(libc::SIGKILL))),
("done", Ok(())),
] {
assert!(state.reserve(name));
state.finish_turn(name, &classify_end(outcome, None));
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(&StatusFacts {
killed: state.killed_by("gone"),
session_exists: true,
..StatusFacts::new("gone")
})
.expect("status");
let done = describe_status(&StatusFacts {
killed: state.killed_by("done"),
session_exists: true,
..StatusFacts::new("done")
})
.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"), signal_url());
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, None);
let killed = turn_end_summary("n", &TurnEnd::Killed { signal: 9 }, None);
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, None, Some("/tmp/some-worktree"), None);
assert_eq!(with.cwd, Some(PathBuf::from("/tmp/some-worktree")));
let without = build_config("n", None, None, None, None, None);
assert_eq!(without.cwd, None);
}
#[test]
fn build_config_sets_effort_alongside_model() {
let config = build_config(
"n",
Some("opus".to_owned()),
Some("high".to_owned()),
None,
None,
None,
);
assert_eq!(config.model, Some("opus".to_owned()));
assert_eq!(config.effort, Some("high".to_owned()));
}
#[test]
fn an_event_starts_the_liveness_clock_and_the_turn_ending_stops_it() {
let state = State::new(PathBuf::from("/dev/null"), signal_url());
assert_eq!(
state.last_event_age("n"),
None,
"a name that never spawned has no clock to read"
);
state.note_event("n");
assert!(
state.last_event_age("n").is_some(),
"the first event must give `status` an age to report"
);
state.finish_turn("n", &TurnEnd::Complete);
assert_eq!(
state.last_event_age("n"),
None,
"the age describes a turn in flight — a finished turn's leftover age would read as a \
stall that never happened"
);
}
#[test]
fn a_later_event_resets_the_age_rather_than_letting_it_climb() {
// The distinction the whole record exists for: a child still
// producing output must not accumulate the age of a wedged one.
let state = State::new(PathBuf::from("/dev/null"), signal_url());
state.note_event("n");
std::thread::sleep(Duration::from_millis(20));
let before = state.last_event_age("n").expect("clock started");
state.note_event("n");
let after = state.last_event_age("n").expect("clock still running");
assert!(
after < before,
"a fresh event must reset the age, not extend it: {after:?} vs {before:?}"
);
}
#[test]
fn the_liveness_clock_does_not_cross_names() {
let state = State::new(PathBuf::from("/dev/null"), signal_url());
state.note_event("a");
assert_eq!(
state.last_event_age("b"),
None,
"one subagent's output says nothing about another's"
);
}
#[test]
fn every_sink_callback_counts_as_liveness() {
// All three, deliberately: a stderr line or a non-JSON stdout line is
// proof the child is alive exactly as much as a stream-json event,
// and reporting a live subagent as wedged is the failure that costs.
use hive_claude::Sink as _;
fn fresh() -> (Arc<State>, LivenessSink) {
let state = Arc::new(State::new(PathBuf::from("/dev/null"), signal_url()));
let sink = LivenessSink {
state: Arc::clone(&state),
name: "n".to_owned(),
verdict: None,
};
(state, sink)
}
let (state, sink) = fresh();
sink.on_event(&serde_json::json!({"type": "system"}));
assert!(
state.last_event_age("n").is_some(),
"a stream-json event is liveness"
);
let (state, sink) = fresh();
sink.on_stdout_line("not json");
assert!(
state.last_event_age("n").is_some(),
"so is a stdout line that didn't parse as JSON"
);
let (state, sink) = fresh();
sink.on_stderr_line("some chatter");
assert!(
state.last_event_age("n").is_some(),
"so is a stderr line — anything the child says at all counts"
);
}
#[test]
fn a_running_status_reports_the_age_and_an_idle_one_does_not() {
let running = describe_status(&StatusFacts {
occupancy: Some(true),
last_event_age: Some(Duration::from_secs(4)),
..StatusFacts::new("n")
})
.expect("running status");
assert!(
running.contains("Last event 4s ago"),
"a running answer must carry the age — the one part of it a caller can act on: \
{running}"
);
let wedged = describe_status(&StatusFacts {
occupancy: Some(true),
last_event_age: Some(Duration::from_mins(15)),
..StatusFacts::new("n")
})
.expect("running status");
assert!(
wedged.contains("Last event 900s ago"),
"the wedged case is the one this exists for: {wedged}"
);
assert_ne!(
running, wedged,
"4s-ago and 900s-ago must not render identically — collapsing them back together is \
the bug this reports"
);
let idle = describe_status(&StatusFacts {
session_exists: true,
..StatusFacts::new("n")
})
.expect("idle status");
assert!(
!idle.contains("Last event"),
"an idle session has no in-flight turn whose progress an age would describe: {idle}"
);
}
#[test]
fn a_running_status_still_answers_when_the_turn_ended_mid_call() {
// `status` reads `running` and the clock under separate locks, so a
// turn can finish between the two. The answer drops the age rather
// than inventing one.
let raced = describe_status(&StatusFacts {
occupancy: Some(true),
..StatusFacts::new("n")
})
.expect("running status");
assert!(
raced.contains("is running") && !raced.contains("Last event"),
"a missing age must cost the sentence, not the answer: {raced}"
);
}
#[test]
fn a_missed_resume_names_the_directory_that_was_searched() {
// The failure that cost an afternoon: the session existed, just not
// where this daemon looked. claude's own message names the value it
// failed to match but never the directory, which is the fact that
// was missing.
let searched = "(searched /home/agent/.claude for cwd /home/agent/work; if it was started \
elsewhere, pass the `dir` it was started in)";
let end = classify_end(Err(hive_claude::Error::SessionNotFound), Some(searched));
let TurnEnd::Failed(msg) = end else {
panic!("a missed resume is a failed turn");
};
assert!(
msg.contains("no session matched"),
"claude's own diagnosis must survive: {msg}"
);
assert!(
msg.contains("/home/agent/.claude") && msg.contains("/home/agent/work"),
"and the search location must be appended to it: {msg}"
);
assert!(
msg.contains("pass the `dir` it was started in"),
"naming the directory is only half of it — say what to do about it: {msg}"
);
}
#[test]
fn only_a_missed_resume_gets_the_search_location() {
// A `start` never passes one (nothing to resume), and an unrelated
// failure must not be decorated with a location that had no part in
// it.
let no_hint = classify_end(Err(hive_claude::Error::SessionNotFound), None);
assert_eq!(
no_hint,
TurnEnd::Failed(
"claude error: no session matched the requested id or title".to_owned()
),
"with no resolvable location the underlying error still reaches the caller, plain"
);
let unrelated = classify_end(Err(hive_claude::Error::PromptTooLong), Some("(searched …)"));
let TurnEnd::Failed(msg) = unrelated else {
panic!("an overflowed context is a failed turn");
};
assert!(
!msg.contains("searched"),
"a context overflow has nothing to do with where the session lives: {msg}"
);
}
#[test]
fn searched_location_names_the_same_place_the_store_would_look() {
// The point of reading both halves off the `Config` rather than
// re-deriving them: the location reported must be the one actually
// searched, so it can't drift from `build_store`/the driver.
let cwd = std::env::current_dir().expect("a cwd");
let config = build_config("n", None, None, None, Some(&cwd.to_string_lossy()), None);
let Some(located) = searched_location(&config) else {
// No `HOME` in this environment — nothing to compare against.
return;
};
let home = config.resolved_claude_home().expect("home resolved");
let resolved_cwd = config.resolved_cwd().expect("cwd resolved");
assert!(
located.contains(&home.display().to_string())
&& located.contains(&resolved_cwd.display().to_string()),
"both halves must come from the config's own resolution: {located}"
);
}
/// The `Failed` end a real missed resume produces, message and all —
/// `classify_end`'s own output for the error the driver raises when
/// `--resume` matches nothing.
fn missed_resume() -> TurnEnd {
classify_end(
Err(hive_claude::Error::SessionNotFound),
Some(
"(searched /home/agent/.claude for cwd /home/agent/work; if it was started \
elsewhere, pass the `dir` it was started in)",
),
)
}
#[tokio::test]
async fn a_continue_whose_resume_missed_is_an_error_not_a_started_message() {
// The whole point of the bounded wait: a `continue` naming a session
// that isn't there must fail the tool call, not answer "started" and
// leave the real answer to a todo nobody is waiting on any more.
let (tx, rx) = oneshot::channel();
tx.send(ResumeVerdict::Ended(missed_resume()))
.map_err(|_| ())
.expect("the waiter is still listening");
let err = await_resume(rx)
.await
.expect_err("a missed resume must reach the caller as an error");
let msg = err.to_string();
assert!(
msg.contains("no session matched"),
"claude's own diagnosis is the error: {msg}"
);
assert!(
msg.contains("pass the `dir` it was started in"),
"and the searched location travels with it: {msg}"
);
}
#[tokio::test(start_paused = true)]
async fn a_turn_that_got_underway_answers_without_waiting_out_the_grace() {
// A successful `continue` must not pay a fixed delay — it returns on
// the first sign of the turn, not on the bound. Paused time makes
// that measurable: the clock only advances if something awaits it.
let (tx, rx) = oneshot::channel();
tx.send(ResumeVerdict::Underway)
.map_err(|_| ())
.expect("the waiter is still listening");
let started = tokio::time::Instant::now();
await_resume(rx)
.await
.expect("an underway turn is not an error");
assert!(
started.elapsed() < RESUME_GRACE,
"a turn that spoke must settle the wait immediately, not on the bound: {:?}",
started.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn a_turn_that_neither_speaks_nor_exits_is_reported_as_started() {
// The bound is a floor under a child that does nothing at all. The
// sender is held open for the whole wait, so only the timeout can
// end it.
let (tx, rx) = oneshot::channel::<ResumeVerdict>();
let started = tokio::time::Instant::now();
await_resume(rx)
.await
.expect("a silent child is not a failed resume");
assert!(
started.elapsed() >= RESUME_GRACE,
"the wait must actually run to the bound: {:?}",
started.elapsed()
);
drop(tx);
}
#[tokio::test]
async fn an_early_end_that_is_not_a_failure_still_reads_as_started() {
// A turn that completed, or that a concurrent `interrupt` killed,
// inside the grace: both ended a turn that genuinely began, and both
// have a todo of their own to explain themselves.
for end in [TurnEnd::Complete, TurnEnd::Killed { signal: 9 }] {
let (tx, rx) = oneshot::channel();
tx.send(ResumeVerdict::Ended(end.clone()))
.map_err(|_| ())
.expect("the waiter is still listening");
assert!(
await_resume(rx).await.is_ok(),
"{end:?} ended a turn that started — only a failure is the caller's error"
);
}
}
#[test]
fn only_the_first_verdict_is_reported_and_only_to_a_live_caller() {
// `settle`'s two jobs: the sink and the background task race for one
// sender, and the winner's `true` is what tells the task the caller
// has already been handed the failure.
let (tx, rx) = oneshot::channel();
let verdict: VerdictTx = Arc::new(Mutex::new(Some(tx)));
assert!(
settle(Some(&verdict), ResumeVerdict::Underway),
"the first report reaches a listening caller"
);
assert!(
!settle(Some(&verdict), ResumeVerdict::Ended(missed_resume())),
"the loser of the race has nothing left to send on — and so must still push its todo"
);
drop(rx);
let (tx, rx) = oneshot::channel();
let verdict: VerdictTx = Arc::new(Mutex::new(Some(tx)));
drop(rx);
assert!(
!settle(Some(&verdict), ResumeVerdict::Ended(missed_resume())),
"a caller whose grace already expired is not listening, so its todo must still be \
pushed"
);
assert!(
!settle(None, ResumeVerdict::Underway),
"a `start` has no verdict channel and nobody waiting on one"
);
}
#[test]
fn a_terminal_result_event_is_liveness_but_not_proof_the_turn_began() {
// Measured, and the reason the underway signal reads the event's
// kind at all: a resume that matched nothing still emits a
// stream-json `result` event (and stderr chatter) before exiting, so
// treating any callback as "underway" would report every missed
// resume as a successful start.
use hive_claude::Sink as _;
fn fresh() -> (oneshot::Receiver<ResumeVerdict>, LivenessSink) {
let (tx, rx) = oneshot::channel();
let sink = LivenessSink {
state: Arc::new(State::new(PathBuf::from("/dev/null"), signal_url())),
name: "n".to_owned(),
verdict: Some(Arc::new(Mutex::new(Some(tx)))),
};
(rx, sink)
}
let (mut rx, sink) = fresh();
sink.on_event(&serde_json::json!({"type": "result", "is_error": true}));
assert!(
rx.try_recv().is_err(),
"the turn's own end-of-turn marker must not settle the wait as underway"
);
assert!(
sink.state.last_event_age("n").is_some(),
"it is still liveness — the child did say something"
);
let (mut rx, sink) = fresh();
sink.on_stderr_line("Error: --resume requires a valid session ID or session title");
assert!(
rx.try_recv().is_err(),
"nor does stderr, which a missed resume writes to before it exits"
);
let (mut rx, sink) = fresh();
sink.on_event(&serde_json::json!({"type": "system", "subtype": "init"}));
assert!(
matches!(rx.try_recv(), Ok(ResumeVerdict::Underway)),
"a non-terminal event is a turn in progress, which is the signal continue waits for"
);
}
#[test]
fn build_config_defaults_effort_to_medium_when_omitted() {
// Unlike `model`, an omitted `effort` does not fall through to
// claude's own default (`high` on most models) — this daemon picks
// `medium` itself, a deliberate cost-conscious choice for subagent
// work.
let config = build_config("n", None, None, None, None, None);
assert_eq!(config.effort, Some("medium".to_owned()));
}
// ---- turn continuation -------------------------------------------------
/// A state with `name` mid-run against `goal`, as `start` would have left
/// it: reserved, goal registered, on turn one of `max_turns`.
fn mid_run(name: &str, goal: &str, max_turns: u32) -> State {
let state = State::new(PathBuf::from("/dev/null"), signal_url());
state.reserve(name);
state.set_goal(name, Some(goal.to_owned()), max_turns);
state
}
#[test]
fn a_session_with_no_goal_stops_after_one_turn() {
// The pre-continuation shape, and the reason `goal` is what switches
// the loop on rather than a separate flag: nothing to continue
// toward is the same fact as nothing to continue.
let state = mid_run("n", "unused", 5);
state.set_goal("n", None, 5);
assert!(
matches!(
state.plan_after_turn("n"),
Continuation::Stop(StopReason::Done)
),
"without a goal the first completed turn ends the run"
);
assert_eq!(
state.turns("n"),
None,
"and there is no turn budget to report being partway through"
);
}
#[test]
fn a_goal_keeps_spending_turns_until_the_cap_and_then_stops() {
let state = mid_run("n", "make the gate pass", 3);
assert_eq!(state.turns("n"), Some((1, 3)), "the first turn is 1 of 3");
for expected in [2, 3] {
let Continuation::Continue { prompt } = state.plan_after_turn("n") else {
panic!("turn {expected} of 3 must still be spent");
};
assert!(
prompt.contains("make the gate pass"),
"the re-prompt continues toward the goal verbatim: {prompt}"
);
assert!(
prompt.contains("have not reported the goal reached"),
"and says the one thing the subagent can't observe for itself: {prompt}"
);
assert_eq!(state.turns("n"), Some((expected, 3)));
}
assert_eq!(
state.plan_after_turn("n"),
Continuation::Stop(StopReason::TurnCap { turns: 3 }),
"the cap is the number of turns run, not one more"
);
assert_eq!(
state.turns("n"),
Some((3, 3)),
"a capped run must not advance past its own cap"
);
}
#[test]
fn the_default_cap_is_five_turns() {
// The number is the feature's own, not a value tuned here — pinned so
// a later edit to `DEFAULT_MAX_TURNS` has to be deliberate.
assert_eq!(DEFAULT_MAX_TURNS, 5);
}
#[test]
fn both_signals_stop_the_continuation_before_the_cap_is_reached() {
// The issue's own words: "both stop goal continues". Turn one of
// five, so only the signal can be what stopped it.
for stop in [
StopReason::GoalReached(Some("wrote the fix".to_owned())),
StopReason::NeedHelp("no credential for the registry".to_owned()),
] {
let state = mid_run("n", "a goal", 5);
state.record_stop("n", stop.clone());
assert_eq!(
state.plan_after_turn("n"),
Continuation::Stop(stop.clone()),
"{stop:?} must end the run with turns still on the clock"
);
assert_eq!(
state.turns("n"),
Some((1, 5)),
"and must not have spent one on the way out"
);
}
}
#[test]
fn a_signal_on_the_last_allowed_turn_outranks_the_cap() {
// Both are true at once, and which one is reported is the difference
// between "it says it finished" and "it ran out of road".
let state = mid_run("n", "a goal", 1);
state.record_stop("n", StopReason::GoalReached(None));
assert_eq!(
state.plan_after_turn("n"),
Continuation::Stop(StopReason::GoalReached(None))
);
}
#[test]
fn a_subagent_cannot_signal_a_different_session() {
// The requirement itself, and the reason it's a test: two siblings
// running concurrently, each holding exactly one signal URL. Half the
// answer is in `mcp.rs` — neither tool has a `name` argument to put a
// sibling's name in (`the_signal_tools_take_no_session_name` pins
// that). The other half is here: whether the only identity a subagent
// *does* hold, its token, can be made to resolve to anyone else.
let state = State::new(PathBuf::from("/dev/null"), signal_url());
state.reserve("alpha");
state.reserve("beta");
let alpha = token_of(&state.mint_signal_url("alpha"));
let beta = token_of(&state.mint_signal_url("beta"));
assert_ne!(alpha, beta, "two sessions must not share a token");
assert_eq!(
state.session_for_signal_token(&alpha).as_deref(),
Some("alpha")
);
assert_eq!(
state.session_for_signal_token(&beta).as_deref(),
Some("beta")
);
// `alpha` signals the only way it can: on its own endpoint, with the
// session resolved from the token rather than supplied by the caller.
let resolved = state
.session_for_signal_token(&alpha)
.expect("alpha's own route resolves");
need_help(&state, &resolved, "no credential".to_owned(), None);
assert_eq!(
state.stop_reason("alpha"),
Some(StopReason::NeedHelp("no credential".to_owned()))
);
assert_eq!(
state.stop_reason("beta"),
None,
"a sibling's run must be untouched — there is no route `alpha` holds that reaches it"
);
// A subagent does know its siblings' *names* (a brief can mention
// them) — and a name is not a token, which is the whole point.
assert_eq!(state.session_for_signal_token("beta"), None);
assert_eq!(state.session_for_signal_token(&format!("{alpha}0")), None);
}
#[test]
fn a_token_stops_resolving_once_its_run_is_over() {
// The expiry half of "unknown or expired token ⇒ 404": a finished
// run's config file still names its URL, and that URL must be dead.
let state = State::new(PathBuf::from("/dev/null"), signal_url());
state.reserve("n");
let token = token_of(&state.mint_signal_url("n"));
state.finish_turn("n", &TurnEnd::Complete);
assert_eq!(
state.session_for_signal_token(&token),
None,
"the run ended, so the route it was issued must resolve to nothing"
);
// Same for a call that never reached a spawn at all.
state.reserve("n");
let unspawned = token_of(&state.mint_signal_url("n"));
state.release_reservation("n");
assert_eq!(state.session_for_signal_token(&unspawned), None);
}
#[test]
fn a_re_minted_url_retires_the_previous_one() {
// A `continue` mints the resumed run its own token; the run before it
// is over, so the URL that run was handed must not still work.
let state = State::new(PathBuf::from("/dev/null"), signal_url());
let first = token_of(&state.mint_signal_url("n"));
let second = token_of(&state.mint_signal_url("n"));
assert_eq!(state.session_for_signal_token(&first), None);
assert_eq!(
state.session_for_signal_token(&second).as_deref(),
Some("n")
);
}
#[test]
fn need_help_is_a_state_a_parent_can_see_without_reading_anything() {
// Requirement in full: it stops the session *and* shows up in
// `status` as its own state, distinct from idle and from killed.
let blocked = describe_status(&StatusFacts {
stop: Some(StopReason::NeedHelp(
"the brief contradicts the code".to_owned(),
)),
turns: Some((2, 5)),
..StatusFacts::new("n")
})
.expect("a blocked session is a state, not an error");
assert!(
blocked.contains("BLOCKED") && blocked.contains("the brief contradicts the code"),
"the block and its reason must both be in the answer: {blocked}"
);
assert!(
blocked.contains("Turn 2 of 5"),
"with the progress that says how far it got: {blocked}"
);
assert!(
!blocked.contains("idle"),
"a blocked subagent must not also read as idle: {blocked}"
);
}
#[test]
fn a_reported_goal_never_reads_as_a_verified_one() {
// The failure this is built against: `goal_reached` is self-reported
// by a subagent that has just been told it hasn't reached the goal.
// Every surface that renders it has to say so.
let status = describe_status(&StatusFacts {
stop: Some(StopReason::GoalReached(Some(
"refactored the parser".to_owned(),
))),
..StatusFacts::new("n")
})
.expect("status");
assert!(
status.contains("refactored the parser") && status.contains("claim"),
"status must carry both the report and the fact it's only a report: {status}"
);
let todo = turn_end_summary(
"n",
&TurnEnd::Complete,
Some(&StopReason::GoalReached(Some(
"refactored the parser".to_owned(),
))),
);
assert!(
todo.contains("self-reported, not verified"),
"and so must the todo, which is the half a parent reads unprompted: {todo}"
);
}
#[test]
fn a_stop_reason_extends_the_done_message_rather_than_replacing_it() {
// "Extend", not "replace": the observed end of the turn and the
// reason the run stopped are different facts, and dropping the first
// would let a self-reported claim stand where an observation was.
let plain = turn_end_summary("n", &TurnEnd::Complete, None);
for stop in [
StopReason::GoalReached(None),
StopReason::NeedHelp("blocked".to_owned()),
StopReason::TurnCap { turns: 5 },
] {
let extended = turn_end_summary("n", &TurnEnd::Complete, Some(&stop));
assert!(
extended.starts_with(&plain),
"{stop:?} must extend the done message, not rewrite it: {extended}"
);
assert!(
extended.len() > plain.len(),
"{stop:?} must actually add something: {extended}"
);
}
assert_eq!(
turn_end_summary("n", &TurnEnd::Complete, Some(&StopReason::Done)),
plain,
"`Done` has nothing to add — the unextended message already says exactly that"
);
}
#[test]
fn the_turn_cap_todo_says_the_harness_limit_was_what_stopped_it() {
// Not a silent stop: the one notification a parent gets unprompted
// has to distinguish "it finished" from "we stopped asking".
let todo = turn_end_summary(
"n",
&TurnEnd::Complete,
Some(&StopReason::TurnCap { turns: 5 }),
);
assert!(
todo.contains("harness turn limit was reached (5 turns)"),
"the todo must name the limit as the cause: {todo}"
);
assert!(
todo.contains("without the goal ever being reported reached"),
"and say what that means for the work: {todo}"
);
}
#[test]
fn status_reports_the_turn_counter_in_every_state_a_goal_run_reaches() {
for occupancy in [Some(true), Some(false), None] {
let answer = describe_status(&StatusFacts {
occupancy,
session_exists: true,
turns: Some((3, 5)),
..StatusFacts::new("n")
})
.expect("status");
assert!(
answer.contains("Turn 3 of 5"),
"progress is the point of the counter — it can't be absent from {occupancy:?}: \
{answer}"
);
}
let goalless = describe_status(&StatusFacts {
occupancy: Some(true),
..StatusFacts::new("n")
})
.expect("status");
assert!(
!goalless.contains("Turn "),
"a session with no goal has no budget to be partway through: {goalless}"
);
}
#[test]
fn a_running_turn_that_has_already_signalled_says_so() {
// The window a parent would otherwise misread: the subagent has said
// it is blocked, its turn hasn't ended yet, and plain "running" would
// tell the parent to leave it alone.
let answer = describe_status(&StatusFacts {
occupancy: Some(true),
stop: Some(StopReason::NeedHelp("no credential".to_owned())),
..StatusFacts::new("n")
})
.expect("status");
assert!(
answer.contains("is running") && answer.contains("no credential"),
"both facts are true at once and both have to be in the answer: {answer}"
);
let quiet = describe_status(&StatusFacts {
occupancy: Some(true),
stop: Some(StopReason::Done),
..StatusFacts::new("n")
})
.expect("status");
assert!(
!quiet.contains("already signalled"),
"`Done` is not a signal the subagent raised — nothing to announce: {quiet}"
);
}
#[test]
fn a_kill_outranks_a_stop_reason_the_subagent_had_already_raised() {
// Both records can be set at once — a subagent calls `goal_reached`
// and something SIGKILLs it before the turn ends. The observed kill
// is the one that can't be a claim.
let answer = describe_status(&StatusFacts {
killed: Some(libc::SIGKILL),
stop: Some(StopReason::GoalReached(None)),
session_exists: true,
..StatusFacts::new("n")
})
.expect("status");
assert!(
answer.contains("was killed"),
"the kill is what happened: {answer}"
);
assert!(
!answer.contains("goal reached"),
"a claim made just before being killed must not be the headline: {answer}"
);
}
#[test]
fn a_continue_gives_a_capped_session_its_allowance_back() {
// `TurnCap` stops the loop, it doesn't retire the session — and the
// parent spending a turn on purpose is not what the cap bounds.
let state = mid_run("n", "a goal", 2);
state.record_stop("n", StopReason::TurnCap { turns: 2 });
state.clear_stop("n");
state.restart_turns("n");
assert_eq!(state.turns("n"), Some((1, 2)));
assert_eq!(state.stop_reason("n"), None);
assert!(
matches!(state.plan_after_turn("n"), Continuation::Continue { .. }),
"with the allowance back, the loop has a turn to spend again"
);
}
#[test]
fn a_fresh_start_inherits_nothing_from_the_run_before_it() {
// `start` archives the prior session precisely so this is a fresh
// start; a leftover stop reason or report path would make the new run
// report the old one's ending, into the old one's file.
let state = Arc::new(State::new(PathBuf::from("/dev/null"), signal_url()));
state.record_stop("n", StopReason::NeedHelp("old block".to_owned()));
state.set_report_file("n", Some("/tmp/old-report.md"));
state.reserve("n");
let refused = start(&state, start_request("n"));
assert!(refused.is_err(), "the reserved name is refused as before");
// The same clearing `start` does once it owns the name.
state.clear_stop("n");
state.clear_report_file("n");
assert_eq!(state.stop_reason("n"), None);
assert_eq!(state.report_file("n"), None);
}
#[test]
fn the_report_path_is_taken_from_the_session_and_never_guessed() {
// Both halves of where it can come from: the brief `start` carried,
// and the subagent saying where it actually wrote. A signal that
// names no path leaves the remembered one alone rather than erasing
// it.
let state = State::new(PathBuf::from("/dev/null"), signal_url());
assert_eq!(
state.report_file("n"),
None,
"a session nobody told about a report file has none — nothing is inferred"
);
state.set_report_file("n", Some("/tmp/brief-said.md"));
goal_reached(&state, "n", None, None);
assert_eq!(
state.report_file("n"),
Some(PathBuf::from("/tmp/brief-said.md")),
"a signal with no path must not erase what the brief named"
);
goal_reached(&state, "n", None, Some("/tmp/actually-wrote.md"));
assert_eq!(
state.report_file("n"),
Some(PathBuf::from("/tmp/actually-wrote.md")),
"and the subagent saying where it wrote is what wins"
);
}
#[tokio::test]
async fn the_stop_reason_is_appended_to_the_report_file_the_session_named() {
let dir = std::env::temp_dir().join(format!("hive-subagent-report-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("scratch dir");
let path = dir.join("report.md");
std::fs::write(&path, "# what the subagent wrote\n").expect("seed the report");
write_stop_to_report(Some(path.clone()), "n", &StopReason::TurnCap { turns: 5 }).await;
let body = std::fs::read_to_string(&path).expect("report still readable");
assert!(
body.starts_with("# what the subagent wrote\n"),
"appended, never rewritten — the subagent wrote that file: {body}"
);
assert!(
body.contains("harness turn limit was reached (5 turns)"),
"and the stop reason lands in the artifact a parent already reads: {body}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn an_unwritable_report_path_costs_the_line_and_nothing_else() {
// Best-effort by design: the same sentence is in the todo, so a path
// this daemon can't write is a missing convenience, not a lost fact.
write_stop_to_report(
Some(PathBuf::from("/proc/definitely/not/writable/report.md")),
"n",
&StopReason::Done,
)
.await;
write_stop_to_report(None, "n", &StopReason::Done).await;
}
#[test]
fn a_goal_briefing_tells_the_subagent_what_it_cannot_otherwise_know() {
let briefing = goal_briefing("batch-1", "get the gate to pass", 5);
assert!(
briefing.contains("get the gate to pass") && briefing.contains("up to 5 turns"),
"the goal and the budget both have to reach the subagent: {briefing}"
);
assert!(
briefing.contains("goal_reached") && briefing.contains("need_help"),
"as do the two ways it has to stop the re-prompting: {briefing}"
);
assert!(
briefing.contains("batch-1"),
"and its own name, which is what those tools are called with: {briefing}"
);
}
#[test]
fn a_signal_url_reaches_the_subagent_and_a_status_check_renders_no_config() {
// `turn` actually writes an --mcp-config file (crate::mcp_config::build),
// which resolves `hive_agent_sock::paths::harness_dir` — normally the
// container's injected `HYPERHIVE_HARNESS_DIR`, unset in a plain `cargo
// test` sandbox. Point it at a scratch dir for just this test rather
// than depending on ambient environment; only this test's call path
// reads the var, so there's no cross-test race to sequence around
// (contrast `otel_attrs_append_ambient_value_or_stand_alone` above).
let dir = std::env::temp_dir().join(format!(
"hive-subagent-mcp-test-harness-dir-{}",
std::process::id()
));
// SAFETY: test-only env mutation; no other test reads this var.
unsafe {
std::env::set_var("HYPERHIVE_HARNESS_DIR", &dir);
}
// The signal surface is how `goal_reached` is callable at all, so a
// spawned turn's config has to carry it; `status` builds a config
// purely to resolve the store and has no subagent to hand it to.
let turn = build_config("n", None, None, None, None, Some(&signal_url()));
let checked = build_config("n", None, None, None, None, None);
// SAFETY: see above.
unsafe {
std::env::remove_var("HYPERHIVE_HARNESS_DIR");
}
let _ = std::fs::remove_dir_all(&dir);
assert!(
turn.mcp_config.is_some(),
"a turn's config must carry an --mcp-config with the signal surface in it"
);
assert!(
turn.strict_mcp_config && checked.strict_mcp_config,
"the safety property is unchanged: no ambient MCP discovery either way"
);
}
}