diff --git a/crates/tui/src/fleet/role.rs b/crates/tui/src/fleet/role.rs index 3c55fb232a..fe729af6b7 100644 --- a/crates/tui/src/fleet/role.rs +++ b/crates/tui/src/fleet/role.rs @@ -181,7 +181,9 @@ impl FleetRole { #[must_use] pub fn description(&self) -> &'static str { match self { - Self::Worker => "General-purpose agent with full tool access for multi-step tasks.", + Self::Worker => { + "General-purpose agent with full tool access for multi-step tasks. Write-capable: claims the parent workspace unless write_roots narrows it, so parallel generals contend — pick a read-only role for research." + } Self::Scout => "Fast read-only exploration for codebase search and analysis.", Self::Planner => { "Grounded strategy: reads the workspace and the web, runs read-only probes, never mutates." diff --git a/crates/tui/src/tools/subagent/coord.rs b/crates/tui/src/tools/subagent/coord.rs index 96c537d777..6dafaab632 100644 --- a/crates/tui/src/tools/subagent/coord.rs +++ b/crates/tui/src/tools/subagent/coord.rs @@ -24,7 +24,7 @@ use crate::tools::spec::{ /// session deaf to typed input, and settled children already report back as /// `` sentinels that start a fresh turn (#4097). pub(crate) const COORD_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 30; -const COORD_WAIT_MIN_TIMEOUT_SECS: u64 = 1; +pub(crate) const COORD_WAIT_MIN_TIMEOUT_SECS: u64 = 1; pub(crate) const COORD_WAIT_MAX_TIMEOUT_SECS: u64 = 120; const COORD_WAIT_CHECK_INTERVAL: Duration = Duration::from_millis(250); const RECENT_PROGRESS_LIMIT: usize = 8; @@ -714,10 +714,7 @@ async fn wait_for_all_children( manager: SharedSubAgentManager, context: &ToolContext, ) -> Result { - let timeout_secs = input - .get("timeout_secs") - .or_else(|| input.get("timeout")) - .and_then(Value::as_u64) + let timeout_secs = super::wait_timeout_secs(input)? .unwrap_or(COORD_WAIT_DEFAULT_TIMEOUT_SECS) .clamp(COORD_WAIT_MIN_TIMEOUT_SECS, COORD_WAIT_MAX_TIMEOUT_SECS); let timeout = Duration::from_secs(timeout_secs); @@ -871,10 +868,7 @@ async fn wait_for_activity( manager: SharedSubAgentManager, context: &ToolContext, ) -> Result { - let timeout_secs = input - .get("timeout_secs") - .or_else(|| input.get("timeout")) - .and_then(Value::as_u64) + let timeout_secs = super::wait_timeout_secs(input)? .unwrap_or(COORD_WAIT_DEFAULT_TIMEOUT_SECS) .clamp(COORD_WAIT_MIN_TIMEOUT_SECS, COORD_WAIT_MAX_TIMEOUT_SECS); let timeout = Duration::from_secs(timeout_secs); @@ -2157,6 +2151,31 @@ mod tests { ); } + #[tokio::test] + async fn wait_rejects_a_malformed_timeout_on_every_face_like_completion() { + // `timeout_secs` is advertised as an integer and all three faces + // share one read, so a wrong-typed value must be a hard error + // everywhere — never the silent 30s default the `all`/`activity` + // arms used before the read was unified, which hid a mis-sized + // bound from the parent. + let tmp = tempdir().unwrap(); + let manager = empty_manager(tmp.path()); + for until in ["all", "activity"] { + let error = dispatch_wait( + &json!({ "until": until, "timeout_secs": "90" }), + Arc::clone(&manager), + &ToolContext::new(tmp.path()), + ) + .await + .expect_err("a string timeout must be a hard error, not a silent 30s default") + .to_string(); + assert!( + error.contains("field 'timeout_secs' must be a non-negative integer"), + "{error}" + ); + } + } + #[tokio::test] async fn wait_until_all_returns_immediately_with_no_children() { let tmp = tempdir().unwrap(); diff --git a/crates/tui/src/tools/subagent/coord/ledger.rs b/crates/tui/src/tools/subagent/coord/ledger.rs index feacd00cdc..36d98e505b 100644 --- a/crates/tui/src/tools/subagent/coord/ledger.rs +++ b/crates/tui/src/tools/subagent/coord/ledger.rs @@ -29,6 +29,12 @@ use crate::tools::subagent::normalize_claim_path; /// Decision records, write-scope claims, and contention detection for parallel /// agent work. Parallel work may proceed only when scopes and contracts do not /// collide silently. +/// Marker at the head of a write-scope contention refusal (see +/// [`CoordinationLedger::register_claim`]); the spawn path in the tool layer +/// keys its read-only recovery guidance on this prefix, and the shared-claim +/// e2e test pins the pairing. +pub(crate) const WRITE_SCOPE_CONTENTION_PREFIX: &str = "write-scope contention"; + /// Status of a coordination decision. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -540,7 +546,7 @@ impl CoordinationLedger { self.contentions.push(receipt); trim_front(&mut self.contentions, COORDINATION_RECORD_LIMIT); return Err(format!( - "write-scope contention with {} (roots: {:?}, files: {:?}, contracts: {:?}); serialize the work, narrow the claim, or use worktree isolation", + "{WRITE_SCOPE_CONTENTION_PREFIX} with {} (roots: {:?}, files: {:?}, contracts: {:?}); serialize the work, narrow the claim, or use worktree isolation", existing.claim.owner, existing.claim.roots, existing.claim.exact_files, diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 89696a03d3..f9c4f0d49d 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -82,7 +82,8 @@ use crate::worker_profile::{ }; use coord::{ CoordinationDetailMetrics, CoordinationHotPath, CoordinationLedger, DecisionRecord, - DecisionStatus, PersistedWriteClaim, ReconciliationReceipt, WriteScopeClaim, + DecisionStatus, PersistedWriteClaim, ReconciliationReceipt, WRITE_SCOPE_CONTENTION_PREFIX, + WriteScopeClaim, }; pub mod advisor; @@ -403,7 +404,7 @@ fn subagent_perf_enabled() -> bool { const VALID_SUBAGENT_TYPES: &str = "general, explore, planner, reviewer, implement, test, advisor, custom \ (legacy aliases remain accepted: worker, scout, builder, verifier, consultant, general-purpose, general_purpose, default, exploration/explorer, plan/planning/awaiter, review/code-review/code_review, implementer/implementation, verify/verification/validator/tester, oracle)"; -const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes. test: runs tests/validation gates and reports evidence. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture. Legacy aliases remain accepted at deserialization boundaries."; +const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs tests/validation gates and reports evidence. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; // === Types === @@ -4178,6 +4179,45 @@ impl SubAgentManager { agent.session_boot_id.is_empty() || agent.session_boot_id != self.current_session_boot_id } + /// Whether a child still holds its session name against a fresh spawn. + /// + /// Only a live child reserves its name: running, or interrupted (parked; + /// only some interrupts leave a checkpoint `followup` can wake). A child + /// that has settled — completed, failed, cancelled, budget-exhausted — + /// releases the name, so a cancel-and-retry under the same name succeeds + /// instead of forcing the parent to invent suffixes while the error text + /// tells it to "wait for the completion event" that already arrived. + fn session_name_is_reserved(status: &SubAgentStatus) -> bool { + matches!( + status, + SubAgentStatus::Running | SubAgentStatus::Interrupted(_) + ) + } + + /// The live child that currently holds `name`, if any. + /// + /// Names are scoped to the live session: a worker hydrated from a + /// previous session's ledger is invisible to `status`/`peek`/`followup`, + /// so it must not reserve the name either (cloud-agent e2e, 2026-08-30: + /// a fresh `exec` in the same workspace could not spawn `worker-a` again). + fn reserved_session_name_holder(&self, name: &str) -> Option<&SubAgent> { + // At most one holder can pass this filter: the spawn gate refuses a + // second live spawn under a taken name, so first match on the + // unordered map is deterministic in effect. + self.agents.values().find(|existing| { + existing.session_name == name && self.is_live_session_name_holder(existing) + }) + } + + /// The liveness predicate both the spawn gate and name resolution filter + /// on, spelled once so the two cannot drift: running, or interrupted + /// (parked), and this boot's record — a worker hydrated from a previous + /// boot is terminal history that neither reserves a name nor outranks + /// this boot's child in resolution. + fn is_live_session_name_holder(&self, agent: &SubAgent) -> bool { + Self::session_name_is_reserved(&agent.status) && !self.is_from_prior_session(agent) + } + #[must_use] fn with_state_path(mut self, path: PathBuf) -> Self { self.state_path = Some(path); @@ -6518,14 +6558,7 @@ impl SubAgentManager { .map(str::trim) .filter(|name| !name.is_empty()) { - // Names are scoped to the live session: a completed worker - // hydrated from a previous session's ledger is invisible to - // `status`/`peek`/`followup`, so it must not reserve the name - // either (cloud-agent e2e, 2026-08-30: a fresh `exec` in the same - // workspace could not spawn `worker-a` again). - if let Some(existing) = self.agents.values().find(|existing| { - existing.session_name == name && !self.is_from_prior_session(existing) - }) { + if let Some(existing) = self.reserved_session_name_holder(name) { // #3020: Include elapsed time so the parent can distinguish a // live worker from a stale/failed earlier spawn (#2656). let elapsed = existing.started_at.elapsed(); @@ -6533,10 +6566,22 @@ impl SubAgentManager { "{} ago", crate::elapsed::format_elapsed_secs(elapsed.as_secs()) ); + // Advice the holder's status can actually act on: a running + // child can be waited out or cancelled, but an interrupted + // one is already terminal — its completion event has fired + // and cancel only touches Running children — so only + // followup (checkpoint permitting) or a different name + // moves forward. + let advice = if matches!(existing.status, SubAgentStatus::Interrupted(_)) { + "It is parked: resume it with action=followup if its checkpoint \ + still allows, or open a new agent with a different name." + } else { + "Wait for its completion event, cancel it, or open a new agent \ + with a different name." + }; return Err(anyhow!( - "Sub-agent session name '{name}' is already in use by agent_id '{}' \ - (status: {}, started {since}). \ - Wait for its completion event, or open a new agent with a different name.", + "Sub-agent session name '{name}' is still held by agent_id '{}' \ + (status: {}, started {since}). {advice}", existing.id, subagent_status_name(&existing.status) )); @@ -6612,6 +6657,7 @@ impl SubAgentManager { let persisted_claim = match persisted_claim { Ok(claim) => claim, Err(error) => { + let error = spawn_claim_error_with_guidance(error); if let Err(persist_error) = self.persist_state_synchronously() { if let Some((worker_records, coordination)) = durable_launch_snapshot.as_ref() { self.worker_records = worker_records.clone(); @@ -6958,13 +7004,6 @@ impl SubAgentManager { active_session_id: Option<&str>, ) -> Result { let agent_ref = agent_ref.trim(); - if let Some(agent) = self.agents.get(agent_ref) - && active_session_id - .is_none_or(|session_id| self.agent_is_owned_by_session(agent, session_id)) - { - return Ok(agent.id.clone()); - } - let matches = self .agents .values() @@ -6973,16 +7012,50 @@ impl SubAgentManager { active_session_id .is_none_or(|session_id| self.agent_is_owned_by_session(agent, session_id)) }) - .map(|agent| agent.id.clone()) .collect::>(); - match matches.as_slice() { - [id] => Ok(id.clone()), - [] => Err(anyhow!("Agent session {agent_ref} not found")), - _ => Err(anyhow!( - "Agent session name '{agent_ref}' is ambiguous; use an agent_id" - )), + // A name is released once its holder settles (see + // `reserved_session_name_holder`), so a retried spawn can leave a + // settled and a live child sharing one name. The live holder is the + // one the name means now; among settled-only holders the newest + // spawn is the one the parent just retried. Liveness is the spawn + // gate's own predicate (`is_live_session_name_holder`): an + // interrupted record hydrated from a previous boot is terminal + // history, not the name's live holder, so it can never outrank this + // boot's child. + let live = matches + .iter() + .filter(|agent| self.is_live_session_name_holder(agent)) + .collect::>(); + match live.as_slice() { + [agent] => return Ok(agent.id.clone()), + [_, _, ..] => { + return Err(anyhow!( + "Agent session name '{agent_ref}' is ambiguous; use an agent_id" + )); + } + _ => {} + } + + // The exact-id fast path only applies when the ref is not any + // child's session name: `validate_session_name` accepts id-shaped + // names and an unnamed child takes `session_name = id`, so an id + // string that is also a name must follow the name rules above — a + // settled record must not shadow the child that name now means, + // live or newest-settled. A ref that names no child still reaches + // its own record by id (a named child stays reachable by its id). + if matches.is_empty() + && let Some(agent) = self.agents.get(agent_ref) + && active_session_id + .is_none_or(|session_id| self.agent_is_owned_by_session(agent, session_id)) + { + return Ok(agent.id.clone()); } + matches + .iter() + .max_by_key(|agent| agent.started_at) + .map(|agent| agent.id.clone()) + .ok_or_else(|| anyhow!("Agent session {agent_ref} not found")) } fn agent_is_owned_by_session(&self, agent: &SubAgent, active_session_id: &str) -> bool { @@ -8492,21 +8565,24 @@ impl ToolSpec for AgentTool { "Use action=roster to inspect Fleet roles and host-presented prompt-only profiles before choosing a type or profile. The host-profile listing is capped and unpaged — search a large pool with profile_query. ", "Child run budgets come from Fleet role defaults and operator [subagents] config; budget fields are not advertised (parse-accepted for compatibility). ", "worktree=true gives the child an isolated git worktree — use it whenever parallel writers must not collide with the parent checkout. ", - "A write-capable child defaults write scope to the parent workspace; narrow it with write_roots (repo-relative paths) so parallel children claim disjoint scope. ", + "A write-capable child defaults write scope to the parent workspace; narrow it with write_roots (repo-relative paths) so parallel children claim disjoint scope. Read-only roles (explore, planner, reviewer, test, advisor) claim no scope and never contend — use them for parallel research instead of type=general. ", "Prefer type=implement for write work and type=test (or the Run tool with action=\"verifiers\") after writes settle — dispatch is not completion. ", "Coordinate through this same tool: action=message queues a note without waking the child; action=followup delivers queued notes and wakes a running child for its next user-provenance turn; action=interrupt stops the current child turn while preserving its checkpoint; action=wait blocks without changing child state, and until=\"all\" joins a whole fan-out in one call. ", "action=claim widens your own enforced write scope: pass write_roots before mutating anything a fail-closed write refusal named. Records a durable claim receipt; fails on peer-claim contention; never touches another agent's scope. ", "Action contract: start requires prompt; message/followup require a target and message; peek/interrupt/cancel require a target; claim requires at least one scope entry; roster, status, and wait are unscoped. ", "This is the whole model-facing sub-agent surface; there is no second transport. ", - "In Operate, use detached=true only for independent or long work that must outlive the active turn; a write-capable root start defaults write scope to the parent workspace unless narrowed with write_roots; arbitrary shell remains gated. " + "In Operate, detached=true marks work that must outlive the active turn; arbitrary shell remains gated. " ) } - /// Advertised `agent` schema: exactly 13 fields (#5324, #5123; T7 added - /// `profile_query`, the roster host-profile keyword filter) — action, - /// prompt, type, profile, profile_query, name, agent_id, message, until, - /// detached, worktree, write_roots, resume_from — plus the - /// action-discriminated `dependentSchemas` tree. Every field removed + /// Advertised `agent` schema: exactly 14 fields (#5324, #5123; T7 added + /// `profile_query`, the roster host-profile keyword filter; the wait + /// timeout `timeout_secs` was re-advertised after real fan-outs showed a + /// model told "default 30s, max 120s" with no way to reach the max just + /// repeats short waits) — action, prompt, type, profile, profile_query, + /// name, agent_id, message, until, timeout_secs, detached, worktree, + /// write_roots, resume_from — plus the action-discriminated + /// `dependentSchemas` tree. Every field removed /// from this schema (budgets, model/thinking overrides, worktree-path /// knobs, deliberate/spawn-contract knobs, wait/status extras) stays /// parse-accepted unchanged for saved transcripts, ACP/MCP clients and @@ -8533,7 +8609,15 @@ impl ToolSpec for AgentTool { "until": { "type": "string", "enum": ["completion", "all", "activity"], - "description": "For action=wait. A wait blocks until one child settles or the timeout (default 30s, max 120s) elapses; on timeout the receipt reports timed_out=true with any already-settled children, and full results still arrive as completion sentinels. completion (default) returns when any one child settles. all returns only once every child running at call time has settled, with each outcome — the fan-out join: start the batch, make one wait, then synthesize. activity also returns on progress." + "description": "For action=wait. A wait blocks until one child settles or the timeout (default 30s, max 120s; set with timeout_secs) elapses; on timeout the receipt reports timed_out=true with any already-settled children, and full results still arrive as completion sentinels. completion (default) returns when any one child settles. all returns only once every child running at call time has settled, with each outcome — the fan-out join: start the batch, make one wait with a timeout_secs sized for the batch, then synthesize. activity also returns on progress." + }, + "timeout_secs": { + "type": "integer", + "minimum": SUBAGENT_WAIT_MIN_TIMEOUT_SECS, + "maximum": SUBAGENT_WAIT_MAX_TIMEOUT_SECS, + "description": format!( + "For action=wait: seconds to block before the receipt returns timed_out=true. Default {SUBAGENT_WAIT_DEFAULT_TIMEOUT_SECS}, max {SUBAGENT_WAIT_MAX_TIMEOUT_SECS}. Size it to the batch instead of repeating short waits — a fan-out of research children typically needs the maximum." + ) }, "agent_id": { "type": "string", @@ -9210,6 +9294,18 @@ const SUBAGENT_WAIT_CHECK_INTERVAL: Duration = Duration::from_millis(250); /// engine turn's cancel token interrupts the block, and no lock is held /// across an await. /// +/// The one wait-bound read shared by every wait face (`until=completion`, +/// `until=all`, `until=activity`): the advertised `timeout_secs` plus its +/// parse-accepted `timeout` alias. The alias is a documented compat contract +/// (docs/SUBAGENTS.md), so the read lives in this one function — every face +/// rejects a malformed value identically instead of silently falling back to +/// the 30s default — and the alias pin test calls it directly, so dropping +/// the alias fails loudly there instead of silently defaulting saved +/// transcripts. +fn wait_timeout_secs(input: &Value) -> Result, ToolError> { + parse_optional_u64(input, &["timeout_secs", "timeout"]) +} + /// This is the `until=completion` arm only. `until=all` — the fan-out join /// that blocks for *every* watched child — lives in /// [`coord::dispatch_wait`]; both arms are reachable from `agents/wait` and @@ -9219,7 +9315,7 @@ async fn wait_for_subagents_from_input( manager: SharedSubAgentManager, context: &ToolContext, ) -> Result { - let timeout_secs = parse_optional_u64(input, &["timeout_secs", "timeout"])? + let timeout_secs = wait_timeout_secs(input)? .unwrap_or(SUBAGENT_WAIT_DEFAULT_TIMEOUT_SECS) .clamp( SUBAGENT_WAIT_MIN_TIMEOUT_SECS, @@ -9789,6 +9885,26 @@ fn mint_child_route_receipt( Ok(receipt) } +/// Recovery guidance for a spawn refused by write-scope contention. +/// +/// The ledger's own text ("serialize the work, narrow the claim, or use +/// worktree isolation") is written for writers. The refusal seen most in +/// practice is a research fan-out launched as several `type=general` children +/// with no `write_roots`: each defaults to the whole workspace and all but +/// the first collide, although none of them needed to write at all. The +/// shortest fix for that case is the read-only path, so name it here; the +/// `claim` action shares the ledger message and keeps the writer-only text. +fn spawn_claim_error_with_guidance(error: String) -> String { + if !error.starts_with(WRITE_SCOPE_CONTENTION_PREFIX) { + return error; + } + format!( + "{error}. Read-only children never contend: for research or inspection launch a \ + read-only type (explore, planner, reviewer, test, advisor) or a prompt-only start; \ + parallel writers need disjoint write_roots or worktree=true." + ) +} + fn apply_spawn_write_authority(runtime: &mut SubAgentRuntime, request: &SpawnRequest) { if request.write_authority != Some(SpawnWriteAuthority::ReadOnly) { return; diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 268caba310..36a6db7265 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -5533,9 +5533,12 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { } assert!(agent_schema["properties"].get("role").is_none()); // #5324/#5123: the advertised surface stays minimal — the 12 post-#5324 - // fields plus the T7 roster discovery filter `profile_query`. Budgets, + // fields plus the T7 roster discovery filter `profile_query`, plus the + // re-advertised wait bound `timeout_secs` (a model told "default 30s, max + // 120s" with no field to reach the max just repeats 30s waits; the + // 2026-10-23 four-child research fan-out needed eight of them). Budgets, // model/thinking overrides, worktree-path knobs and spawn-contract - // ceremony moved off the schema; the parser still accepts them for + // ceremony stay off the schema; the parser still accepts them for // replay compat (pinned by // `agent_tool_unadvertised_fields_remain_parse_accepted` below). let mut advertised: Vec<&str> = agent_schema["properties"] @@ -5555,6 +5558,7 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { "profile_query", "prompt", "resume_from", + "timeout_secs", "type", "until", "worktree", @@ -5563,7 +5567,7 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { expected.sort_unstable(); assert_eq!( advertised, expected, - "the agent tool must advertise exactly the 13-field surface: {}", + "the agent tool must advertise exactly the 14-field surface: {}", agent_schema["properties"] ); for unadvertised in [ @@ -5586,7 +5590,6 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { "expected_artifact", "exact_files", "coordination_contracts", - "timeout_secs", "reason", "include_archived", // Pre-#5324 precedent: parse-accepted but never advertised. @@ -5625,6 +5628,34 @@ fn wait_schema_text_discloses_timeout_bound_and_timed_out_receipt() { "agent(action=wait) until description must disclose the runtime \ timeout bound and the timed_out receipt:\n{until}" ); + // The bound is only honest if the model can actually reach the max: the + // advertised `timeout_secs` carries the same constants as range and text. + assert!( + until.contains("timeout_secs"), + "until description must name the field that sets the bound:\n{until}" + ); + let timeout_field = &agent_schema["properties"]["timeout_secs"]; + assert_eq!(timeout_field["type"], "integer", "{timeout_field}"); + assert_eq!( + timeout_field["minimum"], + json!(super::SUBAGENT_WAIT_MIN_TIMEOUT_SECS), + "{timeout_field}" + ); + assert_eq!( + timeout_field["maximum"], + json!(super::SUBAGENT_WAIT_MAX_TIMEOUT_SECS), + "{timeout_field}" + ); + let timeout_description = schema_property_description(&agent_schema, "timeout_secs"); + assert!( + timeout_description.contains(&format!( + "Default {}, max {}", + super::SUBAGENT_WAIT_DEFAULT_TIMEOUT_SECS, + super::SUBAGENT_WAIT_MAX_TIMEOUT_SECS + )) && timeout_description.contains("timed_out"), + "timeout_secs description must disclose the default/max and the \ + timed_out receipt:\n{timeout_description}" + ); let wait_tool = AgentsWaitTool::new(manager); let wait_description = wait_tool.description(); @@ -5656,6 +5687,26 @@ fn wait_bound_constants_agree_across_both_wait_faces() { "the agent broadcast face and the agents/wait face must advertise the \ same maximum timeout" ); + assert_eq!( + super::SUBAGENT_WAIT_MIN_TIMEOUT_SECS, + super::coord::COORD_WAIT_MIN_TIMEOUT_SECS, + "the agent broadcast face and the agents/wait face must advertise the \ + same minimum timeout" + ); +} + +#[test] +fn wait_timeout_alias_stays_parse_accepted() { + // docs/SUBAGENTS.md pins the `timeout` alias (same meaning as + // `timeout_secs`) as parse-accepted compat, but no schema list covers + // it. The test calls the production read (`wait_timeout_secs`, the + // function the until=completion arm uses), so dropping the alias from + // that key list fails right here instead of silently defaulting saved + // transcripts to 30s. + let read = |input: serde_json::Value| super::wait_timeout_secs(&input).expect("parse ok"); + assert_eq!(read(json!({"timeout_secs": 5})), Some(5)); + assert_eq!(read(json!({"timeout": 7})), Some(7)); + assert_eq!(read(json!({})), None); } #[test] @@ -5920,10 +5971,10 @@ fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { assert!(action.contains("cancel")); assert!(agent_schema["properties"].get("agent_id").is_some()); assert!(agent_schema["properties"].get("message").is_some()); - // `reason` (interrupt), `timeout_secs` (wait) and `include_archived` - // (status) moved off the advertised schema with #5324; the pinning test - // above asserts their absence and the compat test below their continued - // parse-acceptance. + // `reason` (interrupt) and `include_archived` (status) moved off the + // advertised schema with #5324 and the pinning test above asserts their + // absence; `timeout_secs` returned to the advertised surface in #119, + // and the compat test below still covers both plus the `timeout` alias. } #[test] @@ -9695,6 +9746,423 @@ async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { assert!(!guard.is_from_prior_session(fresh)); } +/// Seed a same-session child holding `name` in the given status; returns its +/// agent id. Shared by the name-release tests below. +async fn seed_named_same_session_agent( + manager: &SharedSubAgentManager, + workspace: &Path, + agent_id: &str, + name: &str, + status: SubAgentStatus, +) -> String { + let boot_id = manager.read().await.session_boot_id().to_string(); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut holder = SubAgent::new( + agent_id.to_string(), + FleetRole::Scout, + "scan".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + Some("Blue".to_string()), + Some(vec!["read_file".to_string()]), + input_tx, + workspace.to_path_buf(), + boot_id, + ); + holder.session_name = name.to_string(); + holder.status = status; + // Older than any child the test spawns afterwards, so "newest" is + // unambiguous even on coarse clocks. + holder.started_at = Instant::now() - Duration::from_secs(60); + let id = holder.id.clone(); + let mut guard = manager.write().await; + guard.agents.insert(id.clone(), holder); + assert!(!guard.is_from_prior_session(&guard.agents[&id])); + id +} + +#[tokio::test] +async fn spawn_session_name_released_once_same_session_holder_settles() { + // 2026-10-23 research fan-out: the parent cancelled `macro-policy`, got + // its completion sentinel, and immediately re-launched under the same + // name — refused with "already in use ... (status: cancelled) ... wait for + // its completion event". A settled child must release its name; only a + // live one (running, or interrupted with a wakeable checkpoint) holds it. + for settled in [ + SubAgentStatus::Cancelled, + SubAgentStatus::Completed, + SubAgentStatus::Failed("tool error".to_string()), + SubAgentStatus::BudgetExhausted, + ] { + let tmp = tempdir().expect("tempdir"); + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 5); + let settled_label = subagent_status_name(&settled); + let settled_id = seed_named_same_session_agent( + &manager, + tmp.path(), + "test_agent_settled", + "macro-policy", + settled, + ) + .await; + + let mut runtime = stub_runtime(); + runtime.manager = Arc::clone(&manager); + runtime.context = ToolContext::new(tmp.path()); + let spawned = { + let mut guard = manager.write().await; + guard + .spawn_background_with_assignment_options( + manager.clone(), + runtime, + FleetRole::Scout, + "retry the macro-policy sweep".to_string(), + make_assignment(), + Some(vec!["read_file".to_string()]), + SubAgentSpawnOptions { + name: Some("macro-policy".to_string()), + ..Default::default() + }, + ) + .unwrap_or_else(|err| { + panic!("a {settled_label} holder must release its session name: {err}") + }) + }; + assert_ne!(spawned.agent_id, settled_id); + + let guard = manager.read().await; + let fresh = guard + .agents + .get(&spawned.agent_id) + .expect("fresh agent registered"); + assert_eq!(fresh.session_name, "macro-policy"); + // The name now means the live retry, not the settled history entry. + assert_eq!( + guard + .resolve_agent_ref("macro-policy") + .expect("re-used name resolves instead of reading as ambiguous"), + spawned.agent_id, + "{settled_label} holder must not shadow the live retry" + ); + } +} + +#[tokio::test] +async fn spawn_session_name_still_held_by_interrupted_same_session_child() { + // An interrupted child is parked, not finished: its name stays reserved + // and the refusal names the live holder plus the advice its status can + // actually act on. Cancel only touches Running children and the + // completion event has already fired, so neither may be advised here. + let tmp = tempdir().expect("tempdir"); + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 5); + let holder_id = seed_named_same_session_agent( + &manager, + tmp.path(), + "test_agent_parked", + "macro-policy", + SubAgentStatus::Interrupted("needs input".to_string()), + ) + .await; + + let mut runtime = stub_runtime(); + runtime.manager = Arc::clone(&manager); + runtime.context = ToolContext::new(tmp.path()); + let err = { + let mut guard = manager.write().await; + guard + .spawn_background_with_assignment_options( + manager.clone(), + runtime, + FleetRole::Scout, + "retry".to_string(), + make_assignment(), + Some(vec!["read_file".to_string()]), + SubAgentSpawnOptions { + name: Some("macro-policy".to_string()), + ..Default::default() + }, + ) + .expect_err("an interrupted holder keeps its session name") + }; + let msg = err.to_string(); + assert!(msg.contains(&holder_id), "names the live holder: {msg}"); + assert!( + msg.contains("interrupted"), + "names the holder status: {msg}" + ); + assert!( + msg.contains("action=followup"), + "points the parent at the resume path for a parked child: {msg}" + ); + assert!( + !msg.contains("cancel it"), + "cancel is a silent no-op on an interrupted child: {msg}" + ); +} + +#[tokio::test] +async fn spawn_session_name_running_holder_advises_wait_or_cancel() { + // A running holder can be waited out or cancelled; the refusal must keep + // naming both escapes (the interrupted branch above drops them). + let tmp = tempdir().expect("tempdir"); + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 5); + let holder_id = seed_named_same_session_agent( + &manager, + tmp.path(), + "test_agent_live", + "macro-policy", + SubAgentStatus::Running, + ) + .await; + + let mut runtime = stub_runtime(); + runtime.manager = Arc::clone(&manager); + runtime.context = ToolContext::new(tmp.path()); + let err = { + let mut guard = manager.write().await; + guard + .spawn_background_with_assignment_options( + manager.clone(), + runtime, + FleetRole::Scout, + "retry".to_string(), + make_assignment(), + Some(vec!["read_file".to_string()]), + SubAgentSpawnOptions { + name: Some("macro-policy".to_string()), + ..Default::default() + }, + ) + .expect_err("a running holder keeps its session name") + }; + let msg = err.to_string(); + assert!(msg.contains(&holder_id), "names the live holder: {msg}"); + assert!( + msg.contains("completion event") && msg.contains("cancel it"), + "a running holder can be waited out or cancelled: {msg}" + ); +} + +#[test] +fn session_name_resolution_prefers_live_holder_then_newest_settled() { + let tmp = tempdir().expect("tempdir"); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 4); + let older_id = manager.insert_test_running_agent("older", tmp.path()); + let newer_id = manager.insert_test_running_agent("newer", tmp.path()); + for (id, age_secs) in [(&older_id, 120), (&newer_id, 60)] { + let agent = manager.agents.get_mut(id).expect("seeded agent"); + agent.session_name = "shared".to_string(); + agent.started_at = Instant::now() - Duration::from_secs(age_secs); + } + + // Two live holders cannot arise through `spawn`, but a hydrated ledger + // could carry them: that is the one case a bare name stays ambiguous. + let err = manager + .resolve_agent_ref("shared") + .expect_err("two live holders stay ambiguous"); + assert!(err.to_string().contains("ambiguous"), "{err}"); + + // The older holder settles: the live one is what the name means. + manager.agents.get_mut(&older_id).expect("older").status = SubAgentStatus::Cancelled; + assert_eq!( + manager + .resolve_agent_ref("shared") + .expect("live holder wins"), + newer_id + ); + + // Only settled holders left: the most recently started one wins, so a + // parent that retried under the same name reads the retry's result. + manager.agents.get_mut(&newer_id).expect("newer").status = SubAgentStatus::Completed; + assert_eq!( + manager + .resolve_agent_ref("shared") + .expect("newest settled holder wins"), + newer_id + ); + + // An interrupted holder is live for name purposes even though `wait` + // treats it as settled. + manager.agents.get_mut(&older_id).expect("older").status = + SubAgentStatus::Interrupted("parked".to_string()); + assert_eq!( + manager + .resolve_agent_ref("shared") + .expect("interrupted holder is live"), + older_id + ); +} + +#[test] +fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { + // Hydration rewrites a child that was Running at shutdown into a + // prior-boot Interrupted record. The spawn gate already excludes prior + // boots (`reserved_session_name_holder`); resolution must exclude them + // from its live filter too, or the ghost would silently outrank this + // boot's child once the retry settles. + let tmp = tempdir().expect("tempdir"); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 4); + let ghost_id = manager.insert_test_running_agent("ghost", tmp.path()); + { + let ghost = manager.agents.get_mut(&ghost_id).expect("ghost"); + ghost.session_name = "shared".to_string(); + ghost.session_boot_id = "boot_prior".to_string(); + ghost.status = SubAgentStatus::Interrupted("restart".to_string()); + ghost.started_at = Instant::now() - Duration::from_secs(300); + } + assert!(manager.is_from_prior_session(&manager.agents[&ghost_id])); + + // A lone ghost still resolves — `resume_from` reaches the pre-restart + // checkpoint through exactly this path. + assert_eq!( + manager + .resolve_agent_ref("shared") + .expect("lone ghost stays resolvable"), + ghost_id + ); + + // The current boot respawns the name: the live retry wins outright + // instead of reading as ambiguous against the ghost. + let retry_id = manager.insert_test_running_agent("retry", tmp.path()); + { + let retry = manager.agents.get_mut(&retry_id).expect("retry"); + retry.session_name = "shared".to_string(); + retry.started_at = Instant::now() - Duration::from_secs(60); + } + assert_eq!( + manager + .resolve_agent_ref("shared") + .expect("current-boot live holder beats the prior-boot ghost"), + retry_id + ); + + // The regression: once the retry settles, the ghost must not hijack the + // name — the settled retry is what the parent just waited on. + manager.agents.get_mut(&retry_id).expect("retry").status = SubAgentStatus::Completed; + assert_eq!( + manager + .resolve_agent_ref("shared") + .expect("settled current-boot retry outranks the prior-boot ghost"), + retry_id + ); +} + +#[tokio::test] +async fn id_shaped_session_name_resolves_by_the_name_rules_not_the_settled_id() { + // `validate_session_name` accepts id-shaped names and an unnamed child + // takes `session_name = id`, so a retry can legitimately hold a settled + // sibling's agent id as its session name. The exact-id fast path must + // not let the settled record shadow the child the name now means. + let tmp = tempdir().expect("tempdir"); + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 5); + let settled_id = seed_named_same_session_agent( + &manager, + tmp.path(), + "agent_7e2950c4", + "agent_7e2950c4", + SubAgentStatus::Completed, + ) + .await; + let live_id = seed_named_same_session_agent( + &manager, + tmp.path(), + "agent_9f83d2a1", + "agent_7e2950c4", + SubAgentStatus::Running, + ) + .await; + // Backdate the settled record so "newest settled" is deterministic. + { + let mut guard = manager.write().await; + guard + .agents + .get_mut(&settled_id) + .expect("settled") + .started_at = Instant::now() - Duration::from_secs(300); + } + + // While the retry runs, the id string is its session name: status/ + // interrupt/cancel by that ref must reach the live child, not the + // settled id-holder. + assert_eq!( + manager + .read() + .await + .resolve_agent_ref("agent_7e2950c4") + .expect("the live holder owns the id-shaped name"), + live_id, + "a settled id must not shadow the live name holder" + ); + + // After the retry settles too, the name rules still govern — the + // newest settled holder (the retry the parent just waited on) wins + // over the older id-holder. + { + let mut guard = manager.write().await; + guard.agents.get_mut(&live_id).expect("live").status = SubAgentStatus::Completed; + } + assert_eq!( + manager + .read() + .await + .resolve_agent_ref("agent_7e2950c4") + .expect("newest settled holder wins"), + live_id + ); +} + +#[test] +fn spawn_contention_error_names_the_read_only_path() { + // The ledger text is written for writers. The refusal seen in the + // 2026-10-23 fan-out was four `type=general` research children with no + // write_roots, none of which needed to write; the recovery the parent + // actually needs is the read-only path, so the spawn refusal names it. + let ledger_error = "write-scope contention with agent_7e2950c4 (roots: [\".\"], files: [], contracts: []); serialize the work, narrow the claim, or use worktree isolation".to_string(); + let guided = spawn_claim_error_with_guidance(ledger_error.clone()); + assert!(guided.starts_with(&ledger_error), "{guided}"); + assert!( + guided.contains("Read-only children never contend"), + "{guided}" + ); + for role in ["explore", "planner", "reviewer", "test", "advisor"] { + assert!(guided.contains(role), "guidance must name {role}: {guided}"); + } + assert!( + guided.contains("write_roots") && guided.contains("worktree=true"), + "{guided}" + ); + + // Other claim failures (capacity, schema) keep their own text. + let other = "write-claim capacity is 64 active owners; complete, serialize, or isolate existing work before admitting another writer".to_string(); + assert_eq!(spawn_claim_error_with_guidance(other.clone()), other); +} + +#[test] +fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { + // The roster entry, the type enum text and the tool description are the + // three places a model reads before picking `general` for a research + // fan-out; each must say that generals claim the workspace and that the + // read-only roles never contend. + let roster = FleetRole::Worker.description(); + assert!(roster.contains("claims the parent workspace"), "{roster}"); + assert!(roster.contains("read-only role"), "{roster}"); + + assert!( + SUBAGENT_TYPE_DESCRIPTION.contains("claims the parent workspace") + && SUBAGENT_TYPE_DESCRIPTION.contains("contend"), + "{SUBAGENT_TYPE_DESCRIPTION}" + ); + + let tmp = tempdir().expect("tempdir"); + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 1); + let tool = AgentTool::new(manager, stub_runtime()); + let description = tool.description(); + assert!( + description.contains("never contend") && description.contains("type=general"), + "{description}" + ); +} + #[tokio::test] async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested() { let tmp = tempdir().expect("tempdir"); @@ -9752,6 +10220,18 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( contention.contains("contention") && contention.contains(&first_id), "{contention}" ); + // Guidance contract, pinned end-to-end: the ledger refusal keeps the + // prefix the spawn path keys on, and the spawn path appends the + // read-only recovery. Rewording either side must fail here loudly + // instead of silently disabling the guidance. + assert!( + contention.starts_with(super::WRITE_SCOPE_CONTENTION_PREFIX), + "the spawn guidance keys on this ledger prefix: {contention}" + ); + assert!( + contention.contains("Read-only children never contend"), + "a spawn-path contention refusal must carry the read-only guidance: {contention}" + ); let guard = manager.read().await; let record = guard.get_worker_record(&first_id).expect("worker record"); let manifest = record diff --git a/docs/FLEET.md b/docs/FLEET.md index 941f2e1995..593323404f 100644 --- a/docs/FLEET.md +++ b/docs/FLEET.md @@ -234,7 +234,7 @@ v0.9.9 schema slim (#5324, #5123): the advertised surface no longer carries `model` or `thinking` — a child either runs as a `profile` (whose saved route and thinking tier it uses exactly) or inherits the operator's model. Removed fields stay parse-accepted for saved transcripts, ACP/MCP clients and fleet -configs; see docs/SUBAGENTS.md for the advertised 13-field list and the +configs; see docs/SUBAGENTS.md for the advertised 14-field list and the compat list. When a provider is configured, the review step also offers model-assisted diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 39a6bcbc95..1fba99b4ab 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -381,12 +381,14 @@ provider's resolved fanout, depth, and timeout profile. ## Advertised agent-tool fields (v0.9.9) -The model-facing `agent` tool schema advertises exactly **13 fields** +The model-facing `agent` tool schema advertises exactly **14 fields** (#5324, #5123; T7 later added `profile_query`, the roster host-profile -keyword filter): +keyword filter; `timeout_secs` was re-advertised for `action=wait` — see +below): `action`, `prompt`, `type`, `profile`, `profile_query`, `name`, `agent_id`, -`message`, `until`, `detached`, `worktree`, `write_roots`, `resume_from` +`message`, `until`, `timeout_secs`, `detached`, `worktree`, `write_roots`, +`resume_from` plus the action-discriminated `dependentSchemas` tree (`start` requires `prompt`; `message`/`followup` require a target and `message`; `peek`/ @@ -394,6 +396,14 @@ plus the action-discriminated `dependentSchemas` tree (`start` requires pinned prompt prefix, so upgrading re-fills the provider KV prefix once per session (docs/CACHE.md; accepted at the v0.9.9 boundary). +`timeout_secs` (integer, 1..=120, default 30) bounds a single `action=wait` +block. It had been dropped with the other lifecycle extras, but the `until` +text kept promising "default 30s, max 120s": a model with no field to reach +the max simply repeated 30-second waits — a four-child research fan-out +observed on 2026-10-23 needed eight of them while the receipt kept saying +"do not poll". Advertising the bound lets the parent size one wait to the +batch. + **Parse-accepted but unadvertised (compat).** The following inputs were removed from the advertised schema but remain accepted for saved transcripts, ACP/MCP clients, fleet execution data, and internal/operator compatibility. @@ -411,13 +421,42 @@ intersects them with live policy: `cwd`, `worktree_path`, `worktree_branch`, `worktree_base` - spawn contract: `deliberate`, `dependencies`, `acceptance`, `expected_artifact`, `exact_files`, `coordination_contracts` -- lifecycle extras: `timeout_secs` (wait), `reason` (interrupt), - `include_archived` (status), and `token_budget` +- lifecycle extras: `reason` (interrupt), `include_archived` (status), and + `token_budget`; the wait alias `timeout` (same meaning as `timeout_secs`) Authority never widens: the #5426/#5435 containment clamps are unchanged — `write_authority` moves to roles/profiles, and delegation can only narrow inherited authority. +### Session names and parallel read-only fan-outs + +A `name` passed to `action=start` is reserved only while its holder is live: +running, or interrupted (parked; only some interrupts leave a checkpoint +`followup` can wake). Once the child settles (completed, failed, cancelled, +budget-exhausted) the name is free, so a cancel-and-retry under the same name +succeeds. If a settled and a live child share a name, the name resolves to +the live one; among settled-only holders it resolves to the most recent +spawn. The held-name refusal says what the holder's status can actually do: a +running child can be waited out or cancelled; an interrupted one is already +terminal (its completion event has fired and `cancel` only touches Running +children), so the advice is `action=followup` if its checkpoint still allows, +or a different name. A ref that is no child's session name still reaches its +record by exact `agent_id`; when the string is also a session name — unnamed +children take `session_name = id`, and `name` accepts id-shaped strings — +the name rules above win. A record hydrated from an older boot is history for +resolution too — it never outranks the current boot's child — so the +bare-name "ambiguous" refusal only guards state spawn cannot produce; use the +`agent_id` if you ever meet it. + +`type=general` and `type=implement` are write-capable and claim the whole +parent workspace unless `write_roots` narrows them, so several started +without disjoint `write_roots` collide on that claim even when none of them +writes. For parallel research or inspection use a read-only role (`explore`, +`planner`, `reviewer`, `test`, `advisor`), a prompt-only start, or +`type=custom` without declared write authority (it then starts read-only): +read-only children claim no scope and never contend. The spawn-time +contention refusal names this path. + ## Child budgets (steps, wall time) Per-child run budgets are no longer per-call schema fields (#5324). They come diff --git a/docs/zh_hans/FLEET.md b/docs/zh_hans/FLEET.md index 2f1a0a07b3..df9652850c 100644 --- a/docs/zh_hans/FLEET.md +++ b/docs/zh_hans/FLEET.md @@ -56,7 +56,7 @@ Fleet 状态存储在工作区下的 `.codewhale/fleet.jsonl`。worker 日志与 选择具体模型会显式固定其 provider:保存的配置同时记录 `model` 与 `provider` 字段,因此它命名的 route 不依赖于配置稍后加载时碰巧处于活动状态的 provider。在 review 步骤按 **Enter**("start")会在同一屏内联预览确切的首个配置 TOML;在你保存之前什么都不会写入。`provider` 字段可以是内置 provider id(如 `openrouter`),也可以是配置在 `[providers.]` 下的用户命名 OpenAI 兼容 provider(如 `lm-studio`);启动路径保留该 id,并在 provider 未配置时 fail closed。 -自 v0.9.9 schema 精简(#5324, #5123)以来,配置也是模型面向的 `agent` 工具选择 route 的方式:对外公布的表面不再携带 `model` 或 `thinking`——子任务要么以 `profile` 运行(精确使用其保存的 route 与 thinking 级别),要么继承操作者的 model。已移除的字段对已保存的 transcript、ACP/MCP 客户端与 Fleet 配置仍然保持可解析;对外公布的 12 字段列表与兼容列表见 docs/SUBAGENTS.md。 +自 v0.9.9 schema 精简(#5324, #5123)以来,配置也是模型面向的 `agent` 工具选择 route 的方式:对外公布的表面不再携带 `model` 或 `thinking`——子任务要么以 `profile` 运行(精确使用其保存的 route 与 thinking 级别),要么继承操作者的 model。已移除的字段对已保存的 transcript、ACP/MCP 客户端与 Fleet 配置仍然保持可解析;对外公布的 14 字段列表与兼容列表见 docs/SUBAGENTS.md。 当配置了 provider 时,review 步骤还会在显式的保存前预览门控之后提供模型辅助起草: diff --git a/docs/zh_hans/SUBAGENTS.md b/docs/zh_hans/SUBAGENTS.md index b19edfcb1f..fa4c257d9a 100644 --- a/docs/zh_hans/SUBAGENTS.md +++ b/docs/zh_hans/SUBAGENTS.md @@ -194,22 +194,30 @@ max_admitted = 12 ## 对外公布的 agent 工具字段(v0.9.9) -面向模型的 `agent` 工具 schema 正好公布 **12 个字段**(#5324、#5123): +面向模型的 `agent` 工具 schema 正好公布 **14 个字段**(#5324、#5123;T7 后来加入 `profile_query`,即 roster 的宿主 profile 关键字过滤;`timeout_secs` 为 `action=wait` 重新公布——见下文): -`action`、`prompt`、`type`、`profile`、`name`、`agent_id`、`message`、`until`、`detached`、`worktree`、`write_roots`、`resume_from` +`action`、`prompt`、`type`、`profile`、`profile_query`、`name`、`agent_id`、`message`、`until`、`timeout_secs`、`detached`、`worktree`、`write_roots`、`resume_from` 外加按 action 区分的 `dependentSchemas` 树(`start` 需要 `prompt`;`message`/`followup` 需要目标和 `message`;`peek`/`interrupt`/`cancel` 需要目标)。schema 变更是钉死的提示词前缀的一部分,所以升级会在每个会话中重新填充一次 provider KV 前缀(docs/CACHE.md;在 v0.9.9 边界接受)。 +`timeout_secs`(整数,1..=120,默认 30)限制单次 `action=wait` 阻塞的时长。它曾随其他生命周期附加字段一起被移出公布的 schema,但 `until` 文案仍承诺"默认 30 秒、上限 120 秒":拿不到任何字段去触及上限的模型只会反复做 30 秒的等待。重新公布后,父代理可以按整个批次来设定一次等待。 + **解析接受但未公布(兼容)。** 以下输入已从公布的 schema 中移除,但仍保持解析接受并按原样生效,因此已保存的转录、ACP/MCP 客户端和 Fleet 配置照旧重放——`token_budget` 已经遵循的正是同一个契约: - 预算:`max_steps`、`wall_time_secs`、`max_depth`(参见[子代理预算](#子代理预算步数墙钟时间)了解默认值现在来自何处) - 路由:`model`、`model_strength`、`thinking`(`profile` 钉死路由和思维层级;没有它时子代理继承操作者模型) - workspace/隔离:`workspace_policy`、`write_authority`、`fork_context`、`cwd`、`worktree_path`、`worktree_branch`、`worktree_base` - 发起契约:`deliberate`、`dependencies`、`acceptance`、`expected_artifact`、`exact_files`、`coordination_contracts` -- 生命周期附加:`timeout_secs`(wait)、`reason`(interrupt)、`include_archived`(status)以及 `token_budget` +- 生命周期附加:`reason`(interrupt)、`include_archived`(status)以及 `token_budget`;wait 别名 `timeout`(与 `timeout_secs` 同义) 权限绝不会变宽:#5426/#5435 的遏制钳制不变——`write_authority` 移到角色/profile,委派只能收窄继承的权限。 +### 会话名与并行只读扇出 + +传给 `action=start` 的 `name` 只在其持有者仍然存活时被保留:运行中,或已中断(挂起,checkpoint 未必能被 `followup` 唤醒)。子代理一旦落定(completed、failed、cancelled、budget-exhausted)即释放名字,因此同名的"取消后重试"可以直接成功,不必另造后缀。若一个已落定与一个存活的子代理共用同名,该名字解析到存活的那一个;当只剩已落定的持有者时,解析到最近一次启动的那一个。同名拒绝会按持有者状态给出可行的建议:运行中的子代理可以等待其完成事件或取消;已中断的已经终态(完成事件已发出,且 `cancel` 只作用于运行中的子代理),因此建议是 `action=followup`(checkpoint 仍允许时)或换一个名字。不被任何子代理用作会话名的引用仍按精确 `agent_id` 解析到其自身记录;当该字符串同时也是某个会话名时(未命名的子代理以 `session_name = id` 为名,且 `name` 接受形似 id 的字符串),以上名字规则优先。从更早一次启动水合(hydrate)而来的记录对解析同样是历史——绝不会盖过当前这次启动的子代理——因此裸名的"ambiguous(歧义)"拒绝只防范 spawn 本无法产生的状态;真遇到时请改用 `agent_id`。 + +`type=general` 与 `type=implement` 具备写能力,除非用 `write_roots` 收窄,否则会认领整个父工作区,因此多个未用互斥 `write_roots` 启动的实例即使都不写入也会在该声明上相撞。并行研究或检查请改用只读角色(`explore`、`planner`、`reviewer`、`test`、`advisor`)、仅提示词的启动(只传 `prompt`),或不声明写权限的 `type=custom`(此时它以只读启动):只读子代理不认领任何范围、永不争用。spawn 时的争用拒绝会指明这条路径。 + ## 子代理预算(步数、墙钟时间) 每个子代理的运行预算不再是调用级 schema 字段(#5324)。它们按顺序来自: