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:
parent
b5fc17aa59
commit
34129d776c
11 changed files with 575 additions and 180 deletions
|
|
@ -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
|
||||
serves `start`/`continue`/`status`/`interrupt` directly over
|
||||
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`
|
||||
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
|
||||
|
|
|
|||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2004,6 +2004,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
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,
|
||||
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
|
||||
only as long as the daemon process does. A daemon
|
||||
is, why its run stopped, where it writes its report, and which signal
|
||||
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
|
||||
record of a subagent's existence is claude's own on-disk session
|
||||
(`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,
|
||||
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
|
||||
opted-in subset into its own `--mcp-config` file per turn; an entry left
|
||||
at the default `false` never appears there.
|
||||
opted-in subset into a `--mcp-config` file per session, rewritten each
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -350,9 +350,12 @@ in
|
|||
# crate, own process): spawns nested claude sessions on request, serves
|
||||
# the `start`/`continue`/`status`/`interrupt` MCP tools directly over
|
||||
# streamable-http on `hyperhive.mcp.subagentHttpPort`. The same port also
|
||||
# serves a second, subagent-facing route (`/signal/mcp`:
|
||||
# `goal_reached`/`need_help`) that the daemon hands each subagent it
|
||||
# spawns — not something an agent's own config points at. No task files —
|
||||
# serves a second, subagent-facing route (`/signal/mcp/<token>`:
|
||||
# `goal_reached`/`need_help`) — not something an agent's own config points
|
||||
# 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
|
||||
# is (see `hive-subagent-mcp/src/session.rs`'s module doc); a restart
|
||||
# stops whatever's running, the actual claude session survives
|
||||
|
|
|
|||
Loading…
Reference in a new issue