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

@ -102,7 +102,8 @@ hand-maintained per-file tree drifts out of sync with the code.
(`hive-subagent-daemon`); spawns nested claude sessions on request and (`hive-subagent-daemon`); spawns nested claude sessions on request and
serves `start`/`continue`/`status`/`interrupt` directly over serves `start`/`continue`/`status`/`interrupt` directly over
streamable-http (no stdio bridge), plus a second subagent-facing route streamable-http (no stdio bridge), plus a second subagent-facing route
carrying `goal_reached`/`need_help`. Independent of `hive-bash-mcp` (a carrying `goal_reached`/`need_help` — served per session under a minted
token, since neither tool takes a session name. Independent of `hive-bash-mcp` (a
subagent is a much heavier capability than a bash command). A `start` subagent is a much heavier capability than a bash command). A `start`
with a `goal` is a multi-turn run: the daemon re-prompts the subagent with a `goal` is a multi-turn run: the daemon re-prompts the subagent
toward the goal each time a turn ends, up to a per-session turn cap. No toward the goal each time a turn ends, up to a per-session turn cap. No

1
Cargo.lock generated
View file

@ -2004,6 +2004,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
] ]
[[package]] [[package]]

View file

@ -32,13 +32,34 @@ routes rather than six tools on one, so that being able to report on a run
never carries the ability to start one: there's no route a subagent holds never carries the ability to start one: there's no route a subagent holds
that `start` is reachable from. that `start` is reachable from.
## A subagent can't name a session, not even its own
Neither signal tool takes a session name. **The URL is the identity.** At
each spawn the daemon mints that run an unguessable token, serves it at
`/signal/mcp/<token>`, and writes that one URL into that one subagent's own
`--mcp-config` — a file per session, not a shared one. A request is
resolved to a session before it's dispatched, and the tools read the
session off the resolution.
A subagent therefore has no field in which to name a sibling, and knowing
a sibling's name buys nothing: a name isn't a token. Two subagents running
concurrently can't signal each other — which the earlier shape, a shared
route plus a `name` argument guarded by a liveness check, allowed.
A token that resolves to nothing — never minted, or revoked when its run
ended — gets a bare **404**, the same answer for every token, so nothing
about the refusal says whether some other session exists.
## State ## State
In-memory only: what's running now, where each name's session lives, how In-memory only: what's running now, where each name's session lives, how
each name's last turn ended, when each running turn last produced output, each name's last turn ended, when each running turn last produced output,
what each session is working toward, how far through its turn budget it what each session is working toward, how far through its turn budget it
is, why its run stopped, and where it writes its report. All of it lives is, why its run stopped, where it writes its report, and which signal
only as long as the daemon process does. A daemon token belongs to it. All of it lives only as long as the daemon process
does — so a daemon restart invalidates every signal URL it had issued,
which is the same thing as it having stopped the runs those URLs belonged
to. A daemon
restart stops whatever was running rather than adopting it. The durable restart stops whatever was running rather than adopting it. The durable
record of a subagent's existence is claude's own on-disk session record of a subagent's existence is claude's own on-disk session
(`hive_claude::SessionStore`), which `continue` reattaches to independent (`hive_claude::SessionStore`), which `continue` reattaches to independent
@ -213,5 +234,8 @@ Set `hyperhive.extraMcpServers.<name>.availableToSubagents = true` on a
specific entry to hand that one server to subagents as well — useful for, specific entry to hand that one server to subagents as well — useful for,
say, a read-only lookup or scraper MCP a subagent's bounded, single-batch say, a read-only lookup or scraper MCP a subagent's bounded, single-batch
task might need. `hive-subagent-mcp`'s `mcp_config` module renders the task might need. `hive-subagent-mcp`'s `mcp_config` module renders the
opted-in subset into its own `--mcp-config` file per turn; an entry left opted-in subset into a `--mcp-config` file per session, rewritten each
at the default `false` never appears there. turn; an entry left at the default `false` never appears there. Per
session rather than one shared file, because the `subagent_control` entry
in it carries that session's own signal URL — one file for everyone would
be a race over whose identity each subagent reads at startup.

