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

@ -23,6 +23,10 @@ serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
# Signal-route tokens. `Uuid::new_v4` draws from the OS CSPRNG (getrandom),
# which is the property the per-session URL rests on — see
# `session::State::mint_signal_url`.
uuid.workspace = true
# `test-util` for `#[tokio::test(start_paused = true)]`: the `continue`
# resume-grace tests assert what happens when the bound is actually reached,

View file

@ -3,9 +3,9 @@
Per-agent daemon (`hive-subagent-daemon`) that spawns nested headless
`claude` sessions on request and serves the tool surface
(`start`/`continue`/`status`/`interrupt`, plus a separate
subagent-facing `goal_reached`/`need_help` route) directly over
streamable-http. No stdio bridge, no per-turn respawn — claude
reconnects to the same stable URL every turn.
subagent-facing `goal_reached`/`need_help` route, one per-session URL)
directly over streamable-http. No stdio bridge, no per-turn respawn — an
agent's claude reconnects to the same stable URL every turn.
Independent of `hive-bash-mcp` — a subagent spawns a full nested
`claude` session, a much heavier capability than a bash command, worth
@ -26,5 +26,10 @@ own lib (`src/lib.rs`):
via `hive_claude::SessionStore`).
- **`mcp.rs`** — the `rmcp` tool routers (the parent's
`start`/`continue`/`status`/`interrupt` on `/mcp`, the subagent's
`goal_reached`/`need_help` on `/signal/mcp`) + `serve_http`.
`goal_reached`/`need_help` on `/signal/mcp/<token>`) + `serve_http`.
Neither signal tool takes a session name: the token in the path is
minted per run and resolved to a session before dispatch, so a subagent
has no way to name — and therefore no way to signal — a sibling. One
route with a path parameter, because the `Router` is built once at
startup and sessions come and go for the daemon's whole life.
- **`paths.rs`** — the in-agent todo-socket path.

View file

@ -8,7 +8,9 @@
//! A second route on the same listener serves `goal_reached`/`need_help` to
//! the *subagents*, which is how a run says it's done or stuck; see
//! [`mcp`]'s module doc for why that is a separate surface rather than two
//! more tools on the parent's.
//! more tools on the parent's, and why it is served per session under a
//! minted token rather than at one shared path — neither tool takes a
//! session name, so a subagent has no way to say it is somebody else.
//!
//! See [`session`]'s module doc for the actual design: no task files, no
//! restart recovery, no mid-turn compaction — the daemon's only state is a

View file

@ -44,13 +44,16 @@ async fn main() -> Result<()> {
"hive-subagent-daemon starting"
);
// The one place the subagent-facing signal URL can come from: the
// The one place the subagent-facing signal URLs can come from: the
// address this process was told to listen on. Anything else would be a
// guess at the deployment's own port assignment.
let signal_url = format!("http://{}{}", cli.http, hive_subagent_mcp::mcp::SIGNAL_PATH);
// guess at the deployment's own port assignment. This is only their
// common prefix — each session's actual URL is this plus a token minted
// for that session alone, which is what makes a signal's identity a
// property of the endpoint rather than of the payload.
let signal_base = format!("http://{}{}", cli.http, hive_subagent_mcp::mcp::SIGNAL_PATH);
let state = Arc::new(hive_subagent_mcp::session::State::new(
todo_socket,
signal_url,
signal_base,
));
// Serve the MCP tools over streamable-http forever. No background poll

View file

@ -3,13 +3,28 @@
//! socket.
//!
//! Two surfaces, two routes. `/mcp` is the parent's: the four tools above.
//! `/signal/mcp` is the *subagent's*, and carries `goal_reached` and
//! `/signal/mcp/<token>` is the *subagent's*, and carries `goal_reached` and
//! `need_help` only — it is what a subagent is handed in its own
//! `--mcp-config` (see [`crate::mcp_config`]), so the ability to report on
//! its own run can't be the ability to spawn a nested one.
//!
//! **The token in that path is the subagent's identity, and neither signal
//! tool takes a session name.** One route is registered, once, at startup
//! (sessions come and go far faster than a `Router` can be rebuilt); every
//! request resolves its token to a session and dispatches to a handler bound
//! to *that* session, so the name a signal lands against is never a value the
//! sender chose. A token that resolves to nothing — never minted, or revoked
//! when its run ended — is a bare 404, which is also why the route says
//! nothing that would let a subagent enumerate its siblings.
use std::sync::Arc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use axum::extract::{Path, Request, State as RouteState};
use axum::response::{IntoResponse as _, Response};
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use rmcp::{
ServerHandler,
handler::server::wrapper::Parameters,
@ -92,11 +107,11 @@ fn default_trigger() -> String {
"Carry out the task described in your instructions.".to_owned()
}
/// No session name, deliberately: which session this signal is about is
/// decided by the URL it arrives on (see this module's doc), so there is no
/// field here in which one subagent could name another.
#[derive(Debug, Deserialize, JsonSchema)]
struct GoalReachedArgs {
/// Your own session name — the one your brief and your continuation
/// prompts address you by.
name: String,
/// Optionally, what you did. It's shown to whoever spawned you.
#[serde(default)]
msg: Option<String>,
@ -106,11 +121,9 @@ struct GoalReachedArgs {
report_file: Option<String>,
}
/// No session name here either — same reason as [`GoalReachedArgs`].
#[derive(Debug, Deserialize, JsonSchema)]
struct NeedHelpArgs {
/// Your own session name — the one your brief and your continuation
/// prompts address you by.
name: String,
/// What is blocking you, specifically enough for someone else to act on
/// it. This is the whole content of the signal, which is why it's
/// required.
@ -280,9 +293,15 @@ impl ServerHandler for SubagentMcp {}
/// Separate handler rather than two more tools on [`SubagentMcp`] so the
/// split is structural — there is no route a subagent holds that `start` is
/// reachable from.
///
/// `session` is the whole point of the type: an instance is built only by
/// `signal_route`, from a token that had already resolved, and both tools
/// read the session they act on out of `self`. Nothing in an incoming
/// payload can reach that field.
#[derive(Clone)]
struct SubagentSignalMcp {
state: Arc<State>,
session: String,
}
#[tool_router]
@ -290,76 +309,148 @@ impl SubagentSignalMcp {
#[tool(
description = "Report that you have reached the goal you were given. Stops the harness \
from starting another turn to re-prompt you toward it, and extends the \"subagent \
done\" message your parent gets with what you say here. This records a claim, not a \
result: whoever spawned you reads the diff and the gate output regardless, so \
calling it does not make unfinished work finished. Call it when the goal is actually \
met otherwise keep working, or call `need_help` if you can't proceed."
done\" message your parent gets with what you say here. It always applies to your \
own session and takes no session name the endpoint you are calling it on is yours \
alone, so there is nothing to address it to. This records a claim, not a result: \
whoever spawned you reads the diff and the gate output regardless, so calling it \
does not make unfinished work finished. Call it when the goal is actually met \
otherwise keep working, or call `need_help` if you can't proceed."
)]
fn goal_reached(&self, Parameters(args): Parameters<GoalReachedArgs>) -> String {
match session::goal_reached(
session::goal_reached(
&self.state,
&args.name,
&self.session,
args.msg,
args.report_file.as_deref(),
) {
Ok(msg) => msg,
Err(e) => format!("goal_reached error: {e:#}"),
}
)
}
#[tool(
description = "Report that you cannot proceed, and why. Stops the harness from starting \
another turn to re-prompt you toward your goal, marks your session as blocked so \
whoever spawned you sees it in `status` without reading any file, and extends the \
\"subagent done\" message with your reason. Use it for a genuine block — a missing \
\"subagent done\" message with your reason. Like `goal_reached` it takes no session \
name and always applies to your own session. Use it for a genuine block a missing \
credential, a decision that isn't yours, an instruction that contradicts what you \
found not for work that is merely hard. Say enough that someone else can act on it."
)]
fn need_help(&self, Parameters(args): Parameters<NeedHelpArgs>) -> String {
match session::need_help(
session::need_help(
&self.state,
&args.name,
&self.session,
args.msg,
args.report_file.as_deref(),
) {
Ok(msg) => msg,
Err(e) => format!("need_help error: {e:#}"),
}
)
}
}
#[tool_handler]
impl ServerHandler for SubagentSignalMcp {}
/// Path the subagent-facing signal surface is served at, and the tail of the
/// URL [`crate::session::State`] hands to every subagent it spawns. Kept
/// here, next to the route that answers it, so the two can't drift.
/// Prefix the subagent-facing signal surface is served under, and the part
/// of a subagent's URL that is *not* secret: every real endpoint is this plus
/// that session's own token. Kept here, next to the route that answers it, so
/// the two can't drift. Nothing is served at the bare prefix.
pub const SIGNAL_PATH: &str = "/signal/mcp";
/// Everything the one registered signal route needs to answer a request for
/// any session: the daemon state the token resolves against, and a session
/// manager per live token.
///
/// **Per token, not one shared manager.** rmcp keeps an MCP session (created
/// by `initialize`, named by an `mcp-session-id` header) inside its manager,
/// and a manager shared across tokens would make that header a second way to
/// pick which bound handler serves a request — an id is unguessable, but this
/// is the one property the route exists to make structural rather than
/// probabilistic. With a manager per token, an id minted under one token
/// simply does not exist under another.
#[derive(Clone)]
struct SignalRouting {
state: Arc<State>,
managers: Arc<Mutex<HashMap<String, Arc<LocalSessionManager>>>>,
}
impl SignalRouting {
/// The session manager for `token`, created on its first request.
///
/// Entries for tokens that no longer resolve are dropped on the way
/// past — the map would otherwise grow for the daemon's whole life, and
/// a revoked token's MCP sessions have nothing left to serve. It's a
/// linear scan of a map with one entry per *live* subagent, on a request
/// a subagent makes a handful of times per run.
fn manager_for(&self, token: &str) -> Arc<LocalSessionManager> {
let mut managers = self.managers.lock().unwrap_or_else(PoisonError::into_inner);
managers.retain(|t, _| self.state.session_for_signal_token(t).is_some());
Arc::clone(
managers
.entry(token.to_owned())
.or_insert_with(session_manager),
)
}
}
/// A session manager with this daemon's keep-alive: a subagent turn can run
/// considerably longer than a bash command — same 24h rationale as the bash
/// and matrix daemons.
fn session_manager() -> Arc<LocalSessionManager> {
let mut manager = LocalSessionManager::default();
manager.session_config.keep_alive = Some(std::time::Duration::from_hours(24));
Arc::new(manager)
}
/// Resolve the request's token to a session and serve it with a handler bound
/// to that session — or 404, for a token this daemon never minted or has
/// since revoked.
///
/// The `StreamableHttpService` is built per request, which is cheap (a few
/// `Arc`s) and is what lets the bound session come from the URL: rmcp's
/// service factory takes no request, so the binding has to happen outside it.
/// Only a request that starts an MCP session ever calls the factory; the rest
/// are answered from the token's own manager, against the handler built when
/// that session was initialized.
async fn signal_route(
RouteState(routing): RouteState<SignalRouting>,
Path(token): Path<String>,
request: Request,
) -> Response {
let Some(session) = routing.state.session_for_signal_token(&token) else {
tracing::debug!("signal route: unknown or expired token, refusing with 404");
return axum::http::StatusCode::NOT_FOUND.into_response();
};
let manager = routing.manager_for(&token);
let state = Arc::clone(&routing.state);
let service = StreamableHttpService::new(
move || {
Ok(SubagentSignalMcp {
state: Arc::clone(&state),
session: session.clone(),
})
},
manager,
StreamableHttpServerConfig::default(),
);
service.handle(request).await.into_response()
}
/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`.
/// Loopback-only bind, one long-lived session — same shape as the bash and
/// matrix daemons' own `serve_http`.
///
/// Two routes off one listener: `/mcp` for the parent's four tools, and
/// [`SIGNAL_PATH`] for the subagent's two. Separate session managers because
/// they're separate MCP servers to separate clients — the parent's harness
/// on one, each subagent's own claude on the other.
/// `<SIGNAL_PATH>/{token}` for the subagent's two. Separate session managers
/// because they're separate MCP servers to separate clients — the parent's
/// harness on one, each subagent's own claude on its own token's.
///
/// The signal route is registered **once**, with the token as a path
/// parameter, because this `Router` is built at startup and subagents come
/// and go for the daemon's whole life: there is no mounting a fresh route per
/// session. Resolution therefore happens per request, in `signal_route`.
///
/// # Errors
///
/// Returns an error if the listener cannot bind `addr` or the HTTP server
/// exits with a fatal error.
pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow::Result<()> {
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
// A subagent turn can run considerably longer than a bash command —
// same 24h keep-alive rationale as the bash/matrix daemons.
let manager = || {
let mut session_manager = LocalSessionManager::default();
session_manager.session_config.keep_alive = Some(std::time::Duration::from_hours(24));
std::sync::Arc::new(session_manager)
};
let parent_state = Arc::clone(&state);
let service = StreamableHttpService::new(
move || {
@ -367,21 +458,20 @@ pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow
state: Arc::clone(&parent_state),
})
},
manager(),
StreamableHttpServerConfig::default(),
);
let signal_service = StreamableHttpService::new(
move || {
Ok(SubagentSignalMcp {
state: Arc::clone(&state),
})
},
manager(),
session_manager(),
StreamableHttpServerConfig::default(),
);
let routing = SignalRouting {
state,
managers: Arc::new(Mutex::new(HashMap::new())),
};
let app = axum::Router::new()
.nest_service("/mcp", service)
.nest_service(SIGNAL_PATH, signal_service);
.route(
&format!("{SIGNAL_PATH}/{{token}}"),
axum::routing::any(signal_route),
)
.with_state(routing);
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(
%addr,
@ -391,3 +481,104 @@ pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow
axum::serve(listener, app).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The state a route test resolves against, with `name` already minted a
/// token — the same shape `start` leaves behind.
fn state() -> Arc<State> {
Arc::new(State::new(
std::path::PathBuf::from("/dev/null"),
format!("http://127.0.0.1:1{SIGNAL_PATH}"),
))
}
fn routing(state: Arc<State>) -> SignalRouting {
SignalRouting {
state,
managers: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Mint `name` a signal URL the way a spawning run does, and hand back
/// just the token — what the route would have taken out of the path.
fn minted(state: &State, name: &str) -> String {
let url = state.mint_signal_url(name);
url.rsplit('/')
.next()
.expect("a minted URL always has a last segment")
.to_owned()
}
fn post(uri: &str) -> Request {
axum::http::Request::builder()
.method("POST")
.uri(uri)
.body(axum::body::Body::empty())
.expect("a well-formed test request")
}
#[test]
fn the_signal_tools_take_no_session_name() {
// The fix itself, pinned: a subagent has no field in which to name a
// sibling. Asserted against the generated JSON schema rather than the
// struct, because the schema is what actually reaches the subagent —
// a `name` re-added under any serde rename would show up here.
for (tool, schema) in [
("goal_reached", schemars::schema_for!(GoalReachedArgs)),
("need_help", schemars::schema_for!(NeedHelpArgs)),
] {
let schema = serde_json::to_value(&schema).expect("a schema serializes");
let properties = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.cloned()
.unwrap_or_default();
assert!(
!properties.contains_key("name"),
"`{tool}` must not take a session name — identity comes from the route's token, \
and an argument for it is an argument one subagent can fill in with another's \
name. Properties offered: {:?}",
properties.keys().collect::<Vec<_>>()
);
}
}
#[tokio::test]
async fn an_unminted_signal_token_is_a_bare_404() {
// No enumeration: a token this daemon never issued gets the same
// answer whether or not a session by any name exists.
let state = state();
minted(&state, "live");
let response = signal_route(
RouteState(routing(state)),
Path("not-a-token".to_owned()),
post("/signal/mcp/not-a-token"),
)
.await;
assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_minted_signal_token_reaches_the_surface_at_all() {
// The other half of the 404 above: it is the *resolution* refusing,
// not the route being unreachable. The body here is not a valid MCP
// request, so rmcp rejects it — on its own terms, with some answer
// that is not this route's "no such token".
let state = state();
let token = minted(&state, "live");
let response = signal_route(
RouteState(routing(state)),
Path(token.clone()),
post(&format!("/signal/mcp/{token}")),
)
.await;
assert_ne!(
response.status(),
axum::http::StatusCode::NOT_FOUND,
"a live session's own token must resolve"
);
}
}

View file

@ -15,11 +15,23 @@
//! "a subagent can say it is done or stuck" never widens into "a subagent
//! can spawn subagents", which is what handing it the parent's route would
//! have meant.
//!
//! **One file per session, not one file.** The signal URL carries the
//! session's own token (`State::mint_signal_url`), so the
//! file it is written into has to be the session's own too: a single shared
//! path is a race between two `start`s — whichever wrote last decides which
//! token the other's `claude` reads at startup, which would hand a subagent
//! a sibling's identity by accident.
use std::path::PathBuf;
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`].
const CONFIG_FILE: &str = "subagent-mcp-config.json";
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`],
/// per session. The name is a validated [`hive_types::Ident`] by the time it
/// gets here (`crate::session::validate_name`), so it is a single safe path
/// segment and never escapes the directory.
fn config_file(name: &str) -> String {
format!("subagent-mcp-config-{name}.json")
}
/// Name the signal surface appears under in a subagent's own MCP config,
/// and therefore the prefix its tools are called by
@ -31,18 +43,19 @@ const SIGNAL_SERVER: &str = "subagent_control";
/// [`hive_claude::Config::mcp_config`] stays unset and the subagent gets
/// literally zero MCP servers.
///
/// `signal_url` is this daemon's own `goal_reached`/`need_help` route; it is
/// always included when given, since a subagent that can't say it's done or
/// stuck is exactly the one the turn cap has to stop on its behalf. `None`
/// reproduces the pre-continuation shape — only the opted-in extras, and no
/// file at all when none opt in.
/// `signal_url` is `name`'s *own* `goal_reached`/`need_help` route — this
/// daemon's signal path plus the token minted for this run, and the only
/// place that token is ever written. It is always included when given, since
/// a subagent that can't say it's done or stuck is exactly the one the turn
/// cap has to stop on its behalf. `None` reproduces the pre-continuation
/// shape — only the opted-in extras, and no file at all when none opt in.
///
/// Re-rendered on every call (cheap: a filter plus a small
/// file write) rather than cached once at daemon startup, so a config change
/// takes effect on this subagent's next `start`/`continue` without needing
/// the daemon itself restarted.
#[must_use]
pub fn build(signal_url: Option<&str>) -> Option<PathBuf> {
pub fn build(name: &str, signal_url: Option<&str>) -> Option<PathBuf> {
let state_dir = crate::paths::state_dir();
let mut servers: serde_json::Map<String, serde_json::Value> =
hive_agent_sock::extra_mcp::load_extra_mcp()
@ -70,7 +83,7 @@ pub fn build(signal_url: Option<&str>) -> Option<PathBuf> {
);
return None;
}
let path = dir.join(CONFIG_FILE);
let path = dir.join(config_file(name));
if let Err(e) = std::fs::write(&path, body) {
tracing::warn!(
error = ?e,

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")),