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, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum SubagentStatus {
81    Running,
82    Completed,
83    /// User-aborted via TUI cancel. Never publishes a wake event.
84    Cancelled,
85    TimedOut,
86    Failed(String),
87}
88
89// ── SubagentState ────────────────────────────────────────────────────────────────
90
91/// All mutable state shared between the subagent thread and its handle.
92/// Collapsed behind a single RwLock so a status poll takes exactly one lock.
93#[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    /// Stamped once by finalize_subagent at thread exit.
100    pub finished_at: Option<std::time::Instant>,
101    /// Set by cancel() before the shutdown signal is sent.
102    /// Read by finalize_subagent to label the terminal status correctly.
103    pub cancel_requested: bool,
104    pub terminal: Option<TerminalDiagnostic>,
105}
106
107impl SubagentState {
108    pub fn new() -> Self {
109        Self {
110            status: SubagentStatus::Running,
111            partial_text: String::new(),
112            tool_log: Vec::new(),
113            conversation_state: Vec::new(),
114            finished_at: None,
115            cancel_requested: false,
116            terminal: None,
117        }
118    }
119
120    /// Persist only typed, allowlisted failure data. Raw provider errors never enter state.
121    pub fn safe_fail(&mut self, mut diagnostic: TerminalDiagnostic) {
122        diagnostic.safe_message = safe_failure_message(&diagnostic.category).into();
123        self.status = SubagentStatus::Failed(diagnostic.safe_message.clone());
124        self.terminal = Some(diagnostic);
125    }
126}
127
128impl Default for SubagentState {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134// ── SubagentDisplayRow ────────────────────────────────────────────────────────────
135
136/// Snapshot row produced by SubagentRegistry::display_rows().
137/// Used by the TUI reconcile path as the registry's liveness authority.
138/// Serde: travels in `SessionEventWire::SubagentRows` (phase 3).
139#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
140pub struct SubagentDisplayRow {
141    pub subagent_id: u64,
142    pub agent_name: String,
143    pub status: SubagentStatus,
144    pub cancel_requested: bool,
145    pub elapsed_secs: f64,
146    pub finished_elapsed: Option<std::time::Duration>,
147}
148
149// ── SubagentHandle ───────────────────────────────────────────────────────────────
150
151pub struct SubagentHandle {
152    pub id: String,
153    /// Numeric ID — same value that was passed to SubagentStart/SubagentDone events
154    /// so the TUI can correlate by id without parsing "sa_N" strings.
155    pub subagent_id: u64,
156    pub agent_name: String,
157    pub task_preview: String,
158    pub model: String,
159    pub system_prompt: String,
160    pub started_at: std::time::Instant,
161    pub timeout_secs: u64,
162    pub authorization: Option<AuthorizationDiagnostic>,
163
164    // Shared state updated by the subagent thread — one lock for everything.
165    state: Arc<RwLock<SubagentState>>,
166
167    // Channels
168    steer_tx: Option<mpsc::UnboundedSender<String>>,
169    shutdown_tx: Option<oneshot::Sender<()>>,
170    /// OS thread running the subagent. Stored for graceful shutdown (join).
171    // OS thread handle for graceful shutdown
172    pub(crate) thread_handle: Option<std::thread::JoinHandle<()>>,
173
174    // Final result
175    result_rx: Option<oneshot::Receiver<SubagentResult>>,
176
177    /// Set to true once subagent_collect has read the terminal result.
178    collected: bool,
179}
180
181impl std::fmt::Debug for SubagentHandle {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("SubagentHandle")
184            .field("id", &self.id)
185            .field("subagent_id", &self.subagent_id)
186            .field("agent_name", &self.agent_name)
187            .field("model", &self.model)
188            .finish_non_exhaustive()
189    }
190}
191
192impl SubagentHandle {
193    /// Construct a new handle. The state Arc is shared with the spawned subagent thread.
194    #[allow(clippy::too_many_arguments)]
195    pub fn new(
196        id: String,
197        subagent_id: u64,
198        agent_name: String,
199        task_preview: String,
200        model: String,
201        system_prompt: String,
202        timeout_secs: u64,
203        state: Arc<RwLock<SubagentState>>,
204        steer_tx: Option<mpsc::UnboundedSender<String>>,
205        shutdown_tx: Option<oneshot::Sender<()>>,
206        result_rx: Option<oneshot::Receiver<SubagentResult>>,
207    ) -> Self {
208        Self {
209            id,
210            subagent_id,
211            agent_name,
212            task_preview,
213            model,
214            system_prompt,
215            started_at: std::time::Instant::now(),
216            timeout_secs,
217            authorization: None,
218            state,
219            steer_tx,
220            shutdown_tx,
221            thread_handle: None,
222            result_rx,
223            collected: false,
224        }
225    }
226
227    pub fn with_authorization(
228        mut self,
229        decision: &crate::orchestration::AuthorizedWorkerModel,
230    ) -> Self {
231        self.authorization = Some(AuthorizationDiagnostic {
232            selection_source: decision.selection_source.as_str().into(),
233            policy_digest: decision.policy_digest.clone(),
234            catalog_snapshot_id: decision.catalog_snapshot_id.clone(),
235            catalog_digest: decision.catalog_digest.clone(),
236            correlation_id: decision.correlation_id.clone(),
237            network_attempted: decision.network_attempted,
238            cross_provider_grant_id: decision.cross_provider_grant_id.clone(),
239        });
240        self
241    }
242
243    pub fn set_terminal_diagnostic(&self, diagnostic: TerminalDiagnostic) {
244        let mut state = self.state.write().unwrap();
245        state.safe_fail(diagnostic);
246    }
247
248    /// Current status snapshot.
249    pub fn status(&self) -> SubagentStatus {
250        self.state
251            .read()
252            .unwrap_or_else(|p| p.into_inner())
253            .status
254            .clone()
255    }
256
257    /// Partial output accumulated so far.
258    pub fn partial_output(&self) -> String {
259        self.state
260            .read()
261            .unwrap_or_else(|p| p.into_inner())
262            .partial_text
263            .clone()
264    }
265
266    /// Snapshot of the tool log.
267    pub fn tool_log(&self) -> Vec<String> {
268        self.state
269            .read()
270            .unwrap_or_else(|p| p.into_inner())
271            .tool_log
272            .clone()
273    }
274
275    pub fn terminal_diagnostic(&self) -> Option<TerminalDiagnostic> {
276        if let Some(diagnostic) = self.state.read().unwrap().terminal.clone() {
277            return Some(diagnostic);
278        }
279        match self.status() {
280            SubagentStatus::Running => None,
281            SubagentStatus::Completed => Some(TerminalDiagnostic {
282                category: TerminalCategory::Completed,
283                code: "completed".into(),
284                stage: "inference".into(),
285                correlation_id: self.id.clone(),
286                network_attempted: true,
287                safe_message: safe_failure_message(&TerminalCategory::Completed).into(),
288            }),
289            SubagentStatus::TimedOut => Some(TerminalDiagnostic {
290                category: TerminalCategory::TimedOut,
291                code: "worker_timeout".into(),
292                stage: "inference".into(),
293                correlation_id: self.id.clone(),
294                network_attempted: true,
295                safe_message: safe_failure_message(&TerminalCategory::TimedOut).into(),
296            }),
297            SubagentStatus::Cancelled => Some(TerminalDiagnostic {
298                category: TerminalCategory::Cancelled,
299                code: "worker_cancelled".into(),
300                stage: "inference".into(),
301                correlation_id: self.id.clone(),
302                network_attempted: true,
303                safe_message: safe_failure_message(&TerminalCategory::Cancelled).into(),
304            }),
305            SubagentStatus::Failed(_) => Some(TerminalDiagnostic {
306                category: TerminalCategory::ExecutionFailed,
307                code: "inference_failed".into(),
308                stage: "inference".into(),
309                correlation_id: self.id.clone(),
310                network_attempted: true,
311                safe_message: safe_failure_message(&TerminalCategory::ExecutionFailed).into(),
312            }),
313        }
314    }
315
316    /// Snapshot of conversation state (for resume).
317    pub fn conversation_state(&self) -> Vec<Value> {
318        self.state
319            .read()
320            .unwrap_or_else(|p| p.into_inner())
321            .conversation_state
322            .clone()
323    }
324
325    /// Seconds since this handle was created.
326    pub fn elapsed_secs(&self) -> f64 {
327        self.started_at.elapsed().as_secs_f64()
328    }
329
330    /// Release execution-only resources after a worker has expired while
331    /// retaining bounded terminal identity/output for collection and reconciliation.
332    fn make_tombstone(&mut self) {
333        debug_assert!(self.is_finished());
334        self.steer_tx.take();
335        self.shutdown_tx.take();
336        self.result_rx.take();
337        if let Some(thread) = self.thread_handle.take() {
338            let _ = thread.join();
339        }
340        let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
341        if state.partial_text.len() > TOMBSTONE_OUTPUT_MAX_BYTES {
342            let mut start = state.partial_text.len() - TOMBSTONE_OUTPUT_MAX_BYTES;
343            while !state.partial_text.is_char_boundary(start) {
344                start += 1;
345            }
346            state.partial_text.drain(..start);
347        }
348        state.tool_log.clear();
349        state.conversation_state.clear();
350    }
351
352    /// Send a steering message into the running subagent.
353    pub fn steer(&self, message: &str) -> Result<(), String> {
354        match &self.steer_tx {
355            Some(tx) => tx
356                .send(message.to_string())
357                .map_err(|e| format!("steer channel closed: {e}")),
358            None => Err("no steer channel on this handle".to_string()),
359        }
360    }
361
362    /// Signal the subagent to shut down.
363    /// Store the OS thread handle for graceful shutdown.
364    pub fn set_thread_handle(&mut self, handle: std::thread::JoinHandle<()>) {
365        self.thread_handle = Some(handle);
366    }
367
368    pub fn cancel(&mut self) {
369        // Flag FIRST so finalize_subagent can read it before the thread exits.
370        // Use poison-safe write in case the thread panicked holding the lock.
371        self.state
372            .write()
373            .unwrap_or_else(|p| p.into_inner())
374            .cancel_requested = true;
375        if let Some(tx) = self.shutdown_tx.take() {
376            let _ = tx.send(());
377        }
378    }
379
380    /// True if the subagent is no longer running.
381    pub fn is_finished(&self) -> bool {
382        !matches!(self.status(), SubagentStatus::Running)
383    }
384
385    /// Mark this handle as collected by subagent_collect.
386    pub fn mark_collected(&mut self) {
387        self.collected = true;
388    }
389
390    /// Whether subagent_collect has already read the result.
391    pub fn is_collected(&self) -> bool {
392        self.collected
393    }
394
395    /// Whether execution transports and resumable context have been discarded.
396    pub fn is_tombstone(&self) -> bool {
397        self.is_finished()
398            && self.steer_tx.is_none()
399            && self.shutdown_tx.is_none()
400            && self.result_rx.is_none()
401            && self.thread_handle.is_none()
402    }
403
404    /// Time elapsed since the subagent reached a terminal state.
405    /// Returns `None` if still running or if `finished_at` has not been stamped yet.
406    pub fn finished_elapsed(&self) -> Option<std::time::Duration> {
407        self.state
408            .read()
409            .unwrap_or_else(|p| p.into_inner())
410            .finished_at
411            .map(|t| t.elapsed())
412    }
413
414    /// Consume the handle and wait for the final result.
415    pub async fn collect(mut self) -> Result<SubagentResult, String> {
416        match self.result_rx.take() {
417            Some(rx) => rx
418                .await
419                .map_err(|_| "subagent result channel dropped".to_string()),
420            None => Err("no result receiver — already collected or never set".to_string()),
421        }
422    }
423}
424
425// ── SubagentRegistry ─────────────────────────────────────────────────────────────
426
427/// Finished-but-uncollected handles are retained this long before GC.
428pub const FINISHED_HANDLE_TTL: std::time::Duration = std::time::Duration::from_secs(900); // 15 min
429/// Maximum terminal text retained by a transport-free tombstone.
430pub const TOMBSTONE_OUTPUT_MAX_BYTES: usize = 32 * 1024;
431
432#[derive(Debug)]
433pub struct SubagentRegistry {
434    pub(crate) handles: HashMap<String, SubagentHandle>,
435    // A local session driver owns worker launches until explicit user takeover.
436    // Keep the cancelled fence after revocation so racing late registrations
437    // are cancelled before their thread can start inference.
438    spawn_cancellation: Option<crate::CancellationToken>,
439    spawn_epoch: u64,
440}
441
442impl SubagentRegistry {
443    pub fn new() -> Self {
444        Self {
445            handles: HashMap::new(),
446            spawn_cancellation: None,
447            spawn_epoch: 0,
448        }
449    }
450
451    /// Register a handle and return its id.
452    pub fn register(&mut self, handle: SubagentHandle) -> String {
453        self.register_with_cancellation(handle, None)
454    }
455
456    pub fn register_with_cancellation(
457        &mut self,
458        mut handle: SubagentHandle,
459        origin: Option<&crate::CancellationToken>,
460    ) -> String {
461        if origin.is_some_and(|c| c.is_cancelled())
462            || self
463                .spawn_cancellation
464                .as_ref()
465                .is_some_and(|c| c.is_cancelled())
466        {
467            handle.cancel();
468        }
469        let id = handle.id.clone();
470        self.handles.insert(id.clone(), handle);
471        id
472    }
473
474    /// Frontend-owned launch fence. Model tools cannot clear or replace it.
475    pub fn set_spawn_cancellation(&mut self, cancel: Option<crate::CancellationToken>) -> u64 {
476        self.spawn_epoch = self
477            .spawn_epoch
478            .checked_add(1)
479            .expect("worker launch epoch exhausted");
480        self.spawn_cancellation = cancel;
481        self.spawn_epoch
482    }
483
484    /// A stale deadline/Drop must not cancel workers belonging to newer work.
485    pub fn cancel_spawn_epoch(&mut self, epoch: u64) {
486        if self.spawn_epoch == epoch {
487            for handle in self.iter_mut_handles() {
488                handle.cancel();
489            }
490        }
491    }
492
493    pub fn get(&self, id: &str) -> Option<&SubagentHandle> {
494        self.handles.get(id)
495    }
496
497    pub fn get_mut(&mut self, id: &str) -> Option<&mut SubagentHandle> {
498        self.handles.get_mut(id)
499    }
500
501    pub fn remove(&mut self, id: &str) -> Option<SubagentHandle> {
502        self.handles.remove(id)
503    }
504
505    /// Drop finished execution transports and resumable context while retaining
506    /// bounded terminal diagnostics and output under normal session retention.
507    pub fn release_finished_resources(&mut self, id: &str) {
508        if let Some(handle) = self.handles.get_mut(id) {
509            if handle.is_finished() {
510                handle.make_tombstone();
511            }
512        }
513    }
514
515    /// Returns (id, agent_name, status) for every tracked handle.
516    pub fn list_active(&self) -> Vec<(String, String, SubagentStatus)> {
517        self.handles
518            .values()
519            .map(|h| (h.id.clone(), h.agent_name.clone(), h.status()))
520            .collect()
521    }
522
523    /// Snapshot of every tracked handle for TUI reconcile.
524    /// Reads all state under the lock POISON-SAFE; exposes cancel_requested
525    /// which list_active() does not.
526    pub fn display_rows(&self) -> Vec<SubagentDisplayRow> {
527        self.handles
528            .values()
529            .map(|h| {
530                let s = h.state.read().unwrap_or_else(|p| p.into_inner());
531                SubagentDisplayRow {
532                    subagent_id: h.subagent_id,
533                    agent_name: h.agent_name.clone(),
534                    status: s.status.clone(),
535                    cancel_requested: s.cancel_requested,
536                    elapsed_secs: h.started_at.elapsed().as_secs_f64(),
537                    finished_elapsed: s.finished_at.map(|t| t.elapsed()),
538                }
539            })
540            .collect()
541    }
542
543    /// Drop handles that are no longer running.
544    /// Iterate over all handles mutably (for bulk operations like cancel-all).
545    pub fn iter_mut_handles(&mut self) -> impl Iterator<Item = &mut SubagentHandle> {
546        self.handles.values_mut()
547    }
548
549    /// Reap a finished handle iff:
550    ///   (a) its result was collected via subagent_collect, OR
551    ///   (b) it has been finished longer than `ttl` (abandoned).
552    /// Finished-but-uncollected handles inside the TTL are RETAINED so the
553    /// completion event can wake the parent and collect still succeeds.
554    ///
555    /// Additionally, handles whose OS thread is still running (e.g. still in
556    /// the finalizer path) are deferred — joining a live thread blocks the TUI
557    /// loop. They will be reaped on the next cleanup pass once the thread exits.
558    /// Orchestration-required expired handles become bounded tombstones instead
559    /// of being removed; a later collect/reconcile makes them normally reapable.
560    pub fn cleanup_finished_with_ttl(&mut self, ttl: std::time::Duration) {
561        self.cleanup_finished_with_ttl_and_retention(ttl, |_| false);
562    }
563
564    /// Reap normally, except that an expired uncollected handle still required by
565    /// orchestration is downgraded to a bounded, collectible tombstone.
566    pub fn cleanup_finished_with_ttl_and_retention(
567        &mut self,
568        ttl: std::time::Duration,
569        retain_unreconciled: impl Fn(&str) -> bool,
570    ) {
571        let candidates: Vec<(String, bool)> = self
572            .handles
573            .iter()
574            .filter(|(_, h)| {
575                h.is_finished()
576                    && h.thread_handle.as_ref().map_or(true, |t| t.is_finished())
577                    && (h.is_collected() || h.finished_elapsed().is_some_and(|d| d >= ttl))
578            })
579            .map(|(id, h)| (id.clone(), !h.is_collected() && retain_unreconciled(id)))
580            .collect();
581        for (id, retain) in candidates {
582            if retain {
583                self.release_finished_resources(&id);
584            } else if let Some(mut handle) = self.handles.remove(&id) {
585                if let Some(th) = handle.thread_handle.take() {
586                    let _ = th.join();
587                }
588            }
589        }
590    }
591
592    /// Reap finished handles using the production TTL.
593    /// Finished-but-uncollected handles within the TTL window are retained.
594    pub fn cleanup_finished(&mut self) {
595        self.cleanup_finished_with_ttl(FINISHED_HANDLE_TTL)
596    }
597}
598
599/// Engine-owned housekeeping seam: reap finished subagent handles at end of
600/// each turn.  Acquires the lock poison-safely — a panicked subagent thread
601/// must not prevent the turn from completing.
602///
603/// Intended to be called from the `tokio::spawn` wrapper in `runtime/mod.rs`
604/// after `run_stream_internal` returns and BEFORE sending `SessionEvent::Done`.
605pub(crate) fn reap_finished_with_ttl(
606    registry: &Arc<std::sync::Mutex<SubagentRegistry>>,
607    orchestration: Option<&crate::orchestration::OrchestrationRuntime>,
608    ttl: std::time::Duration,
609) {
610    let retained = orchestration
611        .map(|runtime| runtime.unreconciled_runtime_handles())
612        .unwrap_or_default();
613    let cleanup = |guard: &mut SubagentRegistry| {
614        let before = guard
615            .handles
616            .keys()
617            .cloned()
618            .collect::<std::collections::HashSet<_>>();
619        guard.cleanup_finished_with_ttl_and_retention(ttl, |id| retained.contains(id));
620        before
621            .into_iter()
622            .filter(|id| !guard.handles.contains_key(id))
623            .collect::<std::collections::HashSet<_>>()
624    };
625    // Release the registry lock before entering orchestration (same lock order
626    // as the retention snapshot above). Live workers are never retired.
627    let removed = match registry.lock() {
628        Ok(mut guard) => cleanup(&mut guard),
629        Err(poisoned) => cleanup(&mut poisoned.into_inner()),
630    };
631    if let Some(orchestration) = orchestration {
632        orchestration.retire_reconciled(&removed);
633    }
634}
635
636pub fn reap_finished(
637    registry: &Arc<std::sync::Mutex<SubagentRegistry>>,
638    orchestration: Option<&crate::orchestration::OrchestrationRuntime>,
639) {
640    reap_finished_with_ttl(registry, orchestration, FINISHED_HANDLE_TTL);
641}
642
643impl Default for SubagentRegistry {
644    fn default() -> Self {
645        Self::new()
646    }
647}
648
649impl SubagentStatus {
650    pub fn as_str(&self) -> &str {
651        match self {
652            SubagentStatus::Running => "running",
653            SubagentStatus::Completed => "completed",
654            SubagentStatus::Cancelled => "cancelled",
655            SubagentStatus::TimedOut => "timed_out",
656            SubagentStatus::Failed(_) => "failed",
657        }
658    }
659
660    /// Returns the failure reason string if this is a `Failed` variant.
661    pub fn failure_reason(&self) -> Option<&str> {
662        match self {
663            SubagentStatus::Failed(r) => Some(r.as_str()),
664            _ => None,
665        }
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use tokio::sync::{mpsc, oneshot};
673
674    // Keep receivers alive so channels don't close during tests
675    struct TestHandle {
676        handle: SubagentHandle,
677        _steer_rx: mpsc::UnboundedReceiver<String>,
678        _shutdown_rx: oneshot::Receiver<()>,
679    }
680
681    fn make_test_handle(id: &str) -> TestHandle {
682        let state = Arc::new(RwLock::new(SubagentState::new()));
683        let (steer_tx, steer_rx) = mpsc::unbounded_channel();
684        let (shutdown_tx, shutdown_rx) = oneshot::channel();
685        let (_result_tx, result_rx) = oneshot::channel();
686        // Parse numeric id from "sa_N" or use 0 as fallback for tests
687        let numeric_id: u64 = id
688            .strip_prefix("sa_")
689            .and_then(|n| n.parse().ok())
690            .unwrap_or(0);
691        TestHandle {
692            handle: SubagentHandle::new(
693                id.to_string(),
694                numeric_id,
695                "test-agent".to_string(),
696                "test task".to_string(),
697                "claude-sonnet-4-6".to_string(),
698                "You are a test agent.".to_string(),
699                300,
700                state,
701                Some(steer_tx),
702                Some(shutdown_tx),
703                Some(result_rx),
704            ),
705            _steer_rx: steer_rx,
706            _shutdown_rx: shutdown_rx,
707        }
708    }
709
710    fn make_handle(id: &str) -> SubagentHandle {
711        make_test_handle(id).handle
712    }
713
714    fn make_finished_handle(id: &str) -> SubagentHandle {
715        let h = make_handle(id);
716        {
717            let mut s = h.state.write().unwrap();
718            s.status = SubagentStatus::Completed;
719            s.finished_at = Some(std::time::Instant::now());
720        }
721        h
722    }
723
724    #[test]
725    fn typed_failure_drops_raw_provider_secret_canary() {
726        let mut state = SubagentState::new();
727        let canary = "Bearer secret-canary-provider-body";
728        state.safe_fail(TerminalDiagnostic {
729            category: TerminalCategory::Credential,
730            code: "credential_unavailable".into(),
731            stage: "credential".into(),
732            correlation_id: "sa_safe".into(),
733            network_attempted: false,
734            safe_message: canary.into(),
735        });
736        let rendered = format!("{:?}{:?}", state.status, state.terminal);
737        assert!(!rendered.contains(canary));
738        assert!(rendered.contains("credential_unavailable"));
739    }
740
741    #[test]
742    fn late_registration_cannot_inherit_new_turn_and_stale_sweep_cannot_cancel_it() {
743        let mut registry = SubagentRegistry::new();
744        let old = crate::CancellationToken::new();
745        let epoch = registry.set_spawn_cancellation(Some(old.clone()));
746        old.cancel();
747        registry.cancel_spawn_epoch(epoch);
748        // User takeover/new grant happened while old start was preempted.
749        registry.set_spawn_cancellation(None);
750        registry.register(make_handle("new-user"));
751        registry.register_with_cancellation(make_handle("old-late"), Some(&old));
752        assert!(
753            registry
754                .get("old-late")
755                .unwrap()
756                .state
757                .read()
758                .unwrap()
759                .cancel_requested
760        );
761        assert!(
762            !registry
763                .get("new-user")
764                .unwrap()
765                .state
766                .read()
767                .unwrap()
768                .cancel_requested
769        );
770        registry.cancel_spawn_epoch(epoch);
771        assert!(
772            !registry
773                .get("new-user")
774                .unwrap()
775                .state
776                .read()
777                .unwrap()
778                .cancel_requested
779        );
780        let new_grant = crate::CancellationToken::new();
781        let next = registry.set_spawn_cancellation(Some(new_grant));
782        registry.register(make_handle("new-grant"));
783        registry.register_with_cancellation(make_handle("old-later"), Some(&old));
784        registry.cancel_spawn_epoch(epoch);
785        assert!(
786            !registry
787                .get("new-grant")
788                .unwrap()
789                .state
790                .read()
791                .unwrap()
792                .cancel_requested
793        );
794        assert!(
795            registry
796                .get("old-later")
797                .unwrap()
798                .state
799                .read()
800                .unwrap()
801                .cancel_requested
802        );
803        registry.cancel_spawn_epoch(next);
804        assert!(
805            registry
806                .get("new-grant")
807                .unwrap()
808                .state
809                .read()
810                .unwrap()
811                .cancel_requested
812        );
813    }
814
815    #[test]
816    fn revoked_launch_fence_cancels_late_registration_until_user_takeover() {
817        let mut registry = SubagentRegistry::new();
818        let cancel = crate::CancellationToken::new();
819        registry.set_spawn_cancellation(Some(cancel.clone()));
820        registry.register(make_handle("before"));
821        assert!(!registry.display_rows()[0].cancel_requested);
822        cancel.cancel();
823        let late = make_test_handle("late");
824        let mut shutdown = late._shutdown_rx;
825        registry.register(late.handle);
826        assert!(shutdown.try_recv().is_ok());
827        assert!(
828            registry
829                .get("late")
830                .unwrap()
831                .state
832                .read()
833                .unwrap()
834                .cancel_requested
835        );
836        registry.set_spawn_cancellation(None);
837        registry.register(make_handle("user"));
838        assert!(
839            !registry
840                .get("user")
841                .unwrap()
842                .state
843                .read()
844                .unwrap()
845                .cancel_requested
846        );
847    }
848
849    #[test]
850    fn handle_initial_status_is_running() {
851        let h = make_handle("sa_1");
852        assert_eq!(h.status(), SubagentStatus::Running);
853        assert!(!h.is_finished());
854    }
855
856    #[test]
857    fn handle_partial_output_empty_initially() {
858        let h = make_handle("sa_1");
859        assert_eq!(h.partial_output(), "");
860        assert!(h.tool_log().is_empty());
861        assert!(h.conversation_state().is_empty());
862    }
863
864    #[test]
865    fn handle_status_reflects_state_change() {
866        let h = make_handle("sa_1");
867        {
868            let mut s = h.state.write().unwrap();
869            s.status = SubagentStatus::Completed;
870            s.partial_text = "done!".to_string();
871        }
872        assert_eq!(h.status(), SubagentStatus::Completed);
873        assert!(h.is_finished());
874        assert_eq!(h.partial_output(), "done!");
875    }
876
877    #[test]
878    fn handle_steer_sends_message() {
879        let th = make_test_handle("sa_1");
880        assert!(th.handle.steer("redirect").is_ok());
881    }
882
883    #[test]
884    fn handle_steer_fails_without_channel() {
885        let state = Arc::new(RwLock::new(SubagentState::new()));
886        let (_shutdown_tx, _) = oneshot::channel::<()>();
887        let (_, result_rx) = oneshot::channel();
888        let h = SubagentHandle::new(
889            "sa_1".into(),
890            1,
891            "test".into(),
892            "task".into(),
893            "model".into(),
894            "prompt".into(),
895            300,
896            state,
897            None,
898            None,
899            Some(result_rx),
900        );
901        assert!(h.steer("msg").is_err());
902    }
903
904    #[test]
905    fn handle_cancel_consumes_shutdown() {
906        let mut h = make_handle("sa_1");
907        h.cancel(); // first call sends
908        h.cancel(); // second call is no-op (already taken)
909    }
910
911    #[test]
912    fn handle_elapsed_increases() {
913        let h = make_handle("sa_1");
914        std::thread::sleep(std::time::Duration::from_millis(10));
915        assert!(h.elapsed_secs() > 0.0);
916    }
917
918    #[test]
919    fn registry_register_and_get() {
920        let mut reg = SubagentRegistry::new();
921        let h = make_handle("sa_1");
922        reg.register(h);
923        assert!(reg.get("sa_1").is_some());
924        assert!(reg.get("sa_99").is_none());
925    }
926
927    #[test]
928    fn registry_remove() {
929        let mut reg = SubagentRegistry::new();
930        reg.register(make_handle("sa_1"));
931        assert!(reg.remove("sa_1").is_some());
932        assert!(reg.get("sa_1").is_none());
933    }
934
935    #[test]
936    fn registry_list_active() {
937        let mut reg = SubagentRegistry::new();
938        reg.register(make_handle("sa_1"));
939        reg.register(make_handle("sa_2"));
940        let active = reg.list_active();
941        assert_eq!(active.len(), 2);
942    }
943
944    // Rewritten: finished-but-uncollected handles are RETAINED (new semantics).
945    // The old test encoded the buggy behavior where any finished handle was removed.
946    #[test]
947    fn registry_cleanup_finished() {
948        let mut reg = SubagentRegistry::new();
949        let mut h = make_finished_handle("sa_1");
950        h.mark_collected(); // mark as collected — should be reaped
951        reg.register(h);
952        reg.register(make_handle("sa_2")); // still running — must survive
953
954        reg.cleanup_finished();
955
956        assert!(
957            reg.get("sa_1").is_none(),
958            "collected finished handle must be reaped"
959        );
960        assert!(
961            reg.get("sa_2").is_some(),
962            "running handle must survive cleanup"
963        );
964    }
965
966    // U1: finished, not collected, fresh → retained within TTL
967    #[test]
968    fn reaper_retains_finished_uncollected_within_ttl() {
969        let mut reg = SubagentRegistry::new();
970        let h = make_finished_handle("sa_1"); // not collected
971        reg.register(h);
972
973        reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
974
975        assert!(
976            reg.get("sa_1").is_some(),
977            "finished-uncollected handle within TTL must be retained"
978        );
979    }
980
981    // U2: finished + mark_collected → removed immediately
982    #[test]
983    fn reaper_reaps_finished_and_collected() {
984        let mut reg = SubagentRegistry::new();
985        let mut h = make_finished_handle("sa_1");
986        h.mark_collected();
987        reg.register(h);
988
989        reg.cleanup_finished_with_ttl(std::time::Duration::from_secs(900));
990
991        assert!(
992            reg.get("sa_1").is_none(),
993            "finished+collected handle must be reaped"
994        );
995    }
996
997    // U3: finished, not collected, and TTL-expired, but still required by
998    // orchestration → retained as a resource-free tombstone.
999    #[test]
1000    fn reaper_tombstones_unreconciled_after_ttl() {
1001        let mut reg = SubagentRegistry::new();
1002        let h = make_finished_handle("sa_1"); // not collected
1003        h.state.write().unwrap().partial_text = "🦀".repeat(25_000);
1004        reg.register(h);
1005
1006        reg.cleanup_finished_with_ttl_and_retention(std::time::Duration::ZERO, |_| true);
1007
1008        let h = reg
1009            .get("sa_1")
1010            .expect("gate-relevant handle must remain collectible");
1011        assert!(h.is_tombstone(), "expired handle must release transports");
1012        assert!(h.partial_output().len() <= TOMBSTONE_OUTPUT_MAX_BYTES);
1013    }
1014
1015    #[test]
1016    fn reaper_reaps_abandoned_after_ttl_without_orchestration() {
1017        let mut reg = SubagentRegistry::new();
1018        reg.register(make_finished_handle("sa_1"));
1019
1020        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1021
1022        assert!(reg.get("sa_1").is_none());
1023    }
1024
1025    // U5-reaper: finished-status handle with a still-sleeping OS thread is deferred;
1026    // after thread exits a second cleanup reaps it.
1027    #[test]
1028    fn reaper_defers_handle_with_live_thread() {
1029        let mut reg = SubagentRegistry::new();
1030        let mut h = make_finished_handle("sa_live");
1031        h.mark_collected(); // collected — would normally be reaped immediately
1032
1033        // Attach a real thread that sleeps briefly (simulates thread still in finalizer)
1034        let barrier = Arc::new(std::sync::Barrier::new(2));
1035        let barrier_c = Arc::clone(&barrier);
1036        let thread = std::thread::spawn(move || {
1037            // Wait for the test to run its first cleanup
1038            barrier_c.wait();
1039            // Now sleep a bit more before truly exiting
1040            std::thread::sleep(std::time::Duration::from_millis(20));
1041        });
1042        h.set_thread_handle(thread);
1043        reg.register(h);
1044
1045        // Signal thread (it's waiting on barrier)
1046        barrier.wait();
1047
1048        // First cleanup: thread is still alive → handle must be deferred (retained)
1049        // The thread may or may not have exited by the time we get here, so we only
1050        // assert the positive case when we know the thread is still live.
1051        // Instead: sleep briefly to be sure the thread has NOT exited, then check.
1052        // (The thread sleeps 20ms after barrier; we check immediately after barrier.)
1053        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1054        // Handle may still be present (thread live) or absent (thread exited) — both ok.
1055        // What must NOT happen: a blocking join. The test itself completes in < 1s.
1056
1057        // Wait for thread to truly exit
1058        std::thread::sleep(std::time::Duration::from_millis(50));
1059
1060        // Second cleanup: thread is finished → handle must be reaped
1061        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1062        assert!(
1063            reg.get("sa_live").is_none(),
1064            "handle must be reaped once OS thread exits"
1065        );
1066    }
1067
1068    // U4: running handles never touched by any reaper variant
1069    #[test]
1070    fn reaper_never_touches_running() {
1071        let mut reg = SubagentRegistry::new();
1072        reg.register(make_handle("sa_1")); // Running
1073        reg.register(make_handle("sa_2")); // Running
1074
1075        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
1076        assert!(reg.get("sa_1").is_some());
1077        assert!(reg.get("sa_2").is_some());
1078
1079        reg.cleanup_finished(); // production TTL
1080        assert!(reg.get("sa_1").is_some());
1081        assert!(reg.get("sa_2").is_some());
1082    }
1083
1084    #[test]
1085    fn subagent_state_new_defaults() {
1086        let s = SubagentState::new();
1087        assert_eq!(s.status, SubagentStatus::Running);
1088        assert!(s.partial_text.is_empty());
1089        assert!(s.tool_log.is_empty());
1090        assert!(s.conversation_state.is_empty());
1091        assert!(s.finished_at.is_none());
1092        assert!(
1093            !s.cancel_requested,
1094            "cancel_requested must default to false"
1095        );
1096    }
1097
1098    #[test]
1099    fn subagent_status_as_str() {
1100        assert_eq!(SubagentStatus::Running.as_str(), "running");
1101        assert_eq!(SubagentStatus::Completed.as_str(), "completed");
1102        assert_eq!(SubagentStatus::Cancelled.as_str(), "cancelled");
1103        assert_eq!(SubagentStatus::TimedOut.as_str(), "timed_out");
1104        assert_eq!(SubagentStatus::Failed("oops".into()).as_str(), "failed");
1105    }
1106
1107    // V1: cancel() sets cancel_requested on shared state before sending signal.
1108    #[test]
1109    fn cancel_sets_cancel_requested_flag() {
1110        let mut h = make_handle("sa_cancel");
1111        {
1112            let s = h.state.read().unwrap();
1113            assert!(!s.cancel_requested, "cancel_requested must start false");
1114        }
1115        h.cancel();
1116        {
1117            let s = h.state.read().unwrap();
1118            assert!(
1119                s.cancel_requested,
1120                "cancel_requested must be true after cancel()"
1121            );
1122        }
1123        // Second call is a no-op (shutdown_tx already taken)
1124        h.cancel();
1125    }
1126
1127    // V2: cancelled status counts as finished, is_finished() returns true.
1128    #[test]
1129    fn cancelled_status_is_finished() {
1130        let h = make_handle("sa_c1");
1131        {
1132            let mut s = h.state.write().unwrap();
1133            s.status = SubagentStatus::Cancelled;
1134        }
1135        assert!(h.is_finished(), "Cancelled must count as finished");
1136    }
1137
1138    // D1: display_rows returns a row for each registered handle
1139    #[test]
1140    fn display_rows_running_handle() {
1141        let mut reg = SubagentRegistry::new();
1142        reg.register(make_handle("sa_1"));
1143        reg.register(make_handle("sa_2"));
1144        let rows = reg.display_rows();
1145        assert_eq!(rows.len(), 2);
1146        for row in &rows {
1147            assert_eq!(row.status, SubagentStatus::Running);
1148            assert!(!row.cancel_requested);
1149            assert!(row.elapsed_secs >= 0.0);
1150            assert!(row.finished_elapsed.is_none());
1151        }
1152    }
1153
1154    // D2: display_rows reflects finished status and cancel_requested flag
1155    #[test]
1156    fn display_rows_finished_and_cancel_requested() {
1157        let mut reg = SubagentRegistry::new();
1158        let h = make_finished_handle("sa_1");
1159        reg.register(h);
1160
1161        // Make a handle with cancel_requested = true
1162        let th = make_test_handle("sa_2");
1163        {
1164            let mut s = th.handle.state.write().unwrap();
1165            s.cancel_requested = true;
1166        }
1167        reg.register(th.handle);
1168
1169        let rows = reg.display_rows();
1170        assert_eq!(rows.len(), 2);
1171
1172        let finished = rows.iter().find(|r| r.subagent_id == 1).unwrap();
1173        assert_eq!(finished.status, SubagentStatus::Completed);
1174        assert!(!finished.cancel_requested);
1175        assert!(finished.finished_elapsed.is_some());
1176
1177        let cancelling = rows.iter().find(|r| r.subagent_id == 2).unwrap();
1178        assert!(cancelling.cancel_requested);
1179    }
1180
1181    // D3: display_rows on empty registry returns empty vec
1182    #[test]
1183    fn display_rows_empty_registry() {
1184        let reg = SubagentRegistry::new();
1185        assert!(reg.display_rows().is_empty());
1186    }
1187
1188    // ── reap_finished free-function tests ────────────────────────────────────
1189
1190    /// R1: reap_finished with a collected+finished handle in an Arc<Mutex<>> reaps it.
1191    #[test]
1192    fn reap_finished_headless_reaps_collected() {
1193        let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1194        {
1195            let mut reg = registry.lock().unwrap();
1196            let mut h = make_finished_handle("sa_r1");
1197            h.mark_collected();
1198            reg.register(h);
1199        }
1200        super::reap_finished(&registry, None);
1201        assert!(
1202            registry.lock().unwrap().get("sa_r1").is_none(),
1203            "reap_finished must reap collected+finished handle"
1204        );
1205    }
1206
1207    /// R2: reap_finished retains running handles — must not touch live work.
1208    #[test]
1209    fn reap_finished_headless_retains_running() {
1210        let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1211        {
1212            let mut reg = registry.lock().unwrap();
1213            reg.register(make_handle("sa_running"));
1214        }
1215        super::reap_finished(&registry, None);
1216        assert!(
1217            registry.lock().unwrap().get("sa_running").is_some(),
1218            "reap_finished must not touch running handles"
1219        );
1220    }
1221
1222    /// R3: reap_finished recovers from a poisoned lock without panicking.
1223    #[test]
1224    fn reap_finished_poisoned_lock_recovery() {
1225        let registry = Arc::new(std::sync::Mutex::new(SubagentRegistry::new()));
1226        {
1227            let mut reg = registry.lock().unwrap();
1228            let mut h = make_finished_handle("sa_poison");
1229            h.mark_collected();
1230            reg.register(h);
1231        }
1232
1233        // Poison the mutex by panicking inside a lock guard.
1234        let registry_c = Arc::clone(&registry);
1235        let _ = std::panic::catch_unwind(|| {
1236            let _guard = registry_c.lock().unwrap();
1237            panic!("deliberately poison the mutex");
1238        });
1239
1240        // reap_finished must not panic despite poisoned lock.
1241        super::reap_finished(&registry, None);
1242
1243        // The handle must be reaped (cleanup ran despite poison).
1244        let result = registry
1245            .lock()
1246            .unwrap_or_else(|p| p.into_inner())
1247            .get("sa_poison")
1248            .is_none();
1249        assert!(result, "reap_finished must run cleanup even after poison");
1250    }
1251}
1252
1253// Separate module so finalize_subagent is accessible via its pub(crate) path.
1254#[cfg(test)]
1255mod cancelled_wake_tests {
1256    use super::*;
1257    use std::sync::{Arc, RwLock};
1258
1259    // V3: finalize_subagent with cancel_requested=true → status Cancelled,
1260    // no event published, finished_at stamped.
1261    // Test name matches spec: cancelled_suppresses_wake.
1262    #[test]
1263    fn cancelled_suppresses_wake() {
1264        use crate::events::EventQueue;
1265        use crate::tools::finalize_subagent;
1266
1267        let state = Arc::new(RwLock::new(SubagentState::new()));
1268        {
1269            let mut s = state.write().unwrap();
1270            s.status = SubagentStatus::Completed; // thread set Completed before cancel flag noticed
1271            s.cancel_requested = true; // cancel() was called
1272        }
1273        let queue = Arc::new(EventQueue::new(100));
1274
1275        finalize_subagent(
1276            &state,
1277            Some(&queue),
1278            "sa_v3",
1279            42,
1280            "test-agent",
1281            std::time::Instant::now(),
1282            None,
1283        );
1284
1285        // Queue must be empty — no wake event for cancelled subagents.
1286        assert!(
1287            queue.is_empty(),
1288            "cancelled subagent must not publish a wake event"
1289        );
1290
1291        // Status must have been re-labelled to Cancelled.
1292        let s = state.read().unwrap();
1293        assert_eq!(
1294            s.status,
1295            SubagentStatus::Cancelled,
1296            "status must be Cancelled when cancel_requested"
1297        );
1298
1299        // finished_at must be stamped.
1300        assert!(
1301            s.finished_at.is_some(),
1302            "finished_at must be stamped by finalizer"
1303        );
1304    }
1305}