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