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