View file

@ -23,6 +23,10 @@ serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.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` # `test-util` for `#[tokio::test(start_paused = true)]`: the `continue`
# resume-grace tests assert what happens when the bound is actually reached, # 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 Per-agent daemon (`hive-subagent-daemon`) that spawns nested headless
`claude` sessions on request and serves the tool surface `claude` sessions on request and serves the tool surface
(`start`/`continue`/`status`/`interrupt`, plus a separate (`start`/`continue`/`status`/`interrupt`, plus a separate
subagent-facing `goal_reached`/`need_help` route) directly over subagent-facing `goal_reached`/`need_help` route, one per-session URL)
streamable-http. No stdio bridge, no per-turn respawn — claude directly over streamable-http. No stdio bridge, no per-turn respawn — an
reconnects to the same stable URL every turn. agent's claude reconnects to the same stable URL every turn.
Independent of `hive-bash-mcp` — a subagent spawns a full nested Independent of `hive-bash-mcp` — a subagent spawns a full nested
`claude` session, a much heavier capability than a bash command, worth `claude` session, a much heavier capability than a bash command, worth
@ -26,5 +26,10 @@ own lib (`src/lib.rs`):
via `hive_claude::SessionStore`). via `hive_claude::SessionStore`).
- **`mcp.rs`** — the `rmcp` tool routers (the parent's - **`mcp.rs`** — the `rmcp` tool routers (the parent's
`start`/`continue`/`status`/`interrupt` on `/mcp`, the subagent'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. - **`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 //! 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 //! 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 //! [`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 //! 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 //! 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" "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 // address this process was told to listen on. Anything else would be a
// guess at the deployment's own port assignment. // guess at the deployment's own port assignment. This is only their
let signal_url = format!("http://{}{}", cli.http, hive_subagent_mcp::mcp::SIGNAL_PATH); // 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( let state = Arc::new(hive_subagent_mcp::session::State::new(
todo_socket, todo_socket,
signal_url, signal_base,
)); ));
// Serve the MCP tools over streamable-http forever. No background poll // Serve the MCP tools over streamable-http forever. No background poll

View file

@ -3,13 +3,28 @@
//! socket. //! socket.
//! //!
//! Two surfaces, two routes. `/mcp` is the parent's: the four tools above. //! 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 //! `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 //! `--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. //! 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::{ use rmcp::{
ServerHandler, ServerHandler,
handler::server::wrapper::Parameters, handler::server::wrapper::Parameters,
@ -92,11 +107,11 @@ fn default_trigger() -> String {
"Carry out the task described in your instructions.".to_owned() "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)] #[derive(Debug, Deserialize, JsonSchema)]
struct GoalReachedArgs { 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. /// Optionally, what you did. It's shown to whoever spawned you.
#[serde(default)] #[serde(default)]
msg: Option<String>, msg: Option<String>,
@ -106,11 +121,9 @@ struct GoalReachedArgs {
report_file: Option<String>, report_file: Option<String>,
} }
/// No session name here either — same reason as [`GoalReachedArgs`].
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct NeedHelpArgs { 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 /// 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 /// it. This is the whole content of the signal, which is why it's
/// required. /// required.
@ -280,9 +293,15 @@ impl ServerHandler for SubagentMcp {}
/// Separate handler rather than two more tools on [`SubagentMcp`] so the /// Separate handler rather than two more tools on [`SubagentMcp`] so the
/// split is structural — there is no route a subagent holds that `start` is /// split is structural — there is no route a subagent holds that `start` is
/// reachable from. /// 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)] #[derive(Clone)]
struct SubagentSignalMcp { struct SubagentSignalMcp {
state: Arc<State>, state: Arc<State>,
session: String,
} }
#[tool_router] #[tool_router]
@ -290,76 +309,148 @@ impl SubagentSignalMcp {
#[tool( #[tool(
description = "Report that you have reached the goal you were given. Stops the harness \ 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 \ 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 \ done\" message your parent gets with what you say here. It always applies to your \
result: whoever spawned you reads the diff and the gate output regardless, so \ own session and takes no session name the endpoint you are calling it on is yours \
calling it does not make unfinished work finished. Call it when the goal is actually \ alone, so there is nothing to address it to. This records a claim, not a result: \
met otherwise keep working, or call `need_help` if you can't proceed." 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 { fn goal_reached(&self, Parameters(args): Parameters<GoalReachedArgs>) -> String {
match session::goal_reached( session::goal_reached(
&self.state, &self.state,
&args.name, &self.session,
args.msg, args.msg,
args.report_file.as_deref(), args.report_file.as_deref(),
) { )
Ok(msg) => msg,
Err(e) => format!("goal_reached error: {e:#}"),
}
} }
#[tool( #[tool(
description = "Report that you cannot proceed, and why. Stops the harness from starting \ 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 \ 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 \ 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 \ 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." 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 { fn need_help(&self, Parameters(args): Parameters<NeedHelpArgs>) -> String {
match session::need_help( session::need_help(
&self.state, &self.state,
&args.name, &self.session,
args.msg, args.msg,
args.report_file.as_deref(), args.report_file.as_deref(),
) { )
Ok(msg) => msg,
Err(e) => format!("need_help error: {e:#}"),
}
} }
} }
#[tool_handler] #[tool_handler]
impl ServerHandler for SubagentSignalMcp {} impl ServerHandler for SubagentSignalMcp {}
/// Path the subagent-facing signal surface is served at, and the tail of the /// Prefix the subagent-facing signal surface is served under, and the part
/// URL [`crate::session::State`] hands to every subagent it spawns. Kept /// of a subagent's URL that is *not* secret: every real endpoint is this plus
/// here, next to the route that answers it, so the two can't drift. /// 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"; 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`. /// 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 /// Loopback-only bind, one long-lived session — same shape as the bash and
/// matrix daemons' own `serve_http`. /// matrix daemons' own `serve_http`.
/// ///
/// Two routes off one listener: `/mcp` for the parent's four tools, and /// Two routes off one listener: `/mcp` for the parent's four tools, and
/// [`SIGNAL_PATH`] for the subagent's two. Separate session managers because /// `<SIGNAL_PATH>/{token}` for the subagent's two. Separate session managers
/// they're separate MCP servers to separate clients — the parent's harness /// because they're separate MCP servers to separate clients — the parent's
/// on one, each subagent's own claude on the other. /// 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 /// # Errors
/// ///
/// Returns an error if the listener cannot bind `addr` or the HTTP server /// Returns an error if the listener cannot bind `addr` or the HTTP server
/// exits with a fatal error. /// exits with a fatal error.
pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow::Result<()> { 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 parent_state = Arc::clone(&state);
let service = StreamableHttpService::new( let service = StreamableHttpService::new(
move || { move || {
@ -367,21 +458,20 @@ pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow
state: Arc::clone(&parent_state), state: Arc::clone(&parent_state),
}) })
}, },
manager(), session_manager(),
StreamableHttpServerConfig::default(),
);
let signal_service = StreamableHttpService::new(
move || {
Ok(SubagentSignalMcp {
state: Arc::clone(&state),
})
},
manager(),
StreamableHttpServerConfig::default(), StreamableHttpServerConfig::default(),
); );
let routing = SignalRouting {
state,
managers: Arc::new(Mutex::new(HashMap::new())),
};
let app = axum::Router::new() let app = axum::Router::new()
.nest_service("/mcp", service) .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?; let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!( tracing::info!(
%addr, %addr,
@ -391,3 +481,104 @@ pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) 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 //! "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 //! can spawn subagents", which is what handing it the parent's route would
//! have meant. //! 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; use std::path::PathBuf;
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`]. /// Filename the rendered config lives at, under [`crate::paths::harness_dir`],
const CONFIG_FILE: &str = "subagent-mcp-config.json"; /// 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, /// Name the signal surface appears under in a subagent's own MCP config,
/// and therefore the prefix its tools are called by /// 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 /// [`hive_claude::Config::mcp_config`] stays unset and the subagent gets
/// literally zero MCP servers. /// literally zero MCP servers.
/// ///
/// `signal_url` is this daemon's own `goal_reached`/`need_help` route; it is /// `signal_url` is `name`'s *own* `goal_reached`/`need_help` route — this
/// always included when given, since a subagent that can't say it's done or /// daemon's signal path plus the token minted for this run, and the only
/// stuck is exactly the one the turn cap has to stop on its behalf. `None` /// place that token is ever written. It is always included when given, since
/// reproduces the pre-continuation shape — only the opted-in extras, and no /// a subagent that can't say it's done or stuck is exactly the one the turn
/// file at all when none opt in. /// 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 /// Re-rendered on every call (cheap: a filter plus a small
/// file write) rather than cached once at daemon startup, so a config change /// file write) rather than cached once at daemon startup, so a config change
/// takes effect on this subagent's next `start`/`continue` without needing /// takes effect on this subagent's next `start`/`continue` without needing
/// the daemon itself restarted. /// the daemon itself restarted.
#[must_use] #[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 state_dir = crate::paths::state_dir();
let mut servers: serde_json::Map<String, serde_json::Value> = let mut servers: serde_json::Map<String, serde_json::Value> =
hive_agent_sock::extra_mcp::load_extra_mcp() hive_agent_sock::extra_mcp::load_extra_mcp()
@ -70,7 +83,7 @@ pub fn build(signal_url: Option<&str>) -> Option<PathBuf> {
); );
return None; return None;
} }
let path = dir.join(CONFIG_FILE); let path = dir.join(config_file(name));
if let Err(e) = std::fs::write(&path, body) { if let Err(e) = std::fs::write(&path, body) {
tracing::warn!( tracing::warn!(
error = ?e, error = ?e,

View file

@ -44,6 +44,16 @@
//! about the work, never as the work, and says so. See //! about the work, never as the work, and says so. See
//! `docs/tools/subagent.md`. //! `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 //! **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 //! 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 //! "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>>, stops: Mutex<HashMap<String, StopReason>>,
reports: Mutex<HashMap<String, PathBuf>>, reports: Mutex<HashMap<String, PathBuf>>,
socket: PathBuf, socket: PathBuf,
/// Where a subagent reaches this daemon's own `goal_reached`/`need_help` /// The prefix of every subagent's signal URL — this daemon's own `--http`
/// surface. It lives here because the daemon can only learn it from its /// address with [`crate::mcp::SIGNAL_PATH`] on the end, and *not* a
/// own `--http` argument — deriving it from a convention would be the /// reachable route by itself. It lives here because the daemon can only
/// same inference the report path is careful not to make. /// learn it from its own `--http` argument — deriving it from a
signal_url: String, /// 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 /// What a session is being continued toward, and how far through its turn
@ -329,11 +358,13 @@ struct GoalState {
} }
impl State { impl State {
/// `signal_url` is the streamable-http endpoint a subagent's own claude /// `signal_base` is where the streamable-http endpoint a subagent's own
/// reaches `goal_reached`/`need_help` on — this daemon's `--http` /// claude reaches `goal_reached`/`need_help` on *starts* — this daemon's
/// address with the signal route appended (see `crate::mcp::serve_http`). /// `--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] #[must_use]
pub fn new(socket: PathBuf, signal_url: String) -> Self { pub fn new(socket: PathBuf, signal_base: String) -> Self {
Self { Self {
running: Mutex::new(HashMap::new()), running: Mutex::new(HashMap::new()),
dirs: Mutex::new(HashMap::new()), dirs: Mutex::new(HashMap::new()),
@ -343,7 +374,62 @@ impl State {
stops: Mutex::new(HashMap::new()), stops: Mutex::new(HashMap::new()),
reports: Mutex::new(HashMap::new()), reports: Mutex::new(HashMap::new()),
socket, 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). /// on the slow path between `reserve` and `Claude::spawn` succeeding).
/// A no-op if the entry was already upgraded to `Some` — this only ever /// A no-op if the entry was already upgraded to `Some` — this only ever
/// clears a still-`None` placeholder, never a live process. /// 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) { fn release_reservation(&self, name: &str) {
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner); 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); 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 /// 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 /// 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", /// 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. /// 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) { 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); let mut killed = self.killed.lock().unwrap_or_else(PoisonError::into_inner);
match end { match end {
TurnEnd::Killed { signal } => killed.insert(name.to_owned(), *signal), 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 /// 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 /// 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 /// 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 /// its own. It is also *this* session's own URL — minted per run by
/// resolve the session store — leaves the surface out entirely. /// [`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( fn build_config(
name: &str, name: &str,
model: Option<String>, model: Option<String>,
@ -764,7 +874,7 @@ fn build_config(
model, model,
effort: Some(effort.unwrap_or_else(|| "medium".to_owned())), effort: Some(effort.unwrap_or_else(|| "medium".to_owned())),
cwd: dir.map(PathBuf::from), 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, strict_mcp_config: true,
extra_args, extra_args,
env: vec![( env: vec![(
@ -880,13 +990,14 @@ fn start_reserved(
trigger: String, trigger: String,
dir: Option<&str>, dir: Option<&str>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let signal_url = state.mint_signal_url(name);
let config = build_config( let config = build_config(
name, name,
model, model,
effort, effort,
Some(prompt_file), Some(prompt_file),
dir, dir,
Some(&state.signal_url), Some(&signal_url),
); );
let store = build_store(&config)?; let store = build_store(&config)?;
if store.find_by_title(name).is_some() { if store.find_by_title(name).is_some() {
@ -1043,7 +1154,10 @@ fn continue_reserved(
dir: Option<&str>, dir: Option<&str>,
verdict: &VerdictTx, verdict: &VerdictTx,
) -> anyhow::Result<String> { ) -> 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 // No existence pre-check: claude's own `--resume` is the authority on
// whether the session is there, and it errors rather than quietly // whether the session is there, and it errors rather than quietly
// starting a fresh one. `verdict` is how that answer gets back to the // 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". /// that are the only way it has to say "done" or "stuck".
fn goal_briefing(name: &str, goal: &str, max_turns: u32) -> String { fn goal_briefing(name: &str, goal: &str, max_turns: u32) -> String {
format!( format!(
"\n\nYour goal for this session: {goal}\n\nYou have up to {max_turns} turns to reach it. \ "\n\nYou are the subagent session `{name}`.\n\nYour goal for this session: {goal}\n\nYou \
When a turn of yours ends and you haven't reported the goal reached, the harness starts \ have up to {max_turns} turns to reach it. When a turn of yours ends and you haven't \
another turn re-prompting you toward it. Call the `goal_reached` tool (with `name: \ reported the goal reached, the harness starts another turn re-prompting you toward it. \
\"{name}\"`) once you've genuinely reached it, or `need_help` (same `name`) with what is \ Call the `goal_reached` tool once you've genuinely reached it, or `need_help` with what \
blocking you if you can't proceed either one stops the re-prompting. Running out of \ is blocking you if you can't proceed either one stops the re-prompting. Neither takes \
turns stops it too, with the work left wherever it had got to." 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. /// pressure to claim it.
fn continuation_prompt(name: &str, goal: &str, turn: u32, max_turns: u32) -> String { fn continuation_prompt(name: &str, goal: &str, turn: u32, max_turns: u32) -> String {
format!( format!(
"Your previous turn ended and you have not reported the goal reached.\n\nGoal: \ "Your previous turn ended and you have not reported the goal reached.\n\nYou are the \
{goal}\n\nThis is turn {turn} of {max_turns}. Carry on toward the goal. If you have in \ subagent session `{name}`.\n\nGoal: {goal}\n\nThis is turn {turn} of {max_turns}. Carry \
fact reached it, call `goal_reached` with `name: \"{name}\"`; if you are blocked, call \ on toward the goal. If you have in fact reached it, call `goal_reached`; if you are \
`need_help` with the same `name` and what is blocking you. Neither is a substitute for \ 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 \ the work: whoever spawned you reads what you actually changed, not what you claim about \
it." 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 /// turn continuation. Called by the subagent, from inside its own turn, over
/// the signal route this daemon hands it (see `build_config`). /// 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 /// **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 /// 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 /// 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 /// `report_file` is the subagent saying where it wrote its report, which is
/// the only reason this daemon ever knows that path — see /// the only reason this daemon ever knows that path — see
/// `State::set_report_file`. /// `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( pub fn goal_reached(
state: &State, state: &State,
name: &str, name: &str,
msg: Option<String>, msg: Option<String>,
report_file: Option<&str>, report_file: Option<&str>,
) -> anyhow::Result<String> { ) -> String {
signal_stop( signal_stop(state, name, StopReason::GoalReached(msg), report_file);
state, format!(
name,
StopReason::GoalReached(msg),
report_file,
"goal_reached",
)?;
Ok(format!(
"noted — `{name}`'s goal is recorded as reported reached, so this turn finishes and no \ "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 \ further goal turn is started. It is recorded as your claim, not as verification: whoever \
spawned you still reads what you changed." spawned you still reads what you changed."
)) )
} }
/// Record that the subagent can't proceed, and stop its turn continuation. /// 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 /// `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. /// gives the parent nothing to act on, and acting on it is the entire point.
/// ///
/// # Errors /// `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 {
/// Same as [`goal_reached`]. signal_stop(state, name, StopReason::NeedHelp(msg), report_file);
pub fn need_help( format!(
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!(
"noted — `{name}` is recorded as blocked, so this turn finishes and no further goal turn \ "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 \ 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." 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 /// The half [`goal_reached`] and [`need_help`] share: remember where the
/// coming from a session that's actually in flight, remember where the
/// subagent says it wrote, and record the stop. /// subagent says it wrote, and record the stop.
/// ///
/// The in-flight check is what keeps a signal pointed at its own session. /// It checks nothing, and has nothing left to check. `name` reached it by
/// It's a guard, not a boundary: two subagents running concurrently can each /// being resolved from the route's own token, so "is this signal about the
/// reach the other's name, since the signal route carries no identity of its /// session it claims" is answered before the request is dispatched at all —
/// own. Bounded on purpose — the subagents sharing that route are ones the /// a token that names no live session never reaches this function, it gets a
/// same parent spawned, and the cost of a misfire is a stopped continuation /// 404. What used to stand here was an `occupancy()` liveness check standing
/// the parent can restart with `continue`, not lost work. /// in for identity, which two concurrently-running siblings could each
fn signal_stop( /// satisfy for the other's name.
state: &State, fn signal_stop(state: &State, name: &str, stop: StopReason, report_file: Option<&str>) {
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"
);
}
state.set_report_file(name, report_file); state.set_report_file(name, report_file);
state.record_stop(name, stop); state.record_stop(name, stop);
Ok(())
} }
/// Report whether `name` is currently running — a zero-cost check that /// Report whether `name` is currently running — a zero-cost check that
@ -1766,12 +1848,22 @@ mod tests {
// than the full start/spawn path. // than the full start/spawn path.
/// A stand-in for the signal route a real daemon would hand its /// 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 /// subagents — the *prefix*, as `State::new` takes it. Nothing in these
/// is carry it into `build_config`, which is asserted on directly. /// 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 { fn signal_url() -> String {
"http://127.0.0.1:1/signal/mcp".to_owned() "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 /// A `StartRequest` with only the fields a test cares about set — the
/// other six are the same "nothing asked for" every time. /// other six are the same "nothing asked for" every time.
fn start_request(name: &str) -> StartRequest { fn start_request(name: &str) -> StartRequest {
@ -2695,27 +2787,84 @@ mod tests {
} }
#[test] #[test]
fn a_signal_needs_a_session_with_a_turn_in_flight() { fn a_subagent_cannot_signal_a_different_session() {
// The guard that keeps a signal pointed at its own session: these are // The requirement itself, and the reason it's a test: two siblings
// a running subagent's report about its own turn. // 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 state = State::new(PathBuf::from("/dev/null"), signal_url());
let err = need_help(&state, "ghost", "stuck".to_owned(), None) state.reserve("alpha");
.expect_err("nothing is running under that name"); state.reserve("beta");
assert!( let alpha = token_of(&state.mint_signal_url("alpha"));
err.to_string().contains("check the `name`"), let beta = token_of(&state.mint_signal_url("beta"));
"the error must point at the likely cause: {err}" assert_ne!(alpha, beta, "two sessions must not share a token");
assert_eq!(
state.session_for_signal_token(&alpha).as_deref(),
Some("alpha")
); );
assert!( assert_eq!(
state.stop_reason("ghost").is_none(), state.session_for_signal_token(&beta).as_deref(),
"and must not have recorded a stop for a session that isn't there" Some("beta")
); );
state.reserve("real"); // `alpha` signals the only way it can: on its own endpoint, with the
need_help(&state, "real", "no credential".to_owned(), None).expect("a running session"); // 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!( assert_eq!(
state.stop_reason("real"), state.stop_reason("alpha"),
Some(StopReason::NeedHelp("no credential".to_owned())) 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] #[test]
@ -2944,14 +3093,13 @@ mod tests {
"a session nobody told about a report file has none — nothing is inferred" "a session nobody told about a report file has none — nothing is inferred"
); );
state.set_report_file("n", Some("/tmp/brief-said.md")); state.set_report_file("n", Some("/tmp/brief-said.md"));
state.reserve("n"); goal_reached(&state, "n", None, None);
goal_reached(&state, "n", None, None).expect("a running session");
assert_eq!( assert_eq!(
state.report_file("n"), state.report_file("n"),
Some(PathBuf::from("/tmp/brief-said.md")), Some(PathBuf::from("/tmp/brief-said.md")),
"a signal with no path must not erase what the brief named" "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!( assert_eq!(
state.report_file("n"), state.report_file("n"),
Some(PathBuf::from("/tmp/actually-wrote.md")), Some(PathBuf::from("/tmp/actually-wrote.md")),

View file

@ -350,9 +350,12 @@ in
# crate, own process): spawns nested claude sessions on request, serves # crate, own process): spawns nested claude sessions on request, serves
# the `start`/`continue`/`status`/`interrupt` MCP tools directly over # the `start`/`continue`/`status`/`interrupt` MCP tools directly over
# streamable-http on `hyperhive.mcp.subagentHttpPort`. The same port also # streamable-http on `hyperhive.mcp.subagentHttpPort`. The same port also
# serves a second, subagent-facing route (`/signal/mcp`: # serves a second, subagent-facing route (`/signal/mcp/<token>`:
# `goal_reached`/`need_help`) that the daemon hands each subagent it # `goal_reached`/`need_help`) — not something an agent's own config points
# spawns — not something an agent's own config points at. No task files — # at: the daemon mints each subagent it spawns its own token and writes
# that one URL into that subagent's own `--mcp-config`, which is how a
# signal's identity comes from the endpoint instead of from a `name` the
# caller could have filled in with a sibling's. No task files —
# this daemon's only state is in-memory, live only as long as the process # this daemon's only state is in-memory, live only as long as the process
# is (see `hive-subagent-mcp/src/session.rs`'s module doc); a restart # is (see `hive-subagent-mcp/src/session.rs`'s module doc); a restart
# stops whatever's running, the actual claude session survives # stops whatever's running, the actual claude session survives