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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue