1use serde_json::Value;
2use std::collections::HashMap;
3use std::sync::{Arc, RwLock};
4use tokio::sync::{mpsc, oneshot};
5
6#[derive(Debug)]
9pub struct SubagentResult {
10 pub text: String,
11 pub model: String,
12 pub input_tokens: u64,
13 pub output_tokens: u64,
14 pub cache_read: u64,
15 pub cache_creation: u64,
16 pub cache_creation_5m: Option<u64>,
19 pub cache_creation_1h: Option<u64>,
20 pub tool_count: u32,
21 pub timed_out: bool,
22}
23
24#[derive(Debug, Clone, PartialEq, serde::Serialize)]
25pub struct AuthorizationDiagnostic {
26 pub selection_source: String,
27 pub policy_digest: String,
28 pub catalog_snapshot_id: String,
29 pub catalog_digest: String,
30 pub correlation_id: String,
31 pub network_attempted: bool,
32 pub cross_provider_grant_id: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, serde::Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum TerminalCategory {
40 Completed,
41 Route,
42 Credential,
43 Runtime,
44 ProviderClient,
45 Transport,
46 StartupFailed,
47 ExecutionFailed,
48 TimedOut,
49 Cancelled,
50}
51
52#[derive(Debug, Clone, PartialEq, serde::Serialize)]
53pub struct TerminalDiagnostic {
54 pub category: TerminalCategory,
55 pub code: String,
56 pub stage: String,
57 pub correlation_id: String,
58 pub network_attempted: bool,
59 pub safe_message: String,
60}
61
62pub fn safe_failure_message(category: &TerminalCategory) -> &'static str {
63 match category {
64 TerminalCategory::Route => "worker route could not be resolved",
65 TerminalCategory::Credential => "worker credentials are unavailable",
66 TerminalCategory::Runtime | TerminalCategory::StartupFailed => {
67 "worker runtime could not be started"
68 }
69 TerminalCategory::ProviderClient => "provider client could not be initialized",
70 TerminalCategory::Transport => "provider transport failed",
71 TerminalCategory::TimedOut => "worker timed out",
72 TerminalCategory::Cancelled => "worker was cancelled",
73 TerminalCategory::ExecutionFailed => "worker execution failed",
74 TerminalCategory::Completed => "worker completed",
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum SubagentStatus {
81 Running,
82 Completed,
83 Cancelled,
85 TimedOut,
86 Failed(String),
87}
88
89#[derive(Debug)]
94pub struct SubagentState {
95 pub status: SubagentStatus,
96 pub partial_text: String,
97 pub tool_log: Vec<String>,
98 pub conversation_state: Vec<Value>,
99 pub finished_at: Option<std::time::Instant>,
101 pub cancel_requested: bool,
104 pub terminal: Option<TerminalDiagnostic>,
105 pub step: String,
111 pub tools: u32,
113 pub history: Option<Vec<crate::SharedMessage>>,
117 pub last_response_start: usize,
121 pub archive_meta: Option<SubagentArchiveMeta>,
123 pub archive_dir: Option<std::path::PathBuf>,
125 pub archive_path: Option<std::path::PathBuf>,
127 pub finalization_started: bool,
129 pub finalization_ready: bool,
130 pub archive_error: Option<String>,
131}
132
133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
135pub struct SubagentArchiveMeta {
136 pub agent_name: String,
137 pub model: String,
138 pub system_prompt: String,
139 pub timeout_secs: u64,
140}
141
142impl SubagentState {
143 pub fn public_status(&self) -> SubagentStatus {
144 if (self.archive_meta.is_some() || self.finalization_started) && !self.finalization_ready {
145 SubagentStatus::Running
146 } else {
147 self.status.clone()
148 }
149 }
150
151 fn recovery_pinned(&self) -> bool {
152 self.history.is_some() && self.archive_path.is_none()
153 }
154
155 pub fn new() -> Self {
156 Self {
157 status: SubagentStatus::Running,
158 partial_text: String::new(),
159 tool_log: Vec::new(),
160 conversation_state: Vec::new(),
161 finished_at: None,
162 cancel_requested: false,
163 terminal: None,
164 step: String::new(),
165 tools: 0,
166 history: None,
167 last_response_start: 0,
168 archive_meta: None,
169 archive_dir: None,
170 archive_path: None,
171 finalization_started: false,
172 finalization_ready: false,
173 archive_error: None,
174 }
175 }
176
177 pub fn note_progress(&mut self, step: &str, tools: u32) {
179 self.step.clear();
180 self.step.push_str(step);
181 self.tools = tools;
182 }
183
184 pub fn last_response_text(&self) -> &str {
186 let start = self.last_response_start.min(self.partial_text.len());
187 let mut start = start;
190 while !self.partial_text.is_char_boundary(start) {
191 start -= 1;
192 }
193 &self.partial_text[start..]
194 }
195
196 pub fn safe_fail(&mut self, mut diagnostic: TerminalDiagnostic) {
198 diagnostic.safe_message = safe_failure_message(&diagnostic.category).into();
199 self.status = SubagentStatus::Failed(diagnostic.safe_message.clone());
200 self.terminal = Some(diagnostic);
201 }
202}
203
204impl Default for SubagentState {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
216pub struct SubagentDisplayRow {
217 pub subagent_id: u64,
218 pub agent_name: String,
219 pub status: SubagentStatus,
220 pub cancel_requested: bool,
221 pub elapsed_secs: f64,
222 pub finished_elapsed: Option<std::time::Duration>,
223 #[serde(default)]
226 pub step: String,
227 #[serde(default)]
229 pub tools: u32,
230}
231
232pub struct SubagentHandle {
235 pub id: String,
236 pub subagent_id: u64,
239 pub agent_name: String,
240 pub task_preview: String,
241 pub model: String,
242 pub system_prompt: String,
243 pub started_at: std::time::Instant,
244 pub timeout_secs: u64,
245 pub authorization: Option<AuthorizationDiagnostic>,
246
247 pub(crate) state: Arc<RwLock<SubagentState>>,
249
250 steer_tx: Option<mpsc::UnboundedSender<String>>,
252 shutdown_tx: Option<oneshot::Sender<()>>,
253 pub(crate) thread_handle: Option<std::thread::JoinHandle<()>>,
256
257 result_rx: Option<oneshot::Receiver<SubagentResult>>,
259
260 collected: bool,
262}
263
264impl std::fmt::Debug for SubagentHandle {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 f.debug_struct("SubagentHandle")
267 .field("id", &self.id)
268 .field("subagent_id", &self.subagent_id)
269 .field("agent_name", &self.agent_name)
270 .field("model", &self.model)
271 .finish_non_exhaustive()
272 }
273}
274
275impl SubagentHandle {
276 #[allow(clippy::too_many_arguments)]
278 pub fn new(
279 id: String,
280 subagent_id: u64,
281 agent_name: String,
282 task_preview: String,
283 model: String,
284 system_prompt: String,
285 timeout_secs: u64,
286 state: Arc<RwLock<SubagentState>>,
287 steer_tx: Option<mpsc::UnboundedSender<String>>,
288 shutdown_tx: Option<oneshot::Sender<()>>,
289 result_rx: Option<oneshot::Receiver<SubagentResult>>,
290 ) -> Self {
291 Self {
292 id,
293 subagent_id,
294 agent_name,
295 task_preview,
296 model,
297 system_prompt,
298 started_at: std::time::Instant::now(),
299 timeout_secs,
300 authorization: None,
301 state,
302 steer_tx,
303 shutdown_tx,
304 thread_handle: None,
305 result_rx,
306 collected: false,
307 }
308 }
309
310 pub fn with_authorization(
311 mut self,
312 decision: &crate::orchestration::AuthorizedWorkerModel,
313 ) -> Self {
314 self.authorization = Some(AuthorizationDiagnostic {
315 selection_source: decision.selection_source.as_str().into(),
316 policy_digest: decision.policy_digest.clone(),
317 catalog_snapshot_id: decision.catalog_snapshot_id.clone(),
318 catalog_digest: decision.catalog_digest.clone(),
319 correlation_id: decision.correlation_id.clone(),
320 network_attempted: decision.network_attempted,
321 cross_provider_grant_id: decision.cross_provider_grant_id.clone(),
322 });
323 self
324 }
325
326 pub fn set_terminal_diagnostic(&self, diagnostic: TerminalDiagnostic) {
327 let mut state = self.state.write().unwrap();
328 state.safe_fail(diagnostic);
329 }
330
331 pub fn status(&self) -> SubagentStatus {
333 self.state
334 .read()
335 .unwrap_or_else(|p| p.into_inner())
336 .public_status()
337 }
338
339 pub fn partial_output(&self) -> String {
341 self.state
342 .read()
343 .unwrap_or_else(|p| p.into_inner())
344 .partial_text
345 .clone()
346 }
347
348 pub fn tool_log(&self) -> Vec<String> {
350 self.state
351 .read()
352 .unwrap_or_else(|p| p.into_inner())
353 .tool_log
354 .clone()
355 }
356
357 pub fn archive_error(&self) -> Option<String> {
358 self.state
359 .read()
360 .unwrap_or_else(|p| p.into_inner())
361 .archive_error
362 .clone()
363 }
364
365 pub fn terminal_diagnostic(&self) -> Option<TerminalDiagnostic> {
366 if self.status() == SubagentStatus::Running {
367 return None;
368 }
369 if let Some(diagnostic) = self.state.read().unwrap().terminal.clone() {
370 return Some(diagnostic);
371 }
372 match self.status() {
373 SubagentStatus::Running => None,
374 SubagentStatus::Completed => Some(TerminalDiagnostic {
375 category: TerminalCategory::Completed,
376 code: "completed".into(),
377 stage: "inference".into(),
378 correlation_id: self.id.clone(),
379 network_attempted: true,
380 safe_message: safe_failure_message(&TerminalCategory::Completed).into(),
381 }),
382 SubagentStatus::TimedOut => Some(TerminalDiagnostic {
383 category: TerminalCategory::TimedOut,
384 code: "worker_timeout".into(),
385 stage: "inference".into(),
386 correlation_id: self.id.clone(),
387 network_attempted: true,
388 safe_message: safe_failure_message(&TerminalCategory::TimedOut).into(),
389 }),
390 SubagentStatus::Cancelled => Some(TerminalDiagnostic {
391 category: TerminalCategory::Cancelled,
392 code: "worker_cancelled".into(),
393 stage: "inference".into(),
394 correlation_id: self.id.clone(),
395 network_attempted: true,
396 safe_message: safe_failure_message(&TerminalCategory::Cancelled).into(),
397 }),
398 SubagentStatus::Failed(_) => Some(TerminalDiagnostic {
399 category: TerminalCategory::ExecutionFailed,
400 code: "inference_failed".into(),
401 stage: "inference".into(),
402 correlation_id: self.id.clone(),
403 network_attempted: true,
404 safe_message: safe_failure_message(&TerminalCategory::ExecutionFailed).into(),
405 }),
406 }
407 }
408
409 pub fn conversation_state(&self) -> Vec<Value> {
411 self.state
412 .read()
413 .unwrap_or_else(|p| p.into_inner())
414 .conversation_state
415 .clone()
416 }
417
418 pub fn elapsed_secs(&self) -> f64 {
420 self.started_at.elapsed().as_secs_f64()
421 }
422
423 fn make_tombstone(&mut self) {
426 debug_assert!(self.is_finished());
427 if self
428 .state
429 .read()
430 .unwrap_or_else(|p| p.into_inner())
431 .recovery_pinned()
432 {
433 return;
434 }
435 self.steer_tx.take();
436 self.shutdown_tx.take();
437 self.result_rx.take();
438 if let Some(thread) = self.thread_handle.take() {
439 let _ = thread.join();
440 }
441 let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
442 if state.partial_text.len() > TOMBSTONE_OUTPUT_MAX_BYTES {
443 let mut start = state.partial_text.len() - TOMBSTONE_OUTPUT_MAX_BYTES;
444 while !state.partial_text.is_char_boundary(start) {
445 start += 1;
446 }
447 state.partial_text.drain(..start);
448 }
449 state.tool_log.clear();
450 state.conversation_state.clear();
451 state.history = None;
452 }
453
454 pub fn steer(&self, message: &str) -> Result<(), String> {
456 match &self.steer_tx {
457 Some(tx) => tx
458 .send(message.to_string())
459 .map_err(|e| format!("steer channel closed: {e}")),
460 None => Err("no steer channel on this handle".to_string()),
461 }
462 }
463
464 pub fn set_thread_handle(&mut self, handle: std::thread::JoinHandle<()>) {
467 self.thread_handle = Some(handle);
468 }
469
470 pub fn cancel(&mut self) {
471 self.state
474 .write()
475 .unwrap_or_else(|p| p.into_inner())
476 .cancel_requested = true;
477 if let Some(tx) = self.shutdown_tx.take() {
478 let _ = tx.send(());
479 }
480 }
481
482 pub fn is_finished(&self) -> bool {
484 !matches!(self.status(), SubagentStatus::Running)
485 }
486
487 pub fn mark_collected(&mut self) {
489 self.collected = true;
490 }
491
492 pub fn is_collected(&self) -> bool {
494 self.collected
495 }
496
497 pub fn is_tombstone(&self) -> bool {
499 self.is_finished()
500 && self.steer_tx.is_none()
501 && self.shutdown_tx.is_none()
502 && self.result_rx.is_none()
503 && self.thread_handle.is_none()
504 }
505
506 pub fn finished_elapsed(&self) -> Option<std::time::Duration> {
509 self.state
510 .read()
511 .unwrap_or_else(|p| p.into_inner())
512 .finished_at
513 .map(|t| t.elapsed())
514 }
515
516 pub async fn collect(mut self) -> Result<SubagentResult, String> {
518 match self.result_rx.take() {
519 Some(rx) => rx
520 .await
521 .map_err(|_| "subagent result channel dropped".to_string()),
522 None => Err("no result receiver — already collected or never set".to_string()),
523 }
524 }
525}
526
527pub const FINISHED_HANDLE_TTL: std::time::Duration = std::time::Duration::from_secs(900); pub const TOMBSTONE_OUTPUT_MAX_BYTES: usize = 32 * 1024;
533
534#[derive(Debug)]
535pub struct SubagentRegistry {
536 pub(crate) handles: HashMap<String, SubagentHandle>,
537 spawn_cancellation: Option<crate::CancellationToken>,
541 spawn_epoch: u64,
542 archived: HashMap<String, std::path::PathBuf>,
545}
546
547impl SubagentRegistry {
548 pub fn new() -> Self {
549 Self {
550 handles: HashMap::new(),
551 spawn_cancellation: None,
552 spawn_epoch: 0,
553 archived: HashMap::new(),
554 }
555 }
556
557 pub fn archive_path(&self, id: &str) -> Option<std::path::PathBuf> {
559 if let Some(handle) = self.handles.get(id) {
560 if let Some(path) = handle
561 .state
562 .read()
563 .unwrap_or_else(|p| p.into_inner())
564 .archive_path
565 .clone()
566 {
567 return Some(path);
568 }
569 }
570 self.archived.get(id).cloned()
571 }
572
573 pub fn register(&mut self, handle: SubagentHandle) -> String {
575 self.register_with_cancellation(handle, None)
576 }
577
578 pub fn register_with_cancellation(
579 &mut self,
580 mut handle: SubagentHandle,
581 origin: Option<&crate::CancellationToken>,
582 ) -> String {
583 if origin.is_some_and(|c| c.is_cancelled())
584 || self
585 .spawn_cancellation
586 .as_ref()
587 .is_some_and(|c| c.is_cancelled())
588 {
589 handle.cancel();
590 }
591 let id = handle.id.clone();
592 self.handles.insert(id.clone(), handle);
593 id
594 }
595
596 pub fn set_spawn_cancellation(&mut self, cancel: Option<crate::CancellationToken>) -> u64 {
598 self.spawn_epoch = self
599 .spawn_epoch
600 .checked_add(1)
601 .expect("worker launch epoch exhausted");
602 self.spawn_cancellation = cancel;
603 self.spawn_epoch
604 }
605
606 pub fn cancel_spawn_epoch(&mut self, epoch: u64) {
608 if self.spawn_epoch == epoch {
609 for handle in self.iter_mut_handles() {
610 handle.cancel();
611 }
612 }
613 }
614
615 pub fn get(&self, id: &str) -> Option<&SubagentHandle> {
616 self.handles.get(id)
617 }
618
619 pub fn get_mut(&mut self, id: &str) -> Option<&mut SubagentHandle> {
620 self.handles.get_mut(id)
621 }
622
623 pub fn remove(&mut self, id: &str) -> Option<SubagentHandle> {
624 self.handles.remove(id)
625 }
626
627 pub fn release_finished_resources(&mut self, id: &str) {
630 if let Some(handle) = self.handles.get_mut(id) {
631 if handle.is_finished() {
632 handle.make_tombstone();
633 }
634 }
635 }
636
637 pub fn list_active(&self) -> Vec<(String, String, SubagentStatus)> {
639 self.handles
640 .values()
641 .map(|h| (h.id.clone(), h.agent_name.clone(), h.status()))
642 .collect()
643 }
644
645 pub fn display_rows(&self) -> Vec<SubagentDisplayRow> {
649 self.handles
650 .values()
651 .map(|h| {
652 let s = h.state.read().unwrap_or_else(|p| p.into_inner());
653 SubagentDisplayRow {
654 subagent_id: h.subagent_id,
655 agent_name: h.agent_name.clone(),
656 status: s.public_status(),
657 cancel_requested: s.cancel_requested,
658 elapsed_secs: h.started_at.elapsed().as_secs_f64(),
659 finished_elapsed: s.finished_at.map(|t| t.elapsed()),
660 step: s.step.clone(),
661 tools: s.tools,
662 }
663 })
664 .collect()
665 }
666
667 pub fn iter_mut_handles(&mut self) -> impl Iterator<Item = &mut SubagentHandle> {
670 self.handles.values_mut()
671 }
672
673 pub fn cleanup_finished_with_ttl(&mut self, ttl: std::time::Duration) {
685 self.cleanup_finished_with_ttl_and_retention(ttl, |_| false);
686 }
687
688 pub fn cleanup_finished_with_ttl_and_retention(
691 &mut self,
692 ttl: std::time::Duration,
693 retain_unreconciled: impl Fn(&str) -> bool,
694 ) {
695 let candidates: Vec<(String, bool)> = self
696 .handles
697 .iter()
698 .filter(|(_, h)| {
699 h.is_finished()
700 && !h
701 .state
702 .read()
703 .unwrap_or_else(|p| p.into_inner())
704 .recovery_pinned()
705 && h.thread_handle.as_ref().map_or(true, |t| t.is_finished())
706 && (h.is_collected() || h.finished_elapsed().is_some_and(|d| d >= ttl))
707 })
708 .map(|(id, h)| (id.clone(), !h.is_collected() && retain_unreconciled(id)))
709 .collect();
710 for (id, retain) in candidates {
711 if retain {
712 self.release_finished_resources(&id);
713 } else if let Some(mut handle) = self.handles.remove(&id) {
714 if let Some(path) = handle
715 .state
716 .read()
717 .unwrap_or_else(|p| p.into_inner())
718 .archive_path
719 .clone()
720 {
721 self.archived.insert(id.clone(), path);
722 }
723 if let Some(th) = handle.thread_handle.take() {
724 let _ = th.join();
725 }
726 }
727 }
728 }
729
730 pub fn cleanup_finished(&mut self) {
733 self.cleanup_finished_with_ttl(FINISHED_HANDLE_TTL)
734 }
735}
736
737pub(crate) fn reap_finished_with_ttl(
744 registry: &Arc<std::sync::Mutex<SubagentRegistry>>,
745 orchestration: Option<&crate::orchestration::OrchestrationRuntime>,
746 ttl: std::time::Duration,
747) {
748 let retained = orchestration
749 .map(|runtime| runtime.unreconciled_runtime_handles())
750 .unwrap_or_default();
751 let cleanup = |guard: &mut SubagentRegistry| {
752 let before = guard
753 .handles
754 .keys()
755 .cloned()
756 .collect::<std::collections::HashSet<_>>();
757 guard.cleanup_finished_with_ttl_and_retention(ttl, |id| retained.contains(id));
758 before
759 .into_iter()
760 .filter(|id| !guard.handles.contains_key(id))
761 .collect::<std::collections::HashSet<_>>()
762 };
763 let removed = match registry.lock() {
766 Ok(mut guard) => cleanup(&mut guard),
767 Err(poisoned) => cleanup(&mut poisoned.into_inner()),
768 };
769 if let Some(orchestration) = orchestration {
770 orchestration.retire_reconciled(&removed);
771 }
772}
773
774pub fn reap_finished(
775 registry: &Arc<std::sync::Mutex<SubagentRegistry>>,
776 orchestration: Option<&crate::orchestration::OrchestrationRuntime>,
777) {
778 reap_finished_with_ttl(registry, orchestration, FINISHED_HANDLE_TTL);
779}
780
781impl Default for SubagentRegistry {
782 fn default() -> Self {
783 Self::new()
784 }
785}
786
787impl SubagentStatus {
788 pub fn as_str(&self) -> &str {
789 match self {
790 SubagentStatus::Running => "running",
791 SubagentStatus::Completed => "completed",
792 SubagentStatus::Cancelled => "cancelled",
793 SubagentStatus::TimedOut => "timed_out",
794 SubagentStatus::Failed(_) => "failed",
795 }
796 }
797
798 pub fn failure_reason(&self) -> Option<&str> {
800 match self {
801 SubagentStatus::Failed(r) => Some(r.as_str()),
802 _ => None,
803 }
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810 use tokio::sync::{mpsc, oneshot};
811
812 struct TestHandle {
814 handle: SubagentHandle,
815 _steer_rx: mpsc::UnboundedReceiver<String>,
816 _shutdown_rx: oneshot::Receiver<()>,
817 }
818
819 fn make_test_handle(id: &str) -> TestHandle {
820 let state = Arc::new(RwLock::new(SubagentState::new()));
821 let (steer_tx, steer_rx) = mpsc::unbounded_channel();
822 let (shutdown_tx, shutdown_rx) = oneshot::channel();
823 let (_result_tx, result_rx) = oneshot::channel();
824 let numeric_id: u64 = id
826 .strip_prefix("sa_")
827 .and_then(|n| n.parse().ok())
828 .unwrap_or(0);
829 TestHandle {
830 handle: SubagentHandle::new(
831 id.to_string(),
832 numeric_id,
833 "test-agent".to_string(),
834 "test task".to_string(),
835 "claude-sonnet-4-6".to_string(),
836 "You are a test agent.".to_string(),
837 300,
838 state,
839 Some(steer_tx),
840 Some(shutdown_tx),
841 Some(result_rx),
842 ),
843 _steer_rx: steer_rx,
844 _shutdown_rx: shutdown_rx,
845 }
846 }
847
848 fn make_handle(id: &str) -> SubagentHandle {
849 make_test_handle(id).handle
850 }
851
852 fn make_finished_handle(id: &str) -> SubagentHandle {
853 let h = make_handle(id);
854 {
855 let mut s = h.state.write().unwrap();
856 s.status = SubagentStatus::Completed;
857 s.finished_at = Some(std::time::Instant::now());
858 }
859 h
860 }
861
862 #[test]
865 fn display_rows_carry_the_live_step_and_tool_count() {
866 let mut registry = SubagentRegistry::new();
867 let h = make_handle("sa_7");
868 let state = Arc::clone(&h.state);
869 registry.register(h);
870 let row = |r: &SubagentRegistry| r.display_rows().into_iter().next().unwrap();
871 assert_eq!(row(®istry).step, "", "no progress yet");
872 assert_eq!(row(®istry).tools, 0);
873
874 state
875 .write()
876 .unwrap()
877 .note_progress("\u{2699} bash (tool #1)", 1);
878 state.write().unwrap().note_progress("$ sleep 20", 1);
879 let r = row(®istry);
880 assert_eq!(r.subagent_id, 7);
881 assert_eq!((r.step.as_str(), r.tools), ("$ sleep 20", 1));
882 }
883
884 #[test]
886 fn display_row_without_progress_fields_deserializes() {
887 let old = r#"{"subagent_id":3,"agent_name":"a","status":"running",
888 "cancel_requested":false,"elapsed_secs":1.0,"finished_elapsed":null}"#;
889 let row: SubagentDisplayRow = serde_json::from_str(old).expect("old row");
890 assert_eq!((row.step.as_str(), row.tools), ("", 0));
891 }
892
893 #[test]
894 fn typed_failure_drops_raw_provider_secret_canary() {
895 let mut state = SubagentState::new();
896 let canary = "Bearer secret-canary-provider-body";
897 state.safe_fail(TerminalDiagnostic {
898 category: TerminalCategory::Credential,
899 code: "credential_unavailable".into(),
900 stage: "credential".into(),
901 correlation_id: "sa_safe".into(),
902 network_attempted: false,
903 safe_message: canary.into(),
904 });
905 let rendered = format!("{:?}{:?}", state.status, state.terminal);
906 assert!(!rendered.contains(canary));
907 assert!(rendered.contains("credential_unavailable"));
908 }
909
910 #[test]
911 fn late_registration_cannot_inherit_new_turn_and_stale_sweep_cannot_cancel_it() {
912 let mut registry = SubagentRegistry::new();
913 let old = crate::CancellationToken::new();
914 let epoch = registry.set_spawn_cancellation(Some(old.clone()));
915 old.cancel();
916 registry.cancel_spawn_epoch(epoch);
917 registry.set_spawn_cancellation(None);
919 registry.register(make_handle("new-user"));
920 registry.register_with_cancellation(make_handle("old-late"), Some(&old));
921 assert!(
922 registry
923 .get("old-late")
924 .unwrap()
925 .state
926 .read()
927 .unwrap()
928 .cancel_requested
929 );
930 assert!(
931 !registry
932 .get("new-user")
933 .unwrap()
934 .state
935 .read()
936 .unwrap()
937 .cancel_requested
938 );
939 registry.cancel_spawn_epoch(epoch);
940 assert!(
941 !registry
942 .get("new-user")
943 .unwrap()
944 .state
945 .read()
946 .unwrap()
947 .cancel_requested
948 );
949 let new_grant = crate::CancellationToken::new();
950 let next = registry.set_spawn_cancellation(Some(new_grant));
951 registry.register(make_handle("new-grant"));
952 registry.register_with_cancellation(make_handle("old-later"), Some(&old));
953 registry.cancel_spawn_epoch(epoch);
954 assert!(
955 !registry
956 .get("new-grant")
957 .unwrap()
958 .state
959 .read()
960 .unwrap()
961 .cancel_requested
962 );
963 assert!(
964 registry
965 .get("old-later")
966 .unwrap()
967 .state
968 .read()
969 .unwrap()
970 .cancel_requested
971 );
972 registry.cancel_spawn_epoch(next);
973 assert!(
974 registry
975 .get("new-grant")
976 .unwrap()
977 .state
978 .read()
979 .unwrap()
980 .cancel_requested
981 );
982 }
983
984 #[test]
985 fn revoked_launch_fence_cancels_late_registration_until_user_takeover() {
986 let mut registry = SubagentRegistry::new();
987 let cancel = crate::CancellationToken::new();
988 registry.set_spawn_cancellation(Some(cancel.clone()));
989 registry.register(make_handle("before"));
990 assert!(!registry.display_rows()[0].cancel_requested);
991 cancel.cancel();
992 let late = make_test_handle("late");
993 let mut shutdown = late._shutdown_rx;
994 registry.register(late.handle);
995 assert!(shutdown.try_recv().is_ok());
996 assert!(
997 registry
998 .get("late")
999 .unwrap()
1000 .state
1001 .read()
1002 .unwrap()
1003 .cancel_requested
1004 );
1005 registry.set_spawn_cancellation(None);
1006 registry.register(make_handle("user"));
1007 assert!(
1008 !registry
1009 .get("user")
1010 .unwrap()
1011 .state
1012 .read()
1013 .unwrap()
1014 .cancel_requested
1015 );
1016 }
1017
1018 #[test]
1019 fn handle_initial_status_is_running() {
1020 let h = make_handle("sa_1");
1021 assert_eq!(h.status(), SubagentStatus::Running);
1022 assert!(!h.is_finished());
1023 }
1024
1025 #[test]
1026 fn handle_partial_output_empty_initially() {
1027 let h = make_handle("sa_1");
1028 assert_eq!(h.partial_output(), "");
1029 assert!(h.tool_log().is_empty());
1030 assert!(h.conversation_state().is_empty());
1031 }
1032
1033 #[test]
1034 fn handle_status_reflects_state_change() {
1035 let h = make_handle("sa_1");
1036 {
1037 let mut s = h.state.write().unwrap();
1038 s.status = SubagentStatus::Completed;
1039 s.partial_text = "done!".to_string();
1040 }
1041 assert_eq!(h.status(), SubagentStatus::Completed);
1042 assert!(h.is_finished());
1043 assert_eq!(h.partial_output(), "done!");
1044 }
1045
1046 #[test]
1047 fn handle_steer_sends_message() {
1048 let th = make_test_handle("sa_1");
1049 assert!(th.handle.steer("redirect").is_ok());
1050 }
1051
1052 #[test]
1053 fn handle_steer_fails_without_channel() {
1054 let state = Arc::new(RwLock::new(SubagentState::new()));
1055 let (_shutdown_tx, _) = oneshot::channel::<()>();
1056 let (_, result_rx) = oneshot::channel();
1057 let h = SubagentHandle::new(
1058 "sa_1".into(),
1059 1,
1060 "test".into(),
1061 "task".into(),
1062 "model".into(),
1063 "prompt".into(),
1064 300,
1065 state,
1066 None,
1067 None,
1068 Some(result_rx),
1069 );
1070 assert!(h.steer("msg").is_err());
1071 }
1072
1073 #[test]
1074 fn handle_cancel_consumes_shutdown() {
1075 let mut h = make_handle("sa_1");
1076 h.cancel(); h.cancel(); }
1079
1080 #[test]
1081 fn handle_elapsed_increases() {
1082 let h = make_handle("sa_1");
1083 std::thread::sleep(std::time::Duration::from_millis(10));
1084 assert!(h.elapsed_secs() > 0.0);
1085 }
1086
1087 #[test]
1088 fn registry_register_and_get() {
1089 let mut reg = SubagentRegistry::new();
1090 let h = make_handle("sa_1");
1091 reg.register(h);
1092 assert!(reg.get("sa_1").is_some());
1093 assert!(reg.get("sa_99").is_none());
1094 }
1095
1096 #[test]
1097 fn registry_remove() {
1098 let mut reg = SubagentRegistry::new();
1099 reg.register(make_handle("sa_1"));
1100 assert!(reg.remove("sa_1").is_some());
1101 assert!(reg.get("sa_1").is_none());
1102 }
1103
1104 #[test]
1105 fn registry_list_active() {
1106 let mut reg = SubagentRegistry::new();
1107 reg.register(make_handle("sa_1"));
1108 reg.register(make_handle("sa_2"));
1109 let active = reg.list_active();
1110 assert_eq!(active.len(), 2);
1111 }
1112
1113 #[test]
1116 fn registry_cleanup_finished() {
1117 let mut reg = SubagentRegistry::new();
1118 let mut h = make_finished_handle("sa_1");
1119 h.mark_collected(); reg.register(h);
1121 reg.register(make_handle("sa_2")); reg.cleanup_finished();
1124
1125 assert!(
1126 reg.get("sa_1").is_none(),
1127 "collected finished handle must be reaped"
1128 );
1129 assert!(
1130 reg.get("sa_2").is_some(),
1131 "running handle must survive cleanup"
1132 );
1133 }
1134
1135 #[test]
1137 fn reaper_retains_finished_uncollected_within_ttl() {
1138 let mut reg = SubagentRegistry::new();
1139 let h = make_finished_handle("sa_1"); reg.register(h);
1141
1142 reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
1143
1144 assert!(
1145 reg.get("sa_1").is_some(),
1146 "finished-uncollected handle within TTL must be retained"
1147 );
1148 }
1149
1150 #[test]
1152 fn reaper_reaps_finished_and_collected() {
1153 let mut reg = SubagentRegistry::new();
1154 let mut h = make_finished_handle("sa_1");
1155 h.mark_collected();
1156 reg.register(h);
1157
1158 reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
1159
1160 assert!(
1161 reg.get("sa_1").is_none(),
1162 "finished+collected handle must be reaped"
1163 );
1164 }
1165
1166 #[test]
1169 fn reaper_tombstones_unreconciled_after_ttl() {
1170 let mut reg = SubagentRegistry::new();
1171 let h = make_finished_handle("sa_1"); h.state.write().unwrap().partial_text = "🦀".repeat(25_000);
1173 reg.register(h);
1174
1175 reg.cleanup_finished_with_ttl_and_retention(std::time::Duration::ZERO, |_| true);
1176
1177 let h = reg
1178 .get("sa_1")
1179 .expect("gate-relevant handle must remain collectible");
1180 assert!(h.is_tombstone(), "expired handle must release transports");
1181 assert!(h.partial_output().len() <= TOMBSTONE_OUTPUT_MAX_BYTES);
1182 }
1183
1184 #[test]
1185 fn reaper_reaps_abandoned_after_ttl_without_orchestration() {
1186 let mut reg = SubagentRegistry::new();
1187 reg.register(make_finished_handle("sa_1"));
1188
1189 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1190
1191 assert!(reg.get("sa_1").is_none());
1192 }
1193
1194 #[test]
1197 fn reaper_defers_handle_with_live_thread() {
1198 let mut reg = SubagentRegistry::new();
1199 let mut h = make_finished_handle("sa_live");
1200 h.mark_collected(); let barrier = Arc::new(std::sync::Barrier::new(2));
1204 let barrier_c = Arc::clone(&barrier);
1205 let thread = std::thread::spawn(move || {
1206 barrier_c.wait();
1208 std::thread::sleep(std::time::Duration::from_millis(20));
1210 });
1211 h.set_thread_handle(thread);
1212 reg.register(h);
1213
1214 barrier.wait();
1216
1217 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1223 std::thread::sleep(std::time::Duration::from_millis(50));
1228
1229 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1231 assert!(
1232 reg.get("sa_live").is_none(),
1233 "handle must be reaped once OS thread exits"
1234 );
1235 }
1236
1237 #[test]
1239 fn reaper_never_touches_running() {
1240 let mut reg = SubagentRegistry::new();
1241 reg.register(make_handle("sa_1")); reg.register(make_handle("sa_2")); reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1245 assert!(reg.get("sa_1").is_some());
1246 assert!(reg.get("sa_2").is_some());
1247
1248 reg.cleanup_finished(); assert!(reg.get("sa_1").is_some());
1250 assert!(reg.get("sa_2").is_some());
1251 }
1252
1253 #[test]
1254 fn subagent_state_new_defaults() {
1255 let s = SubagentState::new();
1256 assert_eq!(s.status, SubagentStatus::Running);
1257 assert!(s.partial_text.is_empty());
1258 assert!(s.tool_log.is_empty());
1259 assert!(s.conversation_state.is_empty());
1260 assert!(s.finished_at.is_none());
1261 assert!(
1262 !s.cancel_requested,
1263 "cancel_requested must default to false"
1264 );
1265 }
1266
1267 #[test]
1268 fn subagent_status_as_str() {
1269 assert_eq!(SubagentStatus::Running.as_str(), "running");
1270 assert_eq!(SubagentStatus::Completed.as_str(), "completed");
1271 assert_eq!(SubagentStatus::Cancelled.as_str(), "cancelled");
1272 assert_eq!(SubagentStatus::TimedOut.as_str(), "timed_out");
1273 assert_eq!(SubagentStatus::Failed("oops".into()).as_str(), "failed");
1274 }
1275
1276 #[test]
1278 fn cancel_sets_cancel_requested_flag() {
1279 let mut h = make_handle("sa_cancel");
1280 {
1281 let s = h.state.read().unwrap();
1282 assert!(!s.cancel_requested, "cancel_requested must start false");
1283 }
1284 h.cancel();
1285 {
1286 let s = h.state.read().unwrap();
1287 assert!(
1288 s.cancel_requested,
1289 "cancel_requested must be true after cancel()"
1290 );
1291 }
1292 h.cancel();
1294 }
1295
1296 #[test]
1298 fn cancelled_status_is_finished() {
1299 let h = make_handle("sa_c1");
1300 {
1301 let mut s = h.state.write().unwrap();
1302 s.status = SubagentStatus::Cancelled;
1303 }
1304 assert!(h.is_finished(), "Cancelled must count as finished");
1305 }
1306
1307 #[test]
1309 fn display_rows_running_handle() {
1310 let mut reg = SubagentRegistry::new();
1311 reg.register(make_handle("sa_1"));
1312 reg.register(make_handle("sa_2"));
1313 let rows = reg.display_rows();
1314 assert_eq!(rows.len(), 2);
1315 for row in &rows {
1316 assert_eq!(row.status, SubagentStatus::Running);
1317 assert!(!row.cancel_requested);
1318 assert!(row.elapsed_secs >= 0.0);
1319 assert!(row.finished_elapsed.is_none());
1320 }
1321 }
1322
1323 #[test]
1325 fn display_rows_finished_and_cancel_requested() {
1326 let mut reg = SubagentRegistry::new();
1327 let h = make_finished_handle("sa_1");
1328 reg.register(h);
1329
1330 let th = make_test_handle("sa_2");
1332 {
1333 let mut s = th.handle.state.write().unwrap();
1334 s.cancel_requested = true;
1335 }
1336 reg.register(th.handle);
1337
1338 let rows = reg.display_rows();
1339 assert_eq!(rows.len(), 2);
1340
1341 let finished = rows.iter().find(|r| r.subagent_id == 1).unwrap();
1342 assert_eq!(finished.status, SubagentStatus::Completed);
1343 assert!(!finished.cancel_requested);
1344 assert!(finished.finished_elapsed.is_some());
1345
1346 let cancelling = rows.iter().find(|r| r.subagent_id == 2).unwrap();
1347 assert!(cancelling.cancel_requested);
1348 }
1349
1350 #[test]
1352 fn display_rows_empty_registry() {
1353 let reg = SubagentRegistry::new();
1354 assert!(reg.display_rows().is_empty());
1355 }
1356
1357 #[test]
1361 fn reap_finished_headless_reaps_collected() {
1362 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1363 {
1364 let mut reg = registry.lock().unwrap();
1365 let mut h = make_finished_handle("sa_r1");
1366 h.mark_collected();
1367 reg.register(h);
1368 }
1369 super::reap_finished(®istry, None);
1370 assert!(
1371 registry.lock().unwrap().get("sa_r1").is_none(),
1372 "reap_finished must reap collected+finished handle"
1373 );
1374 }
1375
1376 #[test]
1378 fn reap_finished_headless_retains_running() {
1379 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1380 {
1381 let mut reg = registry.lock().unwrap();
1382 reg.register(make_handle("sa_running"));
1383 }
1384 super::reap_finished(®istry, None);
1385 assert!(
1386 registry.lock().unwrap().get("sa_running").is_some(),
1387 "reap_finished must not touch running handles"
1388 );
1389 }
1390
1391 #[test]
1393 fn reap_finished_poisoned_lock_recovery() {
1394 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1395 {
1396 let mut reg = registry.lock().unwrap();
1397 let mut h = make_finished_handle("sa_poison");
1398 h.mark_collected();
1399 reg.register(h);
1400 }
1401
1402 let registry_c = Arc::clone(®istry);
1404 let _ = std::panic::catch_unwind(|| {
1405 let _guard = registry_c.lock().unwrap();
1406 panic!("deliberately poison the mutex");
1407 });
1408
1409 super::reap_finished(®istry, None);
1411
1412 let result = registry
1414 .lock()
1415 .unwrap_or_else(|p| p.into_inner())
1416 .get("sa_poison")
1417 .is_none();
1418 assert!(result, "reap_finished must run cleanup even after poison");
1419 }
1420}
1421
1422#[cfg(test)]
1424mod cancelled_wake_tests {
1425 use super::*;
1426 use std::sync::{Arc, RwLock};
1427
1428 #[test]
1432 fn cancelled_suppresses_wake() {
1433 use crate::events::EventQueue;
1434 use crate::tools::finalize_subagent;
1435
1436 let state = Arc::new(RwLock::new(SubagentState::new()));
1437 {
1438 let mut s = state.write().unwrap();
1439 s.status = SubagentStatus::Completed; s.cancel_requested = true; }
1442 let queue = Arc::new(EventQueue::new(100));
1443
1444 finalize_subagent(
1445 &state,
1446 Some(&queue),
1447 "sa_v3",
1448 42,
1449 "test-agent",
1450 std::time::Instant::now(),
1451 None,
1452 );
1453
1454 assert!(
1456 queue.is_empty(),
1457 "cancelled subagent must not publish a wake event"
1458 );
1459
1460 let s = state.read().unwrap();
1462 assert_eq!(
1463 s.status,
1464 SubagentStatus::Cancelled,
1465 "status must be Cancelled when cancel_requested"
1466 );
1467
1468 assert!(
1470 s.finished_at.is_some(),
1471 "finished_at must be stamped by finalizer"
1472 );
1473 }
1474}