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}
106
107impl SubagentState {
108 pub fn new() -> Self {
109 Self {
110 status: SubagentStatus::Running,
111 partial_text: String::new(),
112 tool_log: Vec::new(),
113 conversation_state: Vec::new(),
114 finished_at: None,
115 cancel_requested: false,
116 terminal: None,
117 }
118 }
119
120 pub fn safe_fail(&mut self, mut diagnostic: TerminalDiagnostic) {
122 diagnostic.safe_message = safe_failure_message(&diagnostic.category).into();
123 self.status = SubagentStatus::Failed(diagnostic.safe_message.clone());
124 self.terminal = Some(diagnostic);
125 }
126}
127
128impl Default for SubagentState {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
140pub struct SubagentDisplayRow {
141 pub subagent_id: u64,
142 pub agent_name: String,
143 pub status: SubagentStatus,
144 pub cancel_requested: bool,
145 pub elapsed_secs: f64,
146 pub finished_elapsed: Option<std::time::Duration>,
147}
148
149pub struct SubagentHandle {
152 pub id: String,
153 pub subagent_id: u64,
156 pub agent_name: String,
157 pub task_preview: String,
158 pub model: String,
159 pub system_prompt: String,
160 pub started_at: std::time::Instant,
161 pub timeout_secs: u64,
162 pub authorization: Option<AuthorizationDiagnostic>,
163
164 state: Arc<RwLock<SubagentState>>,
166
167 steer_tx: Option<mpsc::UnboundedSender<String>>,
169 shutdown_tx: Option<oneshot::Sender<()>>,
170 pub(crate) thread_handle: Option<std::thread::JoinHandle<()>>,
173
174 result_rx: Option<oneshot::Receiver<SubagentResult>>,
176
177 collected: bool,
179}
180
181impl std::fmt::Debug for SubagentHandle {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("SubagentHandle")
184 .field("id", &self.id)
185 .field("subagent_id", &self.subagent_id)
186 .field("agent_name", &self.agent_name)
187 .field("model", &self.model)
188 .finish_non_exhaustive()
189 }
190}
191
192impl SubagentHandle {
193 #[allow(clippy::too_many_arguments)]
195 pub fn new(
196 id: String,
197 subagent_id: u64,
198 agent_name: String,
199 task_preview: String,
200 model: String,
201 system_prompt: String,
202 timeout_secs: u64,
203 state: Arc<RwLock<SubagentState>>,
204 steer_tx: Option<mpsc::UnboundedSender<String>>,
205 shutdown_tx: Option<oneshot::Sender<()>>,
206 result_rx: Option<oneshot::Receiver<SubagentResult>>,
207 ) -> Self {
208 Self {
209 id,
210 subagent_id,
211 agent_name,
212 task_preview,
213 model,
214 system_prompt,
215 started_at: std::time::Instant::now(),
216 timeout_secs,
217 authorization: None,
218 state,
219 steer_tx,
220 shutdown_tx,
221 thread_handle: None,
222 result_rx,
223 collected: false,
224 }
225 }
226
227 pub fn with_authorization(
228 mut self,
229 decision: &crate::orchestration::AuthorizedWorkerModel,
230 ) -> Self {
231 self.authorization = Some(AuthorizationDiagnostic {
232 selection_source: decision.selection_source.as_str().into(),
233 policy_digest: decision.policy_digest.clone(),
234 catalog_snapshot_id: decision.catalog_snapshot_id.clone(),
235 catalog_digest: decision.catalog_digest.clone(),
236 correlation_id: decision.correlation_id.clone(),
237 network_attempted: decision.network_attempted,
238 cross_provider_grant_id: decision.cross_provider_grant_id.clone(),
239 });
240 self
241 }
242
243 pub fn set_terminal_diagnostic(&self, diagnostic: TerminalDiagnostic) {
244 let mut state = self.state.write().unwrap();
245 state.safe_fail(diagnostic);
246 }
247
248 pub fn status(&self) -> SubagentStatus {
250 self.state
251 .read()
252 .unwrap_or_else(|p| p.into_inner())
253 .status
254 .clone()
255 }
256
257 pub fn partial_output(&self) -> String {
259 self.state
260 .read()
261 .unwrap_or_else(|p| p.into_inner())
262 .partial_text
263 .clone()
264 }
265
266 pub fn tool_log(&self) -> Vec<String> {
268 self.state
269 .read()
270 .unwrap_or_else(|p| p.into_inner())
271 .tool_log
272 .clone()
273 }
274
275 pub fn terminal_diagnostic(&self) -> Option<TerminalDiagnostic> {
276 if let Some(diagnostic) = self.state.read().unwrap().terminal.clone() {
277 return Some(diagnostic);
278 }
279 match self.status() {
280 SubagentStatus::Running => None,
281 SubagentStatus::Completed => Some(TerminalDiagnostic {
282 category: TerminalCategory::Completed,
283 code: "completed".into(),
284 stage: "inference".into(),
285 correlation_id: self.id.clone(),
286 network_attempted: true,
287 safe_message: safe_failure_message(&TerminalCategory::Completed).into(),
288 }),
289 SubagentStatus::TimedOut => Some(TerminalDiagnostic {
290 category: TerminalCategory::TimedOut,
291 code: "worker_timeout".into(),
292 stage: "inference".into(),
293 correlation_id: self.id.clone(),
294 network_attempted: true,
295 safe_message: safe_failure_message(&TerminalCategory::TimedOut).into(),
296 }),
297 SubagentStatus::Cancelled => Some(TerminalDiagnostic {
298 category: TerminalCategory::Cancelled,
299 code: "worker_cancelled".into(),
300 stage: "inference".into(),
301 correlation_id: self.id.clone(),
302 network_attempted: true,
303 safe_message: safe_failure_message(&TerminalCategory::Cancelled).into(),
304 }),
305 SubagentStatus::Failed(_) => Some(TerminalDiagnostic {
306 category: TerminalCategory::ExecutionFailed,
307 code: "inference_failed".into(),
308 stage: "inference".into(),
309 correlation_id: self.id.clone(),
310 network_attempted: true,
311 safe_message: safe_failure_message(&TerminalCategory::ExecutionFailed).into(),
312 }),
313 }
314 }
315
316 pub fn conversation_state(&self) -> Vec<Value> {
318 self.state
319 .read()
320 .unwrap_or_else(|p| p.into_inner())
321 .conversation_state
322 .clone()
323 }
324
325 pub fn elapsed_secs(&self) -> f64 {
327 self.started_at.elapsed().as_secs_f64()
328 }
329
330 fn make_tombstone(&mut self) {
333 debug_assert!(self.is_finished());
334 self.steer_tx.take();
335 self.shutdown_tx.take();
336 self.result_rx.take();
337 if let Some(thread) = self.thread_handle.take() {
338 let _ = thread.join();
339 }
340 let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
341 if state.partial_text.len() > TOMBSTONE_OUTPUT_MAX_BYTES {
342 let mut start = state.partial_text.len() - TOMBSTONE_OUTPUT_MAX_BYTES;
343 while !state.partial_text.is_char_boundary(start) {
344 start += 1;
345 }
346 state.partial_text.drain(..start);
347 }
348 state.tool_log.clear();
349 state.conversation_state.clear();
350 }
351
352 pub fn steer(&self, message: &str) -> Result<(), String> {
354 match &self.steer_tx {
355 Some(tx) => tx
356 .send(message.to_string())
357 .map_err(|e| format!("steer channel closed: {e}")),
358 None => Err("no steer channel on this handle".to_string()),
359 }
360 }
361
362 pub fn set_thread_handle(&mut self, handle: std::thread::JoinHandle<()>) {
365 self.thread_handle = Some(handle);
366 }
367
368 pub fn cancel(&mut self) {
369 self.state
372 .write()
373 .unwrap_or_else(|p| p.into_inner())
374 .cancel_requested = true;
375 if let Some(tx) = self.shutdown_tx.take() {
376 let _ = tx.send(());
377 }
378 }
379
380 pub fn is_finished(&self) -> bool {
382 !matches!(self.status(), SubagentStatus::Running)
383 }
384
385 pub fn mark_collected(&mut self) {
387 self.collected = true;
388 }
389
390 pub fn is_collected(&self) -> bool {
392 self.collected
393 }
394
395 pub fn is_tombstone(&self) -> bool {
397 self.is_finished()
398 && self.steer_tx.is_none()
399 && self.shutdown_tx.is_none()
400 && self.result_rx.is_none()
401 && self.thread_handle.is_none()
402 }
403
404 pub fn finished_elapsed(&self) -> Option<std::time::Duration> {
407 self.state
408 .read()
409 .unwrap_or_else(|p| p.into_inner())
410 .finished_at
411 .map(|t| t.elapsed())
412 }
413
414 pub async fn collect(mut self) -> Result<SubagentResult, String> {
416 match self.result_rx.take() {
417 Some(rx) => rx
418 .await
419 .map_err(|_| "subagent result channel dropped".to_string()),
420 None => Err("no result receiver — already collected or never set".to_string()),
421 }
422 }
423}
424
425pub const FINISHED_HANDLE_TTL: std::time::Duration = std::time::Duration::from_secs(900); pub const TOMBSTONE_OUTPUT_MAX_BYTES: usize = 32 * 1024;
431
432#[derive(Debug)]
433pub struct SubagentRegistry {
434 pub(crate) handles: HashMap<String, SubagentHandle>,
435 spawn_cancellation: Option<crate::CancellationToken>,
439 spawn_epoch: u64,
440}
441
442impl SubagentRegistry {
443 pub fn new() -> Self {
444 Self {
445 handles: HashMap::new(),
446 spawn_cancellation: None,
447 spawn_epoch: 0,
448 }
449 }
450
451 pub fn register(&mut self, handle: SubagentHandle) -> String {
453 self.register_with_cancellation(handle, None)
454 }
455
456 pub fn register_with_cancellation(
457 &mut self,
458 mut handle: SubagentHandle,
459 origin: Option<&crate::CancellationToken>,
460 ) -> String {
461 if origin.is_some_and(|c| c.is_cancelled())
462 || self
463 .spawn_cancellation
464 .as_ref()
465 .is_some_and(|c| c.is_cancelled())
466 {
467 handle.cancel();
468 }
469 let id = handle.id.clone();
470 self.handles.insert(id.clone(), handle);
471 id
472 }
473
474 pub fn set_spawn_cancellation(&mut self, cancel: Option<crate::CancellationToken>) -> u64 {
476 self.spawn_epoch = self
477 .spawn_epoch
478 .checked_add(1)
479 .expect("worker launch epoch exhausted");
480 self.spawn_cancellation = cancel;
481 self.spawn_epoch
482 }
483
484 pub fn cancel_spawn_epoch(&mut self, epoch: u64) {
486 if self.spawn_epoch == epoch {
487 for handle in self.iter_mut_handles() {
488 handle.cancel();
489 }
490 }
491 }
492
493 pub fn get(&self, id: &str) -> Option<&SubagentHandle> {
494 self.handles.get(id)
495 }
496
497 pub fn get_mut(&mut self, id: &str) -> Option<&mut SubagentHandle> {
498 self.handles.get_mut(id)
499 }
500
501 pub fn remove(&mut self, id: &str) -> Option<SubagentHandle> {
502 self.handles.remove(id)
503 }
504
505 pub fn release_finished_resources(&mut self, id: &str) {
508 if let Some(handle) = self.handles.get_mut(id) {
509 if handle.is_finished() {
510 handle.make_tombstone();
511 }
512 }
513 }
514
515 pub fn list_active(&self) -> Vec<(String, String, SubagentStatus)> {
517 self.handles
518 .values()
519 .map(|h| (h.id.clone(), h.agent_name.clone(), h.status()))
520 .collect()
521 }
522
523 pub fn display_rows(&self) -> Vec<SubagentDisplayRow> {
527 self.handles
528 .values()
529 .map(|h| {
530 let s = h.state.read().unwrap_or_else(|p| p.into_inner());
531 SubagentDisplayRow {
532 subagent_id: h.subagent_id,
533 agent_name: h.agent_name.clone(),
534 status: s.status.clone(),
535 cancel_requested: s.cancel_requested,
536 elapsed_secs: h.started_at.elapsed().as_secs_f64(),
537 finished_elapsed: s.finished_at.map(|t| t.elapsed()),
538 }
539 })
540 .collect()
541 }
542
543 pub fn iter_mut_handles(&mut self) -> impl Iterator<Item = &mut SubagentHandle> {
546 self.handles.values_mut()
547 }
548
549 pub fn cleanup_finished_with_ttl(&mut self, ttl: std::time::Duration) {
561 self.cleanup_finished_with_ttl_and_retention(ttl, |_| false);
562 }
563
564 pub fn cleanup_finished_with_ttl_and_retention(
567 &mut self,
568 ttl: std::time::Duration,
569 retain_unreconciled: impl Fn(&str) -> bool,
570 ) {
571 let candidates: Vec<(String, bool)> = self
572 .handles
573 .iter()
574 .filter(|(_, h)| {
575 h.is_finished()
576 && h.thread_handle.as_ref().map_or(true, |t| t.is_finished())
577 && (h.is_collected() || h.finished_elapsed().is_some_and(|d| d >= ttl))
578 })
579 .map(|(id, h)| (id.clone(), !h.is_collected() && retain_unreconciled(id)))
580 .collect();
581 for (id, retain) in candidates {
582 if retain {
583 self.release_finished_resources(&id);
584 } else if let Some(mut handle) = self.handles.remove(&id) {
585 if let Some(th) = handle.thread_handle.take() {
586 let _ = th.join();
587 }
588 }
589 }
590 }
591
592 pub fn cleanup_finished(&mut self) {
595 self.cleanup_finished_with_ttl(FINISHED_HANDLE_TTL)
596 }
597}
598
599pub(crate) fn reap_finished_with_ttl(
606 registry: &Arc<std::sync::Mutex<SubagentRegistry>>,
607 orchestration: Option<&crate::orchestration::OrchestrationRuntime>,
608 ttl: std::time::Duration,
609) {
610 let retained = orchestration
611 .map(|runtime| runtime.unreconciled_runtime_handles())
612 .unwrap_or_default();
613 let cleanup = |guard: &mut SubagentRegistry| {
614 let before = guard
615 .handles
616 .keys()
617 .cloned()
618 .collect::<std::collections::HashSet<_>>();
619 guard.cleanup_finished_with_ttl_and_retention(ttl, |id| retained.contains(id));
620 before
621 .into_iter()
622 .filter(|id| !guard.handles.contains_key(id))
623 .collect::<std::collections::HashSet<_>>()
624 };
625 let removed = match registry.lock() {
628 Ok(mut guard) => cleanup(&mut guard),
629 Err(poisoned) => cleanup(&mut poisoned.into_inner()),
630 };
631 if let Some(orchestration) = orchestration {
632 orchestration.retire_reconciled(&removed);
633 }
634}
635
636pub fn reap_finished(
637 registry: &Arc<std::sync::Mutex<SubagentRegistry>>,
638 orchestration: Option<&crate::orchestration::OrchestrationRuntime>,
639) {
640 reap_finished_with_ttl(registry, orchestration, FINISHED_HANDLE_TTL);
641}
642
643impl Default for SubagentRegistry {
644 fn default() -> Self {
645 Self::new()
646 }
647}
648
649impl SubagentStatus {
650 pub fn as_str(&self) -> &str {
651 match self {
652 SubagentStatus::Running => "running",
653 SubagentStatus::Completed => "completed",
654 SubagentStatus::Cancelled => "cancelled",
655 SubagentStatus::TimedOut => "timed_out",
656 SubagentStatus::Failed(_) => "failed",
657 }
658 }
659
660 pub fn failure_reason(&self) -> Option<&str> {
662 match self {
663 SubagentStatus::Failed(r) => Some(r.as_str()),
664 _ => None,
665 }
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use tokio::sync::{mpsc, oneshot};
673
674 struct TestHandle {
676 handle: SubagentHandle,
677 _steer_rx: mpsc::UnboundedReceiver<String>,
678 _shutdown_rx: oneshot::Receiver<()>,
679 }
680
681 fn make_test_handle(id: &str) -> TestHandle {
682 let state = Arc::new(RwLock::new(SubagentState::new()));
683 let (steer_tx, steer_rx) = mpsc::unbounded_channel();
684 let (shutdown_tx, shutdown_rx) = oneshot::channel();
685 let (_result_tx, result_rx) = oneshot::channel();
686 let numeric_id: u64 = id
688 .strip_prefix("sa_")
689 .and_then(|n| n.parse().ok())
690 .unwrap_or(0);
691 TestHandle {
692 handle: SubagentHandle::new(
693 id.to_string(),
694 numeric_id,
695 "test-agent".to_string(),
696 "test task".to_string(),
697 "claude-sonnet-4-6".to_string(),
698 "You are a test agent.".to_string(),
699 300,
700 state,
701 Some(steer_tx),
702 Some(shutdown_tx),
703 Some(result_rx),
704 ),
705 _steer_rx: steer_rx,
706 _shutdown_rx: shutdown_rx,
707 }
708 }
709
710 fn make_handle(id: &str) -> SubagentHandle {
711 make_test_handle(id).handle
712 }
713
714 fn make_finished_handle(id: &str) -> SubagentHandle {
715 let h = make_handle(id);
716 {
717 let mut s = h.state.write().unwrap();
718 s.status = SubagentStatus::Completed;
719 s.finished_at = Some(std::time::Instant::now());
720 }
721 h
722 }
723
724 #[test]
725 fn typed_failure_drops_raw_provider_secret_canary() {
726 let mut state = SubagentState::new();
727 let canary = "Bearer secret-canary-provider-body";
728 state.safe_fail(TerminalDiagnostic {
729 category: TerminalCategory::Credential,
730 code: "credential_unavailable".into(),
731 stage: "credential".into(),
732 correlation_id: "sa_safe".into(),
733 network_attempted: false,
734 safe_message: canary.into(),
735 });
736 let rendered = format!("{:?}{:?}", state.status, state.terminal);
737 assert!(!rendered.contains(canary));
738 assert!(rendered.contains("credential_unavailable"));
739 }
740
741 #[test]
742 fn late_registration_cannot_inherit_new_turn_and_stale_sweep_cannot_cancel_it() {
743 let mut registry = SubagentRegistry::new();
744 let old = crate::CancellationToken::new();
745 let epoch = registry.set_spawn_cancellation(Some(old.clone()));
746 old.cancel();
747 registry.cancel_spawn_epoch(epoch);
748 registry.set_spawn_cancellation(None);
750 registry.register(make_handle("new-user"));
751 registry.register_with_cancellation(make_handle("old-late"), Some(&old));
752 assert!(
753 registry
754 .get("old-late")
755 .unwrap()
756 .state
757 .read()
758 .unwrap()
759 .cancel_requested
760 );
761 assert!(
762 !registry
763 .get("new-user")
764 .unwrap()
765 .state
766 .read()
767 .unwrap()
768 .cancel_requested
769 );
770 registry.cancel_spawn_epoch(epoch);
771 assert!(
772 !registry
773 .get("new-user")
774 .unwrap()
775 .state
776 .read()
777 .unwrap()
778 .cancel_requested
779 );
780 let new_grant = crate::CancellationToken::new();
781 let next = registry.set_spawn_cancellation(Some(new_grant));
782 registry.register(make_handle("new-grant"));
783 registry.register_with_cancellation(make_handle("old-later"), Some(&old));
784 registry.cancel_spawn_epoch(epoch);
785 assert!(
786 !registry
787 .get("new-grant")
788 .unwrap()
789 .state
790 .read()
791 .unwrap()
792 .cancel_requested
793 );
794 assert!(
795 registry
796 .get("old-later")
797 .unwrap()
798 .state
799 .read()
800 .unwrap()
801 .cancel_requested
802 );
803 registry.cancel_spawn_epoch(next);
804 assert!(
805 registry
806 .get("new-grant")
807 .unwrap()
808 .state
809 .read()
810 .unwrap()
811 .cancel_requested
812 );
813 }
814
815 #[test]
816 fn revoked_launch_fence_cancels_late_registration_until_user_takeover() {
817 let mut registry = SubagentRegistry::new();
818 let cancel = crate::CancellationToken::new();
819 registry.set_spawn_cancellation(Some(cancel.clone()));
820 registry.register(make_handle("before"));
821 assert!(!registry.display_rows()[0].cancel_requested);
822 cancel.cancel();
823 let late = make_test_handle("late");
824 let mut shutdown = late._shutdown_rx;
825 registry.register(late.handle);
826 assert!(shutdown.try_recv().is_ok());
827 assert!(
828 registry
829 .get("late")
830 .unwrap()
831 .state
832 .read()
833 .unwrap()
834 .cancel_requested
835 );
836 registry.set_spawn_cancellation(None);
837 registry.register(make_handle("user"));
838 assert!(
839 !registry
840 .get("user")
841 .unwrap()
842 .state
843 .read()
844 .unwrap()
845 .cancel_requested
846 );
847 }
848
849 #[test]
850 fn handle_initial_status_is_running() {
851 let h = make_handle("sa_1");
852 assert_eq!(h.status(), SubagentStatus::Running);
853 assert!(!h.is_finished());
854 }
855
856 #[test]
857 fn handle_partial_output_empty_initially() {
858 let h = make_handle("sa_1");
859 assert_eq!(h.partial_output(), "");
860 assert!(h.tool_log().is_empty());
861 assert!(h.conversation_state().is_empty());
862 }
863
864 #[test]
865 fn handle_status_reflects_state_change() {
866 let h = make_handle("sa_1");
867 {
868 let mut s = h.state.write().unwrap();
869 s.status = SubagentStatus::Completed;
870 s.partial_text = "done!".to_string();
871 }
872 assert_eq!(h.status(), SubagentStatus::Completed);
873 assert!(h.is_finished());
874 assert_eq!(h.partial_output(), "done!");
875 }
876
877 #[test]
878 fn handle_steer_sends_message() {
879 let th = make_test_handle("sa_1");
880 assert!(th.handle.steer("redirect").is_ok());
881 }
882
883 #[test]
884 fn handle_steer_fails_without_channel() {
885 let state = Arc::new(RwLock::new(SubagentState::new()));
886 let (_shutdown_tx, _) = oneshot::channel::<()>();
887 let (_, result_rx) = oneshot::channel();
888 let h = SubagentHandle::new(
889 "sa_1".into(),
890 1,
891 "test".into(),
892 "task".into(),
893 "model".into(),
894 "prompt".into(),
895 300,
896 state,
897 None,
898 None,
899 Some(result_rx),
900 );
901 assert!(h.steer("msg").is_err());
902 }
903
904 #[test]
905 fn handle_cancel_consumes_shutdown() {
906 let mut h = make_handle("sa_1");
907 h.cancel(); h.cancel(); }
910
911 #[test]
912 fn handle_elapsed_increases() {
913 let h = make_handle("sa_1");
914 std::thread::sleep(std::time::Duration::from_millis(10));
915 assert!(h.elapsed_secs() > 0.0);
916 }
917
918 #[test]
919 fn registry_register_and_get() {
920 let mut reg = SubagentRegistry::new();
921 let h = make_handle("sa_1");
922 reg.register(h);
923 assert!(reg.get("sa_1").is_some());
924 assert!(reg.get("sa_99").is_none());
925 }
926
927 #[test]
928 fn registry_remove() {
929 let mut reg = SubagentRegistry::new();
930 reg.register(make_handle("sa_1"));
931 assert!(reg.remove("sa_1").is_some());
932 assert!(reg.get("sa_1").is_none());
933 }
934
935 #[test]
936 fn registry_list_active() {
937 let mut reg = SubagentRegistry::new();
938 reg.register(make_handle("sa_1"));
939 reg.register(make_handle("sa_2"));
940 let active = reg.list_active();
941 assert_eq!(active.len(), 2);
942 }
943
944 #[test]
947 fn registry_cleanup_finished() {
948 let mut reg = SubagentRegistry::new();
949 let mut h = make_finished_handle("sa_1");
950 h.mark_collected(); reg.register(h);
952 reg.register(make_handle("sa_2")); reg.cleanup_finished();
955
956 assert!(
957 reg.get("sa_1").is_none(),
958 "collected finished handle must be reaped"
959 );
960 assert!(
961 reg.get("sa_2").is_some(),
962 "running handle must survive cleanup"
963 );
964 }
965
966 #[test]
968 fn reaper_retains_finished_uncollected_within_ttl() {
969 let mut reg = SubagentRegistry::new();
970 let h = make_finished_handle("sa_1"); reg.register(h);
972
973 reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
974
975 assert!(
976 reg.get("sa_1").is_some(),
977 "finished-uncollected handle within TTL must be retained"
978 );
979 }
980
981 #[test]
983 fn reaper_reaps_finished_and_collected() {
984 let mut reg = SubagentRegistry::new();
985 let mut h = make_finished_handle("sa_1");
986 h.mark_collected();
987 reg.register(h);
988
989 reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
990
991 assert!(
992 reg.get("sa_1").is_none(),
993 "finished+collected handle must be reaped"
994 );
995 }
996
997 #[test]
1000 fn reaper_tombstones_unreconciled_after_ttl() {
1001 let mut reg = SubagentRegistry::new();
1002 let h = make_finished_handle("sa_1"); h.state.write().unwrap().partial_text = "🦀".repeat(25_000);
1004 reg.register(h);
1005
1006 reg.cleanup_finished_with_ttl_and_retention(std::time::Duration::ZERO, |_| true);
1007
1008 let h = reg
1009 .get("sa_1")
1010 .expect("gate-relevant handle must remain collectible");
1011 assert!(h.is_tombstone(), "expired handle must release transports");
1012 assert!(h.partial_output().len() <= TOMBSTONE_OUTPUT_MAX_BYTES);
1013 }
1014
1015 #[test]
1016 fn reaper_reaps_abandoned_after_ttl_without_orchestration() {
1017 let mut reg = SubagentRegistry::new();
1018 reg.register(make_finished_handle("sa_1"));
1019
1020 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1021
1022 assert!(reg.get("sa_1").is_none());
1023 }
1024
1025 #[test]
1028 fn reaper_defers_handle_with_live_thread() {
1029 let mut reg = SubagentRegistry::new();
1030 let mut h = make_finished_handle("sa_live");
1031 h.mark_collected(); let barrier = Arc::new(std::sync::Barrier::new(2));
1035 let barrier_c = Arc::clone(&barrier);
1036 let thread = std::thread::spawn(move || {
1037 barrier_c.wait();
1039 std::thread::sleep(std::time::Duration::from_millis(20));
1041 });
1042 h.set_thread_handle(thread);
1043 reg.register(h);
1044
1045 barrier.wait();
1047
1048 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1054 std::thread::sleep(std::time::Duration::from_millis(50));
1059
1060 reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1062 assert!(
1063 reg.get("sa_live").is_none(),
1064 "handle must be reaped once OS thread exits"
1065 );
1066 }
1067
1068 #[test]
1070 fn reaper_never_touches_running() {
1071 let mut reg = SubagentRegistry::new();
1072 reg.register(make_handle("sa_1")); reg.register(make_handle("sa_2")); reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1076 assert!(reg.get("sa_1").is_some());
1077 assert!(reg.get("sa_2").is_some());
1078
1079 reg.cleanup_finished(); assert!(reg.get("sa_1").is_some());
1081 assert!(reg.get("sa_2").is_some());
1082 }
1083
1084 #[test]
1085 fn subagent_state_new_defaults() {
1086 let s = SubagentState::new();
1087 assert_eq!(s.status, SubagentStatus::Running);
1088 assert!(s.partial_text.is_empty());
1089 assert!(s.tool_log.is_empty());
1090 assert!(s.conversation_state.is_empty());
1091 assert!(s.finished_at.is_none());
1092 assert!(
1093 !s.cancel_requested,
1094 "cancel_requested must default to false"
1095 );
1096 }
1097
1098 #[test]
1099 fn subagent_status_as_str() {
1100 assert_eq!(SubagentStatus::Running.as_str(), "running");
1101 assert_eq!(SubagentStatus::Completed.as_str(), "completed");
1102 assert_eq!(SubagentStatus::Cancelled.as_str(), "cancelled");
1103 assert_eq!(SubagentStatus::TimedOut.as_str(), "timed_out");
1104 assert_eq!(SubagentStatus::Failed("oops".into()).as_str(), "failed");
1105 }
1106
1107 #[test]
1109 fn cancel_sets_cancel_requested_flag() {
1110 let mut h = make_handle("sa_cancel");
1111 {
1112 let s = h.state.read().unwrap();
1113 assert!(!s.cancel_requested, "cancel_requested must start false");
1114 }
1115 h.cancel();
1116 {
1117 let s = h.state.read().unwrap();
1118 assert!(
1119 s.cancel_requested,
1120 "cancel_requested must be true after cancel()"
1121 );
1122 }
1123 h.cancel();
1125 }
1126
1127 #[test]
1129 fn cancelled_status_is_finished() {
1130 let h = make_handle("sa_c1");
1131 {
1132 let mut s = h.state.write().unwrap();
1133 s.status = SubagentStatus::Cancelled;
1134 }
1135 assert!(h.is_finished(), "Cancelled must count as finished");
1136 }
1137
1138 #[test]
1140 fn display_rows_running_handle() {
1141 let mut reg = SubagentRegistry::new();
1142 reg.register(make_handle("sa_1"));
1143 reg.register(make_handle("sa_2"));
1144 let rows = reg.display_rows();
1145 assert_eq!(rows.len(), 2);
1146 for row in &rows {
1147 assert_eq!(row.status, SubagentStatus::Running);
1148 assert!(!row.cancel_requested);
1149 assert!(row.elapsed_secs >= 0.0);
1150 assert!(row.finished_elapsed.is_none());
1151 }
1152 }
1153
1154 #[test]
1156 fn display_rows_finished_and_cancel_requested() {
1157 let mut reg = SubagentRegistry::new();
1158 let h = make_finished_handle("sa_1");
1159 reg.register(h);
1160
1161 let th = make_test_handle("sa_2");
1163 {
1164 let mut s = th.handle.state.write().unwrap();
1165 s.cancel_requested = true;
1166 }
1167 reg.register(th.handle);
1168
1169 let rows = reg.display_rows();
1170 assert_eq!(rows.len(), 2);
1171
1172 let finished = rows.iter().find(|r| r.subagent_id == 1).unwrap();
1173 assert_eq!(finished.status, SubagentStatus::Completed);
1174 assert!(!finished.cancel_requested);
1175 assert!(finished.finished_elapsed.is_some());
1176
1177 let cancelling = rows.iter().find(|r| r.subagent_id == 2).unwrap();
1178 assert!(cancelling.cancel_requested);
1179 }
1180
1181 #[test]
1183 fn display_rows_empty_registry() {
1184 let reg = SubagentRegistry::new();
1185 assert!(reg.display_rows().is_empty());
1186 }
1187
1188 #[test]
1192 fn reap_finished_headless_reaps_collected() {
1193 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1194 {
1195 let mut reg = registry.lock().unwrap();
1196 let mut h = make_finished_handle("sa_r1");
1197 h.mark_collected();
1198 reg.register(h);
1199 }
1200 super::reap_finished(®istry, None);
1201 assert!(
1202 registry.lock().unwrap().get("sa_r1").is_none(),
1203 "reap_finished must reap collected+finished handle"
1204 );
1205 }
1206
1207 #[test]
1209 fn reap_finished_headless_retains_running() {
1210 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1211 {
1212 let mut reg = registry.lock().unwrap();
1213 reg.register(make_handle("sa_running"));
1214 }
1215 super::reap_finished(®istry, None);
1216 assert!(
1217 registry.lock().unwrap().get("sa_running").is_some(),
1218 "reap_finished must not touch running handles"
1219 );
1220 }
1221
1222 #[test]
1224 fn reap_finished_poisoned_lock_recovery() {
1225 let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1226 {
1227 let mut reg = registry.lock().unwrap();
1228 let mut h = make_finished_handle("sa_poison");
1229 h.mark_collected();
1230 reg.register(h);
1231 }
1232
1233 let registry_c = Arc::clone(®istry);
1235 let _ = std::panic::catch_unwind(|| {
1236 let _guard = registry_c.lock().unwrap();
1237 panic!("deliberately poison the mutex");
1238 });
1239
1240 super::reap_finished(®istry, None);
1242
1243 let result = registry
1245 .lock()
1246 .unwrap_or_else(|p| p.into_inner())
1247 .get("sa_poison")
1248 .is_none();
1249 assert!(result, "reap_finished must run cleanup even after poison");
1250 }
1251}
1252
1253#[cfg(test)]
1255mod cancelled_wake_tests {
1256 use super::*;
1257 use std::sync::{Arc, RwLock};
1258
1259 #[test]
1263 fn cancelled_suppresses_wake() {
1264 use crate::events::EventQueue;
1265 use crate::tools::finalize_subagent;
1266
1267 let state = Arc::new(RwLock::new(SubagentState::new()));
1268 {
1269 let mut s = state.write().unwrap();
1270 s.status = SubagentStatus::Completed; s.cancel_requested = true; }
1273 let queue = Arc::new(EventQueue::new(100));
1274
1275 finalize_subagent(
1276 &state,
1277 Some(&queue),
1278 "sa_v3",
1279 42,
1280 "test-agent",
1281 std::time::Instant::now(),
1282 None,
1283 );
1284
1285 assert!(
1287 queue.is_empty(),
1288 "cancelled subagent must not publish a wake event"
1289 );
1290
1291 let s = state.read().unwrap();
1293 assert_eq!(
1294 s.status,
1295 SubagentStatus::Cancelled,
1296 "status must be Cancelled when cancel_requested"
1297 );
1298
1299 assert!(
1301 s.finished_at.is_some(),
1302 "finished_at must be stamped by finalizer"
1303 );
1304 }
1305}