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