返回 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::collections::BTreeSet;
8 use std::sync::Arc;
9 use std::time::{Duration, Instant};
10
11 use async_trait::async_trait;
12 use serde::{Deserialize, Serialize};
13 use serde_json::{Value, json};
14
15 use super::{
16 COMPLETED_AGENT_RETENTION, ParentMailReceipt, SharedSubAgentManager, SubAgentRuntime,
17 SubAgentStatus, parse_agent_ref, subagent_session_projection, subagent_status_name,
18 wait_for_subagents_from_input,
19 };
20 use crate::tools::registry::ToolRegistryBuilder;
21 use crate::tools::spec::{
22 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
23 };
24
25 const COORD_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 300;
26 const COORD_WAIT_MIN_TIMEOUT_SECS: u64 = 1;
27 const COORD_WAIT_MAX_TIMEOUT_SECS: u64 = 1800;
28 const COORD_WAIT_CHECK_INTERVAL: Duration = Duration::from_millis(250);
29 const RECENT_PROGRESS_LIMIT: usize = 8;
30 pub(super) const COORDINATION_RECORD_LIMIT: usize = 128;
31 const COORDINATION_INSPECT_LIMIT: usize = 24;
32 pub(super) const COORDINATION_PROJECTION_DECISION_LIMIT: usize = 8;
33 pub(super) const COORDINATION_PROJECTION_BYTE_LIMIT: usize = 4096;
34
35 // ── agents/list ──────────────────────────────────────────────────────────
36
37 pub struct AgentsListTool {
38 manager: SharedSubAgentManager,
39 }
40
41 impl AgentsListTool {
42 #[must_use]
43 pub fn new(manager: SharedSubAgentManager) -> Self {
44 Self { manager }
45 }
46 }
47
48 #[async_trait]
49 impl ToolSpec for AgentsListTool {
50 fn name(&self) -> &'static str {
51 "agents/list"
52 }
53
54 fn description(&self) -> &'static str {
55 "List child agents: ids, parent hierarchy, state, bounded recent progress, and token budget. Read-only coordination view — does not spawn or wake workers."
56 }
57
58 fn input_schema(&self) -> Value {
59 json!({
60 "type": "object",
61 "properties": {
62 "include_archived": {
63 "type": "boolean",
64 "description": "Include prior-session / archived agents. Default false."
65 },
66 "agent_id": {
67 "type": "string",
68 "description": "Optional single agent id or session name to inspect."
69 }
70 },
71 "required": []
72 })
73 }
74
75 fn capabilities(&self) -> Vec<ToolCapability> {
76 vec![ToolCapability::ReadOnly]
77 }
78
79 fn approval_requirement(&self) -> ApprovalRequirement {
80 ApprovalRequirement::Auto
81 }
82
83 fn is_read_only_for(&self, _input: &Value) -> bool {
84 true
85 }
86
87 fn supports_parallel_for(&self, _input: &Value) -> bool {
88 true
89 }
90
91 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
92 let include_archived = input
93 .get("include_archived")
94 .and_then(Value::as_bool)
95 .unwrap_or(false);
96 let agent_ref = parse_agent_ref(&input)?;
97
98 let mut manager = self.manager.write().await;
99 manager.cleanup(COMPLETED_AGENT_RETENTION);
100 let summaries = if let Some(agent_ref) = agent_ref {
101 let summary = manager
102 .coordination_summary_for(&agent_ref, RECENT_PROGRESS_LIMIT)
103 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
104 vec![summary]
105 } else {
106 manager.list_coordination_summaries(include_archived, RECENT_PROGRESS_LIMIT)
107 };
108 drop(manager);
109
110 let payload = json!({
111 "action": "list",
112 "count": summaries.len(),
113 "agents": summaries,
114 });
115 let mut tool_result = ToolResult::json(&payload)
116 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
117 tool_result.metadata = Some(json!({
118 "action": "list",
119 "count": summaries.len(),
120 }));
121 Ok(tool_result)
122 }
123 }
124
125 // ── agents/message ───────────────────────────────────────────────────────
126
127 pub struct AgentsMessageTool {
128 manager: SharedSubAgentManager,
129 caller_agent_id: Option<String>,
130 }
131
132 impl AgentsMessageTool {
133 #[must_use]
134 pub fn new(manager: SharedSubAgentManager) -> Self {
135 Self {
136 manager,
137 caller_agent_id: None,
138 }
139 }
140
141 #[must_use]
142 pub(crate) fn with_optional_caller(mut self, caller_agent_id: Option<String>) -> Self {
143 self.caller_agent_id = caller_agent_id;
144 self
145 }
146 }
147
148 #[async_trait]
149 impl ToolSpec for AgentsMessageTool {
150 fn name(&self) -> &'static str {
151 "agents/message"
152 }
153
154 fn description(&self) -> &'static str {
155 "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."
156 }
157
158 fn input_schema(&self) -> Value {
159 json!({
160 "type": "object",
161 "properties": {
162 "agent_id": {
163 "type": "string",
164 "description": "Target child agent id or session name."
165 },
166 "message": {
167 "type": "string",
168 "description": "Message text to queue."
169 }
170 },
171 "required": ["agent_id", "message"]
172 })
173 }
174
175 fn capabilities(&self) -> Vec<ToolCapability> {
176 vec![ToolCapability::RequiresApproval]
177 }
178
179 fn approval_requirement(&self) -> ApprovalRequirement {
180 ApprovalRequirement::Required
181 }
182
183 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
184 let agent_ref =
185 parse_agent_ref(&input)?.ok_or_else(|| ToolError::missing_field("agent_id"))?;
186 let message = input
187 .get("message")
188 .or_else(|| input.get("text"))
189 .and_then(Value::as_str)
190 .map(str::trim)
191 .filter(|s| !s.is_empty())
192 .ok_or_else(|| ToolError::missing_field("message"))?
193 .to_string();
194
195 let receipt = {
196 let mut manager = self.manager.write().await;
197 manager
198 .ensure_caller_controls_descendant(
199 &agent_ref,
200 self.caller_agent_id.as_deref(),
201 "agents/message",
202 )
203 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
204 manager
205 .queue_running_parent_message(&agent_ref, message)
206 .map_err(|err| ToolError::invalid_input(err.to_string()))?
207 };
208
209 let payload = json!({
210 "action": "message",
211 "agent_id": receipt.agent_id,
212 "queued": true,
213 "woke": false,
214 "queue_depth": receipt.queue_depth,
215 "status": receipt.status,
216 "note": "Message queued without waking the child.",
217 });
218 let mut tool_result = ToolResult::json(&payload)
219 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
220 tool_result.metadata = Some(json!({
221 "action": "message",
222 "agent_id": receipt.agent_id,
223 "woke": false,
224 "queue_depth": receipt.queue_depth,
225 }));
226 Ok(tool_result)
227 }
228 }
229
230 // ── agents/followup ──────────────────────────────────────────────────────
231
232 pub struct AgentsFollowupTool {
233 manager: SharedSubAgentManager,
234 caller_agent_id: Option<String>,
235 /// Runtime for checkpoint resume. `None` (legacy/test construction)
236 /// keeps the queue-only followup behavior.
237 runtime: Option<SubAgentRuntime>,
238 }
239
240 impl AgentsFollowupTool {
241 #[must_use]
242 pub fn new(manager: SharedSubAgentManager) -> Self {
243 Self {
244 manager,
245 caller_agent_id: None,
246 runtime: None,
247 }
248 }
249
250 #[must_use]
251 pub fn with_runtime(mut self, runtime: SubAgentRuntime) -> Self {
252 self.runtime = Some(runtime);
253 self
254 }
255
256 #[must_use]
257 pub(crate) fn with_optional_caller(mut self, caller_agent_id: Option<String>) -> Self {
258 self.caller_agent_id = caller_agent_id;
259 self
260 }
261 }
262
263 #[async_trait]
264 impl ToolSpec for AgentsFollowupTool {
265 fn name(&self) -> &'static str {
266 "agents/followup"
267 }
268
269 fn description(&self) -> &'static str {
270 "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."
271 }
272
273 fn input_schema(&self) -> Value {
274 json!({
275 "type": "object",
276 "properties": {
277 "agent_id": {
278 "type": "string",
279 "description": "Target child agent id or session name."
280 },
281 "message": {
282 "type": "string",
283 "description": "Follow-up message text."
284 }
285 },
286 "required": ["agent_id", "message"]
287 })
288 }
289
290 fn capabilities(&self) -> Vec<ToolCapability> {
291 vec![ToolCapability::RequiresApproval]
292 }
293
294 fn approval_requirement(&self) -> ApprovalRequirement {
295 ApprovalRequirement::Required
296 }
297
298 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
299 let agent_ref =
300 parse_agent_ref(&input)?.ok_or_else(|| ToolError::missing_field("agent_id"))?;
301 let message = input
302 .get("message")
303 .or_else(|| input.get("text"))
304 .and_then(Value::as_str)
305 .map(str::trim)
306 .filter(|s| !s.is_empty())
307 .ok_or_else(|| ToolError::missing_field("message"))?
308 .to_string();
309
310 // Enforce the caller hierarchy, then decide between checkpoint resume
311 // (interrupted_continuable with a runtime attached) and queue-only
312 // followup while holding only the read lock. The resume path takes
313 // the write lock itself via the manager method.
314 let should_resume = {
315 let manager = self.manager.read().await;
316 manager
317 .ensure_caller_controls_descendant(
318 &agent_ref,
319 self.caller_agent_id.as_deref(),
320 "agents/followup",
321 )
322 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
323 manager
324 .get_result_by_ref(&agent_ref)
325 .ok()
326 .is_some_and(|snapshot| {
327 matches!(snapshot.status, SubAgentStatus::Interrupted(_))
328 && snapshot
329 .checkpoint
330 .as_ref()
331 .is_some_and(|cp| cp.continuable && !cp.messages.is_empty())
332 })
333 };
334
335 let receipt = if should_resume {
336 match self.runtime.clone() {
337 Some(runtime) => {
338 let mut manager = self.manager.write().await;
339 let snapshot = manager
340 .resume_from_checkpoint(
341 Arc::clone(&self.manager),
342 runtime,
343 &agent_ref,
344 &message,
345 )
346 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
347 ParentMailReceipt {
348 agent_id: snapshot.agent_id.clone(),
349 status: subagent_status_name(&snapshot.status).to_string(),
350 queue_depth: 0,
351 woke: true,
352 continued_from_checkpoint: true,
353 continuation_handle: None,
354 note: format!(
355 "resumed from checkpoint as new agent {} ({}); prior terminal record {} stays intact",
356 snapshot.agent_id, snapshot.model, agent_ref
357 ),
358 }
359 }
360 None => {
361 let mut manager = self.manager.write().await;
362 manager
363 .followup_child(&agent_ref, message)
364 .map_err(|err| ToolError::invalid_input(err.to_string()))?
365 }
366 }
367 } else {
368 let mut manager = self.manager.write().await;
369 manager
370 .followup_child(&agent_ref, message)
371 .map_err(|err| ToolError::invalid_input(err.to_string()))?
372 };
373
374 let payload = json!({
375 "action": "followup",
376 "agent_id": receipt.agent_id,
377 "queued": true,
378 "woke": receipt.woke,
379 "queue_depth": receipt.queue_depth,
380 "status": receipt.status,
381 "continued_from_checkpoint": receipt.continued_from_checkpoint,
382 "continuation_handle": receipt.continuation_handle,
383 "note": receipt.note,
384 });
385 let mut tool_result = ToolResult::json(&payload)
386 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
387 tool_result.metadata = Some(json!({
388 "action": "followup",
389 "agent_id": receipt.agent_id,
390 "woke": receipt.woke,
391 "continued_from_checkpoint": receipt.continued_from_checkpoint,
392 "continuation_handle": receipt.continuation_handle,
393 }));
394 Ok(tool_result)
395 }
396 }
397
398 // ── agents/interrupt ─────────────────────────────────────────────────────
399
400 pub struct AgentsInterruptTool {
401 manager: SharedSubAgentManager,
402 /// Optional caller identity for fail-closed self-interrupt checks.
403 caller_agent_id: Option<String>,
404 }
405
406 impl AgentsInterruptTool {
407 #[must_use]
408 pub fn new(manager: SharedSubAgentManager) -> Self {
409 Self {
410 manager,
411 caller_agent_id: None,
412 }
413 }
414
415 #[must_use]
416 #[allow(dead_code)] // arms self-interrupt fail-closed when child registries thread caller (P1.2)
417 pub fn with_caller(mut self, caller_agent_id: impl Into<String>) -> Self {
418 self.caller_agent_id = Some(caller_agent_id.into());
419 self
420 }
421
422 #[must_use]
423 pub(crate) fn with_optional_caller(mut self, caller_agent_id: Option<String>) -> Self {
424 self.caller_agent_id = caller_agent_id;
425 self
426 }
427 }
428
429 #[async_trait]
430 impl ToolSpec for AgentsInterruptTool {
431 fn name(&self) -> &'static str {
432 "agents/interrupt"
433 }
434
435 fn description(&self) -> &'static str {
436 "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."
437 }
438
439 fn input_schema(&self) -> Value {
440 json!({
441 "type": "object",
442 "properties": {
443 "agent_id": {
444 "type": "string",
445 "description": "Child agent id or session name to interrupt."
446 },
447 "reason": {
448 "type": "string",
449 "description": "Optional interrupt reason recorded on the checkpoint."
450 }
451 },
452 "required": ["agent_id"]
453 })
454 }
455
456 fn capabilities(&self) -> Vec<ToolCapability> {
457 vec![ToolCapability::RequiresApproval]
458 }
459
460 fn approval_requirement(&self) -> ApprovalRequirement {
461 ApprovalRequirement::Required
462 }
463
464 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
465 let agent_ref =
466 parse_agent_ref(&input)?.ok_or_else(|| ToolError::missing_field("agent_id"))?;
467 let reason = input
468 .get("reason")
469 .and_then(Value::as_str)
470 .map(str::trim)
471 .filter(|s| !s.is_empty())
472 .unwrap_or("interrupted by parent via agents/interrupt")
473 .to_string();
474
475 let (prior, snapshot) = {
476 let mut manager = self.manager.write().await;
477 manager
478 .interrupt_child(&agent_ref, self.caller_agent_id.as_deref(), reason)
479 .map_err(|err| ToolError::invalid_input(err.to_string()))?
480 };
481
482 let worker_record = {
483 let manager = self.manager.read().await;
484 manager.get_worker_record(&snapshot.agent_id)
485 };
486 let projection = subagent_session_projection(snapshot, false, context, worker_record).await;
487 let payload = json!({
488 "action": "interrupt",
489 "agent_id": projection.agent_id,
490 "prior_status": subagent_status_name(&prior.status),
491 "prior_steps_taken": prior.steps_taken,
492 "status": projection.status,
493 "checkpoint_preserved": projection.checkpoint.is_some(),
494 "continuable": projection.continuable,
495 "projection": projection,
496 });
497 let mut tool_result = ToolResult::json(&payload)
498 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
499 tool_result.metadata = Some(json!({
500 "action": "interrupt",
501 "agent_id": payload["agent_id"],
502 "checkpoint_preserved": payload["checkpoint_preserved"],
503 }));
504 Ok(tool_result)
505 }
506 }
507
508 // ── agents/wait ──────────────────────────────────────────────────────────
509
510 pub struct AgentsWaitTool {
511 manager: SharedSubAgentManager,
512 }
513
514 impl AgentsWaitTool {
515 #[must_use]
516 pub fn new(manager: SharedSubAgentManager) -> Self {
517 Self { manager }
518 }
519 }
520
521 #[async_trait]
522 impl ToolSpec for AgentsWaitTool {
523 fn name(&self) -> &'static str {
524 "agents/wait"
525 }
526
527 fn description(&self) -> &'static str {
528 "Block until watched children settle or the timeout elapses. One blocking wait is the right shape; polling agents/list in a loop is not. 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."
529 }
530
531 fn input_schema(&self) -> Value {
532 json!({
533 "type": "object",
534 "properties": {
535 "agent_id": {
536 "type": "string",
537 "description": "Optional specific child. When omitted, watches every child running at call time."
538 },
539 "timeout_secs": {
540 "type": "integer",
541 "minimum": 1,
542 "maximum": 1800,
543 "description": "Maximum seconds to block. Default 300."
544 },
545 "until": {
546 "type": "string",
547 "enum": ["completion", "all", "activity"],
548 "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."
549 }
550 },
551 "required": []
552 })
553 }
554
555 fn capabilities(&self) -> Vec<ToolCapability> {
556 vec![ToolCapability::ReadOnly]
557 }
558
559 fn approval_requirement(&self) -> ApprovalRequirement {
560 ApprovalRequirement::Auto
561 }
562
563 fn is_read_only_for(&self, _input: &Value) -> bool {
564 true
565 }
566
567 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
568 dispatch_wait(&input, Arc::clone(&self.manager), context).await
569 }
570 }
571
572 /// Single entry point for every blocking wait, shared by `agents/wait` and
573 /// `agent(action="wait")` so the two surfaces cannot drift.
574 ///
575 /// `until` selects the join shape:
576 /// - `completion` (default) — return as soon as any one watched child settles.
577 /// - `all` — return only when every watched child has settled (the fan-out
578 /// join the parent should use after dispatching a batch).
579 /// - `activity` — also return when a running child makes visible progress.
580 pub(super) async fn dispatch_wait(
581 input: &Value,
582 manager: SharedSubAgentManager,
583 context: &ToolContext,
584 ) -> Result<ToolResult, ToolError> {
585 let until = input
586 .get("until")
587 .and_then(Value::as_str)
588 .unwrap_or("completion")
589 .trim()
590 .to_ascii_lowercase();
591
592 match until.as_str() {
593 "" | "completion" => {
594 let mut wait_input = input.clone();
595 if wait_input.get("action").is_none() {
596 wait_input["action"] = json!("wait");
597 }
598 wait_for_subagents_from_input(&wait_input, manager, context).await
599 }
600 "all" => wait_for_all_children(input, manager, context).await,
601 "activity" => wait_for_activity(input, manager, context).await,
602 other => Err(ToolError::invalid_input(format!(
603 "Invalid until '{other}'. Use completion, all, or activity."
604 ))),
605 }
606 }
607
608 /// `until=all`: block until every child that was running when the call was
609 /// made has left `Running`.
610 ///
611 /// The watch set is fixed at call time. A child spawned while this wait is
612 /// blocked is deliberately **not** joined — the parent asked to join the batch
613 /// it had just dispatched, and silently extending the set would make the call
614 /// unbounded in a way the caller never asked for. Callers that fan out again
615 /// simply issue another wait.
616 ///
617 /// Cancel-safe (no lock is held across an await), honours `timeout_secs`, and
618 /// returns immediately with `all_settled: true` when nothing is running.
619 async fn wait_for_all_children(
620 input: &Value,
621 manager: SharedSubAgentManager,
622 context: &ToolContext,
623 ) -> Result<ToolResult, ToolError> {
624 let timeout_secs = input
625 .get("timeout_secs")
626 .or_else(|| input.get("timeout"))
627 .and_then(Value::as_u64)
628 .unwrap_or(COORD_WAIT_DEFAULT_TIMEOUT_SECS)
629 .clamp(COORD_WAIT_MIN_TIMEOUT_SECS, COORD_WAIT_MAX_TIMEOUT_SECS);
630 let timeout = Duration::from_secs(timeout_secs);
631 let agent_ref = parse_agent_ref(input)?;
632
633 // Resolve the watch set up front so a bad reference fails immediately
634 // rather than blocking for the whole timeout.
635 let watched: Vec<String> = {
636 let manager = manager.read().await;
637 if let Some(agent_ref) = &agent_ref {
638 let snapshot = manager
639 .get_result_by_ref(agent_ref)
640 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
641 if snapshot.status != SubAgentStatus::Running {
642 // Already settled: hand back its outcome rather than an empty
643 // "nothing to join" that hides what the caller asked about.
644 let settled = json!({
645 "agent_id": snapshot.agent_id,
646 "name": snapshot.name,
647 "status": subagent_status_name(&snapshot.status),
648 "steps_taken": snapshot.steps_taken,
649 });
650 drop(manager);
651 return wait_all_payload(&[settled], &[], 0, false);
652 }
653 vec![snapshot.agent_id]
654 } else {
655 manager
656 .list_filtered(false)
657 .into_iter()
658 .filter(|snapshot| snapshot.status == SubAgentStatus::Running)
659 .map(|snapshot| snapshot.agent_id)
660 .collect()
661 }
662 };
663
664 // Zero children is an immediate return, never a hang.
665 if watched.is_empty() {
666 return wait_all_payload(&[], &[], 0, false);
667 }
668
669 let started = Instant::now();
670 let cancelled = async {
671 match &context.cancel_token {
672 Some(token) => token.cancelled().await,
673 None => std::future::pending().await,
674 }
675 };
676 tokio::pin!(cancelled);
677
678 loop {
679 let (settled, still_running) = {
680 let manager = manager.read().await;
681 let mut settled = Vec::new();
682 let mut still_running = Vec::new();
683 for agent_id in &watched {
684 match manager.get_result_by_ref(agent_id) {
685 Ok(snapshot) if snapshot.status == SubAgentStatus::Running => {
686 still_running.push(json!({
687 "agent_id": snapshot.agent_id,
688 "name": snapshot.name,
689 "status": "running",
690 }));
691 }
692 Ok(snapshot) => settled.push(json!({
693 "agent_id": snapshot.agent_id,
694 "name": snapshot.name,
695 "status": subagent_status_name(&snapshot.status),
696 "steps_taken": snapshot.steps_taken,
697 })),
698 // A watched child that vanished from the ledger (retention
699 // cleanup) is no longer running; report it rather than
700 // blocking on a record that will never settle.
701 Err(_) => settled.push(json!({
702 "agent_id": agent_id,
703 "status": "gone",
704 })),
705 }
706 }
707 (settled, still_running)
708 };
709
710 if still_running.is_empty() {
711 return wait_all_payload(&settled, &[], started.elapsed().as_millis(), false);
712 }
713 if started.elapsed() >= timeout {
714 return wait_all_payload(
715 &settled,
716 &still_running,
717 started.elapsed().as_millis(),
718 true,
719 );
720 }
721
722 tokio::select! {
723 biased;
724 () = &mut cancelled => {
725 return Err(ToolError::cancelled(
726 "Wait interrupted by user cancellation before every child settled.".to_string(),
727 ));
728 }
729 () = tokio::time::sleep(COORD_WAIT_CHECK_INTERVAL) => {}
730 }
731 }
732 }
733
734 /// `until=all` result: every watched child with its own outcome, so the parent
735 /// can synthesize from one return instead of re-inspecting each child.
736 fn wait_all_payload(
737 settled: &[Value],
738 still_running: &[Value],
739 waited_ms: u128,
740 timed_out: bool,
741 ) -> Result<ToolResult, ToolError> {
742 let note = if timed_out {
743 "Timed out with children still running. Do not poll — wait again (until=all), or end your turn; results arrive as <codewhale:subagent.done> sentinels."
744 } else if settled.is_empty() {
745 "No sub-agents were running; nothing to join."
746 } else {
747 "Every watched child has settled. Full results arrive as <codewhale:subagent.done> sentinels — synthesize from those."
748 };
749 let payload = json!({
750 "action": "wait",
751 "until": "all",
752 "all_settled": still_running.is_empty(),
753 "settled": settled,
754 "still_running": still_running,
755 "waited_ms": u64::try_from(waited_ms).unwrap_or(u64::MAX),
756 "timed_out": timed_out,
757 "note": note,
758 });
759 let mut tool_result =
760 ToolResult::json(&payload).map_err(|err| ToolError::execution_failed(err.to_string()))?;
761 tool_result.metadata = Some(json!({
762 "action": "wait",
763 "until": "all",
764 "all_settled": still_running.is_empty(),
765 "settled": settled.len(),
766 "running": still_running.len(),
767 "timed_out": timed_out,
768 }));
769 Ok(tool_result)
770 }
771
772 async fn wait_for_activity(
773 input: &Value,
774 manager: SharedSubAgentManager,
775 context: &ToolContext,
776 ) -> Result<ToolResult, ToolError> {
777 let timeout_secs = input
778 .get("timeout_secs")
779 .or_else(|| input.get("timeout"))
780 .and_then(Value::as_u64)
781 .unwrap_or(COORD_WAIT_DEFAULT_TIMEOUT_SECS)
782 .clamp(COORD_WAIT_MIN_TIMEOUT_SECS, COORD_WAIT_MAX_TIMEOUT_SECS);
783 let timeout = Duration::from_secs(timeout_secs);
784 let agent_ref = parse_agent_ref(input)?;
785
786 let (watched, baseline): (Vec<String>, Vec<(String, u64)>) = {
787 let manager = manager.read().await;
788 if let Some(agent_ref) = &agent_ref {
789 let snap = manager
790 .get_result_by_ref(agent_ref)
791 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
792 let fp = manager.activity_fingerprint(&snap.agent_id).unwrap_or(0);
793 if snap.status != SubAgentStatus::Running {
794 let payload = json!({
795 "action": "wait",
796 "until": "activity",
797 "reason": "already_settled",
798 "timed_out": false,
799 "agent_id": snap.agent_id,
800 "status": subagent_status_name(&snap.status),
801 });
802 let mut tool_result = ToolResult::json(&payload)
803 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
804 tool_result.metadata = Some(json!({ "action": "wait", "timed_out": false }));
805 return Ok(tool_result);
806 }
807 (vec![snap.agent_id.clone()], vec![(snap.agent_id, fp)])
808 } else {
809 let running = manager
810 .list_filtered(false)
811 .into_iter()
812 .filter(|s| s.status == SubAgentStatus::Running)
813 .map(|s| s.agent_id)
814 .collect::<Vec<_>>();
815 let baseline = running
816 .iter()
817 .map(|id| {
818 let fp = manager.activity_fingerprint(id).unwrap_or(0);
819 (id.clone(), fp)
820 })
821 .collect();
822 (running, baseline)
823 }
824 };
825
826 if watched.is_empty() {
827 let payload = json!({
828 "action": "wait",
829 "until": "activity",
830 "note": "No running sub-agents; nothing to wait for.",
831 "timed_out": false,
832 });
833 let mut tool_result = ToolResult::json(&payload)
834 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
835 tool_result.metadata = Some(json!({ "action": "wait", "timed_out": false }));
836 return Ok(tool_result);
837 }
838
839 let started = Instant::now();
840 let cancelled = async {
841 match &context.cancel_token {
842 Some(token) => token.cancelled().await,
843 None => std::future::pending().await,
844 }
845 };
846 tokio::pin!(cancelled);
847
848 loop {
849 let outcome = {
850 let manager = manager.read().await;
851 let mut settled = Vec::new();
852 let mut activity = Vec::new();
853 for (id, base_fp) in &baseline {
854 if let Ok(snap) = manager.get_result_by_ref(id) {
855 if snap.status != SubAgentStatus::Running {
856 settled.push(snap);
857 continue;
858 }
859 let fp = manager.activity_fingerprint(id).unwrap_or(0);
860 if fp != *base_fp {
861 activity.push(json!({
862 "agent_id": id,
863 "status": "running",
864 "activity_fingerprint": fp,
865 }));
866 }
867 }
868 }
869 (settled, activity, manager.running_count())
870 };
871
872 if !outcome.0.is_empty() || !outcome.1.is_empty() {
873 let payload = json!({
874 "action": "wait",
875 "until": "activity",
876 "settled": outcome.0.iter().map(|s| json!({
877 "agent_id": s.agent_id,
878 "status": subagent_status_name(&s.status),
879 })).collect::<Vec<_>>(),
880 "activity": outcome.1,
881 "running": outcome.2,
882 "elapsed_ms": started.elapsed().as_millis(),
883 "timed_out": false,
884 });
885 let mut tool_result = ToolResult::json(&payload)
886 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
887 tool_result.metadata = Some(json!({
888 "action": "wait",
889 "timed_out": false,
890 "settled": outcome.0.len(),
891 "activity": outcome.1.len(),
892 }));
893 return Ok(tool_result);
894 }
895
896 if started.elapsed() >= timeout {
897 let payload = json!({
898 "action": "wait",
899 "until": "activity",
900 "settled": [],
901 "activity": [],
902 "running": outcome.2,
903 "elapsed_ms": started.elapsed().as_millis(),
904 "timed_out": true,
905 "note": "Timed out before child activity or completion.",
906 });
907 let mut tool_result = ToolResult::json(&payload)
908 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
909 tool_result.metadata = Some(json!({ "action": "wait", "timed_out": true }));
910 return Ok(tool_result);
911 }
912
913 tokio::select! {
914 biased;
915 () = &mut cancelled => {
916 return Err(ToolError::cancelled(
917 "Wait interrupted by user cancellation before child activity.".to_string(),
918 ));
919 }
920 () = tokio::time::sleep(COORD_WAIT_CHECK_INTERVAL) => {}
921 }
922 }
923 }
924
925 /// Register the narrow coordination tools alongside `agent`.
926 pub fn register_coordination_tools(
927 builder: ToolRegistryBuilder,
928 manager: SharedSubAgentManager,
929 runtime: SubAgentRuntime,
930 ) -> ToolRegistryBuilder {
931 // `runtime.parent_agent_id` is the identity of the agent this registry is
932 // being built FOR: `runtime_for_nested_agent_tools` stamps the child's own
933 // id there before `new_with_owner` registers tools, so anything that agent
934 // spawns records it as parent. Thread that identity through every mutating
935 // hierarchy tool: a child may control only its own descendants, while the
936 // root registry (`None`) may control any child (TUI-DOG-017).
937 let caller = runtime.parent_agent_id.clone();
938 let message = AgentsMessageTool::new(Arc::clone(&manager)).with_optional_caller(caller.clone());
939 let followup = AgentsFollowupTool::new(Arc::clone(&manager))
940 .with_optional_caller(caller.clone())
941 .with_runtime(runtime.clone());
942 let interrupt =
943 AgentsInterruptTool::new(Arc::clone(&manager)).with_optional_caller(caller.clone());
944 let coordinate = AgentsCoordinateTool::new(Arc::clone(&manager), caller);
945 builder
946 .with_tool(Arc::new(AgentsListTool::new(Arc::clone(&manager))))
947 .with_tool(Arc::new(message))
948 .with_tool(Arc::new(followup))
949 .with_tool(Arc::new(interrupt))
950 .with_tool(Arc::new(coordinate))
951 .with_tool(Arc::new(AgentsWaitTool::new(manager)))
952 }
953
954 #[cfg(test)]
955 mod tests {
956 use super::*;
957 use crate::tools::spec::ToolContext;
958 use tempfile::tempdir;
959
960 #[test]
961 fn coordinate_tool_does_not_declare_read_only() {
962 // #5123-class: the tool mutates the coordination ledger and expands
963 // write claims; its declared capabilities must not say ReadOnly.
964 let manager = Arc::new(tokio::sync::RwLock::new(
965 super::super::SubAgentManager::new(std::path::PathBuf::from("."), 1),
966 ));
967 let tool = AgentsCoordinateTool::new(manager, None);
968 let capabilities = ToolSpec::capabilities(&tool);
969 assert!(
970 !capabilities.contains(&ToolCapability::ReadOnly),
971 "agents/coordinate mutates the ledger — ReadOnly is a lie: {capabilities:?}"
972 );
973 // …but the dynamic check still marks inspect as read-only.
974 assert!(tool.is_read_only_for(&json!({"action": "inspect"})));
975 assert!(!tool.is_read_only_for(&json!({"action": "propose"})));
976 }
977
978 #[test]
979 fn coordination_descriptions_match_implemented_resume_behavior() {
980 // Checkpoint resume is implemented (#5242): the descriptions must
981 // describe the real behavior, including the honest queue-only
982 // fallback when no runtime is attached.
983 let manager = Arc::new(tokio::sync::RwLock::new(
984 super::super::SubAgentManager::new(std::env::temp_dir(), 1),
985 ));
986 let message = AgentsMessageTool::new(Arc::clone(&manager));
987 let followup = AgentsFollowupTool::new(manager);
988
989 assert!(!message.description().contains("natural resume"));
990 assert!(message.description().contains("stays queued"));
991 assert!(followup.description().contains("attempt to resume"));
992 assert!(
993 followup
994 .description()
995 .contains("resumed from their checkpoint")
996 );
997 assert!(followup.description().contains("queue-only semantics"));
998 }
999
1000 async fn manager_with_running_child(
1001 workspace: &std::path::Path,
1002 ) -> (SharedSubAgentManager, String) {
1003 let manager = Arc::new(tokio::sync::RwLock::new(
1004 super::super::SubAgentManager::new(workspace.to_path_buf(), 4),
1005 ));
1006 let agent_id = {
1007 let mut guard = manager.write().await;
1008 guard.insert_test_running_agent("coord_child", workspace)
1009 };
1010 (manager, agent_id)
1011 }
1012
1013 async fn manager_with_agent_hierarchy(
1014 workspace: &std::path::Path,
1015 ) -> (SharedSubAgentManager, String, String, String) {
1016 let manager = Arc::new(tokio::sync::RwLock::new(
1017 super::super::SubAgentManager::new(workspace.to_path_buf(), 8),
1018 ));
1019 let (parent, child, sibling) = {
1020 let mut guard = manager.write().await;
1021 let parent = guard.insert_test_running_agent("hierarchy_parent", workspace);
1022 let child = guard.insert_test_running_agent("hierarchy_child", workspace);
1023 let sibling = guard.insert_test_running_agent("hierarchy_sibling", workspace);
1024 for (agent_id, parent_id) in [
1025 (&parent, "root"),
1026 (&child, parent.as_str()),
1027 (&sibling, "root"),
1028 ] {
1029 let record = guard
1030 .worker_records
1031 .get_mut(agent_id)
1032 .expect("hierarchy worker record");
1033 record.parent_run_id = Some(parent_id.to_string());
1034 record.spec.parent_run_id = Some(parent_id.to_string());
1035 }
1036 (parent, child, sibling)
1037 };
1038 (manager, parent, child, sibling)
1039 }
1040
1041 #[tokio::test]
1042 async fn message_queues_without_waking() {
1043 let tmp = tempdir().unwrap();
1044 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
1045 let tool = AgentsMessageTool::new(Arc::clone(&manager));
1046 let result = tool
1047 .execute(
1048 json!({ "agent_id": agent_id, "message": "hold this" }),
1049 &ToolContext::new(tmp.path()),
1050 )
1051 .await
1052 .expect("message ok");
1053 let body: Value = serde_json::from_str(&result.content).unwrap();
1054 assert_eq!(body["woke"], json!(false));
1055 assert_eq!(body["queued"], json!(true));
1056 assert_eq!(body["queue_depth"], json!(1));
1057
1058 let guard = manager.read().await;
1059 let depth = guard.queued_mail_depth(&agent_id).unwrap();
1060 assert_eq!(depth, 1);
1061 assert!(!guard.child_was_woken(&agent_id));
1062 }
1063
1064 #[tokio::test]
1065 async fn followup_does_not_claim_wake_when_live_channel_is_closed() {
1066 let tmp = tempdir().unwrap();
1067 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
1068 let result = AgentsFollowupTool::new(Arc::clone(&manager))
1069 .execute(
1070 json!({ "agent_id": agent_id, "message": "try to wake" }),
1071 &ToolContext::new(tmp.path()),
1072 )
1073 .await
1074 .expect("truthful closed-channel receipt");
1075 let body: Value = serde_json::from_str(&result.content).unwrap();
1076 assert_eq!(body["woke"], json!(false));
1077 assert_eq!(body["queue_depth"], json!(1));
1078 assert!(
1079 body["note"].as_str().unwrap_or_default().contains("closed"),
1080 "{body}"
1081 );
1082
1083 let guard = manager.read().await;
1084 assert_eq!(guard.queued_mail_depth(&agent_id), Some(1));
1085 assert!(!guard.child_was_woken(&agent_id));
1086 }
1087
1088 #[tokio::test]
1089 async fn hierarchy_mutations_allow_own_descendants_and_deny_siblings_or_ancestors() {
1090 let tmp = tempdir().unwrap();
1091 let (manager, parent, child, sibling) = manager_with_agent_hierarchy(tmp.path()).await;
1092 let context = ToolContext::new(tmp.path());
1093
1094 AgentsMessageTool::new(Arc::clone(&manager))
1095 .with_optional_caller(Some(parent.clone()))
1096 .execute(
1097 json!({ "agent_id": child, "message": "bounded parent note" }),
1098 &context,
1099 )
1100 .await
1101 .expect("parent may message its own child");
1102 AgentsFollowupTool::new(Arc::clone(&manager))
1103 .with_optional_caller(Some(parent.clone()))
1104 .execute(
1105 json!({ "agent_id": child, "message": "resume own child" }),
1106 &context,
1107 )
1108 .await
1109 .expect("parent may follow up its own child");
1110
1111 let sibling_message = AgentsMessageTool::new(Arc::clone(&manager))
1112 .with_optional_caller(Some(parent.clone()))
1113 .execute(
1114 json!({ "agent_id": sibling, "message": "cross branch" }),
1115 &context,
1116 )
1117 .await
1118 .expect_err("sibling message must fail closed")
1119 .to_string();
1120 assert!(
1121 sibling_message.contains("own descendants"),
1122 "{sibling_message}"
1123 );
1124
1125 let ancestor_followup = AgentsFollowupTool::new(Arc::clone(&manager))
1126 .with_optional_caller(Some(child.clone()))
1127 .execute(
1128 json!({ "agent_id": parent, "message": "wake ancestor" }),
1129 &context,
1130 )
1131 .await
1132 .expect_err("ancestor followup must fail closed")
1133 .to_string();
1134 assert!(
1135 ancestor_followup.contains("own descendants"),
1136 "{ancestor_followup}"
1137 );
1138
1139 let sibling_interrupt = AgentsInterruptTool::new(Arc::clone(&manager))
1140 .with_optional_caller(Some(parent.clone()))
1141 .execute(json!({ "agent_id": sibling }), &context)
1142 .await
1143 .expect_err("sibling interrupt must fail closed")
1144 .to_string();
1145 assert!(
1146 sibling_interrupt.contains("own descendants"),
1147 "{sibling_interrupt}"
1148 );
1149
1150 let interrupted = AgentsInterruptTool::new(Arc::clone(&manager))
1151 .with_optional_caller(Some(parent))
1152 .execute(json!({ "agent_id": child }), &context)
1153 .await
1154 .expect("parent may interrupt its own child");
1155 let body: Value = serde_json::from_str(&interrupted.content).unwrap();
1156 assert_eq!(body["status"], json!("interrupted"));
1157 }
1158
1159 #[tokio::test]
1160 async fn coordinate_inspect_is_side_effect_free_and_mutations_are_synchronously_durable() {
1161 let tmp = tempdir().unwrap();
1162 let blocked_state_path = tmp.path().join("blocked-state");
1163 std::fs::create_dir(&blocked_state_path).unwrap();
1164 let blocked_manager = Arc::new(tokio::sync::RwLock::new(
1165 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1166 .with_state_path(blocked_state_path),
1167 ));
1168 let blocked_tool = AgentsCoordinateTool::new(Arc::clone(&blocked_manager), None);
1169
1170 blocked_tool
1171 .execute(
1172 json!({ "action": "inspect" }),
1173 &ToolContext::new(tmp.path()),
1174 )
1175 .await
1176 .expect("read-only inspect must not attempt persistence");
1177 let error = blocked_tool
1178 .execute(
1179 json!({
1180 "action": "propose",
1181 "decision_id": "durable-decision",
1182 "subject": "durability",
1183 "constraints": ["persist before acknowledgement"]
1184 }),
1185 &ToolContext::new(tmp.path()),
1186 )
1187 .await
1188 .expect_err("mutation must fail when its receipt cannot persist")
1189 .to_string();
1190 assert!(error.contains("failed to persist"), "{error}");
1191 assert!(
1192 blocked_manager
1193 .read()
1194 .await
1195 .coordination
1196 .decisions
1197 .is_empty(),
1198 "failed persistence must roll the in-memory decision back"
1199 );
1200
1201 let durable_workspace = tempdir().unwrap();
1202 let state_path = durable_workspace.path().join("subagents.v1.json");
1203 let manager = Arc::new(tokio::sync::RwLock::new(
1204 super::super::SubAgentManager::new(durable_workspace.path().to_path_buf(), 4)
1205 .with_state_path(state_path.clone()),
1206 ));
1207 AgentsCoordinateTool::new(Arc::clone(&manager), None)
1208 .execute(
1209 json!({
1210 "action": "propose",
1211 "decision_id": "durable-decision",
1212 "subject": "durability",
1213 "constraints": ["persist before acknowledgement"]
1214 }),
1215 &ToolContext::new(durable_workspace.path()),
1216 )
1217 .await
1218 .expect("durable mutation");
1219 let mut replayed =
1220 super::super::SubAgentManager::new(durable_workspace.path().to_path_buf(), 4)
1221 .with_state_path(state_path);
1222 replayed.load_state().expect("reload durable action");
1223 assert_eq!(replayed.coordination.decisions.len(), 1);
1224 assert_eq!(
1225 replayed.coordination.decisions[0].decision_id,
1226 "durable-decision"
1227 );
1228 }
1229
1230 #[tokio::test]
1231 async fn rejected_claim_contention_is_persisted_before_returning_the_error() {
1232 let tmp = tempdir().unwrap();
1233 let state_path = tmp.path().join("subagents.v1.json");
1234 let manager = Arc::new(tokio::sync::RwLock::new(
1235 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1236 .with_state_path(state_path.clone()),
1237 ));
1238 let (claimant, owner) = {
1239 let mut guard = manager.write().await;
1240 let claimant = guard.insert_test_running_agent("claimant", tmp.path());
1241 let owner = guard.insert_test_running_agent("owner", tmp.path());
1242 let active = [claimant.clone(), owner.clone()]
1243 .into_iter()
1244 .collect::<BTreeSet<_>>();
1245 for claim in [
1246 WriteScopeClaim {
1247 owner: claimant.clone(),
1248 roots: vec!["src/claimant".into()],
1249 exact_files: Vec::new(),
1250 contracts: Vec::new(),
1251 },
1252 WriteScopeClaim {
1253 owner: owner.clone(),
1254 roots: vec!["src/shared".into()],
1255 exact_files: Vec::new(),
1256 contracts: Vec::new(),
1257 },
1258 ] {
1259 guard
1260 .coordination
1261 .register_claim(claim, false, |candidate| active.contains(candidate))
1262 .expect("initial non-overlapping claim");
1263 }
1264 (claimant, owner)
1265 };
1266
1267 let error = AgentsCoordinateTool::new(Arc::clone(&manager), Some(claimant.clone()))
1268 .execute(
1269 json!({ "action": "claim", "roots": ["src/shared/nested"] }),
1270 &ToolContext::new(tmp.path()),
1271 )
1272 .await
1273 .expect_err("overlap must block")
1274 .to_string();
1275 assert!(
1276 error.contains(&owner) && error.contains("contention"),
1277 "{error}"
1278 );
1279
1280 let mut replayed = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1281 .with_state_path(state_path);
1282 replayed.load_state().expect("reload contention receipt");
1283 assert_eq!(replayed.coordination.contentions.len(), 1);
1284 assert_eq!(replayed.coordination.contentions[0].claimant, claimant);
1285 assert_eq!(
1286 replayed.coordination.contentions[0].conflicting_owner,
1287 owner
1288 );
1289 }
1290
1291 #[tokio::test]
1292 async fn coordination_resolution_survives_reload_and_resolving_claim_eviction() {
1293 let tmp = tempdir().unwrap();
1294 let state_path = tmp.path().join("subagents.v1.json");
1295 let manager = Arc::new(tokio::sync::RwLock::new(
1296 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1297 .with_state_path(state_path.clone()),
1298 ));
1299 let claimant = {
1300 let mut guard = manager.write().await;
1301 let claimant = guard.insert_test_running_agent("claimant", tmp.path());
1302 let owner = guard.insert_test_running_agent("owner", tmp.path());
1303 let active = [claimant.clone(), owner.clone()]
1304 .into_iter()
1305 .collect::<BTreeSet<_>>();
1306 for claim in [
1307 WriteScopeClaim {
1308 owner: claimant.clone(),
1309 roots: vec!["src/claimant".into()],
1310 exact_files: Vec::new(),
1311 contracts: Vec::new(),
1312 },
1313 WriteScopeClaim {
1314 owner: owner.clone(),
1315 roots: vec!["src/shared".into()],
1316 exact_files: Vec::new(),
1317 contracts: Vec::new(),
1318 },
1319 ] {
1320 guard
1321 .coordination
1322 .register_claim(claim, false, |candidate| active.contains(candidate))
1323 .expect("initial non-overlapping claim");
1324 }
1325 claimant
1326 };
1327
1328 AgentsCoordinateTool::new(Arc::clone(&manager), Some(claimant.clone()))
1329 .execute(
1330 json!({ "action": "claim", "roots": ["src/shared/nested"] }),
1331 &ToolContext::new(tmp.path()),
1332 )
1333 .await
1334 .expect_err("overlap must block and persist its receipt");
1335
1336 let resolution_sequence = {
1337 let mut guard = manager.write().await;
1338 let record = guard
1339 .coordination
1340 .register_claim(
1341 WriteScopeClaim {
1342 owner: claimant.clone(),
1343 roots: vec!["src/isolated".into()],
1344 exact_files: Vec::new(),
1345 contracts: Vec::new(),
1346 },
1347 true,
1348 |_| true,
1349 )
1350 .expect("later isolated claim resolves contention");
1351 guard
1352 .persist_state_synchronously()
1353 .expect("persist resolved contention");
1354 record.sequence
1355 };
1356
1357 let mut replayed = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1358 .with_state_path(state_path.clone());
1359 replayed.load_state().expect("reload resolved contention");
1360 assert_eq!(replayed.coordination.contentions.len(), 1);
1361 assert_eq!(
1362 replayed.coordination.contentions[0].disposition,
1363 WriteContentionDisposition::ResolvedBySuccessfulClaim
1364 );
1365 assert_eq!(
1366 replayed.coordination.contentions[0].resolution_sequence,
1367 Some(resolution_sequence)
1368 );
1369
1370 let slots = COORDINATION_RECORD_LIMIT - replayed.coordination.write_claims.len();
1371 for index in 0..slots {
1372 replayed
1373 .coordination
1374 .register_claim(
1375 WriteScopeClaim {
1376 owner: format!("inactive-fill-{index:03}"),
1377 roots: vec![format!("pkg/fill-{index:03}")],
1378 exact_files: Vec::new(),
1379 contracts: Vec::new(),
1380 },
1381 true,
1382 |_| false,
1383 )
1384 .expect("fill inactive claim capacity");
1385 }
1386 for index in 0..2 {
1387 replayed
1388 .coordination
1389 .register_claim(
1390 WriteScopeClaim {
1391 owner: format!("inactive-overflow-{index}"),
1392 roots: vec![format!("pkg/overflow-{index}")],
1393 exact_files: Vec::new(),
1394 contracts: Vec::new(),
1395 },
1396 true,
1397 |_| false,
1398 )
1399 .expect("evict oldest inactive claim at capacity");
1400 }
1401 assert!(
1402 !replayed
1403 .coordination
1404 .write_claims
1405 .iter()
1406 .any(|claim| claim.claim.owner == claimant),
1407 "the resolving claimant claim must be evicted for the durability regression"
1408 );
1409 replayed
1410 .persist_state_synchronously()
1411 .expect("persist after inactive claim eviction");
1412
1413 let mut final_replay = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4)
1414 .with_state_path(state_path);
1415 final_replay
1416 .load_state()
1417 .expect("reload after resolving claim eviction");
1418 let projection = final_replay.coordination_detail_projection(None, 24);
1419 assert!(
1420 !projection
1421 .write_claims
1422 .iter()
1423 .any(|claim| claim.claim.owner == claimant)
1424 );
1425 assert_eq!(projection.contentions.len(), 1);
1426 assert_eq!(
1427 projection.contentions[0].disposition,
1428 WriteContentionDisposition::ResolvedBySuccessfulClaim
1429 );
1430 assert_eq!(
1431 projection.contentions[0].resolution_sequence,
1432 Some(resolution_sequence)
1433 );
1434 assert!(!crate::tui::coordination_detail::needs_attention(
1435 &projection
1436 ));
1437 let pager =
1438 crate::tui::coordination_detail::format(crate::localization::Locale::En, &projection);
1439 assert!(
1440 pager.contains("disposition resolved_by_successful_claim"),
1441 "{pager}"
1442 );
1443 assert!(!pager.contains("disposition blocked_pending"), "{pager}");
1444 }
1445
1446 #[tokio::test]
1447 async fn interrupt_fails_closed_on_self() {
1448 let tmp = tempdir().unwrap();
1449 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
1450 let tool = AgentsInterruptTool::new(Arc::clone(&manager)).with_caller(agent_id.clone());
1451 let err = tool
1452 .execute(
1453 json!({ "agent_id": agent_id }),
1454 &ToolContext::new(tmp.path()),
1455 )
1456 .await
1457 .expect_err("self interrupt must fail");
1458 let msg = err.to_string().to_ascii_lowercase();
1459 assert!(
1460 msg.contains("self") || msg.contains("own"),
1461 "unexpected error: {err}"
1462 );
1463 }
1464
1465 #[tokio::test]
1466 async fn interrupt_fails_closed_on_missing_target() {
1467 let tmp = tempdir().unwrap();
1468 let manager = Arc::new(tokio::sync::RwLock::new(
1469 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 2),
1470 ));
1471 let tool = AgentsInterruptTool::new(manager);
1472 let err = tool
1473 .execute(
1474 json!({ "agent_id": "agent_missing" }),
1475 &ToolContext::new(tmp.path()),
1476 )
1477 .await
1478 .expect_err("missing target");
1479 assert!(err.to_string().contains("not found") || err.to_string().contains("Agent"));
1480 }
1481
1482 #[tokio::test]
1483 async fn wait_times_out_when_child_stays_running() {
1484 let tmp = tempdir().unwrap();
1485 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
1486 let tool = AgentsWaitTool::new(manager);
1487 let result = tool
1488 .execute(
1489 json!({
1490 "agent_id": agent_id,
1491 "timeout_secs": 1,
1492 "until": "activity"
1493 }),
1494 &ToolContext::new(tmp.path()),
1495 )
1496 .await
1497 .expect("wait returns");
1498 let body: Value = serde_json::from_str(&result.content).unwrap();
1499 assert_eq!(body["timed_out"], json!(true));
1500 }
1501
1502 #[tokio::test]
1503 async fn list_resolves_target_and_reports_queue() {
1504 let tmp = tempdir().unwrap();
1505 let (manager, agent_id) = manager_with_running_child(tmp.path()).await;
1506 {
1507 let mut guard = manager.write().await;
1508 guard
1509 .queue_parent_message(&agent_id, "note".into(), false)
1510 .unwrap();
1511 }
1512 let tool = AgentsListTool::new(manager);
1513 let result = tool
1514 .execute(
1515 json!({ "agent_id": agent_id }),
1516 &ToolContext::new(tmp.path()),
1517 )
1518 .await
1519 .expect("list ok");
1520 let body: Value = serde_json::from_str(&result.content).unwrap();
1521 assert_eq!(body["count"], json!(1));
1522 assert_eq!(body["agents"][0]["agent_id"], json!(agent_id));
1523 assert!(body["agents"][0]["queued_mail"].as_u64().unwrap_or(0) >= 1);
1524 }
1525
1526 #[tokio::test]
1527 async fn followup_interrupted_continuable_without_runtime_queues_honestly() {
1528 let tmp = tempdir().unwrap();
1529 let manager = Arc::new(tokio::sync::RwLock::new(
1530 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4),
1531 ));
1532 let (agent_id, handle) = {
1533 let mut guard = manager.write().await;
1534 guard.insert_test_interrupted_continuable_agent(
1535 "paused_child",
1536 tmp.path(),
1537 vec![crate::models::Message {
1538 role: "user".to_string(),
1539 content: vec![crate::models::ContentBlock::Text {
1540 text: "prior work".to_string(),
1541 cache_control: None,
1542 }],
1543 }],
1544 )
1545 };
1546 // No runtime attached: checkpoint resume is unavailable, so followup
1547 // keeps the honest queue-only semantics with the continuation handle.
1548 let tool = AgentsFollowupTool::new(Arc::clone(&manager));
1549 let result = tool
1550 .execute(
1551 json!({ "agent_id": agent_id, "message": "please continue" }),
1552 &ToolContext::new(tmp.path()),
1553 )
1554 .await
1555 .expect("followup ok");
1556 let body: Value = serde_json::from_str(&result.content).unwrap();
1557 assert_eq!(body["queued"], json!(true));
1558 assert_eq!(body["woke"], json!(false));
1559 assert_eq!(body["continued_from_checkpoint"], json!(false));
1560 assert_eq!(body["continuation_handle"], json!(handle));
1561 let note = body["note"].as_str().unwrap_or_default();
1562 assert!(
1563 note.contains("attach a runtime") && note.contains(&handle),
1564 "note must point at the resume path with the continuation handle: {note}"
1565 );
1566
1567 let guard = manager.read().await;
1568 assert_eq!(guard.queued_mail_depth(&agent_id).unwrap(), 1);
1569 assert!(!guard.child_was_woken(&agent_id));
1570 }
1571
1572 // === until="all": the fan-out join ===================================
1573 //
1574 // Before this existed a parent with five children had to issue five
1575 // waits — while the prompt told it not to poll. These lock the join in.
1576
1577 fn empty_manager(workspace: &std::path::Path) -> SharedSubAgentManager {
1578 Arc::new(tokio::sync::RwLock::new(
1579 super::super::SubAgentManager::new(workspace.to_path_buf(), 8),
1580 ))
1581 }
1582
1583 async fn settle(manager: &SharedSubAgentManager, agent_id: &str, status: SubAgentStatus) {
1584 let mut guard = manager.write().await;
1585 if let Some(agent) = guard.agents.get_mut(agent_id) {
1586 agent.status = status;
1587 }
1588 }
1589
1590 #[test]
1591 fn wait_schema_offers_all_as_a_first_class_until() {
1592 let tmp = tempdir().unwrap();
1593 let tool = AgentsWaitTool::new(empty_manager(tmp.path()));
1594 let schema = tool.input_schema();
1595 let until = &schema["properties"]["until"];
1596 assert_eq!(
1597 until["enum"],
1598 json!(["completion", "all", "activity"]),
1599 "until must expose all alongside completion/activity: {schema}"
1600 );
1601 let described = until["description"].as_str().unwrap_or_default();
1602 assert!(
1603 described.contains("every watched child") && described.contains("any one child"),
1604 "the schema must make completion vs all unmistakable: {described}"
1605 );
1606 }
1607
1608 #[tokio::test]
1609 async fn wait_until_all_on_an_already_settled_child_reports_its_outcome() {
1610 let tmp = tempdir().unwrap();
1611 let manager = empty_manager(tmp.path());
1612 let agent_id = {
1613 let mut guard = manager.write().await;
1614 guard.insert_test_running_agent("all_already_done", tmp.path())
1615 };
1616 settle(&manager, &agent_id, SubAgentStatus::Completed).await;
1617
1618 let result = dispatch_wait(
1619 &json!({ "until": "all", "agent_id": agent_id, "timeout_secs": 60 }),
1620 Arc::clone(&manager),
1621 &ToolContext::new(tmp.path()),
1622 )
1623 .await
1624 .expect("a settled child is an immediate return");
1625 let body: Value = serde_json::from_str(&result.content).unwrap();
1626 assert_eq!(body["all_settled"], json!(true), "{body}");
1627 let settled = body["settled"].as_array().unwrap();
1628 assert_eq!(settled.len(), 1, "{body}");
1629 assert_eq!(settled[0]["status"], json!("completed"), "{body}");
1630 }
1631
1632 #[tokio::test]
1633 async fn wait_until_all_returns_immediately_with_no_children() {
1634 let tmp = tempdir().unwrap();
1635 let started = Instant::now();
1636 let result = dispatch_wait(
1637 &json!({ "until": "all", "timeout_secs": 60 }),
1638 empty_manager(tmp.path()),
1639 &ToolContext::new(tmp.path()),
1640 )
1641 .await
1642 .expect("wait-for-all with zero children must return, not hang");
1643 assert!(
1644 started.elapsed() < Duration::from_secs(5),
1645 "zero children must not burn the timeout"
1646 );
1647 let body: Value = serde_json::from_str(&result.content).unwrap();
1648 assert_eq!(body["all_settled"], json!(true));
1649 assert_eq!(body["timed_out"], json!(false));
1650 assert!(body["settled"].as_array().unwrap().is_empty(), "{body}");
1651 }
1652
1653 #[tokio::test]
1654 async fn wait_until_all_blocks_until_every_child_settles() {
1655 let tmp = tempdir().unwrap();
1656 let manager = empty_manager(tmp.path());
1657 let (first, second, third) = {
1658 let mut guard = manager.write().await;
1659 (
1660 guard.insert_test_running_agent("all_first", tmp.path()),
1661 guard.insert_test_running_agent("all_second", tmp.path()),
1662 guard.insert_test_running_agent("all_third", tmp.path()),
1663 )
1664 };
1665
1666 // Staggered settles: an `until=completion` wait would return after the
1667 // first one. `until=all` must stay blocked through the last.
1668 let flip = Arc::clone(&manager);
1669 let (a, b, c) = (first.clone(), second.clone(), third.clone());
1670 tokio::spawn(async move {
1671 tokio::time::sleep(Duration::from_millis(50)).await;
1672 settle(&flip, &a, SubAgentStatus::Completed).await;
1673 tokio::time::sleep(Duration::from_millis(150)).await;
1674 settle(&flip, &b, SubAgentStatus::Failed("boom".to_string())).await;
1675 tokio::time::sleep(Duration::from_millis(150)).await;
1676 settle(&flip, &c, SubAgentStatus::Cancelled).await;
1677 });
1678
1679 let result = dispatch_wait(
1680 &json!({ "until": "all", "timeout_secs": 30 }),
1681 Arc::clone(&manager),
1682 &ToolContext::new(tmp.path()),
1683 )
1684 .await
1685 .expect("wait-for-all should succeed");
1686 let body: Value = serde_json::from_str(&result.content).unwrap();
1687 assert_eq!(body["all_settled"], json!(true), "{body}");
1688 assert_eq!(body["timed_out"], json!(false), "{body}");
1689 assert!(
1690 body["still_running"].as_array().unwrap().is_empty(),
1691 "{body}"
1692 );
1693
1694 // Per-child outcomes come back on the single return.
1695 let settled = body["settled"].as_array().unwrap();
1696 assert_eq!(settled.len(), 3, "{body}");
1697 let outcomes: std::collections::BTreeMap<&str, &str> = settled
1698 .iter()
1699 .map(|entry| {
1700 (
1701 entry["agent_id"].as_str().unwrap(),
1702 entry["status"].as_str().unwrap(),
1703 )
1704 })
1705 .collect();
1706 assert_eq!(outcomes.get(first.as_str()), Some(&"completed"), "{body}");
1707 assert_eq!(outcomes.get(second.as_str()), Some(&"failed"), "{body}");
1708 assert_eq!(outcomes.get(third.as_str()), Some(&"cancelled"), "{body}");
1709 }
1710
1711 #[tokio::test]
1712 async fn wait_until_all_times_out_reporting_settled_and_still_running() {
1713 let tmp = tempdir().unwrap();
1714 let manager = empty_manager(tmp.path());
1715 let (done, stuck) = {
1716 let mut guard = manager.write().await;
1717 (
1718 guard.insert_test_running_agent("all_done", tmp.path()),
1719 guard.insert_test_running_agent("all_stuck", tmp.path()),
1720 )
1721 };
1722
1723 let flip = Arc::clone(&manager);
1724 let done_id = done.clone();
1725 tokio::spawn(async move {
1726 tokio::time::sleep(Duration::from_millis(50)).await;
1727 settle(&flip, &done_id, SubAgentStatus::Completed).await;
1728 });
1729
1730 let result = dispatch_wait(
1731 &json!({ "until": "all", "timeout_secs": 1 }),
1732 Arc::clone(&manager),
1733 &ToolContext::new(tmp.path()),
1734 )
1735 .await
1736 .expect("a timeout is a partial receipt, not an error");
1737 let body: Value = serde_json::from_str(&result.content).unwrap();
1738 assert_eq!(body["timed_out"], json!(true), "{body}");
1739 assert_eq!(body["all_settled"], json!(false), "{body}");
1740
1741 let settled = body["settled"].as_array().unwrap();
1742 assert_eq!(settled.len(), 1, "{body}");
1743 assert_eq!(settled[0]["agent_id"], json!(done), "{body}");
1744 assert_eq!(settled[0]["status"], json!("completed"), "{body}");
1745
1746 let running = body["still_running"].as_array().unwrap();
1747 assert_eq!(running.len(), 1, "{body}");
1748 assert_eq!(running[0]["agent_id"], json!(stuck), "{body}");
1749 }
1750
1751 #[tokio::test]
1752 async fn wait_until_all_ignores_children_spawned_mid_wait() {
1753 let tmp = tempdir().unwrap();
1754 let manager = empty_manager(tmp.path());
1755 let original = {
1756 let mut guard = manager.write().await;
1757 guard.insert_test_running_agent("all_original", tmp.path())
1758 };
1759
1760 let flip = Arc::clone(&manager);
1761 let tmp_path = tmp.path().to_path_buf();
1762 let original_id = original.clone();
1763 tokio::spawn(async move {
1764 tokio::time::sleep(Duration::from_millis(50)).await;
1765 {
1766 let mut guard = flip.write().await;
1767 guard.insert_test_running_agent("all_latecomer", &tmp_path);
1768 }
1769 settle(&flip, &original_id, SubAgentStatus::Completed).await;
1770 });
1771
1772 let result = dispatch_wait(
1773 &json!({ "until": "all", "timeout_secs": 30 }),
1774 Arc::clone(&manager),
1775 &ToolContext::new(tmp.path()),
1776 )
1777 .await
1778 .expect("wait-for-all should succeed");
1779 let body: Value = serde_json::from_str(&result.content).unwrap();
1780 // The watch set is the batch as of call time: the latecomer must not
1781 // extend a wait the caller never asked to include it in.
1782 assert_eq!(body["all_settled"], json!(true), "{body}");
1783 assert_eq!(body["timed_out"], json!(false), "{body}");
1784 let settled = body["settled"].as_array().unwrap();
1785 assert_eq!(settled.len(), 1, "{body}");
1786 assert_eq!(settled[0]["agent_id"], json!(original), "{body}");
1787 }
1788
1789 #[tokio::test]
1790 async fn wait_rejects_unknown_until_naming_every_supported_mode() {
1791 let tmp = tempdir().unwrap();
1792 let error = dispatch_wait(
1793 &json!({ "until": "forever" }),
1794 empty_manager(tmp.path()),
1795 &ToolContext::new(tmp.path()),
1796 )
1797 .await
1798 .expect_err("an unknown until must fail loudly");
1799 let message = error.to_string();
1800 for mode in ["completion", "all", "activity"] {
1801 assert!(message.contains(mode), "{message}");
1802 }
1803 }
1804
1805 #[tokio::test]
1806 async fn followup_interrupted_continuable_resumes_with_runtime() {
1807 let tmp = tempdir().unwrap();
1808 let manager = Arc::new(tokio::sync::RwLock::new(
1809 super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4),
1810 ));
1811 let (agent_id, _handle) = {
1812 let mut guard = manager.write().await;
1813 guard.insert_test_interrupted_continuable_agent(
1814 "paused_child",
1815 tmp.path(),
1816 vec![crate::models::Message {
1817 role: "user".to_string(),
1818 content: vec![crate::models::ContentBlock::Text {
1819 text: "prior work".to_string(),
1820 cache_control: None,
1821 }],
1822 }],
1823 )
1824 };
1825 let mut runtime = super::super::tests::stub_runtime();
1826 runtime.manager = Arc::clone(&manager);
1827 let tool = AgentsFollowupTool::new(Arc::clone(&manager)).with_runtime(runtime);
1828 let result = tool
1829 .execute(
1830 json!({ "agent_id": agent_id, "message": "please continue" }),
1831 &ToolContext::new(tmp.path()),
1832 )
1833 .await
1834 .expect("followup ok");
1835 let body: Value = serde_json::from_str(&result.content).unwrap();
1836 assert_eq!(body["queued"], json!(true));
1837 assert_eq!(body["woke"], json!(true));
1838 assert_eq!(body["continued_from_checkpoint"], json!(true));
1839 let note = body["note"].as_str().unwrap_or_default();
1840 assert!(note.contains("resumed from checkpoint"), "{note}");
1841 let resumed_id = body["agent_id"].as_str().unwrap_or_default();
1842 assert_ne!(
1843 resumed_id, agent_id,
1844 "resume re-dispatches under a new agent id"
1845 );
1846
1847 // A fresh record exists for the resumed session; the prior terminal
1848 // record stays immutable (receipts are never rewritten).
1849 let guard = manager.read().await;
1850 guard.get_result(resumed_id).expect("resumed agent exists");
1851 let prior = guard.get_result(&agent_id).expect("prior record");
1852 assert!(matches!(prior.status, SubAgentStatus::Interrupted(_)));
1853 }
1854 }
1855
1856 /// Coordination records for delegated Work (#4647).
1857 ///
1858 /// Decision records, write-scope claims, and contention detection for parallel
1859 /// agent work. Parallel work may proceed only when scopes and contracts do not
1860 /// collide silently.
1861 /// Status of a coordination decision.
1862 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1863 #[serde(rename_all = "snake_case")]
1864 pub enum DecisionStatus {
1865 Proposed,
1866 Accepted,
1867 Superseded,
1868 }
1869
1870 /// Serialized coordination state schema. Increment only with an explicit
1871 /// migration; restart/replay must never infer a newer contract from old data.
1872 pub const COORDINATION_SCHEMA_VERSION: u32 = 1;
1873
1874 const MAX_RECONCILIATION_RETRIES: u32 = 3;
1875
1876 const fn coordination_schema_version() -> u32 {
1877 COORDINATION_SCHEMA_VERSION
1878 }
1879
1880 /// A bounded coordination decision record (#4647).
1881 ///
1882 /// Persisted with stable subject, concise constraints, one active owner,
1883 /// applicability scope, evidence handles, and sequence/version.
1884 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1885 pub struct DecisionRecord {
1886 pub decision_id: String,
1887 pub subject: String,
1888 pub status: DecisionStatus,
1889 pub owner: String,
1890 pub scope: Vec<String>,
1891 pub constraints: Vec<String>,
1892 pub evidence_handles: Vec<String>,
1893 pub version: u32,
1894 pub sequence: u64,
1895 }
1896
1897 /// A write-scope claim for a write-capable child (#4647).
1898 ///
1899 /// Declares expected repo-relative paths/trees and named contracts.
1900 /// This is coordination metadata, not another approval system.
1901 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1902 pub struct WriteScopeClaim {
1903 pub owner: String,
1904 pub roots: Vec<String>,
1905 pub exact_files: Vec<String>,
1906 pub contracts: Vec<String>,
1907 }
1908
1909 impl WriteScopeClaim {
1910 /// Check whether this claim overlaps with another. A claim overlaps when
1911 /// either normalized tree contains the other or exact files collide.
1912 #[must_use]
1913 pub fn overlaps(&self, other: &WriteScopeClaim) -> bool {
1914 for root_a in &self.roots {
1915 for root_b in &other.roots {
1916 if paths_overlap_by_containment(root_a, root_b)
1917 || paths_overlap_by_containment(root_b, root_a)
1918 {
1919 return true;
1920 }
1921 }
1922 }
1923 for file_a in &self.exact_files {
1924 if other
1925 .exact_files
1926 .iter()
1927 .any(|file| paths_overlap_equal(file, file_a))
1928 || other
1929 .roots
1930 .iter()
1931 .any(|root| paths_overlap_by_containment(root, file_a))
1932 {
1933 return true;
1934 }
1935 }
1936 for file_b in &other.exact_files {
1937 if self
1938 .roots
1939 .iter()
1940 .any(|root| paths_overlap_by_containment(root, file_b))
1941 {
1942 return true;
1943 }
1944 }
1945 if self
1946 .contracts
1947 .iter()
1948 .any(|contract| other.contracts.iter().any(|other| other == contract))
1949 {
1950 return true;
1951 }
1952 false
1953 }
1954
1955 #[must_use]
1956 pub fn contains_path(&self, path: &str) -> bool {
1957 self.exact_files.iter().any(|file| file == path)
1958 || self.roots.iter().any(|root| path_contains(root, path))
1959 }
1960 }
1961
1962 fn path_contains(root: &str, candidate: &str) -> bool {
1963 let root = root.trim_end_matches('/');
1964 let candidate = candidate.trim_end_matches('/');
1965 root == "."
1966 || root == candidate
1967 || candidate
1968 .strip_prefix(root)
1969 .is_some_and(|suffix| suffix.starts_with('/'))
1970 }
1971
1972 fn paths_overlap_equal(left: &str, right: &str) -> bool {
1973 left == right || left.to_lowercase() == right.to_lowercase()
1974 }
1975
1976 fn paths_overlap_by_containment(root: &str, candidate: &str) -> bool {
1977 path_contains(root, candidate) || path_contains(&root.to_lowercase(), &candidate.to_lowercase())
1978 }
1979
1980 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1981 pub struct PersistedWriteClaim {
1982 pub claim: WriteScopeClaim,
1983 pub sequence: u64,
1984 #[serde(default)]
1985 pub isolated_worktree: bool,
1986 }
1987
1988 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1989 pub struct ReconciliationReceipt {
1990 pub reconciliation_id: String,
1991 pub subject: String,
1992 pub owner: String,
1993 pub input_decisions: Vec<String>,
1994 pub outcome: String,
1995 pub evidence_handles: Vec<String>,
1996 /// Preserved candidate branches, patches, or artifact handles. A fan-in
1997 /// receipt is not valid if either conflicting candidate was discarded.
1998 #[serde(default)]
1999 pub candidate_handles: Vec<String>,
2000 #[serde(default)]
2001 pub retry_count: u32,
2002 #[serde(default)]
2003 pub retry_limit: u32,
2004 #[serde(default)]
2005 pub reviewer_evidence_handles: Vec<String>,
2006 #[serde(default)]
2007 pub verifier_evidence_handles: Vec<String>,
2008 #[serde(default)]
2009 pub verification_outcome: String,
2010 pub sequence: u64,
2011 }
2012
2013 /// Durable receipt for the minimal accepted-decision context projected into a
2014 /// child. It records counts and stable ids, never the child's transcript.
2015 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2016 pub struct ContextProjectionReceipt {
2017 pub child_id: String,
2018 pub decision_ids: Vec<String>,
2019 pub projected_bytes: usize,
2020 /// Repeated constraint facts elided across otherwise distinct decisions.
2021 /// Decision records themselves are never collapsed by this count.
2022 pub deduplicated: usize,
2023 /// Relevant unique decisions omitted solely because the hard count or
2024 /// byte bound was reached. This must not be conflated with deduplication.
2025 #[serde(default)]
2026 pub omitted: usize,
2027 pub sequence: u64,
2028 }
2029
2030 /// Admission outcome persisted with a write-contention receipt.
2031 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2032 #[serde(rename_all = "snake_case")]
2033 pub enum WriteContentionDisposition {
2034 BlockedPendingIsolationOrSerialization,
2035 ResolvedBySuccessfulClaim,
2036 }
2037
2038 impl WriteContentionDisposition {
2039 #[must_use]
2040 pub const fn as_str(self) -> &'static str {
2041 match self {
2042 Self::BlockedPendingIsolationOrSerialization => {
2043 "blocked_pending_isolation_or_serialization"
2044 }
2045 Self::ResolvedBySuccessfulClaim => "resolved_by_successful_claim",
2046 }
2047 }
2048
2049 #[must_use]
2050 pub const fn blocks_admission(self) -> bool {
2051 matches!(self, Self::BlockedPendingIsolationOrSerialization)
2052 }
2053 }
2054
2055 /// Durable non-secret receipt emitted when two active shared-workspace claims
2056 /// collide. Rejected scope expansion remains visible after restart.
2057 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2058 pub struct WriteContentionReceipt {
2059 pub claimant: String,
2060 pub conflicting_owner: String,
2061 pub roots: Vec<String>,
2062 pub exact_files: Vec<String>,
2063 pub contracts: Vec<String>,
2064 pub disposition: WriteContentionDisposition,
2065 /// Sequence of the later successful claim that resolved this receipt.
2066 /// It intentionally references that claim's sequence instead of consuming
2067 /// another ledger sequence.
2068 #[serde(default, skip_serializing_if = "Option::is_none")]
2069 pub resolution_sequence: Option<u64>,
2070 pub sequence: u64,
2071 }
2072
2073 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2074 pub struct CoordinationHotPath {
2075 pub path: String,
2076 pub active_claims: usize,
2077 }
2078
2079 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2080 pub struct CoordinationDetailMetrics {
2081 pub hottest_paths: Vec<CoordinationHotPath>,
2082 pub package_or_module_growth: Option<Value>,
2083 pub route_or_cost: Option<Value>,
2084 pub note: String,
2085 }
2086
2087 /// One bounded typed projection shared by headless inspection and the TUI.
2088 /// It contains durable coordination facts only, never raw reasoning or a
2089 /// delegated transcript.
2090 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2091 pub struct CoordinationDetailProjection {
2092 pub schema_version: u32,
2093 pub sequence: u64,
2094 pub decisions: Vec<DecisionRecord>,
2095 pub write_claims: Vec<PersistedWriteClaim>,
2096 pub reconciliations: Vec<ReconciliationReceipt>,
2097 pub context_projections: Vec<ContextProjectionReceipt>,
2098 pub contentions: Vec<WriteContentionReceipt>,
2099 pub metrics: CoordinationDetailMetrics,
2100 pub bounded: bool,
2101 pub limit: usize,
2102 /// Whether this process currently holds the workspace coordination flock.
2103 /// When false, durable ledger writes are skipped and the UI must say so —
2104 /// a counter must never tick on a turn the engine has already settled.
2105 #[serde(default = "default_process_lock_held")]
2106 pub process_lock_held: bool,
2107 /// Human-readable reason when [`Self::process_lock_held`] is false.
2108 #[serde(default, skip_serializing_if = "Option::is_none")]
2109 pub process_lock_note: Option<String>,
2110 }
2111
2112 fn default_process_lock_held() -> bool {
2113 // Legacy projections (tests, older sessions) assume the lock is held so
2114 // they do not spuriously light the unavailable banner.
2115 true
2116 }
2117
2118 /// Durable, bounded coordination state owned by `SubAgentManager`.
2119 #[derive(Debug, Clone, Serialize, Deserialize)]
2120 pub struct CoordinationLedger {
2121 #[serde(default = "coordination_schema_version")]
2122 pub schema_version: u32,
2123 #[serde(default)]
2124 pub sequence: u64,
2125 #[serde(default)]
2126 pub decisions: Vec<DecisionRecord>,
2127 #[serde(default)]
2128 pub write_claims: Vec<PersistedWriteClaim>,
2129 #[serde(default)]
2130 pub reconciliations: Vec<ReconciliationReceipt>,
2131 #[serde(default)]
2132 pub projections: Vec<ContextProjectionReceipt>,
2133 #[serde(default)]
2134 pub contentions: Vec<WriteContentionReceipt>,
2135 }
2136
2137 impl Default for CoordinationLedger {
2138 fn default() -> Self {
2139 Self {
2140 schema_version: COORDINATION_SCHEMA_VERSION,
2141 sequence: 0,
2142 decisions: Vec::new(),
2143 write_claims: Vec::new(),
2144 reconciliations: Vec::new(),
2145 projections: Vec::new(),
2146 contentions: Vec::new(),
2147 }
2148 }
2149 }
2150
2151 impl CoordinationLedger {
2152 fn next_sequence(&mut self) -> u64 {
2153 self.sequence = self.sequence.saturating_add(1);
2154 self.sequence
2155 }
2156
2157 pub fn record_decision(
2158 &mut self,
2159 mut decision: DecisionRecord,
2160 ) -> Result<DecisionRecord, String> {
2161 self.validate_schema()?;
2162 decision.decision_id = decision.decision_id.trim().to_string();
2163 if !decision.decision_id.is_empty() {
2164 decision.decision_id = bounded_coordination_atom("decision id", &decision.decision_id)?;
2165 }
2166 decision.subject = bounded_coordination_atom("decision subject", &decision.subject)?;
2167 decision.owner = bounded_coordination_atom("decision owner", &decision.owner)?;
2168 decision.scope = normalize_coordination_values("decision scope", &decision.scope, 24)?;
2169 decision.constraints =
2170 normalize_coordination_values("decision constraints", &decision.constraints, 24)?;
2171 decision.evidence_handles = normalize_coordination_values(
2172 "decision evidence handles",
2173 &decision.evidence_handles,
2174 24,
2175 )?;
2176 reject_sensitive_coordination_values(&decision.constraints)?;
2177 reject_sensitive_coordination_values(&decision.evidence_handles)?;
2178 if decision.subject.trim().is_empty() || decision.owner.trim().is_empty() {
2179 return Err("decision subject and owner are required".to_string());
2180 }
2181 if !decision.decision_id.trim().is_empty()
2182 && self
2183 .decisions
2184 .iter()
2185 .any(|existing| existing.decision_id == decision.decision_id)
2186 {
2187 return Err(format!(
2188 "decision id '{}' already exists",
2189 decision.decision_id
2190 ));
2191 }
2192 if decision.status == DecisionStatus::Accepted
2193 && let Some(existing) = self.decisions.iter().find(|existing| {
2194 existing.subject == decision.subject
2195 && existing.status == DecisionStatus::Accepted
2196 && existing.decision_id != decision.decision_id
2197 })
2198 {
2199 return Err(format!(
2200 "subject '{}' already has accepted decision '{}' owned by '{}'; preserve both candidates and use neutral reconciliation",
2201 decision.subject, existing.decision_id, existing.owner
2202 ));
2203 }
2204 let next_version = self
2205 .decisions
2206 .iter()
2207 .filter(|existing| existing.subject == decision.subject)
2208 .map(|existing| existing.version)
2209 .max()
2210 .unwrap_or(0)
2211 .saturating_add(1);
2212 decision.version = decision.version.max(next_version);
2213 decision.sequence = self.next_sequence();
2214 if decision.decision_id.trim().is_empty() {
2215 decision.decision_id = format!("decision_{}", decision.sequence);
2216 }
2217 self.decisions.push(decision.clone());
2218 if self.decisions.len() > COORDINATION_RECORD_LIMIT {
2219 let referenced = self
2220 .reconciliations
2221 .iter()
2222 .flat_map(|receipt| receipt.input_decisions.iter())
2223 .cloned()
2224 .collect::<BTreeSet<_>>();
2225 if let Some(index) = self.decisions.iter().position(|existing| {
2226 existing.status != DecisionStatus::Accepted
2227 && !referenced.contains(&existing.decision_id)
2228 }) {
2229 self.decisions.remove(index);
2230 } else {
2231 self.decisions.pop();
2232 return Err(
2233 "coordination decision capacity is occupied by accepted or reconciled records"
2234 .to_string(),
2235 );
2236 }
2237 }
2238 Ok(decision)
2239 }
2240
2241 pub fn update_decision_status(
2242 &mut self,
2243 decision_id: &str,
2244 status: DecisionStatus,
2245 owner: &str,
2246 expected_version: u32,
2247 ) -> Result<DecisionRecord, String> {
2248 self.validate_schema()?;
2249 let Some(index) = self
2250 .decisions
2251 .iter()
2252 .position(|decision| decision.decision_id == decision_id)
2253 else {
2254 return Err(format!("decision '{decision_id}' not found"));
2255 };
2256 if self.decisions[index].owner != owner {
2257 return Err(format!(
2258 "decision '{decision_id}' is owned by '{}'; caller '{owner}' cannot change it",
2259 self.decisions[index].owner
2260 ));
2261 }
2262 if self.decisions[index].version != expected_version {
2263 return Err(format!(
2264 "decision '{decision_id}' version changed: expected {expected_version}, current {}",
2265 self.decisions[index].version
2266 ));
2267 }
2268 let subject = self.decisions[index].subject.clone();
2269 if status == DecisionStatus::Accepted
2270 && let Some(existing) =
2271 self.decisions
2272 .iter()
2273 .enumerate()
2274 .find_map(|(other_index, existing)| {
2275 (other_index != index
2276 && existing.subject == subject
2277 && existing.status == DecisionStatus::Accepted)
2278 .then_some(existing)
2279 })
2280 {
2281 return Err(format!(
2282 "subject '{subject}' already has accepted decision '{}' owned by '{}'; preserve both candidates and use neutral reconciliation",
2283 existing.decision_id, existing.owner
2284 ));
2285 }
2286 let sequence = self.next_sequence();
2287 let decision = &mut self.decisions[index];
2288 decision.status = status;
2289 decision.version = decision.version.saturating_add(1);
2290 decision.sequence = sequence;
2291 Ok(decision.clone())
2292 }
2293
2294 pub fn register_claim<F>(
2295 &mut self,
2296 mut claim: WriteScopeClaim,
2297 isolated_worktree: bool,
2298 mut owner_is_active: F,
2299 ) -> Result<PersistedWriteClaim, String>
2300 where
2301 F: FnMut(&str) -> bool,
2302 {
2303 self.validate_schema()?;
2304 claim.owner = bounded_coordination_atom("write claim owner", &claim.owner)?;
2305 claim.roots = normalize_claim_paths(&claim.roots)?;
2306 claim.exact_files = normalize_claim_paths(&claim.exact_files)?;
2307 claim.contracts = normalize_claim_strings(&claim.contracts, 16, 128, "contracts")?;
2308 if claim.roots.is_empty() && claim.exact_files.is_empty() && claim.contracts.is_empty() {
2309 return Err(
2310 "write claim requires an owner and at least one root, file, or contract"
2311 .to_string(),
2312 );
2313 }
2314 let replacing_existing_owner = self
2315 .write_claims
2316 .iter()
2317 .any(|existing| existing.claim.owner == claim.owner);
2318 if !replacing_existing_owner && self.write_claims.len() >= COORDINATION_RECORD_LIMIT {
2319 let mut inactive = Vec::new();
2320 for existing in &self.write_claims {
2321 if !owner_is_active(&existing.claim.owner) {
2322 inactive.push((existing.sequence, existing.claim.owner.clone()));
2323 }
2324 }
2325 inactive.sort_by_key(|(sequence, _)| *sequence);
2326 for (_, owner) in inactive {
2327 if self.write_claims.len() < COORDINATION_RECORD_LIMIT {
2328 break;
2329 }
2330 self.write_claims
2331 .retain(|existing| existing.claim.owner != owner);
2332 }
2333 if self.write_claims.len() >= COORDINATION_RECORD_LIMIT {
2334 return Err(format!(
2335 "write-claim capacity is {COORDINATION_RECORD_LIMIT} active owners; complete, serialize, or isolate existing work before admitting another writer"
2336 ));
2337 }
2338 }
2339 if !isolated_worktree
2340 && let Some(existing) = self
2341 .write_claims
2342 .iter()
2343 .find(|existing| {
2344 !existing.isolated_worktree
2345 && existing.claim.owner != claim.owner
2346 && owner_is_active(&existing.claim.owner)
2347 && existing.claim.overlaps(&claim)
2348 })
2349 .cloned()
2350 {
2351 let receipt = WriteContentionReceipt {
2352 claimant: claim.owner.clone(),
2353 conflicting_owner: existing.claim.owner.clone(),
2354 roots: claim.roots.clone(),
2355 exact_files: claim.exact_files.clone(),
2356 contracts: claim.contracts.clone(),
2357 disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization,
2358 resolution_sequence: None,
2359 sequence: self.next_sequence(),
2360 };
2361 self.contentions.push(receipt);
2362 trim_front(&mut self.contentions, COORDINATION_RECORD_LIMIT);
2363 return Err(format!(
2364 "write-scope contention with {} (roots: {:?}, files: {:?}, contracts: {:?}); serialize the work, narrow the claim, or use worktree isolation",
2365 existing.claim.owner,
2366 existing.claim.roots,
2367 existing.claim.exact_files,
2368 existing.claim.contracts
2369 ));
2370 }
2371 self.write_claims
2372 .retain(|existing| existing.claim.owner != claim.owner);
2373 let record = PersistedWriteClaim {
2374 claim,
2375 sequence: self.next_sequence(),
2376 isolated_worktree,
2377 };
2378 for contention in &mut self.contentions {
2379 if contention.claimant == record.claim.owner
2380 && contention.disposition.blocks_admission()
2381 {
2382 contention.disposition = WriteContentionDisposition::ResolvedBySuccessfulClaim;
2383 contention.resolution_sequence = Some(record.sequence);
2384 }
2385 }
2386 self.write_claims.push(record.clone());
2387 Ok(record)
2388 }
2389
2390 #[allow(clippy::too_many_arguments)]
2391 pub fn reconcile(
2392 &mut self,
2393 subject: String,
2394 owner: String,
2395 input_decisions: Vec<String>,
2396 outcome: String,
2397 evidence_handles: Vec<String>,
2398 candidate_handles: Vec<String>,
2399 retry_count: u32,
2400 retry_limit: u32,
2401 reviewer_evidence_handles: Vec<String>,
2402 verifier_evidence_handles: Vec<String>,
2403 verification_outcome: String,
2404 ) -> Result<ReconciliationReceipt, String> {
2405 self.validate_schema()?;
2406 let subject = bounded_coordination_atom("reconciliation subject", &subject)?;
2407 let owner = bounded_coordination_atom("reconciliation owner", &owner)?;
2408 let outcome = bounded_coordination_atom("reconciliation outcome", &outcome)?;
2409 let verification_outcome = bounded_coordination_atom(
2410 "reconciliation verification outcome",
2411 &verification_outcome,
2412 )?;
2413 if input_decisions.len() < 2 {
2414 return Err("neutral fan-in requires at least two input decisions".to_string());
2415 }
2416 if input_decisions.iter().collect::<BTreeSet<_>>().len() != input_decisions.len() {
2417 return Err("neutral fan-in decision ids must be distinct".to_string());
2418 }
2419 if candidate_handles.len() < 2
2420 || candidate_handles
2421 .iter()
2422 .any(|handle| handle.trim().is_empty())
2423 {
2424 return Err(
2425 "neutral fan-in must preserve at least two candidate branch, patch, or artifact handles"
2426 .to_string(),
2427 );
2428 }
2429 if candidate_handles.iter().collect::<BTreeSet<_>>().len() != candidate_handles.len() {
2430 return Err("neutral fan-in candidate handles must be distinct".to_string());
2431 }
2432 let input_decisions =
2433 normalize_coordination_values("input decision ids", &input_decisions, 24)?;
2434 let evidence_handles = normalize_coordination_values(
2435 "reconciliation evidence handles",
2436 &evidence_handles,
2437 24,
2438 )?;
2439 let candidate_handles =
2440 normalize_coordination_values("candidate handles", &candidate_handles, 24)?;
2441 if input_decisions.len() < 2 {
2442 return Err(
2443 "neutral fan-in requires at least two distinct normalized input decisions"
2444 .to_string(),
2445 );
2446 }
2447 if candidate_handles.len() < 2 {
2448 return Err(
2449 "neutral fan-in must preserve at least two distinct normalized candidate handles"
2450 .to_string(),
2451 );
2452 }
2453 let reviewer_evidence_handles = normalize_coordination_values(
2454 "Reviewer evidence handles",
2455 &reviewer_evidence_handles,
2456 24,
2457 )?;
2458 let verifier_evidence_handles = normalize_coordination_values(
2459 "Verifier evidence handles",
2460 &verifier_evidence_handles,
2461 24,
2462 )?;
2463 reject_sensitive_coordination_values(&evidence_handles)?;
2464 reject_sensitive_coordination_values(&candidate_handles)?;
2465 reject_sensitive_coordination_values(&reviewer_evidence_handles)?;
2466 reject_sensitive_coordination_values(&verifier_evidence_handles)?;
2467 if retry_limit == 0 || retry_limit > MAX_RECONCILIATION_RETRIES {
2468 return Err(format!(
2469 "reconciliation retry_limit must be between 1 and {MAX_RECONCILIATION_RETRIES}"
2470 ));
2471 }
2472 if retry_count > retry_limit {
2473 return Err("reconciliation retry_count exceeds retry_limit".to_string());
2474 }
2475 if reviewer_evidence_handles.is_empty() || verifier_evidence_handles.is_empty() {
2476 return Err(
2477 "neutral fan-in requires independent Reviewer and Verifier evidence handles"
2478 .to_string(),
2479 );
2480 }
2481 if reviewer_evidence_handles.iter().any(|review| {
2482 verifier_evidence_handles
2483 .iter()
2484 .any(|verify| verify == review)
2485 }) {
2486 return Err("Reviewer and Verifier evidence handles must be independent".to_string());
2487 }
2488 if !matches!(
2489 verification_outcome.as_str(),
2490 "verified" | "failed" | "blocked"
2491 ) {
2492 return Err(
2493 "neutral fan-in verification_outcome must be verified, failed, or blocked"
2494 .to_string(),
2495 );
2496 }
2497 if input_decisions.iter().any(|id| {
2498 !self
2499 .decisions
2500 .iter()
2501 .any(|decision| &decision.decision_id == id)
2502 }) {
2503 return Err("reconciliation references an unknown decision".to_string());
2504 }
2505 let inputs = input_decisions
2506 .iter()
2507 .filter_map(|id| {
2508 self.decisions
2509 .iter()
2510 .find(|decision| &decision.decision_id == id)
2511 })
2512 .collect::<Vec<_>>();
2513 if inputs.iter().any(|decision| decision.subject != subject) {
2514 return Err("reconciliation inputs must share the requested subject".to_string());
2515 }
2516 if inputs.iter().any(|decision| decision.owner == owner) {
2517 return Err(
2518 "neutral fan-in owner must differ from every input decision owner".to_string(),
2519 );
2520 }
2521 let sequence = self.next_sequence();
2522 let receipt = ReconciliationReceipt {
2523 reconciliation_id: format!("reconcile_{sequence}"),
2524 subject,
2525 owner,
2526 input_decisions,
2527 outcome,
2528 evidence_handles,
2529 candidate_handles,
2530 retry_count,
2531 retry_limit,
2532 reviewer_evidence_handles,
2533 verifier_evidence_handles,
2534 verification_outcome,
2535 sequence,
2536 };
2537 self.reconciliations.push(receipt.clone());
2538 trim_front(&mut self.reconciliations, COORDINATION_RECORD_LIMIT);
2539 Ok(receipt)
2540 }
2541
2542 pub fn project_relevant_decisions(
2543 &mut self,
2544 child_id: &str,
2545 claim: Option<&WriteScopeClaim>,
2546 capabilities: &[String],
2547 ) -> (String, ContextProjectionReceipt) {
2548 const HEADER: &str = "Accepted coordination decisions relevant to this child (bounded):\n";
2549 let mut seen_constraint_facts = BTreeSet::new();
2550 let mut decision_ids = Vec::new();
2551 let mut lines = Vec::new();
2552 let mut projected_bytes = 0usize;
2553 let mut deduplicated = 0usize;
2554 let mut omitted = 0usize;
2555 for decision in self
2556 .decisions
2557 .iter()
2558 .rev()
2559 .filter(|decision| decision.status == DecisionStatus::Accepted)
2560 .filter(|decision| decision_is_relevant(decision, claim, capabilities))
2561 {
2562 if decision_ids.len() >= COORDINATION_PROJECTION_DECISION_LIMIT {
2563 omitted = omitted.saturating_add(1);
2564 continue;
2565 }
2566 let constraints = decision
2567 .constraints
2568 .iter()
2569 .filter_map(|value| {
2570 let value = bounded_utf8(value, 192);
2571 if seen_constraint_facts.insert(value.clone()) {
2572 Some(value)
2573 } else {
2574 deduplicated = deduplicated.saturating_add(1);
2575 None
2576 }
2577 })
2578 .take(8)
2579 .collect::<Vec<_>>()
2580 .join("; ");
2581 let mut line = format!(
2582 "- {} v{} [{}] owner={}",
2583 decision.subject, decision.version, decision.decision_id, decision.owner,
2584 );
2585 if !constraints.is_empty() {
2586 line.push_str(": ");
2587 line.push_str(&constraints);
2588 }
2589 let line = bounded_utf8(&line, 512);
2590 let added_bytes = line.len().saturating_add(1);
2591 if HEADER
2592 .len()
2593 .saturating_add(projected_bytes)
2594 .saturating_add(added_bytes)
2595 > COORDINATION_PROJECTION_BYTE_LIMIT
2596 {
2597 omitted = omitted.saturating_add(1);
2598 continue;
2599 }
2600 projected_bytes = projected_bytes.saturating_add(added_bytes);
2601 decision_ids.push(decision.decision_id.clone());
2602 lines.push(line);
2603 }
2604 let projection = if lines.is_empty() {
2605 String::new()
2606 } else {
2607 format!("{HEADER}{}", lines.join("\n"))
2608 };
2609 let receipt = ContextProjectionReceipt {
2610 child_id: child_id.to_string(),
2611 decision_ids,
2612 projected_bytes: projection.len(),
2613 deduplicated,
2614 omitted,
2615 sequence: self.next_sequence(),
2616 };
2617 self.projections.push(receipt.clone());
2618 trim_front(&mut self.projections, COORDINATION_RECORD_LIMIT);
2619 (projection, receipt)
2620 }
2621
2622 pub(super) fn validate_replay(&mut self) -> Result<(), String> {
2623 self.validate_schema()?;
2624 if self.decisions.len() > COORDINATION_RECORD_LIMIT
2625 || self.write_claims.len() > COORDINATION_RECORD_LIMIT
2626 || self.reconciliations.len() > COORDINATION_RECORD_LIMIT
2627 || self.projections.len() > COORDINATION_RECORD_LIMIT
2628 || self.contentions.len() > COORDINATION_RECORD_LIMIT
2629 {
2630 return Err("coordination record count exceeds the durable bound".to_string());
2631 }
2632
2633 let mut sequences = BTreeSet::new();
2634 let mut max_sequence = 0_u64;
2635 let mut decision_ids = BTreeSet::new();
2636 let mut accepted_subjects = BTreeSet::new();
2637 for decision in &self.decisions {
2638 bounded_coordination_atom("decision id", &decision.decision_id)?;
2639 bounded_coordination_atom("decision subject", &decision.subject)?;
2640 bounded_coordination_atom("decision owner", &decision.owner)?;
2641 if decision.version == 0 {
2642 return Err(format!(
2643 "decision '{}' has zero version",
2644 decision.decision_id
2645 ));
2646 }
2647 validate_sequence(
2648 decision.sequence,
2649 "decision",
2650 &mut sequences,
2651 &mut max_sequence,
2652 )?;
2653 if !decision_ids.insert(decision.decision_id.clone()) {
2654 return Err(format!("duplicate decision id '{}'", decision.decision_id));
2655 }
2656 if decision.status == DecisionStatus::Accepted
2657 && !accepted_subjects.insert(decision.subject.clone())
2658 {
2659 return Err(format!(
2660 "multiple accepted decisions own subject '{}'",
2661 decision.subject
2662 ));
2663 }
2664 validate_normalized_coordination_values("decision scope", &decision.scope, 24)?;
2665 validate_normalized_coordination_values(
2666 "decision constraints",
2667 &decision.constraints,
2668 24,
2669 )?;
2670 validate_normalized_coordination_values(
2671 "decision evidence handles",
2672 &decision.evidence_handles,
2673 24,
2674 )?;
2675 reject_sensitive_coordination_values(&decision.constraints)?;
2676 reject_sensitive_coordination_values(&decision.evidence_handles)?;
2677 }
2678
2679 let mut claim_owners = BTreeSet::new();
2680 for claim in &self.write_claims {
2681 validate_sequence(
2682 claim.sequence,
2683 "write claim",
2684 &mut sequences,
2685 &mut max_sequence,
2686 )?;
2687 bounded_coordination_atom("write claim owner", &claim.claim.owner)?;
2688 if !claim_owners.insert(claim.claim.owner.clone()) {
2689 return Err(format!(
2690 "duplicate write claim owner '{}'",
2691 claim.claim.owner
2692 ));
2693 }
2694 let roots = normalize_claim_paths(&claim.claim.roots)?;
2695 let exact_files = normalize_claim_paths(&claim.claim.exact_files)?;
2696 let contracts = normalize_claim_strings(&claim.claim.contracts, 16, 128, "contracts")?;
2697 if roots != claim.claim.roots
2698 || exact_files != claim.claim.exact_files
2699 || contracts != claim.claim.contracts
2700 || (roots.is_empty() && exact_files.is_empty() && contracts.is_empty())
2701 {
2702 return Err(format!(
2703 "write claim for '{}' is not normalized and bounded",
2704 claim.claim.owner
2705 ));
2706 }
2707 }
2708
2709 for receipt in &self.reconciliations {
2710 validate_sequence(
2711 receipt.sequence,
2712 "reconciliation",
2713 &mut sequences,
2714 &mut max_sequence,
2715 )?;
2716 validate_reconciliation_receipt(receipt, &self.decisions)?;
2717 }
2718 for projection in &self.projections {
2719 validate_sequence(
2720 projection.sequence,
2721 "context projection",
2722 &mut sequences,
2723 &mut max_sequence,
2724 )?;
2725 bounded_coordination_atom("projection child", &projection.child_id)?;
2726 if projection.decision_ids.len() > COORDINATION_PROJECTION_DECISION_LIMIT
2727 || projection.projected_bytes > COORDINATION_PROJECTION_BYTE_LIMIT
2728 || projection
2729 .decision_ids
2730 .iter()
2731 .collect::<BTreeSet<_>>()
2732 .len()
2733 != projection.decision_ids.len()
2734 {
2735 return Err(format!(
2736 "context projection for '{}' exceeds its bounds or duplicates decisions",
2737 projection.child_id
2738 ));
2739 }
2740 }
2741 for contention in &self.contentions {
2742 validate_sequence(
2743 contention.sequence,
2744 "contention",
2745 &mut sequences,
2746 &mut max_sequence,
2747 )?;
2748 bounded_coordination_atom("contention claimant", &contention.claimant)?;
2749 bounded_coordination_atom(
2750 "contention conflicting owner",
2751 &contention.conflicting_owner,
2752 )?;
2753 match (contention.disposition, contention.resolution_sequence) {
2754 (WriteContentionDisposition::BlockedPendingIsolationOrSerialization, None) => {}
2755 (WriteContentionDisposition::ResolvedBySuccessfulClaim, Some(sequence))
2756 if sequence > contention.sequence && sequence <= self.sequence => {}
2757 (WriteContentionDisposition::BlockedPendingIsolationOrSerialization, Some(_)) => {
2758 return Err(
2759 "blocked contention receipt cannot carry a resolution sequence".to_string(),
2760 );
2761 }
2762 (WriteContentionDisposition::ResolvedBySuccessfulClaim, _) => {
2763 return Err(
2764 "resolved contention receipt requires a later valid resolution sequence"
2765 .to_string(),
2766 );
2767 }
2768 }
2769 if normalize_claim_paths(&contention.roots)? != contention.roots
2770 || normalize_claim_paths(&contention.exact_files)? != contention.exact_files
2771 || normalize_claim_strings(&contention.contracts, 16, 128, "contracts")?
2772 != contention.contracts
2773 {
2774 return Err("contention receipt paths/contracts are not normalized".to_string());
2775 }
2776 }
2777 if self.sequence < max_sequence {
2778 return Err(format!(
2779 "coordination sequence {} is behind record sequence {max_sequence}",
2780 self.sequence
2781 ));
2782 }
2783 Ok(())
2784 }
2785
2786 fn validate_schema(&self) -> Result<(), String> {
2787 if self.schema_version != COORDINATION_SCHEMA_VERSION {
2788 return Err(format!(
2789 "unsupported coordination schema {}; expected {}",
2790 self.schema_version, COORDINATION_SCHEMA_VERSION
2791 ));
2792 }
2793 Ok(())
2794 }
2795 }
2796
2797 fn normalize_claim_paths(paths: &[String]) -> Result<Vec<String>, String> {
2798 if paths.len() > 32 {
2799 return Err("write claim paths accept at most 32 entries".to_string());
2800 }
2801 let mut normalized = Vec::new();
2802 for path in paths {
2803 let path = super::normalize_claim_path(path)?;
2804 if !normalized.contains(&path) {
2805 normalized.push(path);
2806 }
2807 }
2808 Ok(normalized)
2809 }
2810
2811 fn normalize_claim_strings(
2812 values: &[String],
2813 count_limit: usize,
2814 char_limit: usize,
2815 field: &str,
2816 ) -> Result<Vec<String>, String> {
2817 if values.len() > count_limit {
2818 return Err(format!(
2819 "write claim {field} accepts at most {count_limit} entries"
2820 ));
2821 }
2822 let mut normalized = Vec::new();
2823 for value in values {
2824 let value = value.trim();
2825 if value.is_empty()
2826 || value.chars().count() > char_limit
2827 || value.chars().any(char::is_control)
2828 {
2829 return Err(format!(
2830 "write claim {field} entries must be 1..={char_limit} characters"
2831 ));
2832 }
2833 if !normalized.iter().any(|existing| existing == value) {
2834 normalized.push(value.to_string());
2835 }
2836 }
2837 Ok(normalized)
2838 }
2839
2840 fn bounded_coordination_atom(field: &str, value: &str) -> Result<String, String> {
2841 let value = value.trim();
2842 if value.is_empty()
2843 || value.chars().count() > 512
2844 || value.chars().any(|ch| matches!(ch, '\r' | '\n'))
2845 {
2846 return Err(format!(
2847 "{field} must be one non-empty line of at most 512 characters"
2848 ));
2849 }
2850 Ok(value.to_string())
2851 }
2852
2853 fn normalize_coordination_values(
2854 field: &str,
2855 values: &[String],
2856 limit: usize,
2857 ) -> Result<Vec<String>, String> {
2858 if values.len() > limit {
2859 return Err(format!("{field} accepts at most {limit} entries"));
2860 }
2861 let mut normalized = Vec::new();
2862 for value in values {
2863 let value = bounded_coordination_atom(field, value)?;
2864 if !normalized.contains(&value) {
2865 normalized.push(value);
2866 }
2867 }
2868 Ok(normalized)
2869 }
2870
2871 fn validate_normalized_coordination_values(
2872 field: &str,
2873 values: &[String],
2874 limit: usize,
2875 ) -> Result<(), String> {
2876 if normalize_coordination_values(field, values, limit)? != values {
2877 return Err(format!("{field} is not trimmed and deduplicated"));
2878 }
2879 Ok(())
2880 }
2881
2882 fn reject_sensitive_coordination_values(values: &[String]) -> Result<(), String> {
2883 const SENSITIVE_MARKERS: &[&str] = &[
2884 "secret",
2885 "password",
2886 "api_key",
2887 "api-key",
2888 "authorization:",
2889 "bearer ",
2890 "token=",
2891 "sk-",
2892 "ghp_",
2893 "xoxb-",
2894 "<thinking",
2895 "chain of thought",
2896 "raw reasoning",
2897 ];
2898 for value in values {
2899 let lower = value.to_ascii_lowercase();
2900 if let Some(marker) = SENSITIVE_MARKERS
2901 .iter()
2902 .find(|marker| lower.contains(**marker))
2903 {
2904 return Err(format!(
2905 "coordination metadata rejected sensitive or raw-reasoning marker '{marker}'"
2906 ));
2907 }
2908 }
2909 Ok(())
2910 }
2911
2912 fn validate_sequence(
2913 sequence: u64,
2914 kind: &str,
2915 sequences: &mut BTreeSet<u64>,
2916 max_sequence: &mut u64,
2917 ) -> Result<(), String> {
2918 if sequence == 0 || !sequences.insert(sequence) {
2919 return Err(format!(
2920 "{kind} has a zero or duplicate sequence {sequence}"
2921 ));
2922 }
2923 *max_sequence = (*max_sequence).max(sequence);
2924 Ok(())
2925 }
2926
2927 fn validate_reconciliation_receipt(
2928 receipt: &ReconciliationReceipt,
2929 decisions: &[DecisionRecord],
2930 ) -> Result<(), String> {
2931 bounded_coordination_atom("reconciliation id", &receipt.reconciliation_id)?;
2932 bounded_coordination_atom("reconciliation subject", &receipt.subject)?;
2933 bounded_coordination_atom("reconciliation owner", &receipt.owner)?;
2934 bounded_coordination_atom("reconciliation outcome", &receipt.outcome)?;
2935 if receipt.input_decisions.len() < 2
2936 || receipt
2937 .input_decisions
2938 .iter()
2939 .collect::<BTreeSet<_>>()
2940 .len()
2941 != receipt.input_decisions.len()
2942 {
2943 return Err("reconciliation requires at least two distinct decision ids".to_string());
2944 }
2945 let inputs = receipt
2946 .input_decisions
2947 .iter()
2948 .map(|id| {
2949 decisions
2950 .iter()
2951 .find(|decision| &decision.decision_id == id)
2952 .ok_or_else(|| format!("reconciliation references unknown decision '{id}'"))
2953 })
2954 .collect::<Result<Vec<_>, _>>()?;
2955 if inputs
2956 .iter()
2957 .any(|decision| decision.subject != receipt.subject)
2958 {
2959 return Err("reconciliation inputs must share the requested subject".to_string());
2960 }
2961 if inputs
2962 .iter()
2963 .any(|decision| decision.owner == receipt.owner)
2964 {
2965 return Err("neutral fan-in owner must differ from every candidate owner".to_string());
2966 }
2967 if receipt.candidate_handles.len() < 2
2968 || receipt
2969 .candidate_handles
2970 .iter()
2971 .collect::<BTreeSet<_>>()
2972 .len()
2973 != receipt.candidate_handles.len()
2974 {
2975 return Err("reconciliation requires at least two distinct candidate handles".to_string());
2976 }
2977 validate_normalized_coordination_values("candidate handles", &receipt.candidate_handles, 24)?;
2978 validate_normalized_coordination_values(
2979 "reconciliation evidence handles",
2980 &receipt.evidence_handles,
2981 24,
2982 )?;
2983 validate_normalized_coordination_values(
2984 "Reviewer evidence handles",
2985 &receipt.reviewer_evidence_handles,
2986 24,
2987 )?;
2988 validate_normalized_coordination_values(
2989 "Verifier evidence handles",
2990 &receipt.verifier_evidence_handles,
2991 24,
2992 )?;
2993 reject_sensitive_coordination_values(&receipt.candidate_handles)?;
2994 reject_sensitive_coordination_values(&receipt.evidence_handles)?;
2995 reject_sensitive_coordination_values(&receipt.reviewer_evidence_handles)?;
2996 reject_sensitive_coordination_values(&receipt.verifier_evidence_handles)?;
2997 if receipt.retry_limit == 0
2998 || receipt.retry_limit > MAX_RECONCILIATION_RETRIES
2999 || receipt.retry_count > receipt.retry_limit
3000 {
3001 return Err("reconciliation retry count/limit is invalid".to_string());
3002 }
3003 if receipt.reviewer_evidence_handles.is_empty()
3004 || receipt.verifier_evidence_handles.is_empty()
3005 || receipt.reviewer_evidence_handles.iter().any(|review| {
3006 receipt
3007 .verifier_evidence_handles
3008 .iter()
3009 .any(|verify| verify == review)
3010 })
3011 {
3012 return Err("Reviewer and Verifier evidence must be present and independent".to_string());
3013 }
3014 if !matches!(
3015 receipt.verification_outcome.as_str(),
3016 "verified" | "failed" | "blocked"
3017 ) {
3018 return Err("reconciliation verification outcome is invalid".to_string());
3019 }
3020 Ok(())
3021 }
3022
3023 fn decision_is_relevant(
3024 decision: &DecisionRecord,
3025 claim: Option<&WriteScopeClaim>,
3026 capabilities: &[String],
3027 ) -> bool {
3028 if decision.scope.is_empty() {
3029 return true;
3030 }
3031 decision.scope.iter().any(|raw| {
3032 let value = raw.trim();
3033 let (kind, value) = value
3034 .split_once(':')
3035 .map_or(("", value), |(kind, value)| (kind.trim(), value.trim()));
3036 match kind {
3037 "capability" => capabilities.iter().any(|capability| capability == value),
3038 "contract" => {
3039 claim.is_some_and(|claim| claim.contracts.iter().any(|contract| contract == value))
3040 }
3041 "path" => claim.is_some_and(|claim| claim_reaches_path(claim, value)),
3042 _ => {
3043 capabilities.iter().any(|capability| capability == value)
3044 || claim.is_some_and(|claim| {
3045 claim.contracts.iter().any(|contract| contract == value)
3046 || claim_reaches_path(claim, value)
3047 })
3048 }
3049 }
3050 })
3051 }
3052
3053 fn claim_reaches_path(claim: &WriteScopeClaim, path: &str) -> bool {
3054 let Ok(path) = super::normalize_claim_path(path) else {
3055 return false;
3056 };
3057 claim.contains_path(&path)
3058 || claim.roots.iter().any(|root| path_contains(&path, root))
3059 || claim
3060 .exact_files
3061 .iter()
3062 .any(|file| path_contains(&path, file))
3063 }
3064
3065 fn bounded_utf8(value: &str, byte_limit: usize) -> String {
3066 if value.len() <= byte_limit {
3067 return value.to_string();
3068 }
3069 let mut end = byte_limit;
3070 while !value.is_char_boundary(end) {
3071 end = end.saturating_sub(1);
3072 }
3073 value[..end].to_string()
3074 }
3075
3076 fn trim_front<T>(records: &mut Vec<T>, limit: usize) {
3077 if records.len() > limit {
3078 records.drain(..records.len() - limit);
3079 }
3080 }
3081
3082 pub struct AgentsCoordinateTool {
3083 manager: SharedSubAgentManager,
3084 caller: Option<String>,
3085 }
3086
3087 impl AgentsCoordinateTool {
3088 #[must_use]
3089 pub fn new(manager: SharedSubAgentManager, caller: Option<String>) -> Self {
3090 Self { manager, caller }
3091 }
3092 }
3093
3094 #[async_trait]
3095 impl ToolSpec for AgentsCoordinateTool {
3096 fn name(&self) -> &'static str {
3097 "agents/coordinate"
3098 }
3099
3100 fn description(&self) -> &'static str {
3101 "Record or inspect bounded coordination state: propose/accept/supersede decisions, expand the caller's write claim before mutation, or reconcile multiple decision records into one neutral fan-in receipt."
3102 }
3103
3104 fn input_schema(&self) -> Value {
3105 json!({
3106 "type": "object",
3107 "properties": {
3108 "action": { "type": "string", "enum": ["inspect", "propose", "accept", "supersede", "claim", "reconcile"] },
3109 "decision_id": { "type": "string" },
3110 "subject": { "type": "string" },
3111 "expected_version": { "type": "integer", "minimum": 1 },
3112 "scope": { "type": "array", "items": { "type": "string" } },
3113 "constraints": { "type": "array", "items": { "type": "string" } },
3114 "evidence_handles": { "type": "array", "items": { "type": "string" } },
3115 "roots": { "type": "array", "items": { "type": "string" } },
3116 "exact_files": { "type": "array", "items": { "type": "string" } },
3117 "contracts": { "type": "array", "items": { "type": "string" } },
3118 "input_decisions": { "type": "array", "items": { "type": "string" } },
3119 "outcome": { "type": "string" },
3120 "candidate_handles": { "type": "array", "items": { "type": "string" } },
3121 "retry_count": { "type": "integer", "minimum": 0, "maximum": 3 },
3122 "retry_limit": { "type": "integer", "minimum": 1, "maximum": 3 },
3123 "reviewer_evidence_handles": { "type": "array", "items": { "type": "string" } },
3124 "verifier_evidence_handles": { "type": "array", "items": { "type": "string" } },
3125 "verification_outcome": { "type": "string" },
3126 "limit": { "type": "integer", "minimum": 1, "maximum": 24 }
3127 },
3128 "required": ["action"]
3129 })
3130 }
3131
3132 fn capabilities(&self) -> Vec<ToolCapability> {
3133 // #5123-class: this tool mutates the coordination ledger and expands
3134 // the caller's write claim (actions propose/accept/supersede/claim/
3135 // reconcile) — declaring ReadOnly was a lie that let policy layers
3136 // treat a mutating call as a safe read. Only `inspect` is read-only,
3137 // which is what is_read_only_for reports.
3138 vec![ToolCapability::WritesFiles]
3139 }
3140 fn approval_requirement(&self) -> ApprovalRequirement {
3141 // Stays Auto: coordination records are session-scoped in-memory
3142 // state, and gating them would deadlock autonomous sub-agent fan-in.
3143 ApprovalRequirement::Auto
3144 }
3145 fn is_read_only_for(&self, input: &Value) -> bool {
3146 input.get("action").and_then(Value::as_str) == Some("inspect")
3147 }
3148
3149 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
3150 let action = input
3151 .get("action")
3152 .and_then(Value::as_str)
3153 .unwrap_or("inspect");
3154 let bounded_text = |key: &str| {
3155 input
3156 .get(key)
3157 .and_then(Value::as_str)
3158 .map(|value| value.chars().take(512).collect::<String>())
3159 };
3160 // Tool authority is the runtime caller identity. Root cannot supply an
3161 // arbitrary child owner and mutate that child's decisions/claim.
3162 let owner = self.caller.clone().unwrap_or_else(|| "root".to_string());
3163 let strings = |key: &str| {
3164 input
3165 .get(key)
3166 .and_then(Value::as_array)
3167 .map(|items| {
3168 items
3169 .iter()
3170 .take(24)
3171 .filter_map(Value::as_str)
3172 .map(|value| value.chars().take(512).collect::<String>())
3173 .collect::<Vec<_>>()
3174 })
3175 .unwrap_or_default()
3176 };
3177 if action == "inspect" {
3178 let manager = self.manager.read().await;
3179 let value = manager.inspect_coordination(
3180 bounded_text("subject").as_deref(),
3181 input
3182 .get("limit")
3183 .and_then(Value::as_u64)
3184 .unwrap_or(COORDINATION_INSPECT_LIMIT as u64) as usize,
3185 );
3186 return ToolResult::json(&value)
3187 .map_err(|e| ToolError::execution_failed(e.to_string()));
3188 }
3189 if !matches!(
3190 action,
3191 "propose" | "accept" | "supersede" | "claim" | "reconcile"
3192 ) {
3193 return Err(ToolError::invalid_input(format!(
3194 "unknown coordination action '{action}'"
3195 )));
3196 }
3197
3198 let mut manager = self.manager.write().await;
3199 let coordination_before = manager.coordination.clone();
3200 let mutation = match action {
3201 "propose" => manager
3202 .record_coordination_decision(DecisionRecord {
3203 decision_id: bounded_text("decision_id").unwrap_or_default(),
3204 subject: bounded_text("subject").unwrap_or_default(),
3205 status: DecisionStatus::Proposed,
3206 owner,
3207 scope: strings("scope"),
3208 constraints: strings("constraints"),
3209 evidence_handles: strings("evidence_handles"),
3210 version: 1,
3211 sequence: 0,
3212 })
3213 .map_err(ToolError::invalid_input)
3214 .and_then(|record| {
3215 serde_json::to_value(record)
3216 .map_err(|e| ToolError::execution_failed(e.to_string()))
3217 }),
3218 "accept" | "supersede" => input
3219 .get("expected_version")
3220 .and_then(Value::as_u64)
3221 .and_then(|value| u32::try_from(value).ok())
3222 .ok_or_else(|| {
3223 ToolError::invalid_input(
3224 "accept/supersede requires expected_version".to_string(),
3225 )
3226 })
3227 .and_then(|expected_version| {
3228 manager
3229 .update_coordination_decision(
3230 &bounded_text("decision_id").unwrap_or_default(),
3231 if action == "accept" {
3232 DecisionStatus::Accepted
3233 } else {
3234 DecisionStatus::Superseded
3235 },
3236 &owner,
3237 expected_version,
3238 )
3239 .map_err(ToolError::invalid_input)
3240 })
3241 .and_then(|record| {
3242 serde_json::to_value(record)
3243 .map_err(|e| ToolError::execution_failed(e.to_string()))
3244 }),
3245 "claim" => manager
3246 .expand_write_claim(
3247 &owner,
3248 strings("roots"),
3249 strings("exact_files"),
3250 strings("contracts"),
3251 )
3252 .map_err(ToolError::invalid_input)
3253 .and_then(|claim| {
3254 serde_json::to_value(claim)
3255 .map_err(|e| ToolError::execution_failed(e.to_string()))
3256 }),
3257 "reconcile" => manager
3258 .reconcile_coordination(
3259 bounded_text("subject").unwrap_or_default(),
3260 owner,
3261 strings("input_decisions"),
3262 bounded_text("outcome").unwrap_or_default(),
3263 strings("evidence_handles"),
3264 strings("candidate_handles"),
3265 input
3266 .get("retry_count")
3267 .and_then(Value::as_u64)
3268 .and_then(|value| u32::try_from(value).ok())
3269 .unwrap_or_default(),
3270 input
3271 .get("retry_limit")
3272 .and_then(Value::as_u64)
3273 .and_then(|value| u32::try_from(value).ok())
3274 .unwrap_or(MAX_RECONCILIATION_RETRIES),
3275 strings("reviewer_evidence_handles"),
3276 strings("verifier_evidence_handles"),
3277 bounded_text("verification_outcome").unwrap_or_default(),
3278 )
3279 .map_err(ToolError::invalid_input)
3280 .and_then(|receipt| {
3281 serde_json::to_value(receipt)
3282 .map_err(|e| ToolError::execution_failed(e.to_string()))
3283 }),
3284 _ => unreachable!("coordination action validated above"),
3285 };
3286 if let Err(error) = manager.persist_state_synchronously() {
3287 manager.coordination = coordination_before;
3288 return Err(ToolError::execution_failed(format!(
3289 "failed to persist coordination action '{action}': {error}"
3290 )));
3291 }
3292 let value = mutation?;
3293 ToolResult::json(&value).map_err(|e| ToolError::execution_failed(e.to_string()))
3294 }
3295 }
3296
3297 #[cfg(test)]
3298 mod records_tests {
3299 use super::*;
3300
3301 #[test]
3302 fn overlapping_roots_detected() {
3303 let a = WriteScopeClaim {
3304 owner: "agent-a".into(),
3305 roots: vec!["src/tui/".into()],
3306 exact_files: vec![],
3307 contracts: vec![],
3308 };
3309 let b = WriteScopeClaim {
3310 owner: "agent-b".into(),
3311 roots: vec!["src/tui/widgets/".into()],
3312 exact_files: vec![],
3313 contracts: vec![],
3314 };
3315 assert!(a.overlaps(&b));
3316 }
3317
3318 #[test]
3319 fn disjoint_roots_no_overlap() {
3320 let a = WriteScopeClaim {
3321 owner: "agent-a".into(),
3322 roots: vec!["src/tui/".into()],
3323 exact_files: vec![],
3324 contracts: vec![],
3325 };
3326 let b = WriteScopeClaim {
3327 owner: "agent-b".into(),
3328 roots: vec!["src/core/".into()],
3329 exact_files: vec![],
3330 contracts: vec![],
3331 };
3332 assert!(!a.overlaps(&b));
3333 }
3334
3335 #[test]
3336 fn exact_file_collision_detected() {
3337 let a = WriteScopeClaim {
3338 owner: "agent-a".into(),
3339 roots: vec![],
3340 exact_files: vec!["src/main.rs".into()],
3341 contracts: vec![],
3342 };
3343 let b = WriteScopeClaim {
3344 owner: "agent-b".into(),
3345 roots: vec![],
3346 exact_files: vec!["src/main.rs".into()],
3347 contracts: vec![],
3348 };
3349 assert!(a.overlaps(&b));
3350 }
3351
3352 #[test]
3353 fn path_overlap_respects_component_boundaries_and_root_coverage() {
3354 let root = WriteScopeClaim {
3355 owner: "agent-a".into(),
3356 roots: vec!["src".into()],
3357 exact_files: vec![],
3358 contracts: vec![],
3359 };
3360 let sibling = WriteScopeClaim {
3361 owner: "agent-b".into(),
3362 roots: vec!["src2".into()],
3363 exact_files: vec![],
3364 contracts: vec![],
3365 };
3366 let child_file = WriteScopeClaim {
3367 owner: "agent-c".into(),
3368 roots: vec![],
3369 exact_files: vec!["src/lib.rs".into()],
3370 contracts: vec![],
3371 };
3372 assert!(!root.overlaps(&sibling));
3373 assert!(root.overlaps(&child_file));
3374 }
3375
3376 #[test]
3377 fn legacy_blocked_contention_wire_defaults_to_unresolved() {
3378 let receipt: WriteContentionReceipt = serde_json::from_value(json!({
3379 "claimant": "agent-b",
3380 "conflicting_owner": "agent-a",
3381 "roots": ["src"],
3382 "exact_files": [],
3383 "contracts": ["public-api"],
3384 "disposition": "blocked_pending_isolation_or_serialization",
3385 "sequence": 3
3386 }))
3387 .expect("existing blocked wire receipt remains readable");
3388
3389 assert_eq!(
3390 receipt.disposition,
3391 WriteContentionDisposition::BlockedPendingIsolationOrSerialization
3392 );
3393 assert_eq!(receipt.resolution_sequence, None);
3394 assert!(receipt.disposition.blocks_admission());
3395 }
3396
3397 #[test]
3398 fn active_shared_claims_contend_but_isolated_claims_do_not() {
3399 let mut ledger = CoordinationLedger::default();
3400 let first = WriteScopeClaim {
3401 owner: "agent-a".into(),
3402 roots: vec!["src".into()],
3403 exact_files: vec![],
3404 contracts: vec!["public-api".into()],
3405 };
3406 ledger.register_claim(first, false, |_| false).unwrap();
3407 let second = WriteScopeClaim {
3408 owner: "agent-b".into(),
3409 roots: vec!["docs".into()],
3410 exact_files: vec![],
3411 contracts: vec!["public-api".into()],
3412 };
3413 let err = ledger
3414 .register_claim(second.clone(), false, |owner| owner == "agent-a")
3415 .unwrap_err();
3416 assert!(
3417 err.contains("contention") && err.contains("agent-a"),
3418 "{err}"
3419 );
3420 assert_eq!(ledger.contentions.len(), 1);
3421 assert_eq!(ledger.contentions[0].claimant, "agent-b");
3422 assert_eq!(ledger.contentions[0].conflicting_owner, "agent-a");
3423 assert_eq!(
3424 ledger.contentions[0].disposition,
3425 WriteContentionDisposition::BlockedPendingIsolationOrSerialization
3426 );
3427 assert_eq!(
3428 serde_json::to_value(&ledger.contentions[0]).unwrap()["disposition"],
3429 json!("blocked_pending_isolation_or_serialization")
3430 );
3431 let resolving_claim = ledger
3432 .register_claim(second, true, |owner| owner == "agent-a")
3433 .expect("isolated claim resolves the blocked admission");
3434 assert_eq!(
3435 ledger.contentions[0].disposition,
3436 WriteContentionDisposition::ResolvedBySuccessfulClaim
3437 );
3438 assert_eq!(
3439 ledger.contentions[0].resolution_sequence,
3440 Some(resolving_claim.sequence)
3441 );
3442 }
3443
3444 #[test]
3445 fn active_write_claims_are_never_evicted_by_receipt_retention() {
3446 let mut ledger = CoordinationLedger::default();
3447 for index in 0..COORDINATION_RECORD_LIMIT {
3448 ledger
3449 .register_claim(
3450 WriteScopeClaim {
3451 owner: format!("agent-{index:03}"),
3452 roots: vec![format!("pkg-{index:03}")],
3453 exact_files: vec![],
3454 contracts: vec![],
3455 },
3456 false,
3457 |_| true,
3458 )
3459 .unwrap();
3460 }
3461 let error = ledger
3462 .register_claim(
3463 WriteScopeClaim {
3464 owner: "agent-over-cap".into(),
3465 roots: vec!["new-package".into()],
3466 exact_files: vec![],
3467 contracts: vec![],
3468 },
3469 false,
3470 |_| true,
3471 )
3472 .expect_err("all-active capacity must fail before evicting ownership");
3473 assert!(error.contains("active owners"), "{error}");
3474 assert_eq!(ledger.write_claims.len(), COORDINATION_RECORD_LIMIT);
3475 assert!(
3476 ledger
3477 .write_claims
3478 .iter()
3479 .any(|record| record.claim.owner == "agent-000")
3480 );
3481 }
3482
3483 #[test]
3484 fn accepted_decisions_require_owner_and_explicit_neutral_reconciliation() {
3485 let mut ledger = CoordinationLedger::default();
3486 let make = |id: &str, owner: &str, status| DecisionRecord {
3487 decision_id: id.into(),
3488 subject: "storage".into(),
3489 status,
3490 owner: owner.into(),
3491 scope: vec!["router".into()],
3492 constraints: vec![],
3493 evidence_handles: vec![format!("receipt:{id}")],
3494 version: 1,
3495 sequence: 0,
3496 };
3497 ledger
3498 .record_decision(make("a", "agent-a", DecisionStatus::Accepted))
3499 .unwrap();
3500 ledger
3501 .record_decision(make("b", "agent-b", DecisionStatus::Proposed))
3502 .unwrap();
3503 let owner_error = ledger
3504 .update_decision_status("b", DecisionStatus::Accepted, "root", 2)
3505 .unwrap_err();
3506 assert!(owner_error.contains("owned by 'agent-b'"), "{owner_error}");
3507 let stale = ledger
3508 .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 1)
3509 .unwrap_err();
3510 assert!(stale.contains("expected 1, current 2"), "{stale}");
3511 let conflict = ledger
3512 .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 2)
3513 .unwrap_err();
3514 assert!(conflict.contains("neutral reconciliation"), "{conflict}");
3515 ledger
3516 .update_decision_status("a", DecisionStatus::Superseded, "agent-a", 1)
3517 .unwrap();
3518 ledger
3519 .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 2)
3520 .unwrap();
3521 let receipt = ledger
3522 .reconcile(
3523 "storage".into(),
3524 "root".into(),
3525 vec!["a".into(), "b".into()],
3526 "use bounded origin-session artifacts".into(),
3527 vec!["test:coord".into()],
3528 vec!["branch:agent-a".into(), "branch:agent-b".into()],
3529 1,
3530 3,
3531 vec!["review:independent".into()],
3532 vec!["verify:locked".into()],
3533 "verified".into(),
3534 )
3535 .unwrap();
3536 assert_eq!(receipt.input_decisions.len(), 2);
3537 assert!(receipt.sequence > ledger.decisions[1].sequence);
3538 }
3539
3540 #[test]
3541 fn relevant_decision_projection_is_deduplicated_bounded_and_receipted() {
3542 let mut ledger = CoordinationLedger::default();
3543 for (id, subject, scope) in [
3544 ("file", "file-contract", "path:src"),
3545 ("docs", "docs-contract", "path:docs"),
3546 ("api", "api-contract", "contract:public-api"),
3547 ] {
3548 ledger
3549 .record_decision(DecisionRecord {
3550 decision_id: id.into(),
3551 subject: subject.into(),
3552 status: DecisionStatus::Accepted,
3553 owner: "planner".into(),
3554 scope: vec![scope.into()],
3555 constraints: vec!["bounded".into(), "bounded".into()],
3556 evidence_handles: vec![format!("receipt:{id}")],
3557 version: 1,
3558 sequence: 0,
3559 })
3560 .unwrap();
3561 }
3562 let claim = WriteScopeClaim {
3563 owner: "worker".into(),
3564 roots: vec!["src/tui".into()],
3565 exact_files: vec![],
3566 contracts: vec!["public-api".into()],
3567 };
3568 let (projection, receipt) =
3569 ledger.project_relevant_decisions("worker", Some(&claim), &["File".into()]);
3570 assert!(projection.contains("file-contract"), "{projection}");
3571 assert!(projection.contains("api-contract"), "{projection}");
3572 assert!(!projection.contains("docs-contract"), "{projection}");
3573 assert!(projection.len() <= COORDINATION_PROJECTION_BYTE_LIMIT);
3574 assert_eq!(receipt.decision_ids, vec!["api", "file"]);
3575 assert_eq!(receipt.deduplicated, 1);
3576 assert_eq!(ledger.projections.last(), Some(&receipt));
3577 }
3578
3579 #[test]
3580 fn projection_receipt_distinguishes_unique_omissions_from_deduplication() {
3581 let mut ledger = CoordinationLedger::default();
3582 for index in 0..(COORDINATION_PROJECTION_DECISION_LIMIT + 2) {
3583 ledger
3584 .record_decision(DecisionRecord {
3585 decision_id: format!("decision-{index}"),
3586 subject: format!("subject-{index}"),
3587 status: DecisionStatus::Accepted,
3588 owner: "planner".into(),
3589 scope: vec!["path:src".into()],
3590 constraints: vec![format!("constraint-{index}")],
3591 evidence_handles: vec![format!("receipt:{index}")],
3592 version: 1,
3593 sequence: 0,
3594 })
3595 .unwrap();
3596 }
3597 let claim = WriteScopeClaim {
3598 owner: "worker".into(),
3599 roots: vec!["src".into()],
3600 exact_files: vec![],
3601 contracts: vec![],
3602 };
3603
3604 let (projection, receipt) = ledger.project_relevant_decisions("worker", Some(&claim), &[]);
3605
3606 assert!(projection.len() <= COORDINATION_PROJECTION_BYTE_LIMIT);
3607 assert_eq!(
3608 receipt.decision_ids.len(),
3609 COORDINATION_PROJECTION_DECISION_LIMIT
3610 );
3611 assert_eq!(receipt.deduplicated, 0);
3612 assert_eq!(receipt.omitted, 2);
3613 }
3614
3615 #[test]
3616 fn coordination_schema_drift_fails_closed_before_mutation() {
3617 let mut ledger = CoordinationLedger {
3618 schema_version: COORDINATION_SCHEMA_VERSION + 1,
3619 ..CoordinationLedger::default()
3620 };
3621 let error = ledger
3622 .register_claim(
3623 WriteScopeClaim {
3624 owner: "worker".into(),
3625 roots: vec!["src".into()],
3626 exact_files: vec![],
3627 contracts: vec![],
3628 },
3629 false,
3630 |_| false,
3631 )
3632 .unwrap_err();
3633 assert!(error.contains("unsupported coordination schema"), "{error}");
3634 assert!(ledger.write_claims.is_empty());
3635 }
3636 }
3637
3637 lines RUST