Skip to main content

agent_engine/runtime/
subagent.rs

1use std::collections::HashMap;
2use std::sync::{Arc, RwLock};
3use tokio::sync::{mpsc, oneshot};
4use serde_json::Value;
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}
22
23// ── SubagentStatus ───────────────────────────────────────────────────────────────
24
25#[derive(Debug, Clone, PartialEq)]
26pub enum SubagentStatus {
27    Running,
28    Completed,
29    /// User-aborted via TUI cancel. Never publishes a wake event.
30    Cancelled,
31    TimedOut,
32    Failed(String),
33}
34
35// ── SubagentState ────────────────────────────────────────────────────────────────
36
37/// All mutable state shared between the subagent thread and its handle.
38/// Collapsed behind a single RwLock so a status poll takes exactly one lock.
39#[derive(Debug)]
40pub struct SubagentState {
41    pub status: SubagentStatus,
42    pub partial_text: String,
43    pub tool_log: Vec<String>,
44    pub conversation_state: Vec<Value>,
45    /// Stamped once by finalize_subagent at thread exit.
46    pub finished_at: Option<std::time::Instant>,
47    /// Set by cancel() before the shutdown signal is sent.
48    /// Read by finalize_subagent to label the terminal status correctly.
49    pub cancel_requested: bool,
50}
51
52impl SubagentState {
53    pub fn new() -> Self {
54        Self {
55            status: SubagentStatus::Running,
56            partial_text: String::new(),
57            tool_log: Vec::new(),
58            conversation_state: Vec::new(),
59            finished_at: None,
60            cancel_requested: false,
61        }
62    }
63}
64
65impl Default for SubagentState {
66    fn default() -> Self { Self::new() }
67}
68
69// ── SubagentDisplayRow ────────────────────────────────────────────────────────────
70
71/// Snapshot row produced by SubagentRegistry::display_rows().
72/// Used by the TUI reconcile path as the registry's liveness authority.
73#[derive(Debug, Clone)]
74pub struct SubagentDisplayRow {
75    pub subagent_id: u64,
76    pub agent_name: String,
77    pub status: SubagentStatus,
78    pub cancel_requested: bool,
79    pub elapsed_secs: f64,
80    pub finished_elapsed: Option<std::time::Duration>,
81}
82
83// ── SubagentHandle ───────────────────────────────────────────────────────────────
84
85pub struct SubagentHandle {
86    pub id: String,
87    /// Numeric ID — same value that was passed to SubagentStart/SubagentDone events
88    /// so the TUI can correlate by id without parsing "sa_N" strings.
89    pub subagent_id: u64,
90    pub agent_name: String,
91    pub task_preview: String,
92    pub model: String,
93    pub system_prompt: String,
94    pub started_at: std::time::Instant,
95    pub timeout_secs: u64,
96
97    // Shared state updated by the subagent thread — one lock for everything.
98    state: Arc<RwLock<SubagentState>>,
99
100    // Channels
101    steer_tx: Option<mpsc::UnboundedSender<String>>,
102    shutdown_tx: Option<oneshot::Sender<()>>,
103    /// OS thread running the subagent. Stored for graceful shutdown (join).
104    // OS thread handle for graceful shutdown
105    pub(crate) thread_handle: Option<std::thread::JoinHandle<()>>,
106
107    // Final result
108    result_rx: Option<oneshot::Receiver<SubagentResult>>,
109
110    /// Set to true once subagent_collect has read the terminal result.
111    collected: bool,
112}
113
114impl std::fmt::Debug for SubagentHandle {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("SubagentHandle")
117            .field("id", &self.id)
118            .field("subagent_id", &self.subagent_id)
119            .field("agent_name", &self.agent_name)
120            .field("model", &self.model)
121            .finish_non_exhaustive()
122    }
123}
124
125impl SubagentHandle {
126    /// Construct a new handle. The state Arc is shared with the spawned subagent thread.
127    #[allow(clippy::too_many_arguments)]
128    pub fn new(
129        id: String,
130        subagent_id: u64,
131        agent_name: String,
132        task_preview: String,
133        model: String,
134        system_prompt: String,
135        timeout_secs: u64,
136        state: Arc<RwLock<SubagentState>>,
137        steer_tx: Option<mpsc::UnboundedSender<String>>,
138        shutdown_tx: Option<oneshot::Sender<()>>,
139        result_rx: Option<oneshot::Receiver<SubagentResult>>,
140    ) -> Self {
141        Self {
142            id,
143            subagent_id,
144            agent_name,
145            task_preview,
146            model,
147            system_prompt,
148            started_at: std::time::Instant::now(),
149            timeout_secs,
150            state,
151            steer_tx,
152            shutdown_tx,
153            thread_handle: None,
154            result_rx,
155            collected: false,
156        }
157    }
158
159    /// Current status snapshot.
160    pub fn status(&self) -> SubagentStatus {
161        self.state.read().unwrap_or_else(|p| p.into_inner()).status.clone()
162    }
163
164    /// Partial output accumulated so far.
165    pub fn partial_output(&self) -> String {
166        self.state.read().unwrap_or_else(|p| p.into_inner()).partial_text.clone()
167    }
168
169    /// Snapshot of the tool log.
170    pub fn tool_log(&self) -> Vec<String> {
171        self.state.read().unwrap_or_else(|p| p.into_inner()).tool_log.clone()
172    }
173
174    /// Snapshot of conversation state (for resume).
175    pub fn conversation_state(&self) -> Vec<Value> {
176        self.state.read().unwrap_or_else(|p| p.into_inner()).conversation_state.clone()
177    }
178
179    /// Seconds since this handle was created.
180    pub fn elapsed_secs(&self) -> f64 {
181        self.started_at.elapsed().as_secs_f64()
182    }
183
184    /// Send a steering message into the running subagent.
185    pub fn steer(&self, message: &str) -> Result<(), String> {
186        match &self.steer_tx {
187            Some(tx) => tx
188                .send(message.to_string())
189                .map_err(|e| format!("steer channel closed: {e}")),
190            None => Err("no steer channel on this handle".to_string()),
191        }
192    }
193
194    /// Signal the subagent to shut down.
195    /// Store the OS thread handle for graceful shutdown.
196    pub fn set_thread_handle(&mut self, handle: std::thread::JoinHandle<()>) {
197        self.thread_handle = Some(handle);
198    }
199
200    pub fn cancel(&mut self) {
201        // Flag FIRST so finalize_subagent can read it before the thread exits.
202        // Use poison-safe write in case the thread panicked holding the lock.
203        self.state.write().unwrap_or_else(|p| p.into_inner()).cancel_requested = true;
204        if let Some(tx) = self.shutdown_tx.take() {
205            let _ = tx.send(());
206        }
207    }
208
209    /// True if the subagent is no longer running.
210    pub fn is_finished(&self) -> bool {
211        !matches!(self.status(), SubagentStatus::Running)
212    }
213
214    /// Mark this handle as collected by subagent_collect.
215    pub fn mark_collected(&mut self) {
216        self.collected = true;
217    }
218
219    /// Whether subagent_collect has already read the result.
220    pub fn is_collected(&self) -> bool {
221        self.collected
222    }
223
224    /// Time elapsed since the subagent reached a terminal state.
225    /// Returns `None` if still running or if `finished_at` has not been stamped yet.
226    pub fn finished_elapsed(&self) -> Option<std::time::Duration> {
227        self.state.read().unwrap_or_else(|p| p.into_inner()).finished_at.map(|t| t.elapsed())
228    }
229
230    /// Consume the handle and wait for the final result.
231    pub async fn collect(mut self) -> Result<SubagentResult, String> {
232        match self.result_rx.take() {
233            Some(rx) => rx.await.map_err(|_| "subagent result channel dropped".to_string()),
234            None => Err("no result receiver — already collected or never set".to_string()),
235        }
236    }
237}
238
239// ── SubagentRegistry ─────────────────────────────────────────────────────────────
240
241/// Finished-but-uncollected handles are retained this long before GC.
242pub const FINISHED_HANDLE_TTL: std::time::Duration = std::time::Duration::from_secs(900); // 15 min
243
244#[derive(Debug)]
245pub struct SubagentRegistry {
246    pub(crate) handles: HashMap<String, SubagentHandle>,
247}
248
249impl SubagentRegistry {
250    pub fn new() -> Self {
251        Self {
252            handles: HashMap::new(),
253        }
254    }
255
256    /// Register a handle and return its id.
257    pub fn register(&mut self, handle: SubagentHandle) -> String {
258        let id = handle.id.clone();
259        self.handles.insert(id.clone(), handle);
260        id
261    }
262
263    pub fn get(&self, id: &str) -> Option<&SubagentHandle> {
264        self.handles.get(id)
265    }
266
267    pub fn get_mut(&mut self, id: &str) -> Option<&mut SubagentHandle> {
268        self.handles.get_mut(id)
269    }
270
271    pub fn remove(&mut self, id: &str) -> Option<SubagentHandle> {
272        self.handles.remove(id)
273    }
274
275    /// Returns (id, agent_name, status) for every tracked handle.
276    pub fn list_active(&self) -> Vec<(String, String, SubagentStatus)> {
277        self.handles
278            .values()
279            .map(|h| (h.id.clone(), h.agent_name.clone(), h.status()))
280            .collect()
281    }
282
283    /// Snapshot of every tracked handle for TUI reconcile.
284    /// Reads all state under the lock POISON-SAFE; exposes cancel_requested
285    /// which list_active() does not.
286    pub fn display_rows(&self) -> Vec<SubagentDisplayRow> {
287        self.handles
288            .values()
289            .map(|h| {
290                let s = h.state.read().unwrap_or_else(|p| p.into_inner());
291                SubagentDisplayRow {
292                    subagent_id: h.subagent_id,
293                    agent_name: h.agent_name.clone(),
294                    status: s.status.clone(),
295                    cancel_requested: s.cancel_requested,
296                    elapsed_secs: h.started_at.elapsed().as_secs_f64(),
297                    finished_elapsed: s.finished_at.map(|t| t.elapsed()),
298                }
299            })
300            .collect()
301    }
302
303    /// Drop handles that are no longer running.
304    /// Iterate over all handles mutably (for bulk operations like cancel-all).
305    pub fn iter_mut_handles(&mut self) -> impl Iterator<Item = &mut SubagentHandle> {
306        self.handles.values_mut()
307    }
308
309    /// Reap a finished handle iff:
310    ///   (a) its result was collected via subagent_collect, OR
311    ///   (b) it has been finished longer than `ttl` (abandoned).
312    /// Finished-but-uncollected handles inside the TTL are RETAINED so the
313    /// completion event can wake the parent and collect still succeeds.
314    ///
315    /// Additionally, handles whose OS thread is still running (e.g. still in
316    /// the finalizer path) are deferred — joining a live thread blocks the TUI
317    /// loop. They will be reaped on the next cleanup pass once the thread exits.
318    pub fn cleanup_finished_with_ttl(&mut self, ttl: std::time::Duration) {
319        let reap_ids: Vec<String> = self.handles.iter()
320            .filter(|(_, h)| h.is_finished()
321                // Defer handles whose OS thread is still alive — joining a live
322                // thread would block the TUI event loop. Use map_or (stable 1.80+)
323                // rather than is_none_or (stable 1.82+).
324                && h.thread_handle.as_ref().map_or(true, |t| t.is_finished())
325                && (h.is_collected()
326                    || h.finished_elapsed().is_some_and(|d| d >= ttl)))
327            .map(|(id, _)| id.clone())
328            .collect();
329        for id in reap_ids {
330            if let Some(mut handle) = self.handles.remove(&id) {
331                if let Some(th) = handle.thread_handle.take() {
332                    let _ = th.join();
333                }
334            }
335        }
336    }
337
338    /// Reap finished handles using the production TTL.
339    /// Finished-but-uncollected handles within the TTL window are retained.
340    pub fn cleanup_finished(&mut self) {
341        self.cleanup_finished_with_ttl(FINISHED_HANDLE_TTL)
342    }
343}
344
345/// Engine-owned housekeeping seam: reap finished subagent handles at end of
346/// each turn.  Acquires the lock poison-safely — a panicked subagent thread
347/// must not prevent the turn from completing.
348///
349/// Intended to be called from the `tokio::spawn` wrapper in `runtime/mod.rs`
350/// after `run_stream_internal` returns and BEFORE sending `SessionEvent::Done`.
351pub fn reap_finished(registry: &Arc<std::sync::Mutex<SubagentRegistry>>) {
352    match registry.lock() {
353        Ok(mut guard) => guard.cleanup_finished(),
354        Err(poisoned) => poisoned.into_inner().cleanup_finished(),
355    }
356}
357
358impl Default for SubagentRegistry {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364impl SubagentStatus {
365    pub fn as_str(&self) -> &str {
366        match self {
367            SubagentStatus::Running => "running",
368            SubagentStatus::Completed => "completed",
369            SubagentStatus::Cancelled => "cancelled",
370            SubagentStatus::TimedOut => "timed_out",
371            SubagentStatus::Failed(_) => "failed",
372        }
373    }
374
375    /// Returns the failure reason string if this is a `Failed` variant.
376    pub fn failure_reason(&self) -> Option<&str> {
377        match self {
378            SubagentStatus::Failed(r) => Some(r.as_str()),
379            _ => None,
380        }
381    }
382}
383
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use tokio::sync::{mpsc, oneshot};
389
390    // Keep receivers alive so channels don't close during tests
391    struct TestHandle {
392        handle: SubagentHandle,
393        _steer_rx: mpsc::UnboundedReceiver<String>,
394        _shutdown_rx: oneshot::Receiver<()>,
395    }
396
397    fn make_test_handle(id: &str) -> TestHandle {
398        let state = Arc::new(RwLock::new(SubagentState::new()));
399        let (steer_tx, steer_rx) = mpsc::unbounded_channel();
400        let (shutdown_tx, shutdown_rx) = oneshot::channel();
401        let (_result_tx, result_rx) = oneshot::channel();
402        // Parse numeric id from "sa_N" or use 0 as fallback for tests
403        let numeric_id: u64 = id.strip_prefix("sa_").and_then(|n| n.parse().ok()).unwrap_or(0);
404        TestHandle {
405            handle: SubagentHandle::new(
406                id.to_string(),
407                numeric_id,
408                "test-agent".to_string(),
409                "test task".to_string(),
410                "claude-sonnet-4-6".to_string(),
411                "You are a test agent.".to_string(),
412                300,
413                state,
414                Some(steer_tx),
415                Some(shutdown_tx),
416                Some(result_rx),
417            ),
418            _steer_rx: steer_rx,
419            _shutdown_rx: shutdown_rx,
420        }
421    }
422
423    fn make_handle(id: &str) -> SubagentHandle {
424        make_test_handle(id).handle
425    }
426
427    fn make_finished_handle(id: &str) -> SubagentHandle {
428        let h = make_handle(id);
429        {
430            let mut s = h.state.write().unwrap();
431            s.status = SubagentStatus::Completed;
432            s.finished_at = Some(std::time::Instant::now());
433        }
434        h
435    }
436
437    #[test]
438    fn handle_initial_status_is_running() {
439        let h = make_handle("sa_1");
440        assert_eq!(h.status(), SubagentStatus::Running);
441        assert!(!h.is_finished());
442    }
443
444    #[test]
445    fn handle_partial_output_empty_initially() {
446        let h = make_handle("sa_1");
447        assert_eq!(h.partial_output(), "");
448        assert!(h.tool_log().is_empty());
449        assert!(h.conversation_state().is_empty());
450    }
451
452    #[test]
453    fn handle_status_reflects_state_change() {
454        let h = make_handle("sa_1");
455        {
456            let mut s = h.state.write().unwrap();
457            s.status = SubagentStatus::Completed;
458            s.partial_text = "done!".to_string();
459        }
460        assert_eq!(h.status(), SubagentStatus::Completed);
461        assert!(h.is_finished());
462        assert_eq!(h.partial_output(), "done!");
463    }
464
465    #[test]
466    fn handle_steer_sends_message() {
467        let th = make_test_handle("sa_1");
468        assert!(th.handle.steer("redirect").is_ok());
469    }
470
471    #[test]
472    fn handle_steer_fails_without_channel() {
473        let state = Arc::new(RwLock::new(SubagentState::new()));
474        let (_shutdown_tx, _) = oneshot::channel::<()>();
475        let (_, result_rx) = oneshot::channel();
476        let h = SubagentHandle::new(
477            "sa_1".into(), 1, "test".into(), "task".into(),
478            "model".into(), "prompt".into(), 300, state, None, None, Some(result_rx),
479        );
480        assert!(h.steer("msg").is_err());
481    }
482
483    #[test]
484    fn handle_cancel_consumes_shutdown() {
485        let mut h = make_handle("sa_1");
486        h.cancel(); // first call sends
487        h.cancel(); // second call is no-op (already taken)
488    }
489
490    #[test]
491    fn handle_elapsed_increases() {
492        let h = make_handle("sa_1");
493        std::thread::sleep(std::time::Duration::from_millis(10));
494        assert!(h.elapsed_secs() > 0.0);
495    }
496
497    #[test]
498    fn registry_register_and_get() {
499        let mut reg = SubagentRegistry::new();
500        let h = make_handle("sa_1");
501        reg.register(h);
502        assert!(reg.get("sa_1").is_some());
503        assert!(reg.get("sa_99").is_none());
504    }
505
506    #[test]
507    fn registry_remove() {
508        let mut reg = SubagentRegistry::new();
509        reg.register(make_handle("sa_1"));
510        assert!(reg.remove("sa_1").is_some());
511        assert!(reg.get("sa_1").is_none());
512    }
513
514    #[test]
515    fn registry_list_active() {
516        let mut reg = SubagentRegistry::new();
517        reg.register(make_handle("sa_1"));
518        reg.register(make_handle("sa_2"));
519        let active = reg.list_active();
520        assert_eq!(active.len(), 2);
521    }
522
523    // Rewritten: finished-but-uncollected handles are RETAINED (new semantics).
524    // The old test encoded the buggy behavior where any finished handle was removed.
525    #[test]
526    fn registry_cleanup_finished() {
527        let mut reg = SubagentRegistry::new();
528        let mut h = make_finished_handle("sa_1");
529        h.mark_collected(); // mark as collected — should be reaped
530        reg.register(h);
531        reg.register(make_handle("sa_2")); // still running — must survive
532
533        reg.cleanup_finished();
534
535        assert!(reg.get("sa_1").is_none(), "collected finished handle must be reaped");
536        assert!(reg.get("sa_2").is_some(), "running handle must survive cleanup");
537    }
538
539    // U1: finished, not collected, fresh → retained within TTL
540    #[test]
541    fn reaper_retains_finished_uncollected_within_ttl() {
542        let mut reg = SubagentRegistry::new();
543        let h = make_finished_handle("sa_1"); // not collected
544        reg.register(h);
545
546        reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
547
548        assert!(reg.get("sa_1").is_some(), "finished-uncollected handle within TTL must be retained");
549    }
550
551    // U2: finished + mark_collected → removed immediately
552    #[test]
553    fn reaper_reaps_finished_and_collected() {
554        let mut reg = SubagentRegistry::new();
555        let mut h = make_finished_handle("sa_1");
556        h.mark_collected();
557        reg.register(h);
558
559        reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
560
561        assert!(reg.get("sa_1").is_none(), "finished+collected handle must be reaped");
562    }
563
564    // U3: finished, not collected, but TTL=ZERO (expired) → removed
565    #[test]
566    fn reaper_reaps_abandoned_after_ttl() {
567        let mut reg = SubagentRegistry::new();
568        let h = make_finished_handle("sa_1"); // not collected
569        reg.register(h);
570
571        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
572
573        assert!(reg.get("sa_1").is_none(), "handle past TTL must be reaped even if uncollected");
574    }
575
576    // U5-reaper: finished-status handle with a still-sleeping OS thread is deferred;
577    // after thread exits a second cleanup reaps it.
578    #[test]
579    fn reaper_defers_handle_with_live_thread() {
580        let mut reg = SubagentRegistry::new();
581        let mut h = make_finished_handle("sa_live");
582        h.mark_collected(); // collected — would normally be reaped immediately
583
584        // Attach a real thread that sleeps briefly (simulates thread still in finalizer)
585        let barrier = Arc::new(std::sync::Barrier::new(2));
586        let barrier_c = Arc::clone(&barrier);
587        let thread = std::thread::spawn(move || {
588            // Wait for the test to run its first cleanup
589            barrier_c.wait();
590            // Now sleep a bit more before truly exiting
591            std::thread::sleep(std::time::Duration::from_millis(20));
592        });
593        h.set_thread_handle(thread);
594        reg.register(h);
595
596        // Signal thread (it's waiting on barrier)
597        barrier.wait();
598
599        // First cleanup: thread is still alive → handle must be deferred (retained)
600        // The thread may or may not have exited by the time we get here, so we only
601        // assert the positive case when we know the thread is still live.
602        // Instead: sleep briefly to be sure the thread has NOT exited, then check.
603        // (The thread sleeps 20ms after barrier; we check immediately after barrier.)
604        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
605        // Handle may still be present (thread live) or absent (thread exited) — both ok.
606        // What must NOT happen: a blocking join. The test itself completes in < 1s.
607
608        // Wait for thread to truly exit
609        std::thread::sleep(std::time::Duration::from_millis(50));
610
611        // Second cleanup: thread is finished → handle must be reaped
612        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
613        assert!(reg.get("sa_live").is_none(), "handle must be reaped once OS thread exits");
614    }
615
616    // U4: running handles never touched by any reaper variant
617    #[test]
618    fn reaper_never_touches_running() {
619        let mut reg = SubagentRegistry::new();
620        reg.register(make_handle("sa_1")); // Running
621        reg.register(make_handle("sa_2")); // Running
622
623        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
624        assert!(reg.get("sa_1").is_some());
625        assert!(reg.get("sa_2").is_some());
626
627        reg.cleanup_finished(); // production TTL
628        assert!(reg.get("sa_1").is_some());
629        assert!(reg.get("sa_2").is_some());
630    }
631
632    #[test]
633    fn subagent_state_new_defaults() {
634        let s = SubagentState::new();
635        assert_eq!(s.status, SubagentStatus::Running);
636        assert!(s.partial_text.is_empty());
637        assert!(s.tool_log.is_empty());
638        assert!(s.conversation_state.is_empty());
639        assert!(s.finished_at.is_none());
640        assert!(!s.cancel_requested, "cancel_requested must default to false");
641    }
642
643    #[test]
644    fn subagent_status_as_str() {
645        assert_eq!(SubagentStatus::Running.as_str(), "running");
646        assert_eq!(SubagentStatus::Completed.as_str(), "completed");
647        assert_eq!(SubagentStatus::Cancelled.as_str(), "cancelled");
648        assert_eq!(SubagentStatus::TimedOut.as_str(), "timed_out");
649        assert_eq!(SubagentStatus::Failed("oops".into()).as_str(), "failed");
650    }
651
652    // V1: cancel() sets cancel_requested on shared state before sending signal.
653    #[test]
654    fn cancel_sets_cancel_requested_flag() {
655        let mut h = make_handle("sa_cancel");
656        {
657            let s = h.state.read().unwrap();
658            assert!(!s.cancel_requested, "cancel_requested must start false");
659        }
660        h.cancel();
661        {
662            let s = h.state.read().unwrap();
663            assert!(s.cancel_requested, "cancel_requested must be true after cancel()");
664        }
665        // Second call is a no-op (shutdown_tx already taken)
666        h.cancel();
667    }
668
669    // V2: cancelled status counts as finished, is_finished() returns true.
670    #[test]
671    fn cancelled_status_is_finished() {
672        let h = make_handle("sa_c1");
673        {
674            let mut s = h.state.write().unwrap();
675            s.status = SubagentStatus::Cancelled;
676        }
677        assert!(h.is_finished(), "Cancelled must count as finished");
678    }
679
680    // D1: display_rows returns a row for each registered handle
681    #[test]
682    fn display_rows_running_handle() {
683        let mut reg = SubagentRegistry::new();
684        reg.register(make_handle("sa_1"));
685        reg.register(make_handle("sa_2"));
686        let rows = reg.display_rows();
687        assert_eq!(rows.len(), 2);
688        for row in &rows {
689            assert_eq!(row.status, SubagentStatus::Running);
690            assert!(!row.cancel_requested);
691            assert!(row.elapsed_secs >= 0.0);
692            assert!(row.finished_elapsed.is_none());
693        }
694    }
695
696    // D2: display_rows reflects finished status and cancel_requested flag
697    #[test]
698    fn display_rows_finished_and_cancel_requested() {
699        let mut reg = SubagentRegistry::new();
700        let h = make_finished_handle("sa_1");
701        reg.register(h);
702
703        // Make a handle with cancel_requested = true
704        let th = make_test_handle("sa_2");
705        {
706            let mut s = th.handle.state.write().unwrap();
707            s.cancel_requested = true;
708        }
709        reg.register(th.handle);
710
711        let rows = reg.display_rows();
712        assert_eq!(rows.len(), 2);
713
714        let finished = rows.iter().find(|r| r.subagent_id == 1).unwrap();
715        assert_eq!(finished.status, SubagentStatus::Completed);
716        assert!(!finished.cancel_requested);
717        assert!(finished.finished_elapsed.is_some());
718
719        let cancelling = rows.iter().find(|r| r.subagent_id == 2).unwrap();
720        assert!(cancelling.cancel_requested);
721    }
722
723    // D3: display_rows on empty registry returns empty vec
724    #[test]
725    fn display_rows_empty_registry() {
726        let reg = SubagentRegistry::new();
727        assert!(reg.display_rows().is_empty());
728    }
729
730    // ── reap_finished free-function tests ────────────────────────────────────
731
732    /// R1: reap_finished with a collected+finished handle in an Arc<Mutex<>> reaps it.
733    #[test]
734    fn reap_finished_headless_reaps_collected() {
735        let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
736        {
737            let mut reg = registry.lock().unwrap();
738            let mut h = make_finished_handle("sa_r1");
739            h.mark_collected();
740            reg.register(h);
741        }
742        super::reap_finished(&registry);
743        assert!(registry.lock().unwrap().get("sa_r1").is_none(),
744            "reap_finished must reap collected+finished handle");
745    }
746
747    /// R2: reap_finished retains running handles — must not touch live work.
748    #[test]
749    fn reap_finished_headless_retains_running() {
750        let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
751        {
752            let mut reg = registry.lock().unwrap();
753            reg.register(make_handle("sa_running"));
754        }
755        super::reap_finished(&registry);
756        assert!(registry.lock().unwrap().get("sa_running").is_some(),
757            "reap_finished must not touch running handles");
758    }
759
760    /// R3: reap_finished recovers from a poisoned lock without panicking.
761    #[test]
762    fn reap_finished_poisoned_lock_recovery() {
763        let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
764        {
765            let mut reg = registry.lock().unwrap();
766            let mut h = make_finished_handle("sa_poison");
767            h.mark_collected();
768            reg.register(h);
769        }
770
771        // Poison the mutex by panicking inside a lock guard.
772        let registry_c = Arc::clone(&registry);
773        let _ = std::panic::catch_unwind(|| {
774            let _guard = registry_c.lock().unwrap();
775            panic!("deliberately poison the mutex");
776        });
777
778        // reap_finished must not panic despite poisoned lock.
779        super::reap_finished(&registry);
780
781        // The handle must be reaped (cleanup ran despite poison).
782        let result = registry.lock()
783            .unwrap_or_else(|p| p.into_inner())
784            .get("sa_poison")
785            .is_none();
786        assert!(result, "reap_finished must run cleanup even after poison");
787    }
788}
789
790// Separate module so finalize_subagent is accessible via its pub(crate) path.
791#[cfg(test)]
792mod cancelled_wake_tests {
793    use super::*;
794    use std::sync::{Arc, RwLock};
795
796    // V3: finalize_subagent with cancel_requested=true → status Cancelled,
797    // no event published, finished_at stamped.
798    // Test name matches spec: cancelled_suppresses_wake.
799    #[test]
800    fn cancelled_suppresses_wake() {
801        use crate::events::EventQueue;
802        use crate::tools::finalize_subagent;
803
804        let state = Arc::new(RwLock::new(SubagentState::new()));
805        {
806            let mut s = state.write().unwrap();
807            s.status = SubagentStatus::Completed; // thread set Completed before cancel flag noticed
808            s.cancel_requested = true;            // cancel() was called
809        }
810        let queue = Arc::new(EventQueue::new(100));
811
812        finalize_subagent(
813            &state,
814            Some(&queue),
815            "sa_v3", 42, "test-agent",
816            std::time::Instant::now(),
817            None,
818        );
819
820        // Queue must be empty — no wake event for cancelled subagents.
821        assert!(queue.is_empty(), "cancelled subagent must not publish a wake event");
822
823        // Status must have been re-labelled to Cancelled.
824        let s = state.read().unwrap();
825        assert_eq!(s.status, SubagentStatus::Cancelled, "status must be Cancelled when cancel_requested");
826
827        // finished_at must be stamped.
828        assert!(s.finished_at.is_some(), "finished_at must be stamped by finalizer");
829    }
830}