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