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)]
79pub enum SubagentStatus {
80 Running,
81 Completed,
82 Cancelled,
84 TimedOut,
85 Failed(String),
86}
87
88#[derive(Debug)]
93pub struct SubagentState {
94 pub status: SubagentStatus,
95 pub partial_text: String,
96 pub tool_log: Vec<String>,
97 pub conversation_state: Vec<Value>,
98 pub finished_at: Option<std::time::Instant>,
100 pub cancel_requested: bool,
103 pub terminal: Option<TerminalDiagnostic>,
104}
105
106impl SubagentState {
107 pub fn new() -> Self {
108 Self {
109 status: SubagentStatus::Running,
110 partial_text: String::new(),
111 tool_log: Vec::new(),
112 conversation_state: Vec::new(),
113 finished_at: None,
114 cancel_requested: false,
115 terminal: None,
116 }
117 }
118
119 pub fn safe_fail(&mut self, mut diagnostic: TerminalDiagnostic) {
121 diagnostic.safe_message = safe_failure_message(&diagnostic.category).into();
122 self.status = SubagentStatus::Failed(diagnostic.safe_message.clone());
123 self.terminal = Some(diagnostic);
124 }
125}
126
127impl Default for SubagentState {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133#[derive(Debug, Clone)]
138pub struct SubagentDisplayRow {
139 pub subagent_id: u64,
140 pub agent_name: String,
141 pub status: SubagentStatus,
142 pub cancel_requested: bool,
143 pub elapsed_secs: f64,
144 pub finished_elapsed: Option<std::time::Duration>,
145}
146
147pub struct SubagentHandle {
150 pub id: String,
151 pub subagent_id: u64,
154 pub agent_name: String,
155 pub task_preview: String,
156 pub model: String,
157 pub system_prompt: String,
158 pub started_at: std::time::Instant,
159 pub timeout_secs: u64,
160 pub authorization: Option<AuthorizationDiagnostic>,
161
162 state: Arc<RwLock<SubagentState>>,
164
165 steer_tx: Option<mpsc::UnboundedSender<String>>,
167 shutdown_tx: Option<oneshot::Sender<()>>,
168 pub(crate) thread_handle: Option<std::thread::JoinHandle<()>>,
171
172 result_rx: Option<oneshot::Receiver<SubagentResult>>,
174
175 collected: bool,
177}
178
179impl std::fmt::Debug for SubagentHandle {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 f.debug_struct("SubagentHandle")
182 .field("id", &self.id)
183 .field("subagent_id", &self.subagent_id)
184 .field("agent_name", &self.agent_name)
185 .field("model", &self.model)
186 .finish_non_exhaustive()
187 }
188}
189
190impl SubagentHandle {
191 #[allow(clippy::too_many_arguments)]
193 pub fn new(
194 id: String,
195 subagent_id: u64,
196 agent_name: String,
197 task_preview: String,
198 model: String,
199 system_prompt: String,
200 timeout_secs: u64,
201 state: Arc<RwLock<SubagentState>>,
202 steer_tx: Option<mpsc::UnboundedSender<String>>,
203 shutdown_tx: Option<oneshot::Sender<()>>,
204 result_rx: Option<oneshot::Receiver<SubagentResult>>,
205 ) -> Self {
206 Self {
207 id,
208 subagent_id,
209 agent_name,
210 task_preview,
211 model,
212 system_prompt,
213 started_at: std::time::Instant::now(),
214 timeout_secs,
215 authorization: None,
216 state,
217 steer_tx,
218 shutdown_tx,
219 thread_handle: None,
220 result_rx,
221 collected: false,
222 }
223 }
224
225 pub fn with_authorization(
226 mut self,
227 decision: &crate::orchestration::AuthorizedWorkerModel,
228 ) -> Self {
229 self.authorization = Some(AuthorizationDiagnostic {
230 selection_source: decision.selection_source.as_str().into(),
231 policy_digest: decision.policy_digest.clone(),
232 catalog_snapshot_id: decision.catalog_snapshot_id.clone(),
233 catalog_digest: decision.catalog_digest.clone(),
234 correlation_id: decision.correlation_id.clone(),
235 network_attempted: decision.network_attempted,
236 cross_provider_grant_id: decision.cross_provider_grant_id.clone(),
237 });
238 self
239 }
240
241 pub fn set_terminal_diagnostic(&self, diagnostic: TerminalDiagnostic) {
242 let mut state = self.state.write().unwrap();
243 state.safe_fail(diagnostic);
244 }
245
246 pub fn status(&self) -> SubagentStatus {
248 self.state
249 .read()
250 .unwrap_or_else(|p| p.into_inner())
251 .status
252 .clone()
253 }
254
255 pub fn partial_output(&self) -> String {
257 self.state
258 .read()
259 .unwrap_or_else(|p| p.into_inner())
260 .partial_text
261 .clone()
262 }
263
264 pub fn tool_log(&self) -> Vec<String> {
266 self.state
267 .read()
268 .unwrap_or_else(|p| p.into_inner())
269 .tool_log
270 .clone()
271 }
272
273 pub fn terminal_diagnostic(&self) -> Option<TerminalDiagnostic> {
274 if let Some(diagnostic) = self.state.read().unwrap().terminal.clone() {
275 return Some(diagnostic);
276 }
277 match self.status() {
278 SubagentStatus::Running => None,
279 SubagentStatus::Completed => Some(TerminalDiagnostic {
280 category: TerminalCategory::Completed,
281 code: "completed".into(),
282 stage: "inference".into(),
283 correlation_id: self.id.clone(),
284 network_attempted: true,
285 safe_message: safe_failure_message(&TerminalCategory::Completed).into(),
286 }),
287 SubagentStatus::TimedOut => Some(TerminalDiagnostic {
288 category: TerminalCategory::TimedOut,
289 code: "worker_timeout".into(),
290 stage: "inference".into(),
291 correlation_id: self.id.clone(),
292 network_attempted: true,
293 safe_message: safe_failure_message(&TerminalCategory::TimedOut).into(),
294 }),
295 SubagentStatus::Cancelled => Some(TerminalDiagnostic {
296 category: TerminalCategory::Cancelled,
297 code: "worker_cancelled".into(),
298 stage: "inference".into(),
299 correlation_id: self.id.clone(),
300 network_attempted: true,
301 safe_message: safe_failure_message(&TerminalCategory::Cancelled).into(),
302 }),
303 SubagentStatus::Failed(_) => Some(TerminalDiagnostic {
304 category: TerminalCategory::ExecutionFailed,
305 code: "inference_failed".into(),
306 stage: "inference".into(),
307 correlation_id: self.id.clone(),
308 network_attempted: true,
309 safe_message: safe_failure_message(&TerminalCategory::ExecutionFailed).into(),
310 }),
311 }
312 }
313
314 pub fn conversation_state(&self) -> Vec<Value> {
316 self.state
317 .read()
318 .unwrap_or_else(|p| p.into_inner())
319 .conversation_state
320 .clone()
321 }
322
323 pub fn elapsed_secs(&self) -> f64 {
325 self.started_at.elapsed().as_secs_f64()
326 }
327
328 fn make_tombstone(&mut self) {
331 debug_assert!(self.is_finished());
332 self.steer_tx.take();
333 self.shutdown_tx.take();
334 self.result_rx.take();
335 if let Some(thread) = self.thread_handle.take() {
336 let _ = thread.join();
337 }
338 let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
339 if state.partial_text.len() > TOMBSTONE_OUTPUT_MAX_BYTES {
340 let mut start = state.partial_text.len() - TOMBSTONE_OUTPUT_MAX_BYTES;
341 while !state.partial_text.is_char_boundary(start) {
342 start += 1;
343 }
344 state.partial_text.drain(..start);
345 }
346 state.tool_log.clear();
347 state.conversation_state.clear();
348 }
349
350 pub fn steer(&self, message: &str) -> Result<(), String> {
352 match &self.steer_tx {
353 Some(tx) => tx
354 .send(message.to_string())
355 .map_err(|e| format!("steer channel closed: {e}")),
356 None => Err("no steer channel on this handle".to_string()),
357 }
358 }
359
360 pub fn set_thread_handle(&mut self, handle: std::thread::JoinHandle<()>) {
363 self.thread_handle = Some(handle);
364 }
365
366 pub fn cancel(&mut self) {
367 self.state
370 .write()
371 .unwrap_or_else(|p| p.into_inner())
372 .cancel_requested = true;
373 if let Some(tx) = self.shutdown_tx.take() {
374 let _ = tx.send(());
375 }
376 }
377
378 pub fn is_finished(&self) -> bool {
380 !matches!(self.status(), SubagentStatus::Running)
381 }
382
383 pub fn mark_collected(&mut self) {
385 self.collected = true;
386 }
387
388 pub fn is_collected(&self) -> bool {
390 self.collected
391 }
392
393 pub fn is_tombstone(&self) -> bool {
395 self.is_finished()
396 && self.steer_tx.is_none()
397 && self.shutdown_tx.is_none()
398 && self.result_rx.is_none()
399 && self.thread_handle.is_none()
400 }
401
402 pub fn finished_elapsed(&self) -> Option<std::time::Duration> {
405 self.state
406 .read()
407 .unwrap_or_else(|p| p.into_inner())
408 .finished_at
409 .map(|t| t.elapsed())
410 }
411
412 pub async fn collect(mut self) -> Result<SubagentResult, String> {
414 match self.result_rx.take() {
415 Some(rx) => rx
416 .await
417 .map_err(|_| "subagent result channel dropped".to_string()),
418 None => Err("no result receiver — already collected or never set".to_string()),
419 }
420 }
421}
422
423pub const FINISHED_HANDLE_TTL: std::time::Duration = std::time::Duration::from_secs(900); pub const TOMBSTONE_OUTPUT_MAX_BYTES: usize = 32 * 1024;
429
430#[derive(Debug)]
431pub struct SubagentRegistry {
432 pub(crate) handles: HashMap<String, SubagentHandle>,
433}
434
435impl SubagentRegistry {
436 pub fn new() -> Self {
437 Self {
438 handles: HashMap::new(),
439 }
440 }
441
442 pub fn register(&mut self, handle: SubagentHandle) -> String {
444 let id = handle.id.clone();
445 self.handles.insert(id.clone(), handle);
446 id
447 }
448
449 pub fn get(&self, id: &str) -> Option<&SubagentHandle> {
450 self.handles.get(id)
451 }
452
453 pub fn get_mut(&mut self, id: &str) -> Option<&mut SubagentHandle> {
454 self.handles.get_mut(id)
455 }
456
457 pub fn remove(&mut self, id: &str) -> Option<SubagentHandle> {
458 self.handles.remove(id)
459 }
460
461 pub fn release_finished_resources(&mut self, id: &str) {
464 if let Some(handle) = self.handles.get_mut(id) {
465 if handle.is_finished() {
466 handle.make_tombstone();
467 }
468 }
469 }
470
471 pub fn list_active(&self) -> Vec<(String, String, SubagentStatus)> {
473 self.handles
474 .values()
475 .map(|h| (h.id.clone(), h.agent_name.clone(), h.status()))
476 .collect()
477 }
478
479 pub fn display_rows(&self) -> Vec<SubagentDisplayRow> {
483 self.handles
484 .values()
485 .map(|h| {
486 let s = h.state.read().unwrap_or_else(|p| p.into_inner());
487 SubagentDisplayRow {
488 subagent_id: h.subagent_id,
489 agent_name: h.agent_name.clone(),
490 status: s.status.clone(),
491 cancel_requested: s.cancel_requested,
492 elapsed_secs: h.started_at.elapsed().as_secs_f64(),
493 finished_elapsed: s.finished_at.map(|t| t.elapsed()),
494 }
495 })
496 .collect()
497 }
498
499 pub fn iter_mut_handles(&mut self) -> impl Iterator<Item = &mut SubagentHandle> {
502 self.handles.values_mut()
503 }
504
505 pub fn cleanup_finished_with_ttl(&mut self, ttl: std::time::Duration) {
517 self.cleanup_finished_with_ttl_and_retention(ttl, |_| false);
518 }
519
520 pub fn cleanup_finished_with_ttl_and_retention(
523 &mut self,
524 ttl: std::time::Duration,
525 retain_unreconciled: impl Fn(&str) -> bool,
526 ) {
527 let candidates: Vec<(String, bool)> = self
528 .handles
529 .iter()
530 .filter(|(_, h)| {
531 h.is_finished()
532 && h.thread_handle.as_ref().map_or(true, |t| t.is_finished())
533 && (h.is_collected() || h.finished_elapsed().is_some_and(|d| d >= ttl))
534 })
535 .map(|(id, h)| (id.clone(), !h.is_collected() && retain_unreconciled(id)))
536 .collect();
537 for (id, retain) in candidates {
538 if retain {
539 self.release_finished_resources(&id);
540 } else if let Some(mut handle) = self.handles.remove(&id) {
541 if let Some(th) = handle.thread_handle.take() {
542 let _ = th.join();
543 }
544 }
545 }
546 }
547
548 pub fn cleanup_finished(&mut self) {
551 self.cleanup_finished_with_ttl(FINISHED_HANDLE_TTL)
552 }
553}
554
555pub(crate) fn reap_finished_with_ttl(
562 registry: &Arc<std::sync::Mutex<SubagentRegistry>>,
563 orchestration: Option<&crate::orchestration::OrchestrationRuntime>,
564 ttl: std::time::Duration,
565) {
566 let retained = orchestration
567 .map(|runtime| runtime.unreconciled_runtime_handles())
568 .unwrap_or_default();
569 let cleanup = |guard: &mut SubagentRegistry| {
570 guard.cleanup_finished_with_ttl_and_retention(ttl, |id| retained.contains(id));
571 };
572 match registry.lock() {
573 Ok(mut guard) => cleanup(&mut guard),
574 Err(poisoned) => cleanup(&mut poisoned.into_inner()),
575 }
576}
577
578pub fn reap_finished(
579 registry: &Arc<std::sync::Mutex<SubagentRegistry>>,
580 orchestration: Option<&crate::orchestration::OrchestrationRuntime>,
581) {
582 reap_finished_with_ttl(registry, orchestration, FINISHED_HANDLE_TTL);
583}
584
585impl Default for SubagentRegistry {
586 fn default() -> Self {
587 Self::new()
588 }
589}
590
591impl SubagentStatus {
592 pub fn as_str(&self) -> &str {
593 match self {
594 SubagentStatus::Running => "running",
595 SubagentStatus::Completed => "completed",
596 SubagentStatus::Cancelled => "cancelled",
597 SubagentStatus::TimedOut => "timed_out",
598 SubagentStatus::Failed(_) => "failed",
599 }
600 }
601
602 pub fn failure_reason(&self) -> Option<&str> {
604 match self {
605 SubagentStatus::Failed(r) => Some(r.as_str()),
606 _ => None,
607 }
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use tokio::sync::{mpsc, oneshot};
615
616 struct TestHandle {
618 handle: SubagentHandle,
619 _steer_rx: mpsc::UnboundedReceiver<String>,
620 _shutdown_rx: oneshot::Receiver<()>,
621 }
622
623 fn make_test_handle(id: &str) -> TestHandle {
624 let state = Arc::new(RwLock::new(SubagentState::new()));
625 let (steer_tx, steer_rx) = mpsc::unbounded_channel();
626 let (shutdown_tx, shutdown_rx) = oneshot::channel();
627 let (_result_tx, result_rx) = oneshot::channel();
628 let numeric_id: u64 = id
630 .strip_prefix("sa_")
631 .and_then(|n| n.parse().ok())
632 .unwrap_or(0);
633 TestHandle {
634 handle: SubagentHandle::new(
635 id.to_string(),
636 numeric_id,
637 "test-agent".to_string(),
638 "test task".to_string(),
639 "claude-sonnet-4-6".to_string(),
640 "You are a test agent.".to_string(),
641 300,
642 state,
643 Some(steer_tx),
644 Some(shutdown_tx),
645 Some(result_rx),
646 ),
647 _steer_rx: steer_rx,
648 _shutdown_rx: shutdown_rx,
649 }
650 }
651
652 fn make_handle(id: &str) -> SubagentHandle {
653 make_test_handle(id).handle
654 }
655
656 fn make_finished_handle(id: &str) -> SubagentHandle {
657 let h = make_handle(id);
658 {
659 let mut s = h.state.write().unwrap();
660 s.status = SubagentStatus::Completed;
661 s.finished_at = Some(std::time::Instant::now());
662 }
663 h
664 }
665
666 #[test]
667 fn typed_failure_drops_raw_provider_secret_canary() {
668 let mut state = SubagentState::new();
669 let canary = "Bearer secret-canary-provider-body";
670 state.safe_fail(TerminalDiagnostic {
671 category: TerminalCategory::Credential,
672 code: "credential_unavailable".into(),
673 stage: "credential".into(),
674 correlation_id: "sa_safe".into(),
675 network_attempted: false,
676 safe_message: canary.into(),
677 });
678 let rendered = format!("{:?}{:?}", state.status, state.terminal);
679 assert!(!rendered.contains(canary));
680 assert!(rendered.contains("credential_unavailable"));
681 }
682
683 #[test]
684 fn handle_initial_status_is_running() {
685 let h = make_handle("sa_1");
686 assert_eq!(h.status(), SubagentStatus::Running);
687 assert!(!h.is_finished());
688 }
689
690 #[test]
691 fn handle_partial_output_empty_initially() {
692 let h = make_handle("sa_1");
693 assert_eq!(h.partial_output(), "");
694 assert!(h.tool_log().is_empty());
695 assert!(h.conversation_state().is_empty());
696 }
697
698 #[test]
699 fn handle_status_reflects_state_change() {
700 let h = make_handle("sa_1");
701 {
702 let mut s = h.state.write().unwrap();
703 s.status = SubagentStatus::Completed;
704 s.partial_text = "done!".to_string();
705 }
706 assert_eq!(h.status(), SubagentStatus::Completed);
707 assert!(h.is_finished());
708 assert_eq!(h.partial_output(), "done!");
709 }
710
711 #[test]
712 fn handle_steer_sends_message() {
713 let th = make_test_handle("sa_1");
714 assert!(th.handle.steer("redirect").is_ok());
715 }
716
717 #[test]
718 fn handle_steer_fails_without_channel() {
719 let state = Arc::new(RwLock::new(SubagentState::new()));
720 let (_shutdown_tx, _) = oneshot::channel::<()>();
721 let (_, result_rx) = oneshot::channel();
722 let h = SubagentHandle::new(
723 "sa_1".into(),
724 1,
725 "test".into(),
726 "task".into(),
727 "model".into(),
728 "prompt".into(),
729 300,
730 state,
731 None,
732 None,
733 Some(result_rx),
734 );
735 assert!(h.steer("msg").is_err());
736 }
737
738 #[test]
739 fn handle_cancel_consumes_shutdown() {
740 let mut h = make_handle("sa_1");
741 h.cancel(); h.cancel(); }
744
745 #[test]
746 fn handle_elapsed_increases() {
747 let h = make_handle("sa_1");
748 std::thread::sleep(std::time::Duration::from_millis(10));
749 assert!(h.elapsed_secs() > 0.0);
750 }
751
752 #[test]
753 fn registry_register_and_get() {
754 let mut reg = SubagentRegistry::new();
755 let h = make_handle("sa_1");
756 reg.register(h);
757 assert!(reg.get("sa_1").is_some());
758 assert!(reg.get("sa_99").is_none());
759 }
760
761 #[test]
762 fn registry_remove() {
763 let mut reg = SubagentRegistry::new();
764 reg.register(make_handle("sa_1"));
765 assert!(reg.remove("sa_1").is_some());
766 assert!(reg.get("sa_1").is_none());
767 }
768
769 #[test]
770 fn registry_list_active() {
771 let mut reg = SubagentRegistry::new();
772 reg.register(make_handle("sa_1"));
773 reg.register(make_handle("sa_2"));
774 let active = reg.list_active();
775 assert_eq!(active.len(), 2);
776 }
777
778 #[test]
781 fn registry_cleanup_finished() {
782 let mut reg = SubagentRegistry::new();
783 let mut h = make_finished_handle("sa_1");
784 h.mark_collected(); reg.register(h);
786 reg.register(make_handle("sa_2")); reg.cleanup_finished();
789
790 assert!(
791 reg.get("sa_1").is_none(),
792 "collected finished handle must be reaped"
793 );
794 assert!(
795 reg.get("sa_2").is_some(),
796 "running handle must survive cleanup"
797 );
798 }
799
800 #[test]
802 fn reaper_retains_finished_uncollected_within_ttl() {
803 let mut reg = SubagentRegistry::new();
804 let h = make_finished_handle("sa_1"); reg.register(h);
806
807 reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
808
809 assert!(
810 reg.get("sa_1").is_some(),
811 "finished-uncollected handle within TTL must be retained"
812 );
813 }
814
815 #[test]
817 fn reaper_reaps_finished_and_collected() {
818 let mut reg = SubagentRegistry::new();
819 let mut h = make_finished_handle("sa_1");
820 h.mark_collected();
821 reg.register(h);
822
823 reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
824
825 assert!(
826 reg.get("sa_1").is_none(),
827 "finished+collected handle must be reaped"
828 );
829 }
830
831 #[test]
834 fn reaper_tombstones_unreconciled_after_ttl() {
835 let mut reg = SubagentRegistry::new();
836 let h = make_finished_handle("sa_1"); h.state.write().unwrap().partial_text = "🦀".repeat(25_000);
838 reg.register(h);
839
840 reg.cleanup_finished_with_ttl_and_retention(std::time::Duration::ZERO, |_| true);
841
842 let h = reg
843 .get("sa_1")
844 .expect("gate-relevant handle must remain collectible");
845 assert!(h.is_tombstone(), "expired handle must release transports");
846 assert!(h.partial_output().len() <= TOMBSTONE_OUTPUT_MAX_BYTES);
847 }
848
849 #[test]
850 fn reaper_reaps_abandoned_after_ttl_without_orchestration() {
851 let mut reg = SubagentRegistry::new();
852 reg.register(make_finished_handle("sa_1"));
853
854 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
855
856 assert!(reg.get("sa_1").is_none());
857 }
858
859 #[test]
862 fn reaper_defers_handle_with_live_thread() {
863 let mut reg = SubagentRegistry::new();
864 let mut h = make_finished_handle("sa_live");
865 h.mark_collected(); let barrier = Arc::new(std::sync::Barrier::new(2));
869 let barrier_c = Arc::clone(&barrier);
870 let thread = std::thread::spawn(move || {
871 barrier_c.wait();
873 std::thread::sleep(std::time::Duration::from_millis(20));
875 });
876 h.set_thread_handle(thread);
877 reg.register(h);
878
879 barrier.wait();
881
882 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
888 std::thread::sleep(std::time::Duration::from_millis(50));
893
894 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
896 assert!(
897 reg.get("sa_live").is_none(),
898 "handle must be reaped once OS thread exits"
899 );
900 }
901
902 #[test]
904 fn reaper_never_touches_running() {
905 let mut reg = SubagentRegistry::new();
906 reg.register(make_handle("sa_1")); reg.register(make_handle("sa_2")); reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
910 assert!(reg.get("sa_1").is_some());
911 assert!(reg.get("sa_2").is_some());
912
913 reg.cleanup_finished(); assert!(reg.get("sa_1").is_some());
915 assert!(reg.get("sa_2").is_some());
916 }
917
918 #[test]
919 fn subagent_state_new_defaults() {
920 let s = SubagentState::new();
921 assert_eq!(s.status, SubagentStatus::Running);
922 assert!(s.partial_text.is_empty());
923 assert!(s.tool_log.is_empty());
924 assert!(s.conversation_state.is_empty());
925 assert!(s.finished_at.is_none());
926 assert!(
927 !s.cancel_requested,
928 "cancel_requested must default to false"
929 );
930 }
931
932 #[test]
933 fn subagent_status_as_str() {
934 assert_eq!(SubagentStatus::Running.as_str(), "running");
935 assert_eq!(SubagentStatus::Completed.as_str(), "completed");
936 assert_eq!(SubagentStatus::Cancelled.as_str(), "cancelled");
937 assert_eq!(SubagentStatus::TimedOut.as_str(), "timed_out");
938 assert_eq!(SubagentStatus::Failed("oops".into()).as_str(), "failed");
939 }
940
941 #[test]
943 fn cancel_sets_cancel_requested_flag() {
944 let mut h = make_handle("sa_cancel");
945 {
946 let s = h.state.read().unwrap();
947 assert!(!s.cancel_requested, "cancel_requested must start false");
948 }
949 h.cancel();
950 {
951 let s = h.state.read().unwrap();
952 assert!(
953 s.cancel_requested,
954 "cancel_requested must be true after cancel()"
955 );
956 }
957 h.cancel();
959 }
960
961 #[test]
963 fn cancelled_status_is_finished() {
964 let h = make_handle("sa_c1");
965 {
966 let mut s = h.state.write().unwrap();
967 s.status = SubagentStatus::Cancelled;
968 }
969 assert!(h.is_finished(), "Cancelled must count as finished");
970 }
971
972 #[test]
974 fn display_rows_running_handle() {
975 let mut reg = SubagentRegistry::new();
976 reg.register(make_handle("sa_1"));
977 reg.register(make_handle("sa_2"));
978 let rows = reg.display_rows();
979 assert_eq!(rows.len(), 2);
980 for row in &rows {
981 assert_eq!(row.status, SubagentStatus::Running);
982 assert!(!row.cancel_requested);
983 assert!(row.elapsed_secs >= 0.0);
984 assert!(row.finished_elapsed.is_none());
985 }
986 }
987
988 #[test]
990 fn display_rows_finished_and_cancel_requested() {
991 let mut reg = SubagentRegistry::new();
992 let h = make_finished_handle("sa_1");
993 reg.register(h);
994
995 let th = make_test_handle("sa_2");
997 {
998 let mut s = th.handle.state.write().unwrap();
999 s.cancel_requested = true;
1000 }
1001 reg.register(th.handle);
1002
1003 let rows = reg.display_rows();
1004 assert_eq!(rows.len(), 2);
1005
1006 let finished = rows.iter().find(|r| r.subagent_id == 1).unwrap();
1007 assert_eq!(finished.status, SubagentStatus::Completed);
1008 assert!(!finished.cancel_requested);
1009 assert!(finished.finished_elapsed.is_some());
1010
1011 let cancelling = rows.iter().find(|r| r.subagent_id == 2).unwrap();
1012 assert!(cancelling.cancel_requested);
1013 }
1014
1015 #[test]
1017 fn display_rows_empty_registry() {
1018 let reg = SubagentRegistry::new();
1019 assert!(reg.display_rows().is_empty());
1020 }
1021
1022 #[test]
1026 fn reap_finished_headless_reaps_collected() {
1027 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1028 {
1029 let mut reg = registry.lock().unwrap();
1030 let mut h = make_finished_handle("sa_r1");
1031 h.mark_collected();
1032 reg.register(h);
1033 }
1034 super::reap_finished(®istry, None);
1035 assert!(
1036 registry.lock().unwrap().get("sa_r1").is_none(),
1037 "reap_finished must reap collected+finished handle"
1038 );
1039 }
1040
1041 #[test]
1043 fn reap_finished_headless_retains_running() {
1044 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1045 {
1046 let mut reg = registry.lock().unwrap();
1047 reg.register(make_handle("sa_running"));
1048 }
1049 super::reap_finished(®istry, None);
1050 assert!(
1051 registry.lock().unwrap().get("sa_running").is_some(),
1052 "reap_finished must not touch running handles"
1053 );
1054 }
1055
1056 #[test]
1058 fn reap_finished_poisoned_lock_recovery() {
1059 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1060 {
1061 let mut reg = registry.lock().unwrap();
1062 let mut h = make_finished_handle("sa_poison");
1063 h.mark_collected();
1064 reg.register(h);
1065 }
1066
1067 let registry_c = Arc::clone(®istry);
1069 let _ = std::panic::catch_unwind(|| {
1070 let _guard = registry_c.lock().unwrap();
1071 panic!("deliberately poison the mutex");
1072 });
1073
1074 super::reap_finished(®istry, None);
1076
1077 let result = registry
1079 .lock()
1080 .unwrap_or_else(|p| p.into_inner())
1081 .get("sa_poison")
1082 .is_none();
1083 assert!(result, "reap_finished must run cleanup even after poison");
1084 }
1085}
1086
1087#[cfg(test)]
1089mod cancelled_wake_tests {
1090 use super::*;
1091 use std::sync::{Arc, RwLock};
1092
1093 #[test]
1097 fn cancelled_suppresses_wake() {
1098 use crate::events::EventQueue;
1099 use crate::tools::finalize_subagent;
1100
1101 let state = Arc::new(RwLock::new(SubagentState::new()));
1102 {
1103 let mut s = state.write().unwrap();
1104 s.status = SubagentStatus::Completed; s.cancel_requested = true; }
1107 let queue = Arc::new(EventQueue::new(100));
1108
1109 finalize_subagent(
1110 &state,
1111 Some(&queue),
1112 "sa_v3",
1113 42,
1114 "test-agent",
1115 std::time::Instant::now(),
1116 None,
1117 );
1118
1119 assert!(
1121 queue.is_empty(),
1122 "cancelled subagent must not publish a wake event"
1123 );
1124
1125 let s = state.read().unwrap();
1127 assert_eq!(
1128 s.status,
1129 SubagentStatus::Cancelled,
1130 "status must be Cancelled when cancel_requested"
1131 );
1132
1133 assert!(
1135 s.finished_at.is_some(),
1136 "finished_at must be stamped by finalizer"
1137 );
1138 }
1139}