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
This commit is contained in:
atlas 2026-09-14 22:24:51 +02:00
commit 34129d776c
11 changed files with 575 additions and 180 deletions

View file

@ -44,6 +44,16 @@
//! 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
@ -307,11 +317,30 @@ pub struct State {
stops: Mutex<HashMap<String, StopReason>>,
reports: Mutex<HashMap<String, PathBuf>>,
socket: PathBuf,
/// Where a subagent reaches this daemon's own `goal_reached`/`need_help`
/// surface. 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_url: String,
/// 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
@ -329,11 +358,13 @@ struct GoalState {
}
impl State {
/// `signal_url` is the streamable-http endpoint a subagent's own claude
/// reaches `goal_reached`/`need_help` on — this daemon's `--http`
/// address with the signal route appended (see `crate::mcp::serve_http`).
/// `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_url: String) -> Self {
pub fn new(socket: PathBuf, signal_base: String) -> Self {
Self {
running: Mutex::new(HashMap::new()),
dirs: Mutex::new(HashMap::new()),
@ -343,7 +374,62 @@ impl State {
stops: Mutex::new(HashMap::new()),
reports: Mutex::new(HashMap::new()),
socket,
signal_url,
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);
}
}
@ -380,11 +466,23 @@ impl State {
/// 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);
if matches!(running.get(name), Some(None)) {
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
@ -401,7 +499,15 @@ impl State {
/// 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),
@ -745,8 +851,12 @@ fn subagent_otel_attrs(name: &str) -> String {
/// 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. `None` — which only `status` passes, building a config purely to
/// resolve the session store — leaves the surface out entirely.
/// 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>,
@ -764,7 +874,7 @@ fn build_config(
model,
effort: Some(effort.unwrap_or_else(|| "medium".to_owned())),
cwd: dir.map(PathBuf::from),
mcp_config: crate::mcp_config::build(signal_url),
mcp_config: crate::mcp_config::build(name, signal_url),
strict_mcp_config: true,
extra_args,
env: vec![(
@ -880,13 +990,14 @@ fn start_reserved(
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(&state.signal_url),
Some(&signal_url),
);
let store = build_store(&config)?;
if store.find_by_title(name).is_some() {
@ -1043,7 +1154,10 @@ fn continue_reserved(
dir: Option<&str>,
verdict: &VerdictTx,
) -> anyhow::Result<String> {
let config = build_config(name, model, effort, None, dir, Some(&state.signal_url));
// 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
@ -1307,12 +1421,14 @@ async fn write_stop_to_report(path: Option<PathBuf>, name: &str, stop: &StopReas
/// 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\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 (with `name: \
\"{name}\"`) once you've genuinely reached it, or `need_help` (same `name`) with what is \
blocking you if you can't proceed either one stops the re-prompting. Running out of \
turns stops it too, with the work left wherever it had got to."
"\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."
)
}
@ -1323,10 +1439,10 @@ fn goal_briefing(name: &str, goal: &str, max_turns: u32) -> String {
/// 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\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` with `name: \"{name}\"`; if you are blocked, call \
`need_help` with the same `name` and what is blocking you. Neither is a substitute for \
"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."
)
@ -1336,6 +1452,11 @@ fn continuation_prompt(name: &str, goal: &str, turn: u32, max_turns: u32) -> Str
/// 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
@ -1344,30 +1465,18 @@ fn continuation_prompt(name: &str, goal: &str, turn: u32, max_turns: u32) -> Str
/// `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`.
///
/// # Errors
///
/// An invalid name, or a name with nothing in flight: these are a running
/// subagent's signals about its own turn, and a name that isn't running is
/// either a typo or a signal aimed at somebody else's session.
pub fn goal_reached(
state: &State,
name: &str,
msg: Option<String>,
report_file: Option<&str>,
) -> anyhow::Result<String> {
signal_stop(
state,
name,
StopReason::GoalReached(msg),
report_file,
"goal_reached",
)?;
Ok(format!(
) -> 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.
@ -1378,56 +1487,29 @@ pub fn goal_reached(
/// `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.
///
/// # Errors
///
/// Same as [`goal_reached`].
pub fn need_help(
state: &State,
name: &str,
msg: String,
report_file: Option<&str>,
) -> anyhow::Result<String> {
signal_stop(
state,
name,
StopReason::NeedHelp(msg),
report_file,
"need_help",
)?;
Ok(format!(
/// `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: check the signal is
/// coming from a session that's actually in flight, remember where the
/// The half [`goal_reached`] and [`need_help`] share: remember where the
/// subagent says it wrote, and record the stop.
///
/// The in-flight check is what keeps a signal pointed at its own session.
/// It's a guard, not a boundary: two subagents running concurrently can each
/// reach the other's name, since the signal route carries no identity of its
/// own. Bounded on purpose — the subagents sharing that route are ones the
/// same parent spawned, and the cost of a misfire is a stopped continuation
/// the parent can restart with `continue`, not lost work.
fn signal_stop(
state: &State,
name: &str,
stop: StopReason,
report_file: Option<&str>,
tool: &str,
) -> anyhow::Result<()> {
validate_name(name)?;
if state.occupancy(name).is_none() {
anyhow::bail!(
"no subagent named `{name}` has a turn in flight — `{tool}` is a running subagent's \
signal about its own session, so check the `name` you were given"
);
}
/// 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);
Ok(())
}
/// Report whether `name` is currently running — a zero-cost check that
@ -1766,12 +1848,22 @@ mod tests {
// than the full start/spawn path.
/// A stand-in for the signal route a real daemon would hand its
/// subagents. Nothing in these tests dials it: what `State` does with it
/// is carry it into `build_config`, which is asserted on directly.
/// 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 {
@ -2695,27 +2787,84 @@ mod tests {
}
#[test]
fn a_signal_needs_a_session_with_a_turn_in_flight() {
// The guard that keeps a signal pointed at its own session: these are
// a running subagent's report about its own turn.
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());
let err = need_help(&state, "ghost", "stuck".to_owned(), None)
.expect_err("nothing is running under that name");
assert!(
err.to_string().contains("check the `name`"),
"the error must point at the likely cause: {err}"
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!(
state.stop_reason("ghost").is_none(),
"and must not have recorded a stop for a session that isn't there"
assert_eq!(
state.session_for_signal_token(&beta).as_deref(),
Some("beta")
);
state.reserve("real");
need_help(&state, "real", "no credential".to_owned(), None).expect("a running session");
// `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("real"),
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]
@ -2944,14 +3093,13 @@ mod tests {
"a session nobody told about a report file has none — nothing is inferred"
);
state.set_report_file("n", Some("/tmp/brief-said.md"));
state.reserve("n");
goal_reached(&state, "n", None, None).expect("a running session");
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")).expect("a running session");
goal_reached(&state, "n", None, Some("/tmp/actually-wrote.md"));
assert_eq!(
state.report_file("n"),
Some(PathBuf::from("/tmp/actually-wrote.md")),