Skip to main content

agent_engine/runtime/
subagent.rs

1use serde_json::Value;
2use std::collections::HashMap;
3use std::sync::{Arc, RwLock};
4use tokio::sync::{mpsc, oneshot};
5
6// ── SubagentResult ───────────────────────────────────────────────────────────────
7
8#[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    /// TTL split of `cache_creation` across the subagent's turns.
17    /// `None` only if no turn ever reported a split; otherwise the sum.
18    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// ── SubagentStatus ───────────────────────────────────────────────────────────────
36
37#[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    /// User-aborted via TUI cancel. Never publishes a wake event.
83    Cancelled,
84    TimedOut,
85    Failed(String),
86}
87
88// ── SubagentState ────────────────────────────────────────────────────────────────
89
90/// All mutable state shared between the subagent thread and its handle.
91/// Collapsed behind a single RwLock so a status poll takes exactly one lock.
92#[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    /// Stamped once by finalize_subagent at thread exit.
99    pub finished_at: Option<std::time::Instant>,
100    /// Set by cancel() before the shutdown signal is sent.
101    /// Read by finalize_subagent to label the terminal status correctly.
102    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    /// Persist only typed, allowlisted failure data. Raw provider errors never enter state.
120    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// ── SubagentDisplayRow ────────────────────────────────────────────────────────────
134
135/// Snapshot row produced by SubagentRegistry::display_rows().
136/// Used by the TUI reconcile path as the registry's liveness authority.
137#[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
147// ── SubagentHandle ───────────────────────────────────────────────────────────────
148
149pub struct SubagentHandle {
150    pub id: String,
151    /// Numeric ID — same value that was passed to SubagentStart/SubagentDone events
152    /// so the TUI can correlate by id without parsing "sa_N" strings.
153    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    // Shared state updated by the subagent thread — one lock for everything.
163    state: Arc<RwLock<SubagentState>>,
164
165    // Channels
166    steer_tx: Option<mpsc::UnboundedSender<String>>,
167    shutdown_tx: Option<oneshot::Sender<()>>,
168    /// OS thread running the subagent. Stored for graceful shutdown (join).
169    // OS thread handle for graceful shutdown
170    pub(crate) thread_handle: Option<std::thread::JoinHandle<()>>,
171
172    // Final result
173    result_rx: Option<oneshot::Receiver<SubagentResult>>,
174
175    /// Set to true once subagent_collect has read the terminal result.
176    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    /// Construct a new handle. The state Arc is shared with the spawned subagent thread.
192    #[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    /// Current status snapshot.
247    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    /// Partial output accumulated so far.
256    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    /// Snapshot of the tool log.
265    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    /// Snapshot of conversation state (for resume).
315    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    /// Seconds since this handle was created.
324    pub fn elapsed_secs(&self) -> f64 {
325        self.started_at.elapsed().as_secs_f64()
326    }
327
328    /// Release execution-only resources after a worker has expired while
329    /// retaining bounded terminal identity/output for collection and reconciliation.
330    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    /// Send a steering message into the running subagent.
351    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    /// Signal the subagent to shut down.
361    /// Store the OS thread handle for graceful shutdown.
362    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        // Flag FIRST so finalize_subagent can read it before the thread exits.
368        // Use poison-safe write in case the thread panicked holding the lock.
369        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    /// True if the subagent is no longer running.
379    pub fn is_finished(&self) -> bool {
380        !matches!(self.status(), SubagentStatus::Running)
381    }
382
383    /// Mark this handle as collected by subagent_collect.
384    pub fn mark_collected(&mut self) {
385        self.collected = true;
386    }
387
388    /// Whether subagent_collect has already read the result.
389    pub fn is_collected(&self) -> bool {
390        self.collected
391    }
392
393    /// Whether execution transports and resumable context have been discarded.
394    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    /// Time elapsed since the subagent reached a terminal state.
403    /// Returns `None` if still running or if `finished_at` has not been stamped yet.
404    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    /// Consume the handle and wait for the final result.
413    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
423// ── SubagentRegistry ─────────────────────────────────────────────────────────────
424
425/// Finished-but-uncollected handles are retained this long before GC.
426pub const FINISHED_HANDLE_TTL: std::time::Duration = std::time::Duration::from_secs(900); // 15 min
427/// Maximum terminal text retained by a transport-free tombstone.
428pub 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    /// Register a handle and return its id.
443    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    /// Drop finished execution transports and resumable context while retaining
462    /// bounded terminal diagnostics and output under normal session retention.
463    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    /// Returns (id, agent_name, status) for every tracked handle.
472    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    /// Snapshot of every tracked handle for TUI reconcile.
480    /// Reads all state under the lock POISON-SAFE; exposes cancel_requested
481    /// which list_active() does not.
482    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    /// Drop handles that are no longer running.
500    /// Iterate over all handles mutably (for bulk operations like cancel-all).
501    pub fn iter_mut_handles(&mut self) -> impl Iterator<Item = &mut SubagentHandle> {
502        self.handles.values_mut()
503    }
504
505    /// Reap a finished handle iff:
506    ///   (a) its result was collected via subagent_collect, OR
507    ///   (b) it has been finished longer than `ttl` (abandoned).
508    /// Finished-but-uncollected handles inside the TTL are RETAINED so the
509    /// completion event can wake the parent and collect still succeeds.
510    ///
511    /// Additionally, handles whose OS thread is still running (e.g. still in
512    /// the finalizer path) are deferred — joining a live thread blocks the TUI
513    /// loop. They will be reaped on the next cleanup pass once the thread exits.
514    /// Orchestration-required expired handles become bounded tombstones instead
515    /// of being removed; a later collect/reconcile makes them normally reapable.
516    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    /// Reap normally, except that an expired uncollected handle still required by
521    /// orchestration is downgraded to a bounded, collectible tombstone.
522    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    /// Reap finished handles using the production TTL.
549    /// Finished-but-uncollected handles within the TTL window are retained.
550    pub fn cleanup_finished(&mut self) {
551        self.cleanup_finished_with_ttl(FINISHED_HANDLE_TTL)
552    }
553}
554
555/// Engine-owned housekeeping seam: reap finished subagent handles at end of
556/// each turn.  Acquires the lock poison-safely — a panicked subagent thread
557/// must not prevent the turn from completing.
558///
559/// Intended to be called from the `tokio::spawn` wrapper in `runtime/mod.rs`
560/// after `run_stream_internal` returns and BEFORE sending `SessionEvent::Done`.
561pub(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    /// Returns the failure reason string if this is a `Failed` variant.
603    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    // Keep receivers alive so channels don't close during tests
617    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        // Parse numeric id from "sa_N" or use 0 as fallback for tests
629        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(); // first call sends
742        h.cancel(); // second call is no-op (already taken)
743    }
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    // Rewritten: finished-but-uncollected handles are RETAINED (new semantics).
779    // The old test encoded the buggy behavior where any finished handle was removed.
780    #[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(); // mark as collected — should be reaped
785        reg.register(h);
786        reg.register(make_handle("sa_2")); // still running — must survive
787
788        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    // U1: finished, not collected, fresh → retained within TTL
801    #[test]
802    fn reaper_retains_finished_uncollected_within_ttl() {
803        let mut reg = SubagentRegistry::new();
804        let h = make_finished_handle("sa_1"); // not collected
805        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    // U2: finished + mark_collected → removed immediately
816    #[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    // U3: finished, not collected, and TTL-expired, but still required by
832    // orchestration → retained as a resource-free tombstone.
833    #[test]
834    fn reaper_tombstones_unreconciled_after_ttl() {
835        let mut reg = SubagentRegistry::new();
836        let h = make_finished_handle("sa_1"); // not collected
837        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    // U5-reaper: finished-status handle with a still-sleeping OS thread is deferred;
860    // after thread exits a second cleanup reaps it.
861    #[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(); // collected — would normally be reaped immediately
866
867        // Attach a real thread that sleeps briefly (simulates thread still in finalizer)
868        let barrier = Arc::new(std::sync::Barrier::new(2));
869        let barrier_c = Arc::clone(&barrier);
870        let thread = std::thread::spawn(move || {
871            // Wait for the test to run its first cleanup
872            barrier_c.wait();
873            // Now sleep a bit more before truly exiting
874            std::thread::sleep(std::time::Duration::from_millis(20));
875        });
876        h.set_thread_handle(thread);
877        reg.register(h);
878
879        // Signal thread (it's waiting on barrier)
880        barrier.wait();
881
882        // First cleanup: thread is still alive → handle must be deferred (retained)
883        // The thread may or may not have exited by the time we get here, so we only
884        // assert the positive case when we know the thread is still live.
885        // Instead: sleep briefly to be sure the thread has NOT exited, then check.
886        // (The thread sleeps 20ms after barrier; we check immediately after barrier.)
887        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
888        // Handle may still be present (thread live) or absent (thread exited) — both ok.
889        // What must NOT happen: a blocking join. The test itself completes in < 1s.
890
891        // Wait for thread to truly exit
892        std::thread::sleep(std::time::Duration::from_millis(50));
893
894        // Second cleanup: thread is finished → handle must be reaped
895        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    // U4: running handles never touched by any reaper variant
903    #[test]
904    fn reaper_never_touches_running() {
905        let mut reg = SubagentRegistry::new();
906        reg.register(make_handle("sa_1")); // Running
907        reg.register(make_handle("sa_2")); // Running
908
909        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(); // production TTL
914        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    // V1: cancel() sets cancel_requested on shared state before sending signal.
942    #[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        // Second call is a no-op (shutdown_tx already taken)
958        h.cancel();
959    }
960
961    // V2: cancelled status counts as finished, is_finished() returns true.
962    #[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    // D1: display_rows returns a row for each registered handle
973    #[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    // D2: display_rows reflects finished status and cancel_requested flag
989    #[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        // Make a handle with cancel_requested = true
996        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    // D3: display_rows on empty registry returns empty vec
1016    #[test]
1017    fn display_rows_empty_registry() {
1018        let reg = SubagentRegistry::new();
1019        assert!(reg.display_rows().is_empty());
1020    }
1021
1022    // ── reap_finished free-function tests ────────────────────────────────────
1023
1024    /// R1: reap_finished with a collected+finished handle in an Arc<Mutex<>> reaps it.
1025    #[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(&registry, None);
1035        assert!(
1036            registry.lock().unwrap().get("sa_r1").is_none(),
1037            "reap_finished must reap collected+finished handle"
1038        );
1039    }
1040
1041    /// R2: reap_finished retains running handles — must not touch live work.
1042    #[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(&registry, None);
1050        assert!(
1051            registry.lock().unwrap().get("sa_running").is_some(),
1052            "reap_finished must not touch running handles"
1053        );
1054    }
1055
1056    /// R3: reap_finished recovers from a poisoned lock without panicking.
1057    #[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        // Poison the mutex by panicking inside a lock guard.
1068        let registry_c = Arc::clone(&registry);
1069        let _ = std::panic::catch_unwind(|| {
1070            let _guard = registry_c.lock().unwrap();
1071            panic!("deliberately poison the mutex");
1072        });
1073
1074        // reap_finished must not panic despite poisoned lock.
1075        super::reap_finished(&registry, None);
1076
1077        // The handle must be reaped (cleanup ran despite poison).
1078        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// Separate module so finalize_subagent is accessible via its pub(crate) path.
1088#[cfg(test)]
1089mod cancelled_wake_tests {
1090    use super::*;
1091    use std::sync::{Arc, RwLock};
1092
1093    // V3: finalize_subagent with cancel_requested=true → status Cancelled,
1094    // no event published, finished_at stamped.
1095    // Test name matches spec: cancelled_suppresses_wake.
1096    #[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; // thread set Completed before cancel flag noticed
1105            s.cancel_requested = true; // cancel() was called
1106        }
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        // Queue must be empty — no wake event for cancelled subagents.
1120        assert!(
1121            queue.is_empty(),
1122            "cancelled subagent must not publish a wake event"
1123        );
1124
1125        // Status must have been re-labelled to Cancelled.
1126        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        // finished_at must be stamped.
1134        assert!(
1135            s.finished_at.is_some(),
1136            "finished_at must be stamped by finalizer"
1137        );
1138    }
1139}