Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/tui/src/fleet/role.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
37 changes: 28 additions & 9 deletions crates/tui/src/tools/subagent/coord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use crate::tools::spec::{
/// session deaf to typed input, and settled children already report back as
/// `<codewhale:subagent.done>` 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;
Expand Down Expand Up @@ -714,10 +714,7 @@ async fn wait_for_all_children(
manager: SharedSubAgentManager,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
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);
Expand Down Expand Up @@ -871,10 +868,7 @@ async fn wait_for_activity(
manager: SharedSubAgentManager,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
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);
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion crates/tui/src/tools/subagent/coord/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading