返回 CodeWhale
coord.rs
根目录 / crates / tui / src / tools / subagent / coord.rs
1 //! Narrow model-facing agent coordination tools.
2 //!
3 //! Keeps `agent` as the creation surface. These five tools wrap existing
4 //! SubAgentManager / mailbox / checkpoint machinery without restoring the
5 //! retired lifecycle theater (`agent_open` / `agent_eval` / …).
6
7 use std::sync::Arc;
8 use std::time::{Duration, Instant};
9
10 use async_trait::async_trait;
11 use serde_json::{Value, json};
12
13 use super::{
14 COMPLETED_AGENT_RETENTION, ParentMailReceipt, SharedSubAgentManager, SubAgentRuntime,
15 SubAgentStatus, parse_agent_ref, subagent_session_projection, subagent_status_name,
16 wait_for_subagents_from_input,
17 };
18 use crate::tools::registry::ToolRegistryBuilder;
19 use crate::tools::spec::{
20 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
21 };
22
23 /// Bounds for `agents/wait`. Short on purpose: a blocked wait makes the
24 /// session deaf to typed input, and settled children already report back as
25 /// `<codewhale:subagent.done>` sentinels that start a fresh turn (#4097).
26 const COORD_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 30;
27 const COORD_WAIT_MIN_TIMEOUT_SECS: u64 = 1;
28 const COORD_WAIT_MAX_TIMEOUT_SECS: u64 = 120;
29 const COORD_WAIT_CHECK_INTERVAL: Duration = Duration::from_millis(250);
30 const RECENT_PROGRESS_LIMIT: usize = 8;
31 pub(super) const COORDINATION_RECORD_LIMIT: usize = 128;
32 const COORDINATION_INSPECT_LIMIT: usize = 24;
33 pub(super) const COORDINATION_PROJECTION_DECISION_LIMIT: usize = 8;
34 pub(super) const COORDINATION_PROJECTION_BYTE_LIMIT: usize = 4096;
35
36 mod ledger;
37
38 // The ledger types moved to `ledger` unchanged and are re-published here, so
39 // `crate::tools::subagent::coord::{DecisionRecord, …}` still resolves for every
40 // consumer that never had reason to know where the definitions sit — the point
41 // of the split was to shorten two files, not to make four other files import
42 // differently.
43 //
44 // A glob, not a list: several of these types are named only from `cfg(test)`
45 // code in other modules, and an explicit `pub use` of those reads as an unused
46 // import in a release build. The glob also keeps each item's own visibility, so
47 // `MAX_RECONCILIATION_RETRIES` stays reachable here without becoming part of
48 // the module's public surface.
49 pub use ledger::*;
50
51 // ── agents/list ──────────────────────────────────────────────────────────
52
53 pub struct AgentsListTool {
54 manager: SharedSubAgentManager,
55 }
56
57 impl AgentsListTool {
58 #[must_use]
59 pub fn new(manager: SharedSubAgentManager) -> Self {
60 Self { manager }
61 }
62 }
63
64 #[async_trait]
65 impl ToolSpec for AgentsListTool {
66 fn model_visible(&self) -> bool {
67 // #5462: `agent` is the sole model-facing sub-agent surface. These
68 // narrow tools stay registered and executable by name so a persisted
69 // transcript replays byte-for-byte, but they are never advertised in
70 // the catalog and can never be returned by `tool_search` — the same
71 // shape `rlm` and `exec_shell` already use.
72 false
73 }
74
75 fn name(&self) -> &'static str {
76 "agents/list"
77 }
78
79 fn description(&self) -> &'static str {
80 "List child agents: ids, parent hierarchy, state, bounded recent progress, and token budget. Read-only coordination view — does not spawn or wake workers."
81 }
82
83 fn input_schema(&self) -> Value {
84 json!({
85 "type": "object",
86 "properties": {
87 "include_archived": {
88 "type": "boolean",
89 "description": "Include prior-session / archived agents. Default false."
90 },
91 "agent_id": {
92 "type": "string",
93 "description": "Optional single agent id or session name to inspect."
94 }
95 },
96 "required": []
97 })
98 }
99
100 fn capabilities(&self) -> Vec<ToolCapability> {
101 vec![ToolCapability::ReadOnly]
102 }
103
104 fn approval_requirement(&self) -> ApprovalRequirement {
105 ApprovalRequirement::Auto
106 }
107
108 fn is_read_only_for(&self, _input: &Value) -> bool {
109 true
110 }
111
112 fn supports_parallel_for(&self, _input: &Value) -> bool {
113 true
114 }
115
116 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
117 let include_archived = input
118 .get("include_archived")
119 .and_then(Value::as_bool)
120 .unwrap_or(false);
121 let agent_ref = parse_agent_ref(&input)?;
122
123 let mut manager = self.manager.write().await;
124 manager.cleanup_for_session(&context.state_namespace, COMPLETED_AGENT_RETENTION);
125 let summaries = if let Some(agent_ref) = agent_ref {
126 let summary = manager
127 .coordination_summary_for_session(
128 &context.state_namespace,
129 &agent_ref,
130 RECENT_PROGRESS_LIMIT,
131 )
132 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
133 vec![summary]
134 } else {
135 manager.list_coordination_summaries_for_session(
136 &context.state_namespace,
137 include_archived,
138 RECENT_PROGRESS_LIMIT,
139 )
140 };
141 drop(manager);
142
143 let payload = json!({
144 "action": "list",
145 "count": summaries.len(),
146 "agents": summaries,
147 });
148 let mut tool_result = ToolResult::json(&payload)
149 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
150 tool_result.metadata = Some(json!({
151 "action": "list",
152 "count": summaries.len(),
153 }));
154 Ok(tool_result)
155 }
156 }
157
158 // ── agents/message ───────────────────────────────────────────────────────
159
160 pub struct AgentsMessageTool {
161 manager: SharedSubAgentManager,
162 caller_agent_id: Option<String>,
163 }
164
165 impl AgentsMessageTool {
166 #[must_use]
167 pub fn new(manager: SharedSubAgentManager) -> Self {
168 Self {
169 manager,
170 caller_agent_id: None,
171 }
172 }
173
174 #[must_use]
175 pub(crate) fn with_optional_caller(mut self, caller_agent_id: Option<String>) -> Self {
176 self.caller_agent_id = caller_agent_id;
177 self
178 }
179 }
180
181 #[async_trait]
182 impl ToolSpec for AgentsMessageTool {
183 fn model_visible(&self) -> bool {
184 // #5462: `agent` is the sole model-facing sub-agent surface. These
185 // narrow tools stay registered and executable by name so a persisted
186 // transcript replays byte-for-byte, but they are never advertised in
187 // the catalog and can never be returned by `tool_search` — the same
188 // shape `rlm` and `exec_shell` already use.
189 false
190 }
191
192 fn name(&self) -> &'static str {
193 "agents/message"
194 }
195
196 fn description(&self) -> &'static str {
197 "Queue a parent message onto a running child without waking it. The message stays queued until a later agents/followup delivers it through the child's live input channel. Use agents/followup directly when you want immediate delivery."
198 }
199
200 fn input_schema(&self) -> Value {
201 json!({
202 "type": "object",
203 "properties": {
204 "agent_id": {
205 "type": "string",
206 "description": "Target child agent id or session name."
207 },
208 "message": {
209 "type": "string",
210 "description": "Message text to queue."
211 }
212 },
213 "required": ["agent_id", "message"]
214 })
215 }
216
217 fn capabilities(&self) -> Vec<ToolCapability> {
218 vec![ToolCapability::RequiresApproval]
219 }
220
221 fn approval_requirement(&self) -> ApprovalRequirement {
222 ApprovalRequirement::Required
223 }
224
225 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
226 let agent_ref =
227 parse_agent_ref(&input)?.ok_or_else(|| ToolError::missing_field("agent_id"))?;
228 let message = input
229 .get("message")
230 .or_else(|| input.get("text"))
231 .and_then(Value::as_str)
232 .map(str::trim)
233 .filter(|s| !s.is_empty())
234 .ok_or_else(|| ToolError::missing_field("message"))?
235 .to_string();
236
237 let receipt = {
238 let mut manager = self.manager.write().await;
239 manager
240 .ensure_caller_controls_descendant_for_session(
241 &context.state_namespace,
242 &agent_ref,
243 self.caller_agent_id.as_deref(),
244 "agents/message",
245 )
246 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
247 manager
248 .queue_running_parent_message_for_session(
249 &context.state_namespace,
250 &agent_ref,
251 message,
252 )
253 .map_err(|err| ToolError::invalid_input(err.to_string()))?
254 };
255
256 let payload = json!({
257 "action": "message",
258 "agent_id": receipt.agent_id,
259 "queued": true,
260 "woke": false,
261 "queue_depth": receipt.queue_depth,
262 "status": receipt.status,
263 "note": "Message queued without waking the child.",
264 });
265 let mut tool_result = ToolResult::json(&payload)
266 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
267 tool_result.metadata = Some(json!({
268 "action": "message",
269 "agent_id": receipt.agent_id,
270 "woke": false,
271 "queue_depth": receipt.queue_depth,
272 }));
273 Ok(tool_result)
274 }
275 }
276
277 // ── agents/followup ──────────────────────────────────────────────────────
278
279 pub struct AgentsFollowupTool {
280 manager: SharedSubAgentManager,
281 caller_agent_id: Option<String>,
282 /// Runtime for checkpoint resume. `None` (legacy/test construction)
283 /// keeps the queue-only followup behavior.
284 runtime: Option<SubAgentRuntime>,
285 }
286
287 impl AgentsFollowupTool {
288 #[must_use]
289 pub fn new(manager: SharedSubAgentManager) -> Self {
290 Self {
291 manager,
292 caller_agent_id: None,
293 runtime: None,
294 }
295 }
296
297 #[must_use]
298 pub fn with_runtime(mut self, runtime: SubAgentRuntime) -> Self {
299 self.runtime = Some(runtime);
300 self
301 }
302
303 #[must_use]
304 pub(crate) fn with_optional_caller(mut self, caller_agent_id: Option<String>) -> Self {
305 self.caller_agent_id = caller_agent_id;
306 self
307 }
308 }
309
310 impl AgentsFollowupTool {
311 async fn followup_one(
312 &self,
313 agent_ref: &str,
314 message: &str,
315 context: &ToolContext,
316 ) -> Result<Value, ToolError> {
317 let mut manager = self.manager.write().await;
318 let (source, target) = manager
319 .continuation_target_for_caller(
320 &context.state_namespace,
321 agent_ref,
322 self.caller_agent_id.as_deref(),
323 "agents/followup",
324 )
325 .map_err(|error| ToolError::invalid_input(error.to_string()))?;
326 let snapshot = manager
327 .get_result(&target)
328 .map_err(|error| ToolError::invalid_input(error.to_string()))?;
329 let resumed_already = source != target;
330 let receipt = if super::subagent_checkpoint_is_continuable(&snapshot)
331 && self.runtime.is_some()
332 {
333 let snapshot = manager
334 .resume_from_checkpoint_for_session(
335 &context.state_namespace,
336 Arc::clone(&self.manager),
337 self.runtime.clone().expect("runtime checked"),
338 &target,
339 message,
340 )
341 .map_err(|error| ToolError::execution_failed(error.to_string()))?;
342 ParentMailReceipt {
343 agent_id: snapshot.agent_id.clone(),
344 status: subagent_status_name(&snapshot.status).to_string(),
345 queue_depth: 0,
346 woke: true,
347 continued_from_checkpoint: true,
348 continuation_handle: None,
349 note: format!(
350 "resumed from checkpoint {source} as {}; original receipt retained",
351 snapshot.agent_id
352 ),
353 }
354 } else if resumed_already && snapshot.status != SubAgentStatus::Running {
355 ParentMailReceipt {
356 agent_id: target, status: subagent_status_name(&snapshot.status).to_string(),
357 queue_depth: 0, woke: false, continued_from_checkpoint: true,
358 continuation_handle: None,
359 note: "Existing continuation has settled; no duplicate worker was started and no message was delivered.".to_string(),
360 }
361 } else {
362 manager
363 .followup_child_for_session(&context.state_namespace, &target, message.to_string())
364 .map_err(|error| ToolError::invalid_input(error.to_string()))?
365 };
366 let child_route = manager
367 .get_worker_record_for_session(&context.state_namespace, &receipt.agent_id)
368 .and_then(|record| record.spec.child_route);
369 Ok(json!({
370 "action": "followup", "from": source, "to": receipt.agent_id,
371 "agent_id": receipt.agent_id, "queued": receipt.woke || receipt.queue_depth > 0,
372 "woke": receipt.woke, "queue_depth": receipt.queue_depth, "status": receipt.status,
373 "continued_from_checkpoint": receipt.continued_from_checkpoint || resumed_already,
374 "continuation_handle": receipt.continuation_handle, "note": receipt.note,
375 "child_route": child_route,
376 }))
377 }
378 }
379
380 #[async_trait]
381 impl ToolSpec for AgentsFollowupTool {
382 fn model_visible(&self) -> bool {
383 // #5462: `agent` is the sole model-facing sub-agent surface. These
384 // narrow tools stay registered and executable by name so a persisted
385 // transcript replays byte-for-byte, but they are never advertised in
386 // the catalog and can never be returned by `tool_search` — the same
387 // shape `rlm` and `exec_shell` already use.
388 false
389 }
390
391 fn name(&self) -> &'static str {
392 "agents/followup"
393 }
394
395 fn description(&self) -> &'static str {
396 "Queue a message and attempt to resume an idle or interrupted child. Running children receive the message on their next step; interrupted_continuable children are resumed from their checkpoint into a fresh agent loop (new agent id, original prompt plus prior conversation tail) when a runtime is attached, and otherwise keep queue-only semantics with the continuation_handle returned."
397 }
398
399 fn input_schema(&self) -> Value {
400 json!({
401 "type": "object",
402 "properties": {
403 "agent_id": {"type": "string"},
404 "agent_ids": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "maxItems": 32},
405 "all_parked": {"type": "boolean", "description": "Continue every owned parked child, up to 32."},
406 "message": {"type": "string", "minLength": 1}
407 },
408 "required": ["message"],
409 "oneOf": [{"required": ["agent_id"]}, {"required": ["agent_ids"]}, {"required": ["all_parked"]}]
410 })
411 }
412
413 fn capabilities(&self) -> Vec<ToolCapability> {
414 vec![ToolCapability::RequiresApproval]
415 }
416
417 fn approval_requirement(&self) -> ApprovalRequirement {
418 ApprovalRequirement::Required
419 }
420
421 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
422 let message = input
423 .get("message")
424 .or_else(|| input.get("text"))
425 .and_then(Value::as_str)
426 .map(str::trim)
427 .filter(|value| !value.is_empty())
428 .ok_or_else(|| ToolError::missing_field("message"))?;
429 let single = parse_agent_ref(&input)?;
430 let all_parked = super::parse_optional_bool(&input, &["all_parked"])?.unwrap_or(false);
431 let batch = input.get("agent_ids");
432 if usize::from(single.is_some()) + usize::from(batch.is_some()) + usize::from(all_parked)
433 != 1
434 {
435 return Err(ToolError::invalid_input(
436 "followup requires exactly one of agent_id, agent_ids, or all_parked=true",
437 ));
438 }
439 let mut targets = if let Some(ids) = batch {
440 let ids = ids
441 .as_array()
442 .filter(|ids| !ids.is_empty() && ids.len() <= 32)
443 .ok_or_else(|| {
444 ToolError::invalid_input("agent_ids must contain 1..32 nonempty strings")
445 })?;
446 ids.iter()
447 .map(|id| {
448 id.as_str()
449 .map(str::trim)
450 .filter(|id| !id.is_empty())
451 .map(str::to_string)
452 .ok_or_else(|| {
453 ToolError::invalid_input("agent_ids must contain nonempty strings")
454 })
455 })
456 .collect::<Result<Vec<_>, _>>()?
457 } else if let Some(id) = single.as_ref() {
458 vec![id.clone()]
459 } else {
460 let manager = self.manager.read().await;
461 let mut ids = manager
462 .agents
463 .values()
464 .filter(|agent| {
465 manager.agent_is_owned_by_session(agent, &context.state_namespace)
466 && agent
467 .checkpoint
468 .as_ref()
469 .is_some_and(|checkpoint| checkpoint.parked_at_turn_end)
470 && matches!(agent.status, SubAgentStatus::Interrupted(_))
471 && manager
472 .ensure_caller_controls_descendant(
473 &agent.id,
474 self.caller_agent_id.as_deref(),
475 "agents/followup",
476 )
477 .is_ok()
478 && manager
479 .continuation_target(&agent.id)
480 .is_ok_and(|target| target == agent.id)
481 })
482 .map(|agent| agent.id.clone())
483 .collect::<Vec<_>>();
484 ids.sort();
485 if ids.len() > 32 {
486 return Err(ToolError::invalid_input(
487 "More than 32 parked children; use explicit agent_ids batches",
488 ));
489 }
490 ids
491 };
492 let mut seen = std::collections::HashSet::new();
493 targets.retain(|target| seen.insert(target.clone()));
494 let mut results = Vec::new();
495 let mut errors = Vec::new();
496 // Each mutation and both hierarchy checks share the manager write lock.
497 // A target failure cannot erase successful results from another target.
498 for target in targets {
499 match self.followup_one(&target, message, context).await {
500 Ok(payload) => results.push(payload),
501 Err(error) if single.is_some() => return Err(error),
502 Err(error) => errors.push(json!({"from": target, "error": error.to_string()})),
503 }
504 }
505 let payload = if single.is_some() {
506 results.pop().expect("single target returned a result")
507 } else {
508 json!({"action": "followup", "results": results, "errors": errors})
509 };
510 let mut result = ToolResult::json(&payload)
511 .map_err(|error| ToolError::execution_failed(error.to_string()))?;
512 result.metadata = Some(payload.clone());
513 Ok(result)
514 }
515 }
516
517 // ── agents/interrupt ─────────────────────────────────────────────────────
518
519 pub struct AgentsInterruptTool {
520 manager: SharedSubAgentManager,
521 /// Optional caller identity for fail-closed self-interrupt checks.
522 caller_agent_id: Option<String>,
523 }
524
525 impl AgentsInterruptTool {
526 #[must_use]
527 pub fn new(manager: SharedSubAgentManager) -> Self {
528 Self {
529 manager,
530 caller_agent_id: None,
531 }
532 }
533
534 #[must_use]
535 #[allow(dead_code)] // arms self-interrupt fail-closed when child registries thread caller (P1.2)
536 pub fn with_caller(mut self, caller_agent_id: impl Into<String>) -> Self {
537 self.caller_agent_id = Some(caller_agent_id.into());
538 self
539 }
540
541 #[must_use]
542 pub(crate) fn with_optional_caller(mut self, caller_agent_id: Option<String>) -> Self {
543 self.caller_agent_id = caller_agent_id;
544 self
545 }
546 }
547
548 #[async_trait]
549 impl ToolSpec for AgentsInterruptTool {
550 fn model_visible(&self) -> bool {
551 // #5462: `agent` is the sole model-facing sub-agent surface. These
552 // narrow tools stay registered and executable by name so a persisted
553 // transcript replays byte-for-byte, but they are never advertised in
554 // the catalog and can never be returned by `tool_search` — the same
555 // shape `rlm` and `exec_shell` already use.
556 false
557 }
558
559 fn name(&self) -> &'static str {
560 "agents/interrupt"
561 }
562
563 fn description(&self) -> &'static str {
564 "Interrupt a running child agent, preserve its checkpoint, and return the prior state. Fails closed on root or self targets. Prefer this over cancel when you may resume later."
565 }
566
567 fn input_schema(&self) -> Value {
568 json!({
569 "type": "object",
570 "properties": {
571 "agent_id": {
572 "type": "string",
573 "description": "Child agent id or session name to interrupt."
574 },
575 "reason": {
576 "type": "string",
577 "description": "Optional interrupt reason recorded on the checkpoint."
578 }
579 },
580 "required": ["agent_id"]
581 })
582 }
583
584 fn capabilities(&self) -> Vec<ToolCapability> {
585 vec![ToolCapability::RequiresApproval]
586 }
587
588 fn approval_requirement(&self) -> ApprovalRequirement {
589 ApprovalRequirement::Required
590 }
591
592 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
593 let agent_ref =
594 parse_agent_ref(&input)?.ok_or_else(|| ToolError::missing_field("agent_id"))?;
595 let reason = input
596 .get("reason")
597 .and_then(Value::as_str)
598 .map(str::trim)
599 .filter(|s| !s.is_empty())
600 .unwrap_or("interrupted by parent via agents/interrupt")
601 .to_string();
602
603 let (prior, snapshot) = {
604 let mut manager = self.manager.write().await;
605 manager
606 .interrupt_child_for_session(
607 &context.state_namespace,
608 &agent_ref,
609 self.caller_agent_id.as_deref(),
610 reason,
611 )
612 .map_err(|err| ToolError::invalid_input(err.to_string()))?
613 };
614
615 let worker_record = {
616 let manager = self.manager.read().await;
617 manager.get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id)
618 };
619 let projection =
620 subagent_session_projection(&self.manager, snapshot, false, context, worker_record)
621 .await;
622 let payload = json!({
623 "action": "interrupt",
624 "agent_id": projection.agent_id,
625 "prior_status": subagent_status_name(&prior.status),
626 "prior_steps_taken": prior.steps_taken,
627 "status": projection.status,
628 "checkpoint_preserved": projection.checkpoint.is_some(),
629 "continuable": projection.continuable,
630 "projection": projection,
631 "child_route": projection.child_route,
632 });
633 let mut tool_result = ToolResult::json(&payload)
634 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
635 tool_result.metadata = Some(json!({
636 "action": "interrupt",
637 "agent_id": payload["agent_id"],
638 "checkpoint_preserved": payload["checkpoint_preserved"],
639 "child_route": payload["child_route"],
640 }));
641 Ok(tool_result)
642 }
643 }
644
645 // ── agents/wait ──────────────────────────────────────────────────────────
646
647 pub struct AgentsWaitTool {
648 manager: SharedSubAgentManager,
649 }
650
651 impl AgentsWaitTool {
652 #[must_use]
653 pub fn new(manager: SharedSubAgentManager) -> Self {
654 Self { manager }
655 }
656 }
657
658 #[async_trait]
659 impl ToolSpec for AgentsWaitTool {
660 fn model_visible(&self) -> bool {
661 // #5462: `agent` is the sole model-facing sub-agent surface. These
662 // narrow tools stay registered and executable by name so a persisted
663 // transcript replays byte-for-byte, but they are never advertised in
664 // the catalog and can never be returned by `tool_search` — the same
665 // shape `rlm` and `exec_shell` already use.
666 false
667 }
668
669 fn name(&self) -> &'static str {
670 "agents/wait"
671 }
672
673 fn description(&self) -> &'static str {
674 "Block briefly until watched children settle or the timeout elapses. Keep waits short: on timeout, end your turn — settled children wake you automatically as completion sentinels; polling agents/list in a loop is not the right shape either. until=all is the fan-out join: it returns only when every child running at call time has left running, with each child's outcome. until=completion (default) returns as soon as any one child settles. until=activity also returns on progress."
675 }
676
677 fn input_schema(&self) -> Value {
678 json!({
679 "type": "object",
680 "properties": {
681 "agent_id": {
682 "type": "string",
683 "description": "Optional specific child. When omitted, watches every child running at call time."
684 },
685 "timeout_secs": {
686 "type": "integer",
687 "minimum": 1,
688 "maximum": 120,
689 "description": "Maximum seconds to block. Default 30. Keep it short — on timeout, end your turn; settled children report back as completion sentinels."
690 },
691 "until": {
692 "type": "string",
693 "enum": ["completion", "all", "activity"],
694 "description": "completion (default): return when any one child leaves running. all: return only when every watched child has left running — use this after a fan-out so one wait covers the whole batch. activity: also return when recent progress changes. Children spawned after the call are not watched; no children means an immediate return."
695 }
696 },
697 "required": []
698 })
699 }
700
701 fn capabilities(&self) -> Vec<ToolCapability> {
702 vec![ToolCapability::ReadOnly]
703 }
704
705 fn approval_requirement(&self) -> ApprovalRequirement {
706 ApprovalRequirement::Auto
707 }
708
709 fn is_read_only_for(&self, _input: &Value) -> bool {
710 true
711 }
712
713 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
714 dispatch_wait(&input, Arc::clone(&self.manager), context).await
715 }
716 }
717
718 /// Single entry point for every blocking wait, shared by `agents/wait` and
719 /// `agent(action="wait")` so the two surfaces cannot drift.
720 ///
721 /// `until` selects the join shape:
722 /// - `completion` (default) — return as soon as any one watched child settles.
723 /// - `all` — return only when every watched child has settled (the fan-out
724 /// join the parent should use after dispatching a batch).
725 /// - `activity` — also return when a running child makes visible progress.
726 pub(super) async fn dispatch_wait(
727 input: &Value,
728 manager: SharedSubAgentManager,
729 context: &ToolContext,
730 ) -> Result<ToolResult, ToolError> {
731 let until = input
732 .get("until")
733 .and_then(Value::as_str)
734 .unwrap_or("completion")
735 .trim()
736 .to_ascii_lowercase();
737
738 match until.as_str() {
739 "" | "completion" => {
740 let mut wait_input = input.clone();
741 if wait_input.get("action").is_none() {
742 wait_input["action"] = json!("wait");
743 }
744 wait_for_subagents_from_input(&wait_input, manager, context).await
745 }
746 "all" => wait_for_all_children(input, manager, context).await,
747 "activity" => wait_for_activity(input, manager, context).await,
748 other => Err(ToolError::invalid_input(format!(
749 "Invalid until '{other}'. Use completion, all, or activity."
750 ))),
751 }
752 }
753
754 /// `until=all`: block until every child that was running when the call was
755 /// made has left `Running`.
756 ///
757 /// The watch set is fixed at call time. A child spawned while this wait is
758 /// blocked is deliberately **not** joined — the parent asked to join the batch
759 /// it had just dispatched, and silently extending the set would make the call
760 /// unbounded in a way the caller never asked for. Callers that fan out again
761 /// simply issue another wait.
762 ///
763 /// Cancel-safe (no lock is held across an await), honours `timeout_secs`, and
764 /// returns immediately with `all_settled: true` when nothing is running.
765 async fn wait_for_all_children(
766 input: &Value,
767 manager: SharedSubAgentManager,
768 context: &ToolContext,
769 ) -> Result<ToolResult, ToolError> {
770 let timeout_secs = input
771 .get("timeout_secs")
772 .or_else(|| input.get("timeout"))
773 .and_then(Value::as_u64)
774 .unwrap_or(COORD_WAIT_DEFAULT_TIMEOUT_SECS)
775 .clamp(COORD_WAIT_MIN_TIMEOUT_SECS, COORD_WAIT_MAX_TIMEOUT_SECS);
776 let timeout = Duration::from_secs(timeout_secs);
777 let agent_ref = parse_agent_ref(input)?;
778
779 // Resolve the watch set up front so a bad reference fails immediately
780 // rather than blocking for the whole timeout.
781 let watched: Vec<String> = {
782 let manager = manager.read().await;
783 if let Some(agent_ref) = &agent_ref {
784 let snapshot = manager
785 .get_result_by_ref_for_session(&context.state_namespace, agent_ref)
786 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
787 if snapshot.status != SubAgentStatus::Running {
788 // Already settled: hand back its outcome rather than an empty
789 // "nothing to join" that hides what the caller asked about.
790 let settled = json!({
791 "agent_id": snapshot.agent_id,
792 "name": snapshot.name,
793 "status": subagent_status_name(&snapshot.status),
794 "steps_taken": snapshot.steps_taken,
795 });
796 drop(manager);
797 return wait_all_payload(&[settled], &[], 0, false);
798 }
799 vec![snapshot.agent_id]
800 } else {
801 manager
802 .list_filtered_for_session(&context.state_namespace, false)
803 .into_iter()
804 .filter(|snapshot| snapshot.status == SubAgentStatus::Running)
805 .map(|snapshot| snapshot.agent_id)
806 .collect()
807 }
808 };
809
810 // Zero children is an immediate return, never a hang.
811 if watched.is_empty() {
812 return wait_all_payload(&[], &[], 0, false);
813 }
814
815 let started = Instant::now();
816 let cancelled = async {
817 match &context.cancel_token {
818 Some(token) => token.cancelled().await,
819 None => std::future::pending().await,
820 }
821 };
822 tokio::pin!(cancelled);
823
824 loop {
825 let (settled, still_running) = {
826 let manager = manager.read().await;
827 let mut settled = Vec::new();
828 let mut still_running = Vec::new();
829 for agent_id in &watched {
830 match manager.get_result_by_ref_for_session(&context.state_namespace, agent_id) {
831 Ok(snapshot) if snapshot.status == SubAgentStatus::Running => {
832 still_running.push(json!({
833 "agent_id": snapshot.agent_id,
834 "name": snapshot.name,
835 "status": "running",
836 }));
837 }
838 Ok(snapshot) => settled.push(json!({
839 "agent_id": snapshot.agent_id,
840 "name": snapshot.name,
841 "status": subagent_status_name(&snapshot.status),
842 "steps_taken": snapshot.steps_taken,
843 })),
844 // A watched child that vanished from the ledger (retention
845 // cleanup) is no longer running; report it rather than
846 // blocking on a record that will never settle.
847 Err(_) => settled.push(json!({
848 "agent_id": agent_id,
849 "status": "gone",
850 })),
851 }
852 }
853 (settled, still_running)
854 };
855
856 if still_running.is_empty() {
857 return wait_all_payload(&settled, &[], started.elapsed().as_millis(), false);
858 }
859 if started.elapsed() >= timeout {
860 return wait_all_payload(
861 &settled,
862 &still_running,
863 started.elapsed().as_millis(),
864 true,
865 );
866 }
867
868 tokio::select! {
869 biased;
870 () = &mut cancelled => {
871 return Err(ToolError::cancelled(
872 "Wait interrupted by user cancellation before every child settled.".to_string(),
873 ));
874 }
875 () = tokio::time::sleep(COORD_WAIT_CHECK_INTERVAL) => {}
876 }
877 }
878 }
879
880 /// `until=all` result: every watched child with its own outcome, so the parent
881 /// can synthesize from one return instead of re-inspecting each child.
882 fn wait_all_payload(
883 settled: &[Value],
884 still_running: &[Value],
885 waited_ms: u128,
886 timed_out: bool,
887 ) -> Result<ToolResult, ToolError> {
888 let note = if timed_out {
889 "The wait interval ended; the children are still running. You may answer the user or continue other work. Ordinary turn completion keeps them running; results arrive as <codewhale:subagent.done> sentinels. Use followup only when a child actually needs continuation."
890 } else if settled.is_empty() {
891 "No sub-agents were running; nothing to join."
892 } else {
893 "Every watched child has settled. Full results arrive as <codewhale:subagent.done> sentinels — synthesize from those."
894 };
895 let payload = json!({
896 "action": "wait",
897 "until": "all",
898 "all_settled": still_running.is_empty(),
899 "settled": settled,
900 "still_running": still_running,
901 "waited_ms": u64::try_from(waited_ms).unwrap_or(u64::MAX),
902 "timed_out": timed_out,
903 "note": note,
904 });
905 let mut tool_result =
906 ToolResult::json(&payload).map_err(|err| ToolError::execution_failed(err.to_string()))?;
907 tool_result.metadata = Some(json!({
908 "action": "wait",
909 "until": "all",
910 "all_settled": still_running.is_empty(),
911 "settled": settled.len(),
912 "running": still_running.len(),
913 "timed_out": timed_out,
914 }));
915 Ok(tool_result)
916 }
917
918 async fn wait_for_activity(
919 input: &Value,
920 manager: SharedSubAgentManager,
921 context: &ToolContext,
922 ) -> Result<ToolResult, ToolError> {
923 let timeout_secs = input
924 .get("timeout_secs")
925 .or_else(|| input.get("timeout"))
926 .and_then(Value::as_u64)
927 .unwrap_or(COORD_WAIT_DEFAULT_TIMEOUT_SECS)
928 .clamp(COORD_WAIT_MIN_TIMEOUT_SECS, COORD_WAIT_MAX_TIMEOUT_SECS);
929 let timeout = Duration::from_secs(timeout_secs);
930 let agent_ref = parse_agent_ref(input)?;
931
932 let (watched, baseline): (Vec<String>, Vec<(String, u64)>) = {
933 let manager = manager.read().await;
934 if let Some(agent_ref) = &agent_ref {
935 let snap = manager
936 .get_result_by_ref_for_session(&context.state_namespace, agent_ref)
937 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
938 let fp = manager.activity_fingerprint(&snap.agent_id).unwrap_or(0);
939 if snap.status != SubAgentStatus::Running {
940 let payload = json!({
941 "action": "wait",
942 "until": "activity",
943 "reason": "already_settled",
944 "timed_out": false,
945 "agent_id": snap.agent_id,
946 "status": subagent_status_name(&snap.status),
947 });
948 let mut tool_result = ToolResult::json(&payload)
949 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
950 tool_result.metadata = Some(json!({ "action": "wait", "timed_out": false }));
951 return Ok(tool_result);
952 }
953 (vec![snap.agent_id.clone()], vec![(snap.agent_id, fp)])
954 } else {
955 let running = manager
956 .list_filtered_for_session(&context.state_namespace, false)
957 .into_iter()
958 .filter(|s| s.status == SubAgentStatus::Running)
959 .map(|s| s.agent_id)
960 .collect::<Vec<_>>();
961 let baseline = running
962 .iter()
963 .map(|id| {
964 let fp = manager.activity_fingerprint(id).unwrap_or(0);
965 (id.clone(), fp)
966 })
967 .collect();
968 (running, baseline)
969 }
970 };
971
972 if watched.is_empty() {
973 let payload = json!({
974 "action": "wait",
975 "until": "activity",
976 "note": "No running sub-agents; nothing to wait for.",
977 "timed_out": false,
978 });
979 let mut tool_result = ToolResult::json(&payload)
980 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
981 tool_result.metadata = Some(json!({ "action": "wait", "timed_out": false }));
982 return Ok(tool_result);
983 }
984
985 let started = Instant::now();
986 let cancelled = async {
987 match &context.cancel_token {
988 Some(token) => token.cancelled().await,
989 None => std::future::pending().await,
990 }
991 };
992 tokio::pin!(cancelled);
993
994 loop {
995 let outcome = {
996 let manager = manager.read().await;
997 let mut settled = Vec::new();
998 let mut activity = Vec::new();
999 for (id, base_fp) in &baseline {
1000 if let Ok(snap) =
1001 manager.get_result_by_ref_for_session(&context.state_namespace, id)
1002 {
1003 if snap.status != SubAgentStatus::Running {
1004 settled.push(snap);
1005 continue;
1006 }
1007 let fp = manager.activity_fingerprint(id).unwrap_or(0);
1008 if fp != *base_fp {
1009 activity.push(json!({
1010 "agent_id": id,
1011 "status": "running",
1012 "activity_fingerprint": fp,
1013 }));
1014 }
1015 }
1016 }
1017 (
1018 settled,
1019 activity,
1020 manager.running_count_for_session(&context.state_namespace),
1021 )
1022 };
1023
1024 if !outcome.0.is_empty() || !outcome.1.is_empty() {
1025 let payload = json!({
1026 "action": "wait",
1027 "until": "activity",
1028 "settled": outcome.0.iter().map(|s| json!({
1029 "agent_id": s.agent_id,
1030 "status": subagent_status_name(&s.status),
1031 })).collect::<Vec<_>>(),
1032 "activity": outcome.1,
1033 "running": outcome.2,
1034 "elapsed_ms": started.elapsed().as_millis(),
1035 "timed_out": false,
1036 });
1037 let mut tool_result = ToolResult::json(&payload)
1038 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
1039 tool_result.metadata = Some(json!({
1040 "action": "wait",
1041 "timed_out": false,
1042 "settled": outcome.0.len(),
1043 "activity": outcome.1.len(),
1044 }));
1045 return Ok(tool_result);
1046 }
1047
1048 if started.elapsed() >= timeout {
1049 let payload = json!({
1050 "action": "wait",
1051 "until": "activity",
1052 "settled": [],
1053 "activity": [],
1054 "running": outcome.2,
1055 "elapsed_ms": started.elapsed().as_millis(),
1056 "timed_out": true,
1057 "note": "The wait interval ended without new child activity. Children keep running after ordinary turn completion and report through <codewhale:subagent.done> sentinels.",
1058 });
1059 let mut tool_result = ToolResult::json(&payload)
1060 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
1061 tool_result.metadata = Some(json!({ "action": "wait", "timed_out": true }));
1062 return Ok(tool_result);
1063 }
1064
1065 tokio::select! {
1066 biased;
1067 () = &mut cancelled => {
1068 return Err(ToolError::cancelled(
1069 "Wait interrupted by user cancellation before child activity.".to_string(),
1070 ));
1071 }
1072 () = tokio::time::sleep(COORD_WAIT_CHECK_INTERVAL) => {}
1073 }
1074 }
1075 }
1076
1077 /// Register the narrow coordination tools alongside `agent`.
1078 pub fn register_coordination_tools(
1079 builder: ToolRegistryBuilder,
1080 manager: SharedSubAgentManager,
1081 runtime: SubAgentRuntime,
1082 ) -> ToolRegistryBuilder {
1083 // `runtime.parent_agent_id` is the identity of the agent this registry is
1084 // being built FOR: `runtime_for_nested_agent_tools` stamps the child's own
1085 // id there before `new_with_owner` registers tools, so anything that agent
1086 // spawns records it as parent. Thread that identity through every mutating
1087 // hierarchy tool: a child may control only its own descendants, while the
1088 // root registry (`None`) may control any child (TUI-DOG-017).
1089 let caller = runtime.parent_agent_id.clone();
1090 let message = AgentsMessageTool::new(Arc::clone(&manager)).with_optional_caller(caller.clone());
1091 let followup = AgentsFollowupTool::new(Arc::clone(&manager))
1092 .with_optional_caller(caller.clone())
1093 .with_runtime(runtime.clone());
1094 let interrupt =
1095 AgentsInterruptTool::new(Arc::clone(&manager)).with_optional_caller(caller.clone());
1096 let coordinate = AgentsCoordinateTool::new(Arc::clone(&manager), caller);
1097 builder
1098 .with_tool(Arc::new(AgentsListTool::new(Arc::clone(&manager))))
1099 .with_tool(Arc::new(message))
1100 .with_tool(Arc::new(followup))
1101 .with_tool(Arc::new(interrupt))
1102 .with_tool(Arc::new(coordinate))
1103 .with_tool(Arc::new(AgentsWaitTool::new(manager)))
1104 }
1105
1106 pub struct AgentsCoordinateTool {
1107 manager: SharedSubAgentManager,
1108 caller: Option<String>,
1109 }
1110
1111 impl AgentsCoordinateTool {
1112 #[must_use]
1113 pub fn new(manager: SharedSubAgentManager, caller: Option<String>) -> Self {
1114 Self { manager, caller }
1115 }
1116 }
1117
1118 #[async_trait]
1119 impl ToolSpec for AgentsCoordinateTool {
1120 fn model_visible(&self) -> bool {
1121 // #5462: `agent` is the sole model-facing sub-agent surface. These
1122 // narrow tools stay registered and executable by name so a persisted
1123 // transcript replays byte-for-byte, but they are never advertised in
1124 // the catalog and can never be returned by `tool_search` — the same
1125 // shape `rlm` and `exec_shell` already use.
1126 false
1127 }
1128
1129 fn name(&self) -> &'static str {
1130 "agents/coordinate"
1131 }
1132
1133 fn description(&self) -> &'static str {
1134 "Record or inspect bounded coordination state: propose/accept/supersede decisions, expand the caller's write claim before mutation, reconcile multiple decision records into one neutral fan-in receipt, or release stale write-claims whose owner is no longer running."
1135 }
1136
1137 fn input_schema(&self) -> Value {
1138 json!({
1139 "type": "object",
1140 "properties": {
1141 "action": { "type": "string", "enum": ["inspect", "propose", "accept", "supersede", "claim", "reconcile", "release"] },
1142 "decision_id": { "type": "string" },
1143 "subject": { "type": "string" },
1144 "expected_version": { "type": "integer", "minimum": 1 },
1145 "scope": { "type": "array", "items": { "type": "string" } },
1146 "constraints": { "type": "array", "items": { "type": "string" } },
1147 "evidence_handles": { "type": "array", "items": { "type": "string" } },
1148 "roots": { "type": "array", "items": { "type": "string" } },
1149 "exact_files": { "type": "array", "items": { "type": "string" } },
1150 "contracts": { "type": "array", "items": { "type": "string" } },
1151 "owner": { "type": "string" },
1152 "input_decisions": { "type": "array", "items": { "type": "string" } },
1153 "outcome": { "type": "string" },
1154 "candidate_handles": { "type": "array", "items": { "type": "string" } },
1155 "retry_count": { "type": "integer", "minimum": 0, "maximum": 3 },
1156 "retry_limit": { "type": "integer", "minimum": 1, "maximum": 3 },
1157 "reviewer_evidence_handles": { "type": "array", "items": { "type": "string" } },
1158 "verifier_evidence_handles": { "type": "array", "items": { "type": "string" } },
1159 "verification_outcome": { "type": "string" },
1160 "limit": { "type": "integer", "minimum": 1, "maximum": 24 }
1161 },
1162 "required": ["action"]
1163 })
1164 }
1165
1166 fn capabilities(&self) -> Vec<ToolCapability> {
1167 // #5123-class: this tool mutates the coordination ledger and expands
1168 // the caller's write claim (actions propose/accept/supersede/claim/
1169 // reconcile) — declaring ReadOnly was a lie that let policy layers
1170 // treat a mutating call as a safe read. Only `inspect` is read-only,
1171 // which is what is_read_only_for reports.
1172 vec![ToolCapability::WritesFiles]
1173 }
1174 fn approval_requirement(&self) -> ApprovalRequirement {
1175 // Stays Auto: coordination records are session-scoped in-memory
1176 // state, and gating them would deadlock autonomous sub-agent fan-in.
1177 ApprovalRequirement::Auto
1178 }
1179 fn is_read_only_for(&self, input: &Value) -> bool {
1180 input.get("action").and_then(Value::as_str) == Some("inspect")
1181 }
1182
1183 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
1184 let action = input
1185 .get("action")
1186 .and_then(Value::as_str)
1187 .unwrap_or("inspect");
1188 let bounded_text = |key: &str| {
1189 input
1190 .get(key)
1191 .and_then(Value::as_str)
1192 .map(|value| value.chars().take(512).collect::<String>())
1193 };
1194 // Tool authority is the runtime caller identity. Root cannot supply an
1195 // arbitrary child owner and mutate that child's decisions/claim.
1196 let owner = self.caller.clone().unwrap_or_else(|| "root".to_string());
1197 let strings = |key: &str| {
1198 input
1199 .get(key)
1200 .and_then(Value::as_array)
1201 .map(|items| {
1202 items
1203 .iter()
1204 .take(24)
1205 .filter_map(Value::as_str)
1206 .map(|value| value.chars().take(512).collect::<String>())
1207 .collect::<Vec<_>>()
1208 })
1209 .unwrap_or_default()
1210 };
1211 if action == "inspect" {
1212 let manager = self.manager.read().await;
1213 let value = manager.inspect_coordination_for_session(
1214 &context.state_namespace,
1215 bounded_text("subject").as_deref(),
1216 input
1217 .get("limit")
1218 .and_then(Value::as_u64)
1219 .unwrap_or(COORDINATION_INSPECT_LIMIT as u64) as usize,
1220 );
1221 return ToolResult::json(&value)
1222 .map_err(|e| ToolError::execution_failed(e.to_string()));
1223 }
1224 if !matches!(
1225 action,
1226 "propose" | "accept" | "supersede" | "claim" | "reconcile" | "release"
1227 ) {
1228 return Err(ToolError::invalid_input(format!(
1229 "unknown coordination action '{action}'"
1230 )));
1231 }
1232
1233 let mut manager = self.manager.write().await;
1234 if let Some(caller) = self.caller.as_deref() {
1235 manager
1236 .get_result_by_ref_for_session(&context.state_namespace, caller)
1237 .map_err(|_| {
1238 ToolError::invalid_input("Agent not found in the active session".to_string())
1239 })?;
1240 }
1241 if matches!(action, "accept" | "supersede") {
1242 let decision_id = bounded_text("decision_id").unwrap_or_default();
1243 if !manager
1244 .coordination_decision_is_owned_by_session(&context.state_namespace, &decision_id)
1245 {
1246 return Err(ToolError::invalid_input(
1247 "Coordination decision not found in the active session".to_string(),
1248 ));
1249 }
1250 }
1251 if action == "reconcile"
1252 && strings("input_decisions").iter().any(|decision_id| {
1253 !manager.coordination_decision_is_owned_by_session(
1254 &context.state_namespace,
1255 decision_id,
1256 )
1257 })
1258 {
1259 return Err(ToolError::invalid_input(
1260 "One or more coordination decisions were not found in the active session"
1261 .to_string(),
1262 ));
1263 }
1264 let coordination_before = manager.coordination.clone();
1265 let mutation = match action {
1266 "propose" => manager
1267 .record_coordination_decision(DecisionRecord {
1268 decision_id: bounded_text("decision_id").unwrap_or_default(),
1269 subject: bounded_text("subject").unwrap_or_default(),
1270 status: DecisionStatus::Proposed,
1271 owner,
1272 scope: strings("scope"),
1273 constraints: strings("constraints"),
1274 evidence_handles: strings("evidence_handles"),
1275 version: 1,
1276 sequence: 0,
1277 })
1278 .map_err(ToolError::invalid_input)
1279 .and_then(|record| {
1280 serde_json::to_value(record)
1281 .map_err(|e| ToolError::execution_failed(e.to_string()))
1282 }),
1283 "accept" | "supersede" => input
1284 .get("expected_version")
1285 .and_then(Value::as_u64)
1286 .and_then(|value| u32::try_from(value).ok())
1287 .ok_or_else(|| {
1288 ToolError::invalid_input(
1289 "accept/supersede requires expected_version".to_string(),
1290 )
1291 })
1292 .and_then(|expected_version| {
1293 manager
1294 .update_coordination_decision(
1295 &bounded_text("decision_id").unwrap_or_default(),
1296 if action == "accept" {
1297 DecisionStatus::Accepted
1298 } else {
1299 DecisionStatus::Superseded
1300 },
1301 &owner,
1302 expected_version,
1303 )
1304 .map_err(ToolError::invalid_input)
1305 })
1306 .and_then(|record| {
1307 serde_json::to_value(record)
1308 .map_err(|e| ToolError::execution_failed(e.to_string()))
1309 }),
1310 "claim" => manager
1311 .expand_write_claim(
1312 &owner,
1313 strings("roots"),
1314 strings("exact_files"),
1315 strings("contracts"),
1316 )
1317 .map_err(ToolError::invalid_input)
1318 .and_then(|claim| {
1319 serde_json::to_value(claim)
1320 .map_err(|e| ToolError::execution_failed(e.to_string()))
1321 }),
1322 "reconcile" => manager
1323 .reconcile_coordination(
1324 bounded_text("subject").unwrap_or_default(),
1325 owner,
1326 strings("input_decisions"),
1327 bounded_text("outcome").unwrap_or_default(),
1328 strings("evidence_handles"),
1329 strings("candidate_handles"),
1330 input
1331 .get("retry_count")
1332 .and_then(Value::as_u64)
1333 .and_then(|value| u32::try_from(value).ok())
1334 .unwrap_or_default(),
1335 input
1336 .get("retry_limit")
1337 .and_then(Value::as_u64)
1338 .and_then(|value| u32::try_from(value).ok())
1339 .unwrap_or(MAX_RECONCILIATION_RETRIES),
1340 strings("reviewer_evidence_handles"),
1341 strings("verifier_evidence_handles"),
1342 bounded_text("verification_outcome").unwrap_or_default(),
1343 )
1344 .map_err(ToolError::invalid_input)
1345 .and_then(|receipt| {
1346 serde_json::to_value(receipt)
1347 .map_err(|e| ToolError::execution_failed(e.to_string()))
1348 }),
1349 "release" => {
1350 let owner = input
1351 .get("owner")
1352 .and_then(Value::as_str)
1353 .map(|value| value.to_string());
1354 let released = manager
1355 .release_stale_write_claims(owner)
1356 .map_err(ToolError::invalid_input)?;
1357 let sequence = manager.coordination.sequence;
1358 serde_json::to_value(json!({
1359 "released": released.len(),
1360 "owners": released,
1361 "sequence": sequence
1362 }))
1363 .map_err(|e| ToolError::execution_failed(e.to_string()))
1364 }
1365 _ => unreachable!("coordination action validated above"),
1366 };
1367 let value = match mutation {
1368 Ok(value) => value,
1369 Err(error) => {
1370 // Contention failures deliberately append a durable receipt.
1371 // Stamp and persist every sequence allocated by the failed
1372 // action before returning its error; validation failures that
1373 // did not mutate the ledger allocate nothing.
1374 let first_new_sequence = coordination_before.sequence.saturating_add(1);
1375 let last_new_sequence = manager.coordination.sequence;
1376 for sequence in first_new_sequence..=last_new_sequence {
1377 if let Err(stamp_error) = manager
1378 .stamp_coordination_sequence_for_session(sequence, &context.state_namespace)
1379 {
1380 manager.coordination = coordination_before;
1381 return Err(ToolError::execution_failed(format!(
1382 "{error}; additionally failed to stamp coordination receipt: {stamp_error}"
1383 )));
1384 }
1385 }
1386 if last_new_sequence >= first_new_sequence
1387 && let Err(persist_error) = manager.persist_state_synchronously()
1388 {
1389 manager.coordination = coordination_before;
1390 return Err(ToolError::execution_failed(format!(
1391 "{error}; additionally failed to persist coordination receipt: {persist_error}"
1392 )));
1393 }
1394 return Err(error);
1395 }
1396 };
1397 let Some(sequence) = value.get("sequence").and_then(Value::as_u64) else {
1398 manager.coordination = coordination_before;
1399 return Err(ToolError::execution_failed(format!(
1400 "coordination action '{action}' produced no durable sequence"
1401 )));
1402 };
1403 if let Err(error) =
1404 manager.stamp_coordination_sequence_for_session(sequence, &context.state_namespace)
1405 {
1406 manager.coordination = coordination_before;
1407 return Err(ToolError::execution_failed(error));
1408 }
1409 if let Err(error) = manager.persist_state_synchronously() {
1410 manager.coordination = coordination_before;
1411 return Err(ToolError::execution_failed(format!(
1412 "failed to persist coordination action '{action}': {error}"
1413 )));
1414 }
1415 ToolResult::json(&value).map_err(|e| ToolError::execution_failed(e.to_string()))
1416 }
1417 }
1418
1419 #[cfg(test)]
1420 mod tests {
1421 use super::*;
1422 use crate::tools::spec::ToolContext;
1423 use codewhale_models::Role;
1424 use std::collections::BTreeSet;
1425 use tempfile::tempdir;
1426
1427 #[test]
1428 fn coordinate_tool_does_not_declare_read_only() {
1429 // #5123-class: the tool mutates the coordination ledger and expands
1430 // write claims; its declared capabilities must not say ReadOnly.
1431 let manager = Arc::new(tokio::sync::RwLock::new(
1432 super::super::SubAgentManager::new(std::path::PathBuf::from("."), 1),
1433 ));
1434 let tool = AgentsCoordinateTool::new(manager, None);
1435 let capabilities = ToolSpec::capabilities(&tool);
1436 assert!(
1437 !capabilities.contains(&ToolCapability::ReadOnly),
1438 "agents/coordinate mutates the ledger — ReadOnly is a lie: {capabilities:?}"
1439 );
1440 // …but the dynamic check still marks inspect as read-only.
1441 assert!(tool.is_read_only_for(&json!({"action": "inspect"})));
1442 assert!(!tool.is_read_only_for(&json!({"action": "propose"})));
1443 }
1444
1445 #[test]
1446 fn coordination_descriptions_match_implemented_resume_behavior() {
1447 // Checkpoint resume is implemented (#5242): the descriptions must
1448 // describe the real behavior, including the honest queue-only
1449 // fallback when no runtime is attached.
1450 let manager = Arc::new(tokio::sync::RwLock::new(
1451 super::super::SubAgentManager::new(std::env::temp_dir(), 1),
1452 ));
1453 let message = AgentsMessageTool::new(Arc::clone(&manager));
1454 let followup = AgentsFollowupTool::new(manager);
1455
1456 assert!(!message.description().contains("natural resume"));
1457 assert!(message.description().contains("stays queued"));
1458 assert!(followup.description().contains("attempt to resume"));
1459 assert!(
1460 followup
1461 .description()
1462 .contains("resumed from their checkpoint")
1463 );
1464 assert!(followup.description().contains("queue-only semantics"));
1465 }
1466
1467 async fn manager_with_running_child(
1468 workspace: &std::path::Path,
1469 ) -> (SharedSubAgentManager, String) {
1470 let manager = Arc::new(tokio::sync::RwLock::new(
1471 super::super::SubAgentManager::new(workspace.to_path_buf(), 4),
1472 ));
1473 let agent_id = {
1474 let mut guard = manager.write().await;
1475 guard.insert_test_running_agent("coord_child", workspace)
1476 };
1477 (manager, agent_id)
1478 }
1479
1480 async fn manager_with_agent_hierarchy(
1481 workspace: &std::path::Path,
1482 ) -> (SharedSubAgentManager, String, String, String) {
1483 let manager = Arc::new(tokio::sync::RwLock::new(
1484 super::super::SubAgentManager::new(workspace.to_path_buf(), 8),
1485 ));
1486 let (parent, child, sibling) = {
1487 let mut guard = manager.write().await;
1488 let parent = guard.insert_test_running_agent("hierarchy_parent", workspace);
1489 let child = guard.insert_test_running_agent("hierarchy_child", workspace);
1490 let sibling = guard.insert_test_running_agent("hierarchy_sibling", workspace);
1491 for (agent_id, parent_id) in [
1492 (&parent, "root"),
1493 (&child, parent.as_str()),
1494 (&sibling, "root"),
1495 ] {
1496 let record = guard
1497 .worker_records
1498 .get_mut(agent_id)
1499 .expect("hierarchy worker record");
1500 record.parent_run_id = Some(parent_id.to_string());
1501 record.spec.parent_run_id = Some(parent_id.to_string());
1502 }
1503 (parent, child, sibling)
1504 };
1505 (manager, parent, child, sibling)
1506 }
1507
1508 #[tokio::test]
1509 async fn message_queues_without_waking() {
1510 let tmp = tempdir().unwrap();
1511 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
1512 let tool = AgentsMessageTool::new(Arc::clone(&manager));
1513 let result = tool
1514 .execute(
1515 json!({ "agent_id": agent_id, "message": "hold this" }),
1516 &ToolContext::new(tmp.path()),
1517 )
1518 .await
1519 .expect("message ok");
1520 let body: Value = serde_json::from_str(&result.content).unwrap();
1521 assert_eq!(body["woke"], json!(false));
1522 assert_eq!(body["queued"], json!(true));
1523 assert_eq!(body["queue_depth"], json!(1));
1524
1525 let guard = manager.read().await;
1526 let depth = guard.queued_mail_depth(&agent_id).unwrap();
1527 assert_eq!(depth, 1);
1528 assert!(!guard.child_was_woken(&agent_id));
1529 }
1530
1531 #[tokio::test]
1532 async fn followup_does_not_claim_wake_when_live_channel_is_closed() {
1533 let tmp = tempdir().unwrap();
1534 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
1535 let result = AgentsFollowupTool::new(Arc::clone(&manager))
1536 .execute(
1537 json!({ "agent_id": agent_id, "message": "try to wake" }),
1538 &ToolContext::new(tmp.path()),
1539 )
1540 .await
1541 .expect("truthful closed-channel receipt");
1542 let body: Value = serde_json::from_str(&result.content).unwrap();
1543 assert_eq!(body["woke"], json!(false));
1544 assert_eq!(body["queue_depth"], json!(1));
1545 assert!(
1546 body["note"].as_str().unwrap_or_default().contains("closed"),
1547 "{body}"
1548 );
1549
1550 let guard = manager.read().await;
1551 assert_eq!(guard.queued_mail_depth(&agent_id), Some(1));
1552 assert!(!guard.child_was_woken(&agent_id));
1553 }
1554
1555 #[tokio::test]
1556 async fn hierarchy_mutations_allow_own_descendants_and_deny_siblings_or_ancestors() {
1557 let tmp = tempdir().unwrap();
1558 let (manager, parent, child, sibling) = manager_with_agent_hierarchy(tmp.path()).await;
1559 let context = ToolContext::new(tmp.path());
1560
1561 AgentsMessageTool::new(Arc::clone(&manager))
1562 .with_optional_caller(Some(parent.clone()))
1563 .execute(
1564 json!({ "agent_id": child, "message": "bounded parent note" }),
1565 &context,
1566 )
1567 .await
1568 .expect("parent may message its own child");
1569 AgentsFollowupTool::new(Arc::clone(&manager))
1570 .with_optional_caller(Some(parent.clone()))
1571 .execute(
1572 json!({ "agent_id": child, "message": "resume own child" }),
1573 &context,
1574 )
1575 .await
1576 .expect("parent may follow up its own child");
1577
1578 let sibling_message = AgentsMessageTool::new(Arc::clone(&manager))
1579 .with_optional_caller(Some(parent.clone()))
1580 .execute(
1581 json!({ "agent_id": sibling, "message": "cross branch" }),
1582 &context,
1583 )
1584 .await
1585 .expect_err("sibling message must fail closed")
1586 .to_string();
1587 assert!(
1588 sibling_message.contains("own descendants"),
1589 "{sibling_message}"
1590 );
1591
1592 let ancestor_followup = AgentsFollowupTool::new(Arc::clone(&manager))
1593 .with_optional_caller(Some(child.clone()))
1594 .execute(
1595 json!({ "agent_id": parent, "message": "wake ancestor" }),
1596 &context,
1597 )
1598 .await
1599 .expect_err("ancestor followup must fail closed")
1600 .to_string();
1601 assert!(
1602 ancestor_followup.contains("own descendants"),
1603 "{ancestor_followup}"
1604 );
1605
1606 let sibling_interrupt = AgentsInterruptTool::new(Arc::clone(&manager))
1607 .with_optional_caller(Some(parent.clone()))
1608 .execute(json!({ "agent_id": sibling }), &context)
1609 .await
1610 .expect_err("sibling interrupt must fail closed")
1611 .to_string();
1612 assert!(
1613 sibling_interrupt.contains("own descendants"),
1614 "{sibling_interrupt}"
1615 );
1616
1617 let interrupted = AgentsInterruptTool::new(Arc::clone(&manager))
1618 .with_optional_caller(Some(parent))
1619 .execute(json!({ "agent_id": child }), &context)
1620 .await
1621 .expect("parent may interrupt its own child");
1622 let body: Value = serde_json::from_str(&interrupted.content).unwrap();
1623 assert_eq!(body["status"], json!("interrupted"));
1624 }
1625
1626 #[tokio::test]
1627 async fn coordinate_inspect_is_side_effect_free_and_mutations_are_synchronously_durable() {
1628 let tmp = tempdir().unwrap();
1629 let blocked_state_path = tmp.path().join("blocked-state");
1630 std::fs::create_dir(&blocked_state_path).unwrap();
1631 let blocked_manager = Arc::new(tokio::sync::RwLock::new(
1632 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1633 .with_state_path(blocked_state_path),
1634 ));
1635 let blocked_tool = AgentsCoordinateTool::new(Arc::clone(&blocked_manager), None);
1636
1637 blocked_tool
1638 .execute(
1639 json!({ "action": "inspect" }),
1640 &ToolContext::new(tmp.path()),
1641 )
1642 .await
1643 .expect("read-only inspect must not attempt persistence");
1644 let error = blocked_tool
1645 .execute(
1646 json!({
1647 "action": "propose",
1648 "decision_id": "durable-decision",
1649 "subject": "durability",
1650 "constraints": ["persist before acknowledgement"]
1651 }),
1652 &ToolContext::new(tmp.path()),
1653 )
1654 .await
1655 .expect_err("mutation must fail when its receipt cannot persist")
1656 .to_string();
1657 assert!(error.contains("failed to persist"), "{error}");
1658 assert!(
1659 blocked_manager
1660 .read()
1661 .await
1662 .coordination
1663 .decisions
1664 .is_empty(),
1665 "failed persistence must roll the in-memory decision back"
1666 );
1667
1668 let durable_workspace = tempdir().unwrap();
1669 let state_path = durable_workspace.path().join("subagents.v1.json");
1670 let manager = Arc::new(tokio::sync::RwLock::new(
1671 super::super::SubAgentManager::new(durable_workspace.path().to_path_buf(), 4)
1672 .with_state_path(state_path.clone()),
1673 ));
1674 AgentsCoordinateTool::new(Arc::clone(&manager), None)
1675 .execute(
1676 json!({
1677 "action": "propose",
1678 "decision_id": "durable-decision",
1679 "subject": "durability",
1680 "constraints": ["persist before acknowledgement"]
1681 }),
1682 &ToolContext::new(durable_workspace.path()),
1683 )
1684 .await
1685 .expect("durable mutation");
1686 let mut replayed =
1687 super::super::SubAgentManager::new(durable_workspace.path().to_path_buf(), 4)
1688 .with_state_path(state_path);
1689 replayed.load_state().expect("reload durable action");
1690 assert_eq!(replayed.coordination.decisions.len(), 1);
1691 assert_eq!(
1692 replayed.coordination.decisions[0].decision_id,
1693 "durable-decision"
1694 );
1695 }
1696
1697 #[tokio::test]
1698 async fn release_action_clears_only_stale_write_claims_and_persists() {
1699 let tmp = tempdir().unwrap();
1700 let state_path = tmp.path().join("subagents.v1.json");
1701 let manager = Arc::new(tokio::sync::RwLock::new(
1702 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1703 .with_state_path(state_path.clone()),
1704 ));
1705 let live = {
1706 let mut guard = manager.write().await;
1707 let live = guard.insert_test_running_agent("live-builder", tmp.path());
1708 let active = [live.clone()].into_iter().collect::<BTreeSet<_>>();
1709 for claim in [
1710 WriteScopeClaim {
1711 owner: live.clone(),
1712 roots: vec!["src/live".into()],
1713 exact_files: Vec::new(),
1714 contracts: Vec::new(),
1715 },
1716 WriteScopeClaim {
1717 owner: "zombie-builder".into(),
1718 roots: vec!["src/zombie".into()],
1719 exact_files: Vec::new(),
1720 contracts: Vec::new(),
1721 },
1722 ] {
1723 guard
1724 .coordination
1725 .register_claim(claim, false, |candidate| active.contains(candidate))
1726 .expect("initial claim");
1727 }
1728 let _ = guard.persist_state_synchronously();
1729 live
1730 };
1731
1732 let result = AgentsCoordinateTool::new(Arc::clone(&manager), None)
1733 .execute(
1734 json!({ "action": "release" }),
1735 &ToolContext::new(tmp.path()),
1736 )
1737 .await
1738 .expect("release sweeps stale claims");
1739 let body: Value = serde_json::from_str(&result.content).unwrap();
1740 assert_eq!(body["released"], json!(1));
1741 assert_eq!(body["owners"], json!(["zombie-builder"]));
1742
1743 // The sweep is durable: reloading the ledger keeps only the live claim.
1744 let mut replayed = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1745 .with_state_path(state_path);
1746 replayed.load_state().expect("reload released ledger");
1747 assert_eq!(replayed.coordination.write_claims.len(), 1);
1748 assert_eq!(replayed.coordination.write_claims[0].claim.owner, live);
1749
1750 // Named-owner release of a live claimant is a no-op and still succeeds.
1751 let noop = AgentsCoordinateTool::new(Arc::clone(&manager), None)
1752 .execute(
1753 json!({ "action": "release", "owner": live }),
1754 &ToolContext::new(tmp.path()),
1755 )
1756 .await
1757 .expect("live release is a no-op");
1758 let body: Value = serde_json::from_str(&noop.content).unwrap();
1759 assert_eq!(body["released"], json!(0));
1760 }
1761
1762 #[tokio::test]
1763 async fn rejected_claim_contention_is_persisted_before_returning_the_error() {
1764 let tmp = tempdir().unwrap();
1765 let state_path = tmp.path().join("subagents.v1.json");
1766 let manager = Arc::new(tokio::sync::RwLock::new(
1767 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1768 .with_state_path(state_path.clone()),
1769 ));
1770 let (claimant, owner) = {
1771 let mut guard = manager.write().await;
1772 let claimant = guard.insert_test_running_agent("claimant", tmp.path());
1773 let owner = guard.insert_test_running_agent("owner", tmp.path());
1774 let active = [claimant.clone(), owner.clone()]
1775 .into_iter()
1776 .collect::<BTreeSet<_>>();
1777 for claim in [
1778 WriteScopeClaim {
1779 owner: claimant.clone(),
1780 roots: vec!["src/claimant".into()],
1781 exact_files: Vec::new(),
1782 contracts: Vec::new(),
1783 },
1784 WriteScopeClaim {
1785 owner: owner.clone(),
1786 roots: vec!["src/shared".into()],
1787 exact_files: Vec::new(),
1788 contracts: Vec::new(),
1789 },
1790 ] {
1791 guard
1792 .coordination
1793 .register_claim(claim, false, |candidate| active.contains(candidate))
1794 .expect("initial non-overlapping claim");
1795 }
1796 (claimant, owner)
1797 };
1798
1799 let error = AgentsCoordinateTool::new(Arc::clone(&manager), Some(claimant.clone()))
1800 .execute(
1801 json!({ "action": "claim", "roots": ["src/shared/nested"] }),
1802 &ToolContext::new(tmp.path()),
1803 )
1804 .await
1805 .expect_err("overlap must block")
1806 .to_string();
1807 assert!(
1808 error.contains(&owner) && error.contains("contention"),
1809 "{error}"
1810 );
1811
1812 let mut replayed = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1813 .with_state_path(state_path);
1814 replayed.load_state().expect("reload contention receipt");
1815 assert_eq!(replayed.coordination.contentions.len(), 1);
1816 assert_eq!(replayed.coordination.contentions[0].claimant, claimant);
1817 assert_eq!(
1818 replayed.coordination.contentions[0].conflicting_owner,
1819 owner
1820 );
1821 }
1822
1823 #[tokio::test]
1824 async fn coordination_resolution_survives_reload_and_resolving_claim_eviction() {
1825 let tmp = tempdir().unwrap();
1826 let state_path = tmp.path().join("subagents.v1.json");
1827 let manager = Arc::new(tokio::sync::RwLock::new(
1828 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1829 .with_state_path(state_path.clone()),
1830 ));
1831 let claimant = {
1832 let mut guard = manager.write().await;
1833 let claimant = guard.insert_test_running_agent("claimant", tmp.path());
1834 let owner = guard.insert_test_running_agent("owner", tmp.path());
1835 let active = [claimant.clone(), owner.clone()]
1836 .into_iter()
1837 .collect::<BTreeSet<_>>();
1838 for claim in [
1839 WriteScopeClaim {
1840 owner: claimant.clone(),
1841 roots: vec!["src/claimant".into()],
1842 exact_files: Vec::new(),
1843 contracts: Vec::new(),
1844 },
1845 WriteScopeClaim {
1846 owner: owner.clone(),
1847 roots: vec!["src/shared".into()],
1848 exact_files: Vec::new(),
1849 contracts: Vec::new(),
1850 },
1851 ] {
1852 guard
1853 .coordination
1854 .register_claim(claim, false, |candidate| active.contains(candidate))
1855 .expect("initial non-overlapping claim");
1856 }
1857 claimant
1858 };
1859
1860 AgentsCoordinateTool::new(Arc::clone(&manager), Some(claimant.clone()))
1861 .execute(
1862 json!({ "action": "claim", "roots": ["src/shared/nested"] }),
1863 &ToolContext::new(tmp.path()),
1864 )
1865 .await
1866 .expect_err("overlap must block and persist its receipt");
1867
1868 let resolution_sequence = {
1869 let mut guard = manager.write().await;
1870 let record = guard
1871 .coordination
1872 .register_claim(
1873 WriteScopeClaim {
1874 owner: claimant.clone(),
1875 roots: vec!["src/isolated".into()],
1876 exact_files: Vec::new(),
1877 contracts: Vec::new(),
1878 },
1879 true,
1880 |_| true,
1881 )
1882 .expect("later isolated claim resolves contention");
1883 guard
1884 .persist_state_synchronously()
1885 .expect("persist resolved contention");
1886 record.sequence
1887 };
1888
1889 let mut replayed = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1890 .with_state_path(state_path.clone());
1891 replayed.load_state().expect("reload resolved contention");
1892 assert_eq!(replayed.coordination.contentions.len(), 1);
1893 assert_eq!(
1894 replayed.coordination.contentions[0].disposition,
1895 WriteContentionDisposition::ResolvedBySuccessfulClaim
1896 );
1897 assert_eq!(
1898 replayed.coordination.contentions[0].resolution_sequence,
1899 Some(resolution_sequence)
1900 );
1901
1902 let slots = COORDINATION_RECORD_LIMIT - replayed.coordination.write_claims.len();
1903 for index in 0..slots {
1904 replayed
1905 .coordination
1906 .register_claim(
1907 WriteScopeClaim {
1908 owner: format!("inactive-fill-{index:03}"),
1909 roots: vec![format!("pkg/fill-{index:03}")],
1910 exact_files: Vec::new(),
1911 contracts: Vec::new(),
1912 },
1913 true,
1914 |_| false,
1915 )
1916 .expect("fill inactive claim capacity");
1917 }
1918 for index in 0..2 {
1919 replayed
1920 .coordination
1921 .register_claim(
1922 WriteScopeClaim {
1923 owner: format!("inactive-overflow-{index}"),
1924 roots: vec![format!("pkg/overflow-{index}")],
1925 exact_files: Vec::new(),
1926 contracts: Vec::new(),
1927 },
1928 true,
1929 |_| false,
1930 )
1931 .expect("evict oldest inactive claim at capacity");
1932 }
1933 assert!(
1934 !replayed
1935 .coordination
1936 .write_claims
1937 .iter()
1938 .any(|claim| claim.claim.owner == claimant),
1939 "the resolving claimant claim must be evicted for the durability regression"
1940 );
1941 replayed
1942 .persist_state_synchronously()
1943 .expect("persist after inactive claim eviction");
1944
1945 let mut final_replay = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1946 .with_state_path(state_path);
1947 final_replay
1948 .load_state()
1949 .expect("reload after resolving claim eviction");
1950 let projection = final_replay.coordination_detail_projection(None, 24);
1951 assert!(
1952 !projection
1953 .write_claims
1954 .iter()
1955 .any(|claim| claim.claim.owner == claimant)
1956 );
1957 assert_eq!(projection.contentions.len(), 1);
1958 assert_eq!(
1959 projection.contentions[0].disposition,
1960 WriteContentionDisposition::ResolvedBySuccessfulClaim
1961 );
1962 assert_eq!(
1963 projection.contentions[0].resolution_sequence,
1964 Some(resolution_sequence)
1965 );
1966 assert!(!crate::tui::coordination_detail::needs_attention(
1967 &projection
1968 ));
1969 let pager = crate::tui::coordination_detail::format(
1970 codewhale_localization::Locale::En,
1971 &projection,
1972 );
1973 assert!(
1974 pager.contains("disposition resolved_by_successful_claim"),
1975 "{pager}"
1976 );
1977 assert!(!pager.contains("disposition blocked_pending"), "{pager}");
1978 }
1979
1980 #[tokio::test]
1981 async fn interrupt_fails_closed_on_self() {
1982 let tmp = tempdir().unwrap();
1983 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
1984 let tool = AgentsInterruptTool::new(Arc::clone(&manager)).with_caller(agent_id.clone());
1985 let err = tool
1986 .execute(
1987 json!({ "agent_id": agent_id }),
1988 &ToolContext::new(tmp.path()),
1989 )
1990 .await
1991 .expect_err("self interrupt must fail");
1992 let msg = err.to_string().to_ascii_lowercase();
1993 assert!(
1994 msg.contains("self") || msg.contains("own"),
1995 "unexpected error: {err}"
1996 );
1997 }
1998
1999 #[tokio::test]
2000 async fn interrupt_fails_closed_on_missing_target() {
2001 let tmp = tempdir().unwrap();
2002 let manager = Arc::new(tokio::sync::RwLock::new(
2003 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 2),
2004 ));
2005 let tool = AgentsInterruptTool::new(manager);
2006 let err = tool
2007 .execute(
2008 json!({ "agent_id": "agent_missing" }),
2009 &ToolContext::new(tmp.path()),
2010 )
2011 .await
2012 .expect_err("missing target");
2013 assert!(err.to_string().contains("not found") || err.to_string().contains("Agent"));
2014 }
2015
2016 #[tokio::test]
2017 async fn wait_times_out_when_child_stays_running() {
2018 let tmp = tempdir().unwrap();
2019 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
2020 let tool = AgentsWaitTool::new(manager);
2021 let result = tool
2022 .execute(
2023 json!({
2024 "agent_id": agent_id,
2025 "timeout_secs": 1,
2026 "until": "activity"
2027 }),
2028 &ToolContext::new(tmp.path()),
2029 )
2030 .await
2031 .expect("wait returns");
2032 let body: Value = serde_json::from_str(&result.content).unwrap();
2033 assert_eq!(body["timed_out"], json!(true));
2034 }
2035
2036 #[tokio::test]
2037 async fn list_resolves_target_and_reports_queue() {
2038 let tmp = tempdir().unwrap();
2039 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
2040 {
2041 let mut guard = manager.write().await;
2042 guard
2043 .queue_parent_message(&agent_id, "note".into(), false)
2044 .unwrap();
2045 }
2046 let tool = AgentsListTool::new(manager);
2047 let result = tool
2048 .execute(
2049 json!({ "agent_id": agent_id }),
2050 &ToolContext::new(tmp.path()),
2051 )
2052 .await
2053 .expect("list ok");
2054 let body: Value = serde_json::from_str(&result.content).unwrap();
2055 assert_eq!(body["count"], json!(1));
2056 assert_eq!(body["agents"][0]["agent_id"], json!(agent_id));
2057 assert!(body["agents"][0]["queued_mail"].as_u64().unwrap_or(0) >= 1);
2058 }
2059
2060 #[tokio::test]
2061 async fn followup_interrupted_continuable_without_runtime_queues_honestly() {
2062 let tmp = tempdir().unwrap();
2063 let manager = Arc::new(tokio::sync::RwLock::new(
2064 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4),
2065 ));
2066 let (agent_id, handle) = {
2067 let mut guard = manager.write().await;
2068 guard.insert_test_interrupted_continuable_agent(
2069 "paused_child",
2070 tmp.path(),
2071 vec![codewhale_models::Message {
2072 role: Role::User,
2073 content: vec![codewhale_models::ContentBlock::Text {
2074 text: "prior work".to_string(),
2075 cache_control: None,
2076 }],
2077 }],
2078 )
2079 };
2080 // No runtime attached: checkpoint resume is unavailable, so followup
2081 // keeps the honest queue-only semantics with the continuation handle.
2082 let tool = AgentsFollowupTool::new(Arc::clone(&manager));
2083 let result = tool
2084 .execute(
2085 json!({ "agent_id": agent_id, "message": "please continue" }),
2086 &ToolContext::new(tmp.path()),
2087 )
2088 .await
2089 .expect("followup ok");
2090 let body: Value = serde_json::from_str(&result.content).unwrap();
2091 assert_eq!(body["queued"], json!(true));
2092 assert_eq!(body["woke"], json!(false));
2093 assert_eq!(body["continued_from_checkpoint"], json!(false));
2094 assert_eq!(body["continuation_handle"], json!(handle));
2095 let note = body["note"].as_str().unwrap_or_default();
2096 assert!(
2097 note.contains("attach a runtime") && note.contains(&handle),
2098 "note must point at the resume path with the continuation handle: {note}"
2099 );
2100
2101 let guard = manager.read().await;
2102 assert_eq!(guard.queued_mail_depth(&agent_id).unwrap(), 1);
2103 assert!(!guard.child_was_woken(&agent_id));
2104 }
2105
2106 // === until="all": the fan-out join ===================================
2107 //
2108 // Before this existed a parent with five children had to issue five
2109 // waits — while the prompt told it not to poll. These lock the join in.
2110
2111 fn empty_manager(workspace: &std::path::Path) -> SharedSubAgentManager {
2112 Arc::new(tokio::sync::RwLock::new(
2113 super::super::SubAgentManager::new(workspace.to_path_buf(), 8),
2114 ))
2115 }
2116
2117 async fn settle(manager: &SharedSubAgentManager, agent_id: &str, status: SubAgentStatus) {
2118 let mut guard = manager.write().await;
2119 if let Some(agent) = guard.agents.get_mut(agent_id) {
2120 agent.status = status;
2121 }
2122 }
2123
2124 #[test]
2125 fn wait_schema_offers_all_as_a_first_class_until() {
2126 let tmp = tempdir().unwrap();
2127 let tool = AgentsWaitTool::new(empty_manager(tmp.path()));
2128 let schema = tool.input_schema();
2129 let until = &schema["properties"]["until"];
2130 assert_eq!(
2131 until["enum"],
2132 json!(["completion", "all", "activity"]),
2133 "until must expose all alongside completion/activity: {schema}"
2134 );
2135 let described = until["description"].as_str().unwrap_or_default();
2136 assert!(
2137 described.contains("every watched child") && described.contains("any one child"),
2138 "the schema must make completion vs all unmistakable: {described}"
2139 );
2140 }
2141
2142 #[tokio::test]
2143 async fn wait_until_all_on_an_already_settled_child_reports_its_outcome() {
2144 let tmp = tempdir().unwrap();
2145 let manager = empty_manager(tmp.path());
2146 let agent_id = {
2147 let mut guard = manager.write().await;
2148 guard.insert_test_running_agent("all_already_done", tmp.path())
2149 };
2150 settle(&manager, &agent_id, SubAgentStatus::Completed).await;
2151
2152 let result = dispatch_wait(
2153 &json!({ "until": "all", "agent_id": agent_id, "timeout_secs": 60 }),
2154 Arc::clone(&manager),
2155 &ToolContext::new(tmp.path()),
2156 )
2157 .await
2158 .expect("a settled child is an immediate return");
2159 let body: Value = serde_json::from_str(&result.content).unwrap();
2160 assert_eq!(body["all_settled"], json!(true), "{body}");
2161 let settled = body["settled"].as_array().unwrap();
2162 assert_eq!(settled.len(), 1, "{body}");
2163 assert_eq!(settled[0]["status"], json!("completed"), "{body}");
2164 }
2165
2166 #[tokio::test]
2167 async fn foreign_session_is_excluded_from_default_and_explicit_list_waits() {
2168 let tmp = tempdir().unwrap();
2169 let manager = empty_manager(tmp.path());
2170 let agent_a = {
2171 let mut guard = manager.write().await;
2172 let agent_id = guard.insert_test_running_agent("foreign_wait_a", tmp.path());
2173 guard.assign_test_session_owner(&agent_id, "session-a");
2174 agent_id
2175 };
2176 let context_b = ToolContext::new(tmp.path()).with_state_namespace("session-b");
2177
2178 let listed = AgentsListTool::new(Arc::clone(&manager))
2179 .execute(json!({}), &context_b)
2180 .await
2181 .expect("B list");
2182 let listed: Value = serde_json::from_str(&listed.content).unwrap();
2183 assert_eq!(listed["count"], json!(0));
2184
2185 let default_wait = dispatch_wait(
2186 &json!({ "until": "all", "timeout_secs": 60 }),
2187 Arc::clone(&manager),
2188 &context_b,
2189 )
2190 .await
2191 .expect("B default wait has no visible children");
2192 let default_wait: Value = serde_json::from_str(&default_wait.content).unwrap();
2193 assert!(default_wait["settled"].as_array().unwrap().is_empty());
2194
2195 let error = dispatch_wait(
2196 &json!({ "until": "all", "agent_id": agent_a, "timeout_secs": 60 }),
2197 manager,
2198 &context_b,
2199 )
2200 .await
2201 .expect_err("B explicit wait must reject A")
2202 .to_string();
2203 assert!(
2204 error.contains("Agent not found in the active session"),
2205 "{error}"
2206 );
2207 }
2208
2209 #[tokio::test]
2210 async fn wait_until_all_returns_immediately_with_no_children() {
2211 let tmp = tempdir().unwrap();
2212 let started = Instant::now();
2213 let result = dispatch_wait(
2214 &json!({ "until": "all", "timeout_secs": 60 }),
2215 empty_manager(tmp.path()),
2216 &ToolContext::new(tmp.path()),
2217 )
2218 .await
2219 .expect("wait-for-all with zero children must return, not hang");
2220 assert!(
2221 started.elapsed() < Duration::from_secs(5),
2222 "zero children must not burn the timeout"
2223 );
2224 let body: Value = serde_json::from_str(&result.content).unwrap();
2225 assert_eq!(body["all_settled"], json!(true));
2226 assert_eq!(body["timed_out"], json!(false));
2227 assert!(body["settled"].as_array().unwrap().is_empty(), "{body}");
2228 }
2229
2230 #[tokio::test]
2231 async fn wait_until_all_blocks_until_every_child_settles() {
2232 let tmp = tempdir().unwrap();
2233 let manager = empty_manager(tmp.path());
2234 let (first, second, third) = {
2235 let mut guard = manager.write().await;
2236 (
2237 guard.insert_test_running_agent("all_first", tmp.path()),
2238 guard.insert_test_running_agent("all_second", tmp.path()),
2239 guard.insert_test_running_agent("all_third", tmp.path()),
2240 )
2241 };
2242
2243 // Staggered settles: an `until=completion` wait would return after the
2244 // first one. `until=all` must stay blocked through the last.
2245 let flip = Arc::clone(&manager);
2246 let (a, b, c) = (first.clone(), second.clone(), third.clone());
2247 tokio::spawn(async move {
2248 tokio::time::sleep(Duration::from_millis(50)).await;
2249 settle(&flip, &a, SubAgentStatus::Completed).await;
2250 tokio::time::sleep(Duration::from_millis(150)).await;
2251 settle(&flip, &b, SubAgentStatus::Failed("boom".to_string())).await;
2252 tokio::time::sleep(Duration::from_millis(150)).await;
2253 settle(&flip, &c, SubAgentStatus::Cancelled).await;
2254 });
2255
2256 let result = dispatch_wait(
2257 &json!({ "until": "all", "timeout_secs": 30 }),
2258 Arc::clone(&manager),
2259 &ToolContext::new(tmp.path()),
2260 )
2261 .await
2262 .expect("wait-for-all should succeed");
2263 let body: Value = serde_json::from_str(&result.content).unwrap();
2264 assert_eq!(body["all_settled"], json!(true), "{body}");
2265 assert_eq!(body["timed_out"], json!(false), "{body}");
2266 assert!(
2267 body["still_running"].as_array().unwrap().is_empty(),
2268 "{body}"
2269 );
2270
2271 // Per-child outcomes come back on the single return.
2272 let settled = body["settled"].as_array().unwrap();
2273 assert_eq!(settled.len(), 3, "{body}");
2274 let outcomes: std::collections::BTreeMap<&str, &str> = settled
2275 .iter()
2276 .map(|entry| {
2277 (
2278 entry["agent_id"].as_str().unwrap(),
2279 entry["status"].as_str().unwrap(),
2280 )
2281 })
2282 .collect();
2283 assert_eq!(outcomes.get(first.as_str()), Some(&"completed"), "{body}");
2284 assert_eq!(outcomes.get(second.as_str()), Some(&"failed"), "{body}");
2285 assert_eq!(outcomes.get(third.as_str()), Some(&"cancelled"), "{body}");
2286 }
2287
2288 #[tokio::test]
2289 async fn wait_until_all_times_out_reporting_settled_and_still_running() {
2290 let tmp = tempdir().unwrap();
2291 let manager = empty_manager(tmp.path());
2292 let (done, stuck) = {
2293 let mut guard = manager.write().await;
2294 (
2295 guard.insert_test_running_agent("all_done", tmp.path()),
2296 guard.insert_test_running_agent("all_stuck", tmp.path()),
2297 )
2298 };
2299
2300 let request = json!({ "until": "all", "timeout_secs": 1 });
2301 let context = ToolContext::new(tmp.path());
2302 let wait = dispatch_wait(&request, Arc::clone(&manager), &context);
2303 tokio::pin!(wait);
2304 // Capture both running children before completing one. The timeout
2305 // receipt must not depend on a background task winning a 50ms race.
2306 assert!(futures_util::poll!(wait.as_mut()).is_pending());
2307 settle(&manager, &done, SubAgentStatus::Completed).await;
2308
2309 let result = wait
2310 .await
2311 .expect("a timeout is a partial receipt, not an error");
2312 let body: Value = serde_json::from_str(&result.content).unwrap();
2313 assert_eq!(body["timed_out"], json!(true), "{body}");
2314 assert_eq!(body["all_settled"], json!(false), "{body}");
2315
2316 let settled = body["settled"].as_array().unwrap();
2317 assert_eq!(settled.len(), 1, "{body}");
2318 assert_eq!(settled[0]["agent_id"], json!(done), "{body}");
2319 assert_eq!(settled[0]["status"], json!("completed"), "{body}");
2320
2321 let running = body["still_running"].as_array().unwrap();
2322 assert_eq!(running.len(), 1, "{body}");
2323 assert_eq!(running[0]["agent_id"], json!(stuck), "{body}");
2324 }
2325
2326 #[tokio::test]
2327 async fn wait_until_all_ignores_children_spawned_mid_wait() {
2328 let tmp = tempdir().unwrap();
2329 let manager = empty_manager(tmp.path());
2330 let original = {
2331 let mut guard = manager.write().await;
2332 guard.insert_test_running_agent("all_original", tmp.path())
2333 };
2334
2335 let flip = Arc::clone(&manager);
2336 let tmp_path = tmp.path().to_path_buf();
2337 let original_id = original.clone();
2338 tokio::spawn(async move {
2339 tokio::time::sleep(Duration::from_millis(50)).await;
2340 {
2341 let mut guard = flip.write().await;
2342 guard.insert_test_running_agent("all_latecomer", &tmp_path);
2343 }
2344 settle(&flip, &original_id, SubAgentStatus::Completed).await;
2345 });
2346
2347 let result = dispatch_wait(
2348 &json!({ "until": "all", "timeout_secs": 30 }),
2349 Arc::clone(&manager),
2350 &ToolContext::new(tmp.path()),
2351 )
2352 .await
2353 .expect("wait-for-all should succeed");
2354 let body: Value = serde_json::from_str(&result.content).unwrap();
2355 // The watch set is the batch as of call time: the latecomer must not
2356 // extend a wait the caller never asked to include it in.
2357 assert_eq!(body["all_settled"], json!(true), "{body}");
2358 assert_eq!(body["timed_out"], json!(false), "{body}");
2359 let settled = body["settled"].as_array().unwrap();
2360 assert_eq!(settled.len(), 1, "{body}");
2361 assert_eq!(settled[0]["agent_id"], json!(original), "{body}");
2362 }
2363
2364 #[tokio::test]
2365 async fn wait_rejects_unknown_until_naming_every_supported_mode() {
2366 let tmp = tempdir().unwrap();
2367 let error = dispatch_wait(
2368 &json!({ "until": "forever" }),
2369 empty_manager(tmp.path()),
2370 &ToolContext::new(tmp.path()),
2371 )
2372 .await
2373 .expect_err("an unknown until must fail loudly");
2374 let message = error.to_string();
2375 for mode in ["completion", "all", "activity"] {
2376 assert!(message.contains(mode), "{message}");
2377 }
2378 }
2379
2380 #[tokio::test]
2381 async fn followup_interrupted_continuable_resumes_with_runtime() {
2382 let tmp = tempdir().unwrap();
2383 let manager = Arc::new(tokio::sync::RwLock::new(
2384 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4),
2385 ));
2386 let (agent_id, _handle) = {
2387 let mut guard = manager.write().await;
2388 guard.insert_test_interrupted_continuable_agent(
2389 "paused_child",
2390 tmp.path(),
2391 vec![codewhale_models::Message {
2392 role: Role::User,
2393 content: vec![codewhale_models::ContentBlock::Text {
2394 text: "prior work".to_string(),
2395 cache_control: None,
2396 }],
2397 }],
2398 )
2399 };
2400 let mut runtime = super::super::tests::stub_runtime();
2401 runtime.manager = Arc::clone(&manager);
2402 let tool = AgentsFollowupTool::new(Arc::clone(&manager)).with_runtime(runtime);
2403 let result = tool
2404 .execute(
2405 json!({ "agent_id": agent_id, "message": "please continue" }),
2406 &ToolContext::new(tmp.path()),
2407 )
2408 .await
2409 .expect("followup ok");
2410 let body: Value = serde_json::from_str(&result.content).unwrap();
2411 assert_eq!(body["queued"], json!(true));
2412 assert_eq!(body["woke"], json!(true));
2413 assert_eq!(body["continued_from_checkpoint"], json!(true));
2414 let note = body["note"].as_str().unwrap_or_default();
2415 assert!(note.contains("resumed from checkpoint"), "{note}");
2416 let resumed_id = body["agent_id"].as_str().unwrap_or_default();
2417 assert_ne!(
2418 resumed_id, agent_id,
2419 "resume re-dispatches under a new agent id"
2420 );
2421
2422 // A fresh record exists for the resumed session; the prior terminal
2423 // record stays immutable (receipts are never rewritten).
2424 let guard = manager.read().await;
2425 guard.get_result(resumed_id).expect("resumed agent exists");
2426 let prior = guard.get_result(&agent_id).expect("prior record");
2427 assert!(matches!(prior.status, SubAgentStatus::Interrupted(_)));
2428 }
2429 }
2430
2430 lines RUST