From 2d9fce9d6cbba81c9ceb7050b04881548dfef744 Mon Sep 17 00:00:00 2001 From: CodeWhale Agent Date: Mon, 29 Jun 2026 16:53:56 -0700 Subject: [PATCH] fix(tui): allow agent starts to join parallel dispatch batches (#3801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent tool now reports starts_detached_for(action=start) = true, enabling independent agent launches to join parallel batches via the detached-start path in plan_tool_execution_batches. In auto-approved modes (YOLO), multiple agent calls in one model turn no longer serialize under the global tool-exec write lock — they dispatch concurrently up to configured concurrency limits. Also: - is_read_only_for returns true for status/peek (read-only queries) - supports_parallel_for returns true for status/peek - approval_requirement_for returns Auto for status/peek (no approval needed) Tests: agent_start_detached_plans_join_single_parallel_batch, agent_start_detached_plans_batch_with_readonly_tools. Closes #3801 (child of #3800). --- crates/tui/src/core/engine/tests.rs | 62 ++++++++++++++++++++++++++++ crates/tui/src/tools/subagent/mod.rs | 34 +++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index dcd05c460..f932568f3 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1168,6 +1168,68 @@ fn background_verifier_starts_batch_with_readonly_tools_when_auto_approved() { } } +// #3801: agent `action=start` plans with `detached_start=true` and no approval +// (YOLO / auto-approve mode) should all join one parallel batch instead of +// being serialized N ways under the global tool-execution write lock. +#[test] +fn agent_start_detached_plans_join_single_parallel_batch() { + // Simulate 4 independent `agent start` calls — each is a detached_start, + // not read-only, not parallel-safe in the read-only sense, but qualifies + // for the detached-start parallel-batch path. + let plans: Vec = (0..4) + .map(|i| { + let mut plan = make_plan_at(i, false, false, false, false); + plan.name = "agent".to_string(); + plan.detached_start = true; + plan + }) + .collect(); + + let batches = plan_tool_execution_batches(plans); + assert_eq!( + batches.len(), + 1, + "all 4 agent starts should form 1 parallel batch" + ); + match &batches[0] { + ToolExecutionBatch::Parallel(plans) => { + assert_eq!(plans.len(), 4); + assert!( + plans.iter().all(|p| p.detached_start), + "every plan in the parallel batch should be a detached_start" + ); + } + ToolExecutionBatch::Serial(_) => { + panic!("agent starts should be parallel, not serial"); + } + } +} + +// #3801: mixed agent starts and read-only tools should coexist in a parallel batch. +#[test] +fn agent_start_detached_plans_batch_with_readonly_tools() { + let mut grep_a = make_plan_at(0, true, true, false, false); + grep_a.name = "grep_files".to_string(); + + let mut agent_start = make_plan_at(1, false, false, false, false); + agent_start.name = "agent".to_string(); + agent_start.detached_start = true; + + let mut grep_b = make_plan_at(2, true, true, false, false); + grep_b.name = "grep_files".to_string(); + + let batches = plan_tool_execution_batches(vec![grep_a, agent_start, grep_b]); + assert_eq!(batches.len(), 1); + match &batches[0] { + ToolExecutionBatch::Parallel(plans) => { + assert_eq!(plans.len(), 3); + } + ToolExecutionBatch::Serial(_) => { + panic!("read-only tools + detached agent start should form 1 parallel batch"); + } + } +} + #[test] fn successful_update_plan_ends_plan_mode_turn_immediately() { assert!(should_stop_after_plan_tool( diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 678cdfa8d..445060066 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -3531,6 +3531,40 @@ impl ToolSpec for AgentTool { ApprovalRequirement::Required } + /// #3801: status and peek are read-only queries — no approval needed. + fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement { + match parse_agent_tool_action(input) { + Ok(AgentToolAction::Status) | Ok(AgentToolAction::Peek) => ApprovalRequirement::Auto, + _ => ApprovalRequirement::Required, + } + } + + /// #3801: `action=start` launches a background agent and returns immediately — + /// it is a detached start that should not hold the global tool-exec write + /// lock while the child spins up. In auto-approved modes (YOLO) this lets + /// multiple independent `agent start` calls join a single parallel batch + /// instead of being serialized N ways. + fn starts_detached_for(&self, input: &Value) -> bool { + matches!(parse_agent_tool_action(input), Ok(AgentToolAction::Start)) + } + + /// #3801: Read-only `agent` actions (status, peek) can safely run in + /// parallel batches. + fn supports_parallel_for(&self, input: &Value) -> bool { + matches!( + parse_agent_tool_action(input), + Ok(AgentToolAction::Status) | Ok(AgentToolAction::Peek) + ) + } + + /// #3801: status/peek/cancel actions are read-only queries of manager state. + fn is_read_only_for(&self, input: &Value) -> bool { + matches!( + parse_agent_tool_action(input), + Ok(AgentToolAction::Status) | Ok(AgentToolAction::Peek) + ) + } + async fn execute(&self, input: Value, context: &ToolContext) -> Result { let action = parse_agent_tool_action(&input)?; match action {