Skip to main content

zeph_common/
task_supervisor.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Supervised lifecycle task manager for long-running named services.
5//!
6//! [`TaskSupervisor`] manages named, long-lived background tasks (config watcher,
7//! scheduler loop, gateway, MCP connections, etc.) with restart policies, health
8//! snapshots, and graceful shutdown. Unlike `BackgroundSupervisor`
9//! (which is `&mut self`-only, lossy, and turn-scoped), `TaskSupervisor` is
10//! `Clone + Send + Sync` and designed for the full agent session lifetime.
11//!
12//! # Design rationale
13//!
14//! - **Shared handle**: `Arc<Inner>` interior allows passing the supervisor to bootstrap
15//!   code, TUI status display, and shutdown orchestration without lifetime coupling.
16//! - **Event-driven reap**: An internal mpsc channel delivers completion events to a
17//!   reap driver task; no polling interval required.
18//! - **No `JoinSet`**: Individual `JoinHandle`s per task enable per-name abort, status
19//!   tracking, and restart policies — `JoinSet` is better for homogeneous work.
20//! - **Mutex held briefly**: `parking_lot::Mutex` guards only bookkeeping operations
21//!   (insert/remove from `HashMap`). The lock is **never held across `.await`**.
22//!
23//! # Examples
24//!
25//! ```rust,no_run
26//! use std::time::Duration;
27//! use tokio_util::sync::CancellationToken;
28//! use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
29//!
30//! # #[tokio::main]
31//! # async fn main() {
32//! let cancel = CancellationToken::new();
33//! let supervisor = TaskSupervisor::new(cancel.clone());
34//!
35//! supervisor.spawn(TaskDescriptor {
36//!     name: "my-service",
37//!     restart: RestartPolicy::Restart { max: 3, base_delay: Duration::from_secs(1) },
38//!     factory: || async { /* service loop */ },
39//! });
40//!
41//! // Graceful shutdown — waits up to 5 s for all tasks to stop.
42//! supervisor.shutdown_all(Duration::from_secs(5)).await;
43//! # }
44//! ```
45
46use std::collections::HashMap;
47use std::future::Future;
48use std::pin::Pin;
49use std::sync::Arc;
50use std::time::{Duration, Instant};
51
52use tokio::sync::{mpsc, oneshot};
53use tokio::task::AbortHandle;
54use tokio_util::sync::CancellationToken;
55use tracing::Instrument as _;
56
57use crate::BlockingSpawner;
58
59// ── Public types ─────────────────────────────────────────────────────────────
60
61/// Policy governing what happens when a supervised task completes or panics.
62///
63/// Used in [`TaskDescriptor`] to configure restart behaviour for a task.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum RestartPolicy {
67    /// Task runs once; normal completion removes it from the registry.
68    RunOnce,
69    /// Task is restarted **only on panic**, up to `max` times.
70    ///
71    /// Normal completion (the future returns `()`) does **not** trigger a restart.
72    /// The task is removed from the registry on normal exit.
73    ///
74    /// A `max` of `0` means the task is monitored but **never** restarted —
75    /// a panic leaves the entry as `Failed` in the registry for observability.
76    /// Use `RunOnce` when you want the entry removed on completion.
77    ///
78    /// Restart delays follow **exponential backoff**: the delay before attempt `n`
79    /// is `base_delay * 2^(n-1)`, capped at [`MAX_RESTART_DELAY`].
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use std::time::Duration;
85    /// use zeph_common::task_supervisor::RestartPolicy;
86    ///
87    /// // Restart up to 3 times with exponential backoff starting at 1 s.
88    /// let policy = RestartPolicy::Restart { max: 3, base_delay: Duration::from_secs(1) };
89    /// ```
90    Restart { max: u32, base_delay: Duration },
91}
92
93/// Maximum delay between restart attempts (caps exponential backoff).
94pub const MAX_RESTART_DELAY: Duration = Duration::from_mins(1);
95
96/// Configuration passed to [`TaskSupervisor::spawn`] to describe a supervised task.
97///
98/// `F` must be `Fn` (not `FnOnce`) to support restarts: the factory is called once on
99/// initial spawn and once per restart attempt.
100pub struct TaskDescriptor<F> {
101    /// Unique name for this task (e.g., `"config-watcher"`, `"scheduler-loop"`).
102    ///
103    /// Names must be `'static` — they are typically compile-time string literals.
104    /// Spawning a task with a name that already exists aborts the prior instance.
105    pub name: &'static str,
106    /// Restart policy applied when the task exits unexpectedly.
107    pub restart: RestartPolicy,
108    /// Factory called to produce a new future. Must be `Fn` for restart support.
109    pub factory: F,
110}
111
112/// Opaque handle to a single supervised task.
113///
114/// Can be used to abort the task by name independently of the supervisor.
115#[derive(Debug, Clone)]
116pub struct TaskHandle {
117    name: &'static str,
118    abort: AbortHandle,
119}
120
121impl TaskHandle {
122    /// Abort the task immediately.
123    pub fn abort(&self) {
124        tracing::debug!(task.name = self.name, "task aborted via handle");
125        self.abort.abort();
126    }
127
128    /// Return the task's name.
129    #[must_use]
130    pub const fn name(&self) -> &'static str {
131        self.name
132    }
133}
134
135/// Error returned by [`BlockingHandle::join`].
136#[derive(Debug, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum BlockingError {
139    /// The task panicked before producing a result.
140    Panicked,
141    /// The supervisor (or the task's abort handle) was dropped before the task completed.
142    SupervisorDropped,
143}
144
145impl std::fmt::Display for BlockingError {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            Self::Panicked => write!(f, "supervised blocking task panicked"),
149            Self::SupervisorDropped => write!(f, "supervisor dropped before task completed"),
150        }
151    }
152}
153
154impl std::error::Error for BlockingError {}
155
156/// Handle returned by [`TaskSupervisor::spawn_blocking`].
157///
158/// Awaiting [`BlockingHandle::join`] blocks until the OS-thread task produces a
159/// value. Dropping the handle without joining does **not** cancel the task — it
160/// continues to run on the blocking thread pool but the result is discarded.
161///
162/// A panic inside the closure is captured and returned as
163/// [`BlockingError::Panicked`] rather than propagating to the caller.
164pub struct BlockingHandle<R> {
165    rx: oneshot::Receiver<Result<R, BlockingError>>,
166    abort: AbortHandle,
167}
168
169impl<R> BlockingHandle<R> {
170    /// Await the task result.
171    ///
172    /// # Errors
173    ///
174    /// - [`BlockingError::Panicked`] — the task closure panicked.
175    /// - [`BlockingError::SupervisorDropped`] — the task was aborted or the
176    ///   supervisor was dropped before a value was produced.
177    pub async fn join(self) -> Result<R, BlockingError> {
178        self.rx
179            .await
180            .unwrap_or(Err(BlockingError::SupervisorDropped))
181    }
182
183    /// Non-blocking poll: return the result if the task has already finished, or `None`
184    /// if it is still running.
185    ///
186    /// This is the `BlockingHandle` equivalent of `FutureExt::now_or_never` on a
187    /// [`tokio::task::JoinHandle`]. Call this inside a synchronous context (e.g., between
188    /// agent turns) to apply a completed background result without blocking.
189    ///
190    /// The handle is consumed on success. If the task is not yet done, the handle
191    /// is returned as `Err(self)` so the caller can re-store it.
192    ///
193    /// # Examples
194    ///
195    /// ```rust,no_run
196    /// # use zeph_common::task_supervisor::{BlockingHandle, BlockingError};
197    /// async fn example(mut handle: BlockingHandle<u32>) {
198    ///     // Try to get the result without blocking.
199    ///     match handle.try_join() {
200    ///         Ok(result) => println!("done: {result:?}"),
201    ///         Err(handle) => {
202    ///             // Task still running — `handle` is returned for re-storage.
203    ///             drop(handle);
204    ///         }
205    ///     }
206    /// }
207    /// ```
208    ///
209    /// # Errors
210    ///
211    /// Returns `Err(self)` when the task has not yet produced a result (still running).
212    /// The inner `Ok(Err(BlockingError::...))` variants are returned when the task
213    /// panicked or the supervisor was dropped before the task completed.
214    pub fn try_join(mut self) -> Result<Result<R, BlockingError>, Self> {
215        match self.rx.try_recv() {
216            Ok(result) => Ok(result),
217            Err(tokio::sync::oneshot::error::TryRecvError::Empty) => Err(self),
218            Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
219                Ok(Err(BlockingError::SupervisorDropped))
220            }
221        }
222    }
223
224    /// Abort the underlying task immediately.
225    pub fn abort(&self) {
226        self.abort.abort();
227    }
228
229    /// Non-blocking, non-consuming check for whether the task has finished.
230    ///
231    /// Unlike [`try_join`][Self::try_join], this does not consume `self` or retrieve the
232    /// result — it only reports completion, including a panicked or aborted task. Useful
233    /// for reap sweeps that must detect a task exit even when the task's own
234    /// application-level status channel never got a chance to publish a terminal value
235    /// before exiting (e.g. a panic partway through the task body).
236    #[must_use]
237    pub fn is_finished(&self) -> bool {
238        self.abort.is_finished()
239    }
240}
241
242/// Point-in-time state of a supervised task.
243#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum TaskStatus {
246    /// Task is actively running.
247    Running,
248    /// Task is waiting for the restart delay before the next attempt.
249    Restarting { attempt: u32, max: u32 },
250    /// Task completed normally.
251    Completed,
252    /// Task was force-aborted during shutdown.
253    Aborted,
254    /// Task exhausted all restart attempts and is permanently failed.
255    Failed { reason: String },
256}
257
258/// Point-in-time snapshot of a supervised task, returned by [`TaskSupervisor::snapshot`].
259#[derive(Debug, Clone)]
260/// Observability surface per field:
261///
262/// | Field | tokio-console | Jaeger / OTLP | TUI | `metrics` histogram |
263/// |-------|--------------|--------------|-----|---------------------|
264/// | `name` | span name | span name | task list | label `"task"` |
265/// | `task.wall_time_ms` | — | span field | — | `zeph.task.wall_time_ms` |
266/// | `task.cpu_time_ms` | — | span field | — | `zeph.task.cpu_time_ms` |
267/// | `status` | — | — | task list | — |
268/// | `restart_count` | — | — | task list | — |
269pub struct TaskSnapshot {
270    /// Task name.
271    pub name: Arc<str>,
272    /// Current status.
273    pub status: TaskStatus,
274    /// Instant the task was first spawned.
275    pub started_at: Instant,
276    /// Number of times the task has been restarted.
277    pub restart_count: u32,
278}
279
280// ── Internal types ───────────────────────────────────────────────────────────
281
282// `Output = bool` (not `()`) so `spawn_classified` can report an inner failure through
283// the same internal plumbing `spawn` uses (`true` = success/`Normal`, `false` = `Failed`).
284type BoxFuture = Pin<Box<dyn Future<Output = bool> + Send>>;
285type BoxFactory = Box<dyn Fn() -> BoxFuture + Send + Sync>;
286
287struct TaskEntry {
288    name: Arc<str>,
289    status: TaskStatus,
290    started_at: Instant,
291    restart_count: u32,
292    restart_policy: RestartPolicy,
293    abort_handle: AbortHandle,
294    /// `Some` only for `Restart` policy tasks.
295    factory: Option<BoxFactory>,
296}
297
298/// How a supervised task ended.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300enum CompletionKind {
301    /// Future returned normally.
302    Normal,
303    /// Future returned a value that the caller-supplied classifier (see
304    /// [`TaskSupervisor::spawn_oneshot_classified`]) identified as an inner failure —
305    /// distinct from `Normal` even though the outer `JoinHandle` itself resolved
306    /// successfully (no panic, no cancellation).
307    Failed,
308    /// Future panicked.
309    Panicked,
310    /// Future was cancelled via the cancellation token or abort handle.
311    Cancelled,
312}
313
314struct Completion {
315    name: Arc<str>,
316    kind: CompletionKind,
317}
318
319struct SupervisorState {
320    tasks: HashMap<Arc<str>, TaskEntry>,
321}
322
323struct Inner {
324    state: parking_lot::Mutex<SupervisorState>,
325    /// Completion events from spawned tasks → reap driver.
326    /// Lives in `Inner` (not `SupervisorState`) to avoid double mutex acquisition
327    /// — callers clone it once during spawn without re-locking state.
328    completion_tx: mpsc::UnboundedSender<Completion>,
329    cancel: CancellationToken,
330    /// Limits the number of concurrently running `spawn_blocking` tasks to prevent
331    /// runaway thread-pool growth under burst load.
332    blocking_semaphore: Arc<tokio::sync::Semaphore>,
333    /// Notified when `active_count()` reaches zero so `shutdown_all` wakes immediately.
334    shutdown_notify: Arc<tokio::sync::Notify>,
335}
336
337// ── Main type ────────────────────────────────────────────────────────────────
338
339/// Shared, cloneable handle to the supervised lifecycle task registry.
340///
341/// `TaskSupervisor` manages named, long-lived background tasks with restart
342/// policies, health snapshots, and graceful shutdown. It is `Clone + Send + Sync`
343/// so it can be distributed to bootstrap code, TUI, and shutdown orchestration
344/// without any additional synchronisation.
345///
346/// # Thread safety
347///
348/// Interior state is guarded by a `parking_lot::Mutex`. The lock is **never**
349/// held across `.await` points.
350///
351/// # Examples
352///
353/// ```rust,no_run
354/// use std::time::Duration;
355/// use tokio_util::sync::CancellationToken;
356/// use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
357///
358/// # #[tokio::main]
359/// # async fn main() {
360/// let cancel = CancellationToken::new();
361/// let sup = TaskSupervisor::new(cancel.clone());
362///
363/// let _handle = sup.spawn(TaskDescriptor {
364///     name: "watcher",
365///     restart: RestartPolicy::RunOnce,
366///     factory: || async { tokio::time::sleep(std::time::Duration::from_secs(1)).await },
367/// });
368///
369/// sup.shutdown_all(Duration::from_secs(5)).await;
370/// # }
371/// ```
372#[derive(Clone)]
373pub struct TaskSupervisor {
374    inner: Arc<Inner>,
375}
376
377impl std::fmt::Debug for TaskSupervisor {
378    /// Prints a stable placeholder rather than task internals (factory closures
379    /// inside `TaskEntry` are not `Debug`); callers needing task details should
380    /// use [`TaskSupervisor::snapshot`] instead.
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        f.debug_struct("TaskSupervisor").finish_non_exhaustive()
383    }
384}
385
386impl TaskSupervisor {
387    /// Create a new supervisor and start its reap driver.
388    ///
389    /// The `cancel` token is propagated into every spawned task via `tokio::select!`.
390    /// When the token is cancelled, all tasks exit cooperatively on their next
391    /// cancellation check. Call [`shutdown_all`][Self::shutdown_all] to wait for
392    /// them to finish.
393    ///
394    /// When called outside a Tokio runtime context (e.g. in synchronous unit tests),
395    /// the reap driver is skipped. The supervisor still accepts task registrations but
396    /// completion callbacks are not processed — safe because no tasks can actually be
397    /// spawned without a runtime.
398    #[must_use]
399    pub fn new(cancel: CancellationToken) -> Self {
400        // NOTE: unbounded channel is acceptable here because supervised tasks are
401        // O(10–20) lifecycle services, not high-throughput work. Backpressure would
402        // complicate the spawn path without practical benefit.
403        let (completion_tx, completion_rx) = mpsc::unbounded_channel();
404        let inner = Arc::new(Inner {
405            state: parking_lot::Mutex::new(SupervisorState {
406                tasks: HashMap::new(),
407            }),
408            completion_tx,
409            cancel: cancel.clone(),
410            blocking_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
411            shutdown_notify: Arc::new(tokio::sync::Notify::new()),
412        });
413
414        // Only start the reap driver when a Tokio runtime is available. In synchronous
415        // unit tests that construct Agent/LifecycleState directly there is no reactor,
416        // so we skip the spawn. Without a runtime no tasks can be spawned either, so
417        // the driver is not needed.
418        if tokio::runtime::Handle::try_current().is_ok() {
419            Self::start_reap_driver(Arc::clone(&inner), completion_rx, cancel);
420        }
421
422        Self { inner }
423    }
424
425    /// Spawn a named, supervised async task.
426    ///
427    /// If a task with the same `name` already exists, it is aborted before the
428    /// new one is started.
429    ///
430    /// # Examples
431    ///
432    /// ```rust,no_run
433    /// use std::time::Duration;
434    /// use tokio_util::sync::CancellationToken;
435    /// use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskHandle, TaskSupervisor};
436    ///
437    /// # #[tokio::main]
438    /// # async fn main() {
439    /// let cancel = CancellationToken::new();
440    /// let sup = TaskSupervisor::new(cancel.clone());
441    ///
442    /// let handle: TaskHandle = sup.spawn(TaskDescriptor {
443    ///     name: "config-watcher",
444    ///     restart: RestartPolicy::Restart { max: 3, base_delay: Duration::from_secs(1) },
445    ///     factory: || async { /* watch loop */ },
446    /// });
447    /// # }
448    /// ```
449    pub fn spawn<F, Fut>(&self, desc: TaskDescriptor<F>) -> TaskHandle
450    where
451        F: Fn() -> Fut + Send + Sync + 'static,
452        Fut: Future<Output = ()> + Send + 'static,
453    {
454        let user_factory = desc.factory;
455        let factory: BoxFactory = Box::new(move || {
456            let fut = user_factory();
457            Box::pin(async move {
458                fut.await;
459                true
460            })
461        });
462        self.spawn_boxed(desc.name, desc.restart, factory)
463    }
464
465    /// Spawn a named, supervised async task like [`spawn`][Self::spawn], but classify the
466    /// produced value's own success/failure via `is_success` — mirrors
467    /// [`spawn_oneshot_classified`][Self::spawn_oneshot_classified] except it supports
468    /// [`RestartPolicy::Restart`] and returns a [`TaskHandle`] (not a typed
469    /// [`BlockingHandle<R>`]), since a restart-capable task's result value cannot be handed
470    /// back to a single caller across restarts — each restart attempt produces a fresh value
471    /// that only the registry's [`TaskStatus`] can observe.
472    ///
473    /// This matters whenever `Fut::Output` itself encodes failure (typically `Result<T, E>`):
474    /// without a classifier, a task that runs to completion but produces `Err(..)` is
475    /// classified and reported identically to one that produced `Ok(..)` — `spawn` alone only
476    /// observes whether the *outer* future resolved, never what value it resolved to.
477    /// `is_success` is called once, by reference, after the future resolves.
478    ///
479    /// # Examples
480    ///
481    /// ```rust,no_run
482    /// use std::time::Duration;
483    /// use tokio_util::sync::CancellationToken;
484    /// use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
485    ///
486    /// # #[tokio::main]
487    /// # async fn main() {
488    /// let sup = TaskSupervisor::new(CancellationToken::new());
489    ///
490    /// let _handle = sup.spawn_classified(
491    ///     TaskDescriptor {
492    ///         name: "fallible-service",
493    ///         restart: RestartPolicy::Restart { max: 0, base_delay: Duration::from_secs(1) },
494    ///         factory: || async { Result::<(), String>::Err("boom".to_string()) },
495    ///     },
496    ///     Result::is_ok,
497    /// );
498    /// # }
499    /// ```
500    pub fn spawn_classified<F, Fut, R>(
501        &self,
502        desc: TaskDescriptor<F>,
503        is_success: impl Fn(&R) -> bool + Send + Sync + 'static,
504    ) -> TaskHandle
505    where
506        F: Fn() -> Fut + Send + Sync + 'static,
507        Fut: Future<Output = R> + Send + 'static,
508        R: Send + 'static,
509    {
510        let user_factory = desc.factory;
511        let is_success = Arc::new(is_success);
512        let factory: BoxFactory = Box::new(move || {
513            let fut = user_factory();
514            let is_success = Arc::clone(&is_success);
515            Box::pin(async move {
516                let val = fut.await;
517                is_success(&val)
518            })
519        });
520        self.spawn_boxed(desc.name, desc.restart, factory)
521    }
522
523    /// Shared registration tail for [`spawn`][Self::spawn] and
524    /// [`spawn_classified`][Self::spawn_classified]: spawns the boxed factory, wires up the
525    /// completion reporter, and inserts the registry entry.
526    fn spawn_boxed(
527        &self,
528        name: &'static str,
529        restart: RestartPolicy,
530        factory: BoxFactory,
531    ) -> TaskHandle {
532        let cancel = self.inner.cancel.clone();
533        let completion_tx = self.inner.completion_tx.clone();
534        let name_arc: Arc<str> = Arc::from(name);
535
536        let (abort_handle, jh) = Self::do_spawn(name, &factory, cancel);
537        Self::wire_completion_reporter(Arc::clone(&name_arc), jh, completion_tx);
538
539        let entry = TaskEntry {
540            name: Arc::clone(&name_arc),
541            status: TaskStatus::Running,
542            started_at: Instant::now(),
543            restart_count: 0,
544            restart_policy: restart,
545            abort_handle: abort_handle.clone(),
546            factory: match restart {
547                RestartPolicy::RunOnce => None,
548                RestartPolicy::Restart { .. } => Some(factory),
549            },
550        };
551
552        {
553            let mut state = self.inner.state.lock();
554            if let Some(old) = state.tasks.remove(&name_arc) {
555                old.abort_handle.abort();
556            }
557            state.tasks.insert(Arc::clone(&name_arc), entry);
558        }
559
560        TaskHandle {
561            name,
562            abort: abort_handle,
563        }
564    }
565
566    /// Spawn a CPU-bound closure on the OS blocking thread pool.
567    ///
568    /// The closure runs via [`tokio::task::spawn_blocking`] — it is never polled
569    /// on tokio worker threads and cannot block async I/O. The task is registered
570    /// in the supervisor registry and is visible to [`snapshot`][Self::snapshot]
571    /// and [`shutdown_all`][Self::shutdown_all].
572    ///
573    /// Dropping the returned [`BlockingHandle`] without calling `.join()` does
574    /// **not** cancel the task; it runs to completion but the result is discarded.
575    ///
576    /// A panic inside `f` is captured and returned as [`BlockingError::Panicked`]
577    /// rather than propagating to the caller.
578    ///
579    /// # Examples
580    ///
581    /// ```rust,no_run
582    /// use std::sync::Arc;
583    /// use tokio_util::sync::CancellationToken;
584    /// use zeph_common::task_supervisor::{BlockingHandle, TaskSupervisor};
585    ///
586    /// # #[tokio::main]
587    /// # async fn main() {
588    /// let cancel = CancellationToken::new();
589    /// let sup = TaskSupervisor::new(cancel);
590    ///
591    /// let handle: BlockingHandle<u32> = sup.spawn_blocking(Arc::from("compute"), || {
592    ///     // CPU-bound work — safe to block here
593    ///     42_u32
594    /// });
595    /// let result = handle.join().await.unwrap();
596    /// assert_eq!(result, 42);
597    /// # }
598    /// ```
599    ///
600    /// # Capacity limit
601    ///
602    /// At most 8 `spawn_blocking` tasks run concurrently. Additional tasks wait for a
603    /// semaphore permit, bounding thread-pool growth under burst load.
604    ///
605    /// # Panics
606    ///
607    /// Panics inside `f` are captured and returned as [`BlockingError::Panicked`] — they
608    /// do not propagate to the caller.
609    #[allow(clippy::needless_pass_by_value)] // `name` is cloned into async task and registry
610    pub fn spawn_blocking<F, R>(&self, name: Arc<str>, f: F) -> BlockingHandle<R>
611    where
612        F: FnOnce() -> R + Send + 'static,
613        R: Send + 'static,
614    {
615        let (tx, rx) = oneshot::channel::<Result<R, BlockingError>>();
616        let span = tracing::info_span!(
617            "supervised_blocking_task",
618            task.name = %name,
619            task.wall_time_ms = tracing::field::Empty,
620            task.cpu_time_ms = tracing::field::Empty,
621        );
622
623        let semaphore = Arc::clone(&self.inner.blocking_semaphore);
624        let inner = Arc::clone(&self.inner);
625        let name_clone = Arc::clone(&name);
626        let completion_tx = self.inner.completion_tx.clone();
627
628        // Wrap the blocking spawn in an async task that first acquires a semaphore
629        // permit, bounding the number of concurrently running blocking tasks to 8.
630        let outer = tokio::spawn(async move {
631            let _permit = semaphore
632                .acquire_owned()
633                .await
634                .expect("blocking semaphore closed");
635
636            let name_for_measure = Arc::clone(&name_clone);
637            let join_handle = tokio::task::spawn_blocking(move || {
638                let _enter = span.enter();
639                measure_blocking(&name_for_measure, f)
640            });
641            let abort = join_handle.abort_handle();
642
643            // Update registry with the real abort handle now that spawn_blocking is live.
644            {
645                let mut state = inner.state.lock();
646                if let Some(entry) = state.tasks.get_mut(&name_clone) {
647                    entry.abort_handle = abort;
648                }
649            }
650
651            let kind = match join_handle.await {
652                Ok(val) => {
653                    let _ = tx.send(Ok(val));
654                    CompletionKind::Normal
655                }
656                Err(e) if e.is_panic() => {
657                    let _ = tx.send(Err(BlockingError::Panicked));
658                    CompletionKind::Panicked
659                }
660                Err(_) => {
661                    // Aborted — drop tx so rx returns SupervisorDropped.
662                    CompletionKind::Cancelled
663                }
664            };
665            // _permit released here, freeing the semaphore slot.
666            let _ = completion_tx.send(Completion {
667                name: name_clone,
668                kind,
669            });
670        });
671        let abort = outer.abort_handle();
672
673        // Register in registry so snapshot/shutdown sees the task.
674        {
675            let mut state = self.inner.state.lock();
676            if let Some(old) = state.tasks.remove(&name) {
677                old.abort_handle.abort();
678            }
679            state.tasks.insert(
680                Arc::clone(&name),
681                TaskEntry {
682                    name: Arc::clone(&name),
683                    status: TaskStatus::Running,
684                    started_at: Instant::now(),
685                    restart_count: 0,
686                    restart_policy: RestartPolicy::RunOnce,
687                    abort_handle: abort.clone(),
688                    factory: None,
689                },
690            );
691        }
692
693        BlockingHandle { rx, abort }
694    }
695
696    /// Spawn an async task that produces a typed result value (runs on tokio worker thread).
697    ///
698    /// Unlike [`spawn`][Self::spawn], no restart policy is supported — the task
699    /// runs once. The task is registered in the supervisor registry under the
700    /// provided `name` and is visible to [`snapshot`][Self::snapshot] and
701    /// [`shutdown_all`][Self::shutdown_all].
702    ///
703    /// For CPU-bound work that must not block tokio workers, use
704    /// [`spawn_blocking`][Self::spawn_blocking] instead.
705    ///
706    /// # Examples
707    ///
708    /// ```rust,no_run
709    /// use std::sync::Arc;
710    /// use tokio_util::sync::CancellationToken;
711    /// use zeph_common::task_supervisor::{BlockingHandle, TaskSupervisor};
712    ///
713    /// # #[tokio::main]
714    /// # async fn main() {
715    /// let cancel = CancellationToken::new();
716    /// let sup = TaskSupervisor::new(cancel.clone());
717    ///
718    /// let handle: BlockingHandle<u32> = sup.spawn_oneshot(Arc::from("compute"), || async { 42_u32 });
719    /// let result = handle.join().await.unwrap();
720    /// assert_eq!(result, 42);
721    /// # }
722    /// ```
723    pub fn spawn_oneshot<F, Fut, R>(&self, name: Arc<str>, factory: F) -> BlockingHandle<R>
724    where
725        F: FnOnce() -> Fut + Send + 'static,
726        Fut: Future<Output = R> + Send + 'static,
727        R: Send + 'static,
728    {
729        self.spawn_oneshot_classified(name, factory, |_| true)
730    }
731
732    /// Spawn an async task like [`spawn_oneshot`][Self::spawn_oneshot], but additionally
733    /// classify the produced value's own success/failure via `is_success` instead of only
734    /// the outer `JoinHandle` outcome (normal exit vs. panic vs. cancellation).
735    ///
736    /// This matters whenever `Fut::Output` itself encodes failure (typically
737    /// `Result<T, E>`): without a classifier, a task that runs to completion but produces
738    /// `Err(..)` is classified and logged identically to one that produced `Ok(..)` —
739    /// `spawn_oneshot` alone only observes whether the *outer* future resolved, never what
740    /// value it resolved to. `is_success` is called once, by reference, before the value is
741    /// sent to the returned [`BlockingHandle`]'s receiver — it never consumes or blocks on
742    /// the value, and the value itself is delivered to the caller unchanged either way.
743    ///
744    /// # Examples
745    ///
746    /// ```rust,no_run
747    /// use std::sync::Arc;
748    /// use tokio_util::sync::CancellationToken;
749    /// use zeph_common::task_supervisor::TaskSupervisor;
750    ///
751    /// # #[tokio::main]
752    /// # async fn main() {
753    /// let sup = TaskSupervisor::new(CancellationToken::new());
754    /// let handle = sup.spawn_oneshot_classified(
755    ///     Arc::from("fallible-task"),
756    ///     || async { Result::<u32, String>::Err("boom".to_string()) },
757    ///     Result::is_ok,
758    /// );
759    /// let result = handle.join().await.unwrap();
760    /// assert!(result.is_err());
761    /// # }
762    /// ```
763    pub fn spawn_oneshot_classified<F, Fut, R>(
764        &self,
765        name: Arc<str>,
766        factory: F,
767        is_success: impl Fn(&R) -> bool + Send + 'static,
768    ) -> BlockingHandle<R>
769    where
770        F: FnOnce() -> Fut + Send + 'static,
771        Fut: Future<Output = R> + Send + 'static,
772        R: Send + 'static,
773    {
774        let (tx, rx) = oneshot::channel::<Result<R, BlockingError>>();
775        let cancel = self.inner.cancel.clone();
776        let span = tracing::info_span!("supervised_task", task.name = %name);
777        let join_handle: tokio::task::JoinHandle<Option<R>> = tokio::spawn(
778            async move {
779                let fut = factory();
780                tokio::select! {
781                    result = fut => Some(result),
782                    () = cancel.cancelled() => None,
783                }
784            }
785            .instrument(span),
786        );
787        let abort = join_handle.abort_handle();
788
789        {
790            let mut state = self.inner.state.lock();
791            if let Some(old) = state.tasks.remove(&name) {
792                old.abort_handle.abort();
793            }
794            state.tasks.insert(
795                Arc::clone(&name),
796                TaskEntry {
797                    name: Arc::clone(&name),
798                    status: TaskStatus::Running,
799                    started_at: Instant::now(),
800                    restart_count: 0,
801                    restart_policy: RestartPolicy::RunOnce,
802                    abort_handle: abort.clone(),
803                    factory: None,
804                },
805            );
806        }
807
808        let completion_tx = self.inner.completion_tx.clone();
809        tokio::spawn(async move {
810            let kind = match join_handle.await {
811                Ok(Some(val)) => {
812                    let kind = if is_success(&val) {
813                        CompletionKind::Normal
814                    } else {
815                        CompletionKind::Failed
816                    };
817                    let _ = tx.send(Ok(val));
818                    kind
819                }
820                Err(e) if e.is_panic() => {
821                    let _ = tx.send(Err(BlockingError::Panicked));
822                    CompletionKind::Panicked
823                }
824                _ => CompletionKind::Cancelled,
825            };
826            let _ = completion_tx.send(Completion { name, kind });
827        });
828        BlockingHandle { rx, abort }
829    }
830
831    /// Abort a task by name. No-op if no task with that name is registered.
832    pub fn abort(&self, name: &'static str) {
833        let state = self.inner.state.lock();
834        let key: Arc<str> = Arc::from(name);
835        if let Some(entry) = state.tasks.get(&key) {
836            entry.abort_handle.abort();
837            tracing::debug!(task.name = name, "task aborted via supervisor");
838        }
839    }
840
841    /// Gracefully shut down all supervised tasks.
842    ///
843    /// Cancels the supervisor's [`CancellationToken`] and waits up to `timeout`
844    /// for all tasks to exit. Tasks that do not exit within the timeout are
845    /// aborted forcefully and their registry entries updated to [`TaskStatus::Aborted`].
846    ///
847    /// # Note
848    ///
849    /// This cancels the token passed to [`TaskSupervisor::new`]. If you share
850    /// that token with other subsystems, they will be cancelled too. Use a child
851    /// token (`cancel.child_token()`) when the supervisor should not affect
852    /// unrelated components.
853    pub async fn shutdown_all(&self, timeout: Duration) {
854        self.inner.cancel.cancel();
855        let sleep = tokio::time::sleep(timeout);
856        tokio::pin!(sleep);
857        loop {
858            let active = self.active_count();
859            if active == 0 {
860                break;
861            }
862            // Subscribe before re-checking so we cannot miss a notification that
863            // fires between the active_count() call above and the select below.
864            let notified = self.inner.shutdown_notify.notified();
865            tokio::select! {
866                biased;
867                () = notified => {
868                    // reap driver decremented active count — re-check at top of loop
869                }
870                () = &mut sleep => {
871                    let mut remaining_names: Vec<Arc<str>> = Vec::new();
872                    {
873                        let mut state = self.inner.state.lock();
874                        for entry in state.tasks.values_mut() {
875                            if matches!(
876                                entry.status,
877                                TaskStatus::Running | TaskStatus::Restarting { .. }
878                            ) {
879                                remaining_names.push(Arc::clone(&entry.name));
880                                entry.abort_handle.abort();
881                                entry.status = TaskStatus::Aborted;
882                            }
883                        }
884                    }
885                    tracing::warn!(
886                        remaining = active,
887                        tasks = ?remaining_names,
888                        "shutdown timeout — aborting remaining tasks"
889                    );
890                    break;
891                }
892            }
893        }
894    }
895
896    /// Return a point-in-time snapshot of all registered tasks.
897    ///
898    /// Suitable for TUI status panels and structured logging. The returned
899    /// list is sorted by `started_at` ascending.
900    #[must_use]
901    pub fn snapshot(&self) -> Vec<TaskSnapshot> {
902        let state = self.inner.state.lock();
903        let mut snaps: Vec<TaskSnapshot> = state
904            .tasks
905            .values()
906            .map(|e| TaskSnapshot {
907                name: Arc::clone(&e.name),
908                status: e.status.clone(),
909                started_at: e.started_at,
910                restart_count: e.restart_count,
911            })
912            .collect();
913        snaps.sort_by_key(|s| s.started_at);
914        snaps
915    }
916
917    /// Return the number of tasks currently in `Running` or `Restarting` state.
918    #[must_use]
919    pub fn active_count(&self) -> usize {
920        let state = self.inner.state.lock();
921        state
922            .tasks
923            .values()
924            .filter(|e| {
925                matches!(
926                    e.status,
927                    TaskStatus::Running | TaskStatus::Restarting { .. }
928                )
929            })
930            .count()
931    }
932
933    /// Return a clone of the supervisor's [`CancellationToken`].
934    ///
935    /// Callers can use this to check whether shutdown has been initiated.
936    #[must_use]
937    pub fn cancellation_token(&self) -> CancellationToken {
938        self.inner.cancel.clone()
939    }
940
941    // ── Internal helpers ──────────────────────────────────────────────────────
942
943    /// Spawn the actual tokio task. Returns `(AbortHandle, JoinHandle)`.
944    fn do_spawn(
945        name: &'static str,
946        factory: &BoxFactory,
947        cancel: CancellationToken,
948    ) -> (AbortHandle, tokio::task::JoinHandle<bool>) {
949        let fut = factory();
950        let span = tracing::info_span!("supervised_task", task.name = name);
951        let jh = tokio::spawn(
952            async move {
953                tokio::select! {
954                    ok = fut => ok,
955                    // Token-cancelled = graceful exit, not a failure.
956                    () = cancel.cancelled() => true,
957                }
958            }
959            .instrument(span),
960        );
961        let abort = jh.abort_handle();
962        (abort, jh)
963    }
964
965    /// Wire a completion reporter: drives `jh` and sends the result to `completion_tx`.
966    fn wire_completion_reporter(
967        name: Arc<str>,
968        jh: tokio::task::JoinHandle<bool>,
969        completion_tx: mpsc::UnboundedSender<Completion>,
970    ) {
971        tokio::spawn(async move {
972            let kind = match jh.await {
973                Ok(true) => CompletionKind::Normal,
974                Ok(false) => CompletionKind::Failed,
975                Err(e) if e.is_panic() => CompletionKind::Panicked,
976                Err(_) => CompletionKind::Cancelled,
977            };
978            let _ = completion_tx.send(Completion { name, kind });
979        });
980    }
981
982    /// Spawn the reap driver. The driver processes completion events from the mpsc channel.
983    ///
984    /// After the cancellation token fires, the driver continues draining the channel
985    /// until the registry reports no active tasks (or the channel closes) — this
986    /// ensures that tasks which complete after cancel, however long that takes,
987    /// still have their registry entries updated, allowing `shutdown_all` to observe
988    /// `active_count() == 0` correctly and wake early. The driver enforces no
989    /// deadline of its own: [`TaskSupervisor::shutdown_all`]'s own `sleep(timeout)`
990    /// is the sole authority for giving up on a stuck task, regardless of how long
991    /// the gap is between the token being cancelled (typically out-of-band, by a
992    /// shutdown bridge or signal handler — see `src/runner.rs`, `src/serve/mod.rs`)
993    /// and `shutdown_all` actually being invoked. See the Phase 2 comment below for
994    /// why this is correct and terminates.
995    fn start_reap_driver(
996        inner: Arc<Inner>,
997        mut completion_rx: mpsc::UnboundedReceiver<Completion>,
998        cancel: CancellationToken,
999    ) {
1000        tokio::spawn(async move {
1001            // Phase 1: normal operation — process completions until cancel fires.
1002            loop {
1003                tokio::select! {
1004                    biased;
1005                    Some(completion) = completion_rx.recv() => {
1006                        Self::handle_completion(&inner, completion).await;
1007                    }
1008                    () = cancel.cancelled() => break,
1009                }
1010            }
1011
1012            // Phase 2: post-cancel drain — keep receiving completions until the
1013            // registry reports no active tasks, or the channel closes. This prevents
1014            // losing completions that arrive after tasks observe cancellation
1015            // (#3161), for however long that takes.
1016            //
1017            // Deliberately no independent deadline here (#5926): an earlier version
1018            // of this drain phase enforced its own short fallback timeout, which
1019            // could — and at every real call site, reliably did — expire before
1020            // `shutdown_all` was ever invoked (the shared `CancellationToken` is
1021            // cancelled out-of-band by a shutdown bridge/signal handler well before
1022            // `shutdown_all` runs; see `src/runner.rs`, `src/serve/mod.rs`), causing
1023            // the driver to exit and silently drop any later completion. There is
1024            // exactly one deadline authority for the whole shutdown sequence:
1025            // `shutdown_all`'s own `sleep(timeout)` (below), which force-aborts any
1026            // task still `Running`/`Restarting` under the registry lock directly —
1027            // that abort itself generates a `Cancelled` completion for `spawn`/
1028            // `spawn_oneshot` tasks via `wire_completion_reporter`, waking this loop
1029            // promptly. The one exception is a `spawn_blocking` task force-aborted
1030            // while its outer wrapper is still parked on the concurrency semaphore
1031            // (see `spawn_blocking`, below) — no completion is sent in that case, but
1032            // `shutdown_all` has already marked the entry `Aborted` directly under the
1033            // state lock and returned correctly; only this now-idle reap-driver task
1034            // may linger parked until process/test teardown, which is not a hang. This
1035            // task holds its own `Arc<Inner>` (which also owns a `completion_tx`
1036            // clone) for as long as it runs, so `completion_rx.recv()` can only
1037            // resolve via a real completion or the channel genuinely closing — never a
1038            // stale timeout.
1039            let active = Self::has_active_tasks(&inner);
1040            tracing::debug!(active, "reap driver entered post-cancel drain phase");
1041            loop {
1042                if !Self::has_active_tasks(&inner) {
1043                    break;
1044                }
1045                match completion_rx.recv().await {
1046                    Some(completion) => Self::handle_completion(&inner, completion).await,
1047                    None => break, // channel closed — unreachable in practice
1048                }
1049            }
1050            tracing::debug!(
1051                active = Self::has_active_tasks(&inner),
1052                "reap driver drain phase complete"
1053            );
1054        });
1055    }
1056
1057    /// Returns `true` if any task is in `Running` or `Restarting` state.
1058    fn has_active_tasks(inner: &Arc<Inner>) -> bool {
1059        let state = inner.state.lock();
1060        state.tasks.values().any(|e| {
1061            matches!(
1062                e.status,
1063                TaskStatus::Running | TaskStatus::Restarting { .. }
1064            )
1065        })
1066    }
1067
1068    /// Process a single task completion event.
1069    ///
1070    /// Lock is never held across `.await`. Phase 1 classifies the completion
1071    /// under lock; Phase 2 sleeps with exponential backoff without a lock;
1072    /// Phase 3 spawns the next instance and updates the registry.
1073    async fn handle_completion(inner: &Arc<Inner>, completion: Completion) {
1074        // Short-circuit: once cancellation has fired, never schedule restarts.
1075        // Without this, Restart-policy tasks re-register as Running, causing
1076        // has_active_tasks() to stay true and the drain loop to spin until timeout.
1077        if inner.cancel.is_cancelled() {
1078            {
1079                let mut state = inner.state.lock();
1080                state.tasks.remove(&completion.name);
1081            }
1082            inner.shutdown_notify.notify_waiters();
1083            return;
1084        }
1085
1086        let Some((attempt, max, delay)) = Self::classify_completion(inner, &completion) else {
1087            // Task removed from registry (RunOnce completed or Restart exhausted) —
1088            // wake shutdown_all so it can re-check active_count immediately.
1089            inner.shutdown_notify.notify_waiters();
1090            return;
1091        };
1092
1093        tracing::warn!(
1094            task.name = %completion.name,
1095            attempt,
1096            max,
1097            delay_ms = delay.as_millis(),
1098            "restarting supervised task"
1099        );
1100
1101        if !delay.is_zero() {
1102            tokio::time::sleep(delay).await;
1103        }
1104
1105        Self::do_restart(inner, &completion.name, attempt);
1106    }
1107
1108    /// Phase 1: classify the completion under lock and return restart parameters if needed.
1109    ///
1110    /// Returns `Some((attempt, max, backoff_delay))` when a restart should be scheduled.
1111    fn classify_completion(
1112        inner: &Arc<Inner>,
1113        completion: &Completion,
1114    ) -> Option<(u32, u32, Duration)> {
1115        let mut state = inner.state.lock();
1116        let entry = state.tasks.get_mut(&completion.name)?;
1117
1118        match completion.kind {
1119            CompletionKind::Panicked => {
1120                tracing::warn!(task.name = %completion.name, "supervised task panicked");
1121            }
1122            CompletionKind::Failed => {
1123                tracing::warn!(
1124                    task.name = %completion.name,
1125                    "supervised task completed with an inner failure"
1126                );
1127            }
1128            CompletionKind::Normal => {
1129                tracing::info!(task.name = %completion.name, "supervised task completed");
1130            }
1131            CompletionKind::Cancelled => {
1132                tracing::debug!(task.name = %completion.name, "supervised task cancelled");
1133            }
1134        }
1135
1136        match entry.restart_policy {
1137            RestartPolicy::RunOnce => {
1138                if completion.kind == CompletionKind::Failed {
1139                    entry.status = TaskStatus::Failed {
1140                        reason: "task completed with an inner failure".to_string(),
1141                    };
1142                } else {
1143                    entry.status = TaskStatus::Completed;
1144                }
1145                state.tasks.remove(&completion.name);
1146                None
1147            }
1148            RestartPolicy::Restart { max, base_delay } => {
1149                // Only *restart* on panic — normal exit, an inner failure, and cancellation
1150                // are not retried. An inner failure is retained (not removed) as a durable
1151                // Failed entry so it stays visible in snapshot()/TUI, mirroring the
1152                // panic-exhausted path below — restart-policy tasks use fixed, bounded
1153                // `&'static str` names, so retention here cannot leak (#6510).
1154                if completion.kind != CompletionKind::Panicked {
1155                    if completion.kind == CompletionKind::Failed {
1156                        entry.status = TaskStatus::Failed {
1157                            reason: "task completed with an inner failure".to_string(),
1158                        };
1159                    } else {
1160                        entry.status = TaskStatus::Completed;
1161                        state.tasks.remove(&completion.name);
1162                    }
1163                    return None;
1164                }
1165                if entry.restart_count >= max {
1166                    let reason = format!("panicked after {max} restart(s)");
1167                    tracing::error!(
1168                        task.name = %completion.name,
1169                        attempts = max,
1170                        "task failed permanently"
1171                    );
1172                    entry.status = TaskStatus::Failed { reason };
1173                    None
1174                } else {
1175                    let attempt = entry.restart_count + 1;
1176                    entry.status = TaskStatus::Restarting { attempt, max };
1177                    // Exponential backoff: base_delay * 2^(attempt-1), capped at MAX_RESTART_DELAY.
1178                    let multiplier = 1_u32
1179                        .checked_shl(attempt.saturating_sub(1))
1180                        .unwrap_or(u32::MAX);
1181                    let delay = base_delay.saturating_mul(multiplier).min(MAX_RESTART_DELAY);
1182                    Some((attempt, max, delay))
1183                }
1184            }
1185        }
1186        // lock released here
1187    }
1188
1189    /// Phase 3: TOCTOU check, collect spawn params under lock, then spawn outside.
1190    fn do_restart(inner: &Arc<Inner>, name: &Arc<str>, attempt: u32) {
1191        let spawn_params = {
1192            let mut state = inner.state.lock();
1193            let Some(entry) = state.tasks.get_mut(name.as_ref()) else {
1194                tracing::debug!(
1195                    task.name = %name,
1196                    "task removed during restart delay — skipping"
1197                );
1198                return;
1199            };
1200            if !matches!(entry.status, TaskStatus::Restarting { .. }) {
1201                return;
1202            }
1203            let Some(factory) = &entry.factory else {
1204                return;
1205            };
1206            // Wrap factory() in catch_unwind to prevent a factory panic from crashing
1207            // the reap driver and orphaning the registry.
1208            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(factory)) {
1209                Err(_) => {
1210                    let reason = format!("factory panicked on restart attempt {attempt}");
1211                    tracing::error!(task.name = %name, attempt, "factory panicked during restart");
1212                    entry.status = TaskStatus::Failed { reason };
1213                    None
1214                }
1215                Ok(fut) => Some((
1216                    fut,
1217                    inner.cancel.clone(),
1218                    inner.completion_tx.clone(),
1219                    name.clone(),
1220                )),
1221            }
1222            // lock released here
1223        };
1224
1225        let Some((fut, cancel, completion_tx, name)) = spawn_params else {
1226            return;
1227        };
1228
1229        let span = tracing::info_span!("supervised_task", task.name = %name);
1230        let jh = tokio::spawn(
1231            async move {
1232                tokio::select! {
1233                    ok = fut => ok,
1234                    // Token-cancelled = graceful exit, not a failure.
1235                    () = cancel.cancelled() => true,
1236                }
1237            }
1238            .instrument(span),
1239        );
1240        let new_abort = jh.abort_handle();
1241
1242        {
1243            let mut state = inner.state.lock();
1244            if let Some(entry) = state.tasks.get_mut(name.as_ref()) {
1245                entry.restart_count = attempt;
1246                entry.status = TaskStatus::Running;
1247                entry.abort_handle = new_abort;
1248            }
1249        }
1250
1251        Self::wire_completion_reporter(name, jh, completion_tx);
1252    }
1253}
1254
1255// ── Task metrics helpers ──────────────────────────────────────────────────────
1256
1257/// Run `f` and record wall-time and CPU-time metrics via `metrics` crate.
1258#[inline]
1259fn measure_blocking<F, R>(name: &str, f: F) -> R
1260where
1261    F: FnOnce() -> R,
1262{
1263    use cpu_time::ThreadTime;
1264    let wall_start = std::time::Instant::now();
1265    let cpu_start = ThreadTime::now();
1266    let result = f();
1267    let wall_ms = wall_start.elapsed().as_secs_f64() * 1000.0;
1268    let cpu_ms = cpu_start.elapsed().as_secs_f64() * 1000.0;
1269    metrics::histogram!("zeph.task.wall_time_ms", "task" => name.to_owned()).record(wall_ms);
1270    metrics::histogram!("zeph.task.cpu_time_ms", "task" => name.to_owned()).record(cpu_ms);
1271    tracing::Span::current().record("task.wall_time_ms", wall_ms);
1272    tracing::Span::current().record("task.cpu_time_ms", cpu_ms);
1273    result
1274}
1275
1276// ── BlockingSpawner impl ──────────────────────────────────────────────────────
1277
1278impl BlockingSpawner for TaskSupervisor {
1279    /// Spawn a named blocking closure through the supervisor.
1280    ///
1281    /// The task is registered in the supervisor registry (visible in
1282    /// [`snapshot`][Self::snapshot] and subject to graceful shutdown) before
1283    /// the closure begins executing.
1284    fn spawn_blocking_named(
1285        &self,
1286        name: Arc<str>,
1287        f: Box<dyn FnOnce() + Send + 'static>,
1288    ) -> tokio::task::JoinHandle<()> {
1289        let handle = self.spawn_blocking(Arc::clone(&name), f);
1290        tokio::spawn(async move {
1291            if let Err(e) = handle.join().await {
1292                tracing::error!(task.name = %name, error = %e, "supervised blocking task failed");
1293            }
1294        })
1295    }
1296}
1297
1298// ── Unit tests ────────────────────────────────────────────────────────────────
1299
1300#[cfg(test)]
1301mod tests {
1302    use std::sync::Arc;
1303    use std::sync::atomic::{AtomicU32, Ordering};
1304    use std::time::Duration;
1305
1306    use tokio_util::sync::CancellationToken;
1307
1308    use super::*;
1309
1310    fn make_supervisor() -> (TaskSupervisor, CancellationToken) {
1311        let cancel = CancellationToken::new();
1312        let sup = TaskSupervisor::new(cancel.clone());
1313        (sup, cancel)
1314    }
1315
1316    #[tokio::test]
1317    async fn test_spawn_and_complete() {
1318        let (sup, _cancel) = make_supervisor();
1319
1320        let done = Arc::new(tokio::sync::Notify::new());
1321        let done2 = Arc::clone(&done);
1322
1323        sup.spawn(TaskDescriptor {
1324            name: "simple",
1325            restart: RestartPolicy::RunOnce,
1326            factory: move || {
1327                let d = Arc::clone(&done2);
1328                async move {
1329                    d.notify_one();
1330                }
1331            },
1332        });
1333
1334        tokio::time::timeout(Duration::from_secs(2), done.notified())
1335            .await
1336            .expect("task should complete");
1337
1338        tokio::time::sleep(Duration::from_millis(50)).await;
1339        assert_eq!(
1340            sup.active_count(),
1341            0,
1342            "RunOnce task should be removed after completion"
1343        );
1344    }
1345
1346    #[tokio::test]
1347    async fn test_panic_capture() {
1348        let (sup, _cancel) = make_supervisor();
1349
1350        sup.spawn(TaskDescriptor {
1351            name: "panicking",
1352            restart: RestartPolicy::RunOnce,
1353            factory: || async { panic!("intentional test panic") },
1354        });
1355
1356        tokio::time::sleep(Duration::from_millis(200)).await;
1357
1358        let snaps = sup.snapshot();
1359        assert!(
1360            snaps.iter().all(|s| s.name.as_ref() != "panicking"),
1361            "entry should be reaped"
1362        );
1363        assert_eq!(
1364            sup.active_count(),
1365            0,
1366            "active count must be 0 after RunOnce panic"
1367        );
1368    }
1369
1370    /// Regression test for S2: Restart-policy tasks must only restart on panic,
1371    /// not on normal completion.
1372    #[tokio::test]
1373    async fn test_restart_only_on_panic() {
1374        let (sup, _cancel) = make_supervisor();
1375
1376        // Part 1: normal completion — must NOT restart.
1377        let normal_counter = Arc::new(AtomicU32::new(0));
1378        let nc = Arc::clone(&normal_counter);
1379        sup.spawn(TaskDescriptor {
1380            name: "normal-exit",
1381            restart: RestartPolicy::Restart {
1382                max: 3,
1383                base_delay: Duration::from_millis(10),
1384            },
1385            factory: move || {
1386                let c = Arc::clone(&nc);
1387                async move {
1388                    c.fetch_add(1, Ordering::SeqCst);
1389                    // Returns normally — no panic.
1390                }
1391            },
1392        });
1393
1394        tokio::time::sleep(Duration::from_millis(300)).await;
1395        assert_eq!(
1396            normal_counter.load(Ordering::SeqCst),
1397            1,
1398            "normal exit must not restart"
1399        );
1400        assert!(
1401            sup.snapshot()
1402                .iter()
1403                .all(|s| s.name.as_ref() != "normal-exit"),
1404            "entry removed after normal exit"
1405        );
1406
1407        // Part 2: panic — MUST restart up to max times.
1408        let panic_counter = Arc::new(AtomicU32::new(0));
1409        let pc = Arc::clone(&panic_counter);
1410        sup.spawn(TaskDescriptor {
1411            name: "panic-exit",
1412            restart: RestartPolicy::Restart {
1413                max: 2,
1414                base_delay: Duration::from_millis(10),
1415            },
1416            factory: move || {
1417                let c = Arc::clone(&pc);
1418                async move {
1419                    c.fetch_add(1, Ordering::SeqCst);
1420                    panic!("test panic");
1421                }
1422            },
1423        });
1424
1425        // initial + 2 restarts = 3 total
1426        tokio::time::sleep(Duration::from_millis(500)).await;
1427        assert!(
1428            panic_counter.load(Ordering::SeqCst) >= 3,
1429            "panicking task must restart max times"
1430        );
1431        let snap = sup
1432            .snapshot()
1433            .into_iter()
1434            .find(|s| s.name.as_ref() == "panic-exit");
1435        assert!(
1436            matches!(snap.unwrap().status, TaskStatus::Failed { .. }),
1437            "task must be Failed after exhausting restarts"
1438        );
1439    }
1440
1441    #[tokio::test]
1442    async fn test_restart_policy() {
1443        let (sup, _cancel) = make_supervisor();
1444
1445        let counter = Arc::new(AtomicU32::new(0));
1446        let counter2 = Arc::clone(&counter);
1447
1448        sup.spawn(TaskDescriptor {
1449            name: "restartable",
1450            restart: RestartPolicy::Restart {
1451                max: 2,
1452                base_delay: Duration::from_millis(10),
1453            },
1454            factory: move || {
1455                let c = Arc::clone(&counter2);
1456                async move {
1457                    c.fetch_add(1, Ordering::SeqCst);
1458                    panic!("always panic");
1459                }
1460            },
1461        });
1462
1463        tokio::time::sleep(Duration::from_millis(500)).await;
1464
1465        let runs = counter.load(Ordering::SeqCst);
1466        assert!(
1467            runs >= 3,
1468            "expected at least 3 invocations (initial + 2 restarts), got {runs}"
1469        );
1470
1471        let snaps = sup.snapshot();
1472        let snap = snaps.iter().find(|s| s.name.as_ref() == "restartable");
1473        assert!(snap.is_some(), "failed task should remain in registry");
1474        assert!(
1475            matches!(snap.unwrap().status, TaskStatus::Failed { .. }),
1476            "task should be Failed after exhausting retries"
1477        );
1478    }
1479
1480    /// Verify exponential backoff: delay doubles on each restart attempt.
1481    #[tokio::test]
1482    async fn test_exponential_backoff() {
1483        let (sup, _cancel) = make_supervisor();
1484
1485        let timestamps = Arc::new(parking_lot::Mutex::new(Vec::<std::time::Instant>::new()));
1486        let ts = Arc::clone(&timestamps);
1487
1488        sup.spawn(TaskDescriptor {
1489            name: "backoff-task",
1490            restart: RestartPolicy::Restart {
1491                max: 3,
1492                base_delay: Duration::from_millis(50),
1493            },
1494            factory: move || {
1495                let t = Arc::clone(&ts);
1496                async move {
1497                    t.lock().push(std::time::Instant::now());
1498                    panic!("always panic");
1499                }
1500            },
1501        });
1502
1503        // Wait long enough for all restarts: 50 + 100 + 200 ms = 350 ms + overhead
1504        tokio::time::sleep(Duration::from_millis(800)).await;
1505
1506        let ts = timestamps.lock();
1507        assert!(
1508            ts.len() >= 3,
1509            "expected at least 3 invocations, got {}",
1510            ts.len()
1511        );
1512
1513        // Verify delays are roughly doubling (within 2x tolerance for CI jitter).
1514        if ts.len() >= 3 {
1515            let d1 = ts[1].duration_since(ts[0]);
1516            let d2 = ts[2].duration_since(ts[1]);
1517            // d2 should be at least 1.5x d1 (allowing for jitter).
1518            assert!(
1519                d2 >= d1.mul_f64(1.5),
1520                "expected exponential backoff: d1={d1:?} d2={d2:?}"
1521            );
1522        }
1523    }
1524
1525    #[tokio::test]
1526    async fn test_graceful_shutdown() {
1527        let (sup, _cancel) = make_supervisor();
1528
1529        for name in ["svc-a", "svc-b", "svc-c"] {
1530            sup.spawn(TaskDescriptor {
1531                name,
1532                restart: RestartPolicy::RunOnce,
1533                factory: || async {
1534                    tokio::time::sleep(Duration::from_mins(1)).await;
1535                },
1536            });
1537        }
1538
1539        assert_eq!(sup.active_count(), 3);
1540
1541        tokio::time::timeout(
1542            Duration::from_secs(2),
1543            sup.shutdown_all(Duration::from_secs(1)),
1544        )
1545        .await
1546        .expect("shutdown should complete within timeout");
1547    }
1548
1549    /// Verify that force-aborted tasks get `TaskStatus::Aborted` in the registry (A2 fix).
1550    #[tokio::test]
1551    async fn test_force_abort_marks_aborted() {
1552        let cancel = CancellationToken::new();
1553        let sup = TaskSupervisor::new(cancel.clone());
1554
1555        sup.spawn(TaskDescriptor {
1556            name: "stubborn-for-abort",
1557            restart: RestartPolicy::RunOnce,
1558            factory: || async {
1559                // Does not cooperate with cancellation.
1560                std::future::pending::<()>().await;
1561            },
1562        });
1563
1564        // Use a very short timeout to trigger force-abort.
1565        sup.shutdown_all(Duration::from_millis(1)).await;
1566
1567        // Entry should be Aborted, not Running.
1568        let snaps = sup.snapshot();
1569        if let Some(snap) = snaps
1570            .iter()
1571            .find(|s| s.name.as_ref() == "stubborn-for-abort")
1572        {
1573            assert_eq!(
1574                snap.status,
1575                TaskStatus::Aborted,
1576                "force-aborted task must have Aborted status"
1577            );
1578        }
1579        // If entry was already reaped (cooperative cancel won), that's also acceptable.
1580    }
1581
1582    #[tokio::test]
1583    async fn test_registry_snapshot() {
1584        let (sup, _cancel) = make_supervisor();
1585
1586        for name in ["alpha", "beta"] {
1587            sup.spawn(TaskDescriptor {
1588                name,
1589                restart: RestartPolicy::RunOnce,
1590                factory: || async {
1591                    tokio::time::sleep(Duration::from_secs(10)).await;
1592                },
1593            });
1594        }
1595
1596        let snaps = sup.snapshot();
1597        assert_eq!(snaps.len(), 2);
1598        let names: Vec<&str> = snaps.iter().map(|s| s.name.as_ref()).collect();
1599        assert!(names.contains(&"alpha"));
1600        assert!(names.contains(&"beta"));
1601        assert!(snaps.iter().all(|s| s.status == TaskStatus::Running));
1602    }
1603
1604    #[tokio::test]
1605    async fn test_blocking_returns_value() {
1606        let (sup, cancel) = make_supervisor();
1607
1608        let handle: BlockingHandle<u32> = sup.spawn_blocking(Arc::from("compute"), || 42_u32);
1609        let result = handle.join().await.expect("should return value");
1610        assert_eq!(result, 42);
1611        cancel.cancel();
1612    }
1613
1614    #[tokio::test]
1615    async fn test_blocking_panic() {
1616        let (sup, _cancel) = make_supervisor();
1617
1618        let handle: BlockingHandle<u32> =
1619            sup.spawn_blocking(Arc::from("panicking-compute"), || panic!("intentional"));
1620        let err = handle
1621            .join()
1622            .await
1623            .expect_err("should return error on panic");
1624        assert_eq!(err, BlockingError::Panicked);
1625    }
1626
1627    /// Verify `spawn_blocking` tasks appear in registry (M3 fix).
1628    #[tokio::test]
1629    async fn test_blocking_registered_in_registry() {
1630        let (sup, cancel) = make_supervisor();
1631
1632        let (tx, rx) = std::sync::mpsc::channel::<()>();
1633        let _handle: BlockingHandle<()> =
1634            sup.spawn_blocking(Arc::from("blocking-task"), move || {
1635                // Block until signalled.
1636                let _ = rx.recv();
1637            });
1638
1639        tokio::time::sleep(Duration::from_millis(10)).await;
1640        assert_eq!(
1641            sup.active_count(),
1642            1,
1643            "blocking task must appear in active_count"
1644        );
1645
1646        let _ = tx.send(());
1647        tokio::time::sleep(Duration::from_millis(100)).await;
1648        assert_eq!(
1649            sup.active_count(),
1650            0,
1651            "blocking task must be removed after completion"
1652        );
1653
1654        cancel.cancel();
1655    }
1656
1657    /// Verify `spawn_oneshot` tasks appear in registry (M3 fix).
1658    #[tokio::test]
1659    async fn test_oneshot_registered_in_registry() {
1660        let (sup, cancel) = make_supervisor();
1661
1662        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1663        let _handle: BlockingHandle<()> =
1664            sup.spawn_oneshot(Arc::from("oneshot-task"), move || async move {
1665                let _ = rx.await;
1666            });
1667
1668        tokio::time::sleep(Duration::from_millis(10)).await;
1669        assert_eq!(
1670            sup.active_count(),
1671            1,
1672            "oneshot task must appear in active_count"
1673        );
1674
1675        let _ = tx.send(());
1676        tokio::time::sleep(Duration::from_millis(50)).await;
1677        assert_eq!(
1678            sup.active_count(),
1679            0,
1680            "oneshot task must be removed after completion"
1681        );
1682
1683        cancel.cancel();
1684    }
1685
1686    /// `spawn_oneshot_classified` must deliver the factory's actual return value to the
1687    /// caller unchanged, regardless of how `is_success` classifies it — the classifier only
1688    /// affects internal registry/log bookkeeping, never the value received via `join()`.
1689    #[tokio::test]
1690    async fn test_oneshot_classified_delivers_actual_value_on_success_and_failure() {
1691        let (sup, _cancel) = make_supervisor();
1692
1693        let ok_handle = sup.spawn_oneshot_classified(
1694            Arc::from("classified-ok"),
1695            || async { Result::<u32, String>::Ok(7) },
1696            Result::is_ok,
1697        );
1698        assert_eq!(ok_handle.join().await.unwrap(), Ok(7));
1699
1700        let err_handle = sup.spawn_oneshot_classified(
1701            Arc::from("classified-err"),
1702            || async { Result::<u32, String>::Err("boom".to_string()) },
1703            Result::is_ok,
1704        );
1705        assert_eq!(
1706            err_handle.join().await.unwrap(),
1707            Err("boom".to_string()),
1708            "the real Err value must reach the caller unchanged even though it is \
1709             classified as CompletionKind::Failed internally"
1710        );
1711    }
1712
1713    /// Regression test for #6257: an `Err`-returning factory must be classified as
1714    /// `CompletionKind::Failed` (logged at `warn` as "completed with an inner failure"),
1715    /// not `CompletionKind::Normal` (logged at `info` as a plain completion). This is the
1716    /// distinction `spawn_agent_task` (zeph-subagent) relies on so a subagent task that
1717    /// returns `Err` after a genuine completion (e.g. a worktree-setup failure) is reported
1718    /// as a supervisor-level failure instead of a normal completion. The registry entry is
1719    /// set to `Failed` and removed in the same lock acquisition, so there is no window in
1720    /// which `snapshot()` could observe the transient status — this test only checks the
1721    /// log line.
1722    #[tokio::test]
1723    #[tracing_test::traced_test]
1724    async fn test_oneshot_classified_logs_failed_for_inner_err() {
1725        let (sup, _cancel) = make_supervisor();
1726
1727        let handle = sup.spawn_oneshot_classified(
1728            Arc::from("classified-inner-err"),
1729            || async { Result::<u32, String>::Err("boom".to_string()) },
1730            Result::is_ok,
1731        );
1732        handle.join().await.unwrap().unwrap_err();
1733
1734        // Give the reap driver a moment to process the completion event.
1735        tokio::time::sleep(Duration::from_millis(50)).await;
1736
1737        assert!(
1738            logs_contain("completed with an inner failure"),
1739            "an Err value must be classified as CompletionKind::Failed, not Normal"
1740        );
1741    }
1742
1743    /// Counterpart to the above: an `Ok`-returning factory must be classified as
1744    /// `CompletionKind::Normal` (logged at `info`), never as an inner failure — confirms
1745    /// the classifier doesn't over-fire on the success path.
1746    #[tokio::test]
1747    #[tracing_test::traced_test]
1748    async fn test_oneshot_classified_logs_normal_for_inner_ok() {
1749        let (sup, _cancel) = make_supervisor();
1750
1751        let handle = sup.spawn_oneshot_classified(
1752            Arc::from("classified-inner-ok"),
1753            || async { Result::<u32, String>::Ok(1) },
1754            Result::is_ok,
1755        );
1756        handle.join().await.unwrap().unwrap();
1757
1758        tokio::time::sleep(Duration::from_millis(50)).await;
1759
1760        assert!(
1761            !logs_contain("completed with an inner failure"),
1762            "an Ok value must not be classified as CompletionKind::Failed"
1763        );
1764    }
1765
1766    /// Regression guard for #6257: `spawn_oneshot`'s ~20 existing call sites across the
1767    /// workspace must remain unaffected by the addition of `spawn_oneshot_classified` —
1768    /// `spawn_oneshot` delegates with an always-true classifier (`|_| true`), so a plain
1769    /// (non-`Result`) factory value must still always be classified `Normal`/logged as a
1770    /// plain completion, never as an inner failure.
1771    #[tokio::test]
1772    #[tracing_test::traced_test]
1773    async fn test_oneshot_backward_compat_never_logs_inner_failure() {
1774        let (sup, _cancel) = make_supervisor();
1775
1776        let handle: BlockingHandle<u32> =
1777            sup.spawn_oneshot(Arc::from("plain-oneshot"), || async { 42_u32 });
1778        assert_eq!(handle.join().await.unwrap(), 42);
1779
1780        tokio::time::sleep(Duration::from_millis(50)).await;
1781
1782        assert!(
1783            !logs_contain("completed with an inner failure"),
1784            "spawn_oneshot's default always-true classifier must never report an inner failure"
1785        );
1786    }
1787
1788    /// Regression test for #6510: a `spawn_classified` task registered with
1789    /// `RestartPolicy::Restart` must classify an inner failure as `CompletionKind::Failed`
1790    /// and — critically — retain the registry entry as `TaskStatus::Failed` so it stays
1791    /// durably visible via [`TaskSupervisor::snapshot`] (the TUI's only data source),
1792    /// instead of being removed in the same lock acquisition that sets the status. Before
1793    /// this fix, `spawn`/`TaskDescriptor` only supported `Fut::Output = ()`, so a
1794    /// `Restart`-policy task could never produce anything but
1795    /// `CompletionKind::Normal | Panicked | Cancelled`; even once `Failed` became reachable,
1796    /// an earlier revision of this fix set the status and immediately removed the entry in
1797    /// the same critical section, leaving a zero-width observation window (#6510's own
1798    /// reproduction still showed the task vanish rather than surface as `Failed`). The
1799    /// primary assertion below is on `snapshot()`, not the log line, because a `snapshot()`
1800    /// poll is what would actually fail if the status-branch fix were reverted back to
1801    /// unconditional `TaskStatus::Completed`.
1802    #[tokio::test]
1803    #[tracing_test::traced_test]
1804    async fn spawn_classified_restart_reports_failed_for_inner_err() {
1805        let (sup, _cancel) = make_supervisor();
1806
1807        let _handle = sup.spawn_classified(
1808            TaskDescriptor {
1809                name: "classified-restart-fail",
1810                restart: RestartPolicy::Restart {
1811                    max: 0,
1812                    base_delay: Duration::from_millis(10),
1813                },
1814                factory: || async { Result::<(), &'static str>::Err("boom") },
1815            },
1816            Result::is_ok,
1817        );
1818
1819        // Give the reap driver a beat to process the completion.
1820        tokio::time::sleep(Duration::from_millis(50)).await;
1821
1822        let snaps = sup.snapshot();
1823        let snap = snaps
1824            .iter()
1825            .find(|s| s.name.as_ref() == "classified-restart-fail");
1826        assert!(
1827            matches!(snap.map(|s| &s.status), Some(TaskStatus::Failed { .. })),
1828            "an Err value from a Restart-policy spawn_classified task must surface as a \
1829             durably-retained TaskStatus::Failed entry in snapshot()/TUI, not vanish or \
1830             settle as Completed — got {snap:?}"
1831        );
1832        assert!(
1833            logs_contain("completed with an inner failure"),
1834            "the inner-failure warning log must still fire alongside the status change"
1835        );
1836    }
1837
1838    /// Counterpart to the above: an `Ok`-returning factory on a `Restart`-policy
1839    /// `spawn_classified` task must be classified `Completed` and removed from the
1840    /// registry exactly as before this fix — only the `Failed` path changes behavior.
1841    #[tokio::test]
1842    #[tracing_test::traced_test]
1843    async fn spawn_classified_restart_does_not_report_failed_for_inner_ok() {
1844        let (sup, _cancel) = make_supervisor();
1845
1846        let _handle = sup.spawn_classified(
1847            TaskDescriptor {
1848                name: "classified-restart-ok",
1849                restart: RestartPolicy::Restart {
1850                    max: 0,
1851                    base_delay: Duration::from_millis(10),
1852                },
1853                factory: || async { Result::<(), &'static str>::Ok(()) },
1854            },
1855            Result::is_ok,
1856        );
1857
1858        tokio::time::sleep(Duration::from_millis(50)).await;
1859
1860        assert!(
1861            sup.snapshot()
1862                .iter()
1863                .all(|s| s.name.as_ref() != "classified-restart-ok"),
1864            "an Ok completion must still be removed from the registry, unchanged from \
1865             pre-#6510 behavior — only Failed completions are now retained"
1866        );
1867        assert!(
1868            !logs_contain("completed with an inner failure"),
1869            "an Ok value must not be classified as CompletionKind::Failed"
1870        );
1871    }
1872
1873    /// A classified task registered with `RestartPolicy::Restart { max > 0, .. }` must still
1874    /// restart on panic (the classifier only affects status *reporting*, never the restart
1875    /// *decision*, which stays panic-only per `classify_completion`), and the classifier must
1876    /// keep working correctly on the post-restart attempt.
1877    #[tokio::test]
1878    async fn spawn_classified_restart_after_panic_then_classifies_next_attempt() {
1879        let (sup, _cancel) = make_supervisor();
1880
1881        let attempt = Arc::new(AtomicU32::new(0));
1882        let attempt2 = Arc::clone(&attempt);
1883
1884        let _handle = sup.spawn_classified(
1885            TaskDescriptor {
1886                name: "classified-restart-after-panic",
1887                restart: RestartPolicy::Restart {
1888                    max: 3,
1889                    base_delay: Duration::from_millis(10),
1890                },
1891                factory: move || {
1892                    let n = attempt2.fetch_add(1, Ordering::SeqCst);
1893                    async move {
1894                        assert!(n != 0, "first attempt always panics");
1895                        Result::<(), &'static str>::Err("boom on retry")
1896                    }
1897                },
1898            },
1899            Result::is_ok,
1900        );
1901
1902        // Panic (attempt 0) triggers a restart; attempt 1 returns Err, which is terminal
1903        // (Restart only retries on panic) and must be classified Failed and retained.
1904        tokio::time::sleep(Duration::from_millis(300)).await;
1905
1906        assert_eq!(
1907            attempt.load(Ordering::SeqCst),
1908            2,
1909            "factory should run exactly twice: initial panic + one restart"
1910        );
1911        let snaps = sup.snapshot();
1912        let snap = snaps
1913            .iter()
1914            .find(|s| s.name.as_ref() == "classified-restart-after-panic");
1915        assert!(
1916            matches!(snap.map(|s| &s.status), Some(TaskStatus::Failed { .. })),
1917            "the post-restart Err attempt must classify and retain as Failed — got {snap:?}"
1918        );
1919    }
1920
1921    #[tokio::test]
1922    async fn test_restart_max_zero() {
1923        let (sup, _cancel) = make_supervisor();
1924
1925        let counter = Arc::new(AtomicU32::new(0));
1926        let counter2 = Arc::clone(&counter);
1927
1928        sup.spawn(TaskDescriptor {
1929            name: "zero-max",
1930            restart: RestartPolicy::Restart {
1931                max: 0,
1932                base_delay: Duration::from_millis(10),
1933            },
1934            factory: move || {
1935                let c = Arc::clone(&counter2);
1936                async move {
1937                    c.fetch_add(1, Ordering::SeqCst);
1938                    panic!("always panic");
1939                }
1940            },
1941        });
1942
1943        tokio::time::sleep(Duration::from_millis(200)).await;
1944
1945        assert_eq!(
1946            counter.load(Ordering::SeqCst),
1947            1,
1948            "max=0 should not restart"
1949        );
1950
1951        let snaps = sup.snapshot();
1952        let snap = snaps.iter().find(|s| s.name.as_ref() == "zero-max");
1953        assert!(snap.is_some(), "entry should remain as Failed");
1954        assert!(
1955            matches!(snap.unwrap().status, TaskStatus::Failed { .. }),
1956            "status should be Failed"
1957        );
1958    }
1959
1960    /// Stress test: spawn 50 tasks concurrently, all must complete and registry must be accurate.
1961    #[tokio::test]
1962    async fn test_concurrent_spawns() {
1963        // All task names must be 'static — pre-defined before any let statements.
1964        static NAMES: [&str; 50] = [
1965            "t00", "t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11",
1966            "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23",
1967            "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31", "t32", "t33", "t34", "t35",
1968            "t36", "t37", "t38", "t39", "t40", "t41", "t42", "t43", "t44", "t45", "t46", "t47",
1969            "t48", "t49",
1970        ];
1971        let (sup, cancel) = make_supervisor();
1972
1973        let completed = Arc::new(AtomicU32::new(0));
1974        for name in &NAMES {
1975            let c = Arc::clone(&completed);
1976            sup.spawn(TaskDescriptor {
1977                name,
1978                restart: RestartPolicy::RunOnce,
1979                factory: move || {
1980                    let c = Arc::clone(&c);
1981                    async move {
1982                        c.fetch_add(1, Ordering::SeqCst);
1983                    }
1984                },
1985            });
1986        }
1987
1988        // Wait for all tasks to complete.
1989        tokio::time::timeout(Duration::from_secs(5), async {
1990            loop {
1991                if completed.load(Ordering::SeqCst) == 50 {
1992                    break;
1993                }
1994                tokio::time::sleep(Duration::from_millis(10)).await;
1995            }
1996        })
1997        .await
1998        .expect("all 50 tasks should complete");
1999
2000        // Give reap driver time to process all completions.
2001        tokio::time::sleep(Duration::from_millis(100)).await;
2002        assert_eq!(sup.active_count(), 0, "all tasks must be reaped");
2003
2004        cancel.cancel();
2005    }
2006
2007    #[tokio::test]
2008    async fn test_shutdown_timeout_expiry() {
2009        let cancel = CancellationToken::new();
2010        let sup = TaskSupervisor::new(cancel.clone());
2011
2012        sup.spawn(TaskDescriptor {
2013            name: "stubborn",
2014            restart: RestartPolicy::RunOnce,
2015            factory: || async {
2016                tokio::time::sleep(Duration::from_mins(1)).await;
2017            },
2018        });
2019
2020        assert_eq!(sup.active_count(), 1);
2021
2022        tokio::time::timeout(
2023            Duration::from_secs(2),
2024            sup.shutdown_all(Duration::from_millis(50)),
2025        )
2026        .await
2027        .expect("shutdown_all should return even on timeout expiry");
2028
2029        assert!(
2030            cancel.is_cancelled(),
2031            "cancel token must be cancelled after shutdown"
2032        );
2033    }
2034
2035    #[tokio::test]
2036    async fn test_cancellation_token() {
2037        let cancel = CancellationToken::new();
2038        let sup = TaskSupervisor::new(cancel.clone());
2039
2040        assert!(!sup.cancellation_token().is_cancelled());
2041
2042        sup.shutdown_all(Duration::from_millis(100)).await;
2043
2044        assert!(
2045            sup.cancellation_token().is_cancelled(),
2046            "token must be cancelled after shutdown"
2047        );
2048    }
2049
2050    /// Regression test for #3161: after `shutdown_all`, all tasks must be reaped
2051    /// even when they complete *after* the cancel signal.
2052    ///
2053    /// The yield loop forces the reap driver to observe cancel and exit phase-1
2054    /// before the tasks send their completions — reliably reproducing the race.
2055    #[tokio::test]
2056    async fn test_shutdown_drains_post_cancel_completions() {
2057        let cancel = CancellationToken::new();
2058        let sup = TaskSupervisor::new(cancel.clone());
2059
2060        for name in [
2061            "loop-1", "loop-2", "loop-3", "loop-4", "loop-5", "loop-6", "loop-7",
2062        ] {
2063            let cancel_inner = cancel.clone();
2064            sup.spawn(TaskDescriptor {
2065                name,
2066                restart: RestartPolicy::RunOnce,
2067                factory: move || {
2068                    let c = cancel_inner.clone();
2069                    async move {
2070                        c.cancelled().await;
2071                        // Yield multiple times so the reap driver observes cancel first.
2072                        for _ in 0..64 {
2073                            tokio::task::yield_now().await;
2074                        }
2075                    }
2076                },
2077            });
2078        }
2079        assert_eq!(sup.active_count(), 7);
2080
2081        sup.shutdown_all(Duration::from_secs(2)).await;
2082
2083        assert_eq!(
2084            sup.active_count(),
2085            0,
2086            "all tasks must be reaped after shutdown (#3161)"
2087        );
2088    }
2089
2090    /// Regression test for #5926: `shutdown_all` must wait for a task that is
2091    /// still running when the caller-supplied `timeout` has not yet elapsed,
2092    /// rather than giving up on any independent, shorter deadline.
2093    ///
2094    /// `spawn_blocking` tasks don't observe the cancellation token — unlike
2095    /// `spawn`-based tasks, whose futures are dropped immediately by `do_spawn`'s
2096    /// `tokio::select!` against `cancel.cancelled()` — so a `spawn_blocking` task is
2097    /// the only way to have a task genuinely keep running past cancel and exercise
2098    /// the drain phase.
2099    #[tokio::test]
2100    async fn test_shutdown_all_reaps_blocking_task_finishing_after_cancel() {
2101        let (sup, _cancel) = make_supervisor();
2102
2103        let handle = sup.spawn_blocking(Arc::from("slow-blocking"), || {
2104            std::thread::sleep(Duration::from_millis(300));
2105        });
2106
2107        // Let the blocking task actually start before triggering shutdown.
2108        tokio::time::sleep(Duration::from_millis(20)).await;
2109
2110        let start = tokio::time::Instant::now();
2111        sup.shutdown_all(Duration::from_secs(2)).await;
2112        let elapsed = start.elapsed();
2113
2114        assert!(
2115            elapsed < Duration::from_secs(1),
2116            "shutdown_all must wake as soon as the blocking task actually finishes \
2117             (~300ms), not wait out the full 2s caller timeout; elapsed={elapsed:?}"
2118        );
2119
2120        // Successfully reaped tasks are removed from the registry entirely (see
2121        // `handle_completion`'s post-cancel short-circuit); only force-aborted tasks
2122        // remain with `TaskStatus::Aborted`. Absence here means the completion was
2123        // processed before shutdown_all's own timeout forced an abort.
2124        let snapshot = sup.snapshot();
2125        assert!(
2126            snapshot.iter().all(|t| t.name.as_ref() != "slow-blocking"),
2127            "task that completed cleanly after cancel must be reaped from the \
2128             registry, not left behind and force-aborted: {snapshot:?}"
2129        );
2130
2131        // `join` returns `Ok` only when the closure ran to completion and the
2132        // outer task wasn't aborted — the `BlockingHandle` equivalent of
2133        // `TaskStatus::Completed` vs `TaskStatus::Aborted`.
2134        handle
2135            .join()
2136            .await
2137            .expect("blocking task must complete, not be reported as aborted (#5926)");
2138    }
2139
2140    /// Regression test for #5926, critical topology (out-of-band cancel):
2141    /// production call sites (`src/runner.rs`, `src/serve/mod.rs`) always cancel
2142    /// the shared `CancellationToken` *before* calling `shutdown_all` — a
2143    /// separate shutdown bridge/signal handler owns the cancel, and `shutdown_all`
2144    /// is only invoked afterward as a waiter. A fix that only works when
2145    /// `shutdown_all` is the sole canceller would pass a naive test but stay
2146    /// broken in production. This test replicates the real topology explicitly:
2147    /// cancel first, let the reap driver start draining, *then* call
2148    /// `shutdown_all`, and confirm the caller's real timeout still governs the
2149    /// wait.
2150    #[tokio::test]
2151    async fn test_shutdown_all_reaps_task_after_out_of_band_cancel() {
2152        let cancel = CancellationToken::new();
2153        let sup = TaskSupervisor::new(cancel.clone());
2154
2155        let handle = sup.spawn_blocking(Arc::from("slow-blocking-oob"), || {
2156            std::thread::sleep(Duration::from_millis(300));
2157        });
2158        tokio::time::sleep(Duration::from_millis(20)).await;
2159
2160        // Out-of-band cancel — nothing to do with shutdown_all, mirrors a shutdown
2161        // bridge/signal handler cancelling the shared token directly.
2162        cancel.cancel();
2163        // Give the reap driver time to observe cancellation and enter Phase 2
2164        // before shutdown_all ever runs.
2165        tokio::time::sleep(Duration::from_millis(50)).await;
2166
2167        let start = tokio::time::Instant::now();
2168        sup.shutdown_all(Duration::from_secs(2)).await;
2169        let elapsed = start.elapsed();
2170
2171        assert!(
2172            elapsed < Duration::from_secs(1),
2173            "shutdown_all must wake once the blocking task actually finishes even \
2174             though the token was cancelled out-of-band before shutdown_all was \
2175             called; elapsed={elapsed:?}"
2176        );
2177
2178        handle.join().await.expect(
2179            "blocking task must complete, not be reported as aborted, even under \
2180             the out-of-band-cancel topology (#5926)",
2181        );
2182    }
2183
2184    /// Regression test for #5926 residual (critic finding S1): the reap driver's
2185    /// drain phase must never give up on a still-active, non-cooperative task on
2186    /// its own — only `shutdown_all`'s own `sleep(timeout)` may do that. An
2187    /// earlier version of the fix gave the drain phase its own short fallback
2188    /// deadline; if the gap between the out-of-band cancel and `shutdown_all`
2189    /// being invoked exceeded that fallback, the reap driver exited *before*
2190    /// `shutdown_all` ever ran, silently dropping the eventual completion and
2191    /// misreporting the task `Aborted`. This is realistic in production: e.g.
2192    /// `serve/mod.rs`'s `axum::serve(...).with_graceful_shutdown(...)` drains
2193    /// in-flight connections with no timeout of its own before `shutdown_all(30s)`
2194    /// is ever reached, and a long-running agent-turn request can hold that gap
2195    /// open well past any short fallback.
2196    ///
2197    /// Uses paused virtual time (`tokio::time::advance`) to jump the cancel-to-
2198    /// `shutdown_all` gap arbitrarily far forward without a real multi-second
2199    /// test. A `spawn_blocking` task runs on a real OS thread (unaffected by
2200    /// paused tokio time) and is held open via a channel until explicitly
2201    /// released, so its completion stays under exact manual control relative to
2202    /// the virtual-time advance and to when `shutdown_all` is actually called.
2203    #[tokio::test(start_paused = true)]
2204    async fn test_shutdown_all_reaps_task_after_long_pre_invocation_gap() {
2205        let cancel = CancellationToken::new();
2206        let sup = TaskSupervisor::new(cancel.clone());
2207
2208        let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
2209        let handle: BlockingHandle<()> = sup.spawn_blocking(Arc::from("held-task"), move || {
2210            let _ = release_rx.recv();
2211        });
2212
2213        // Out-of-band cancel — before shutdown_all is ever called.
2214        cancel.cancel();
2215        for _ in 0..8 {
2216            tokio::task::yield_now().await;
2217        }
2218
2219        // Advance virtual time by a large margin — deliberately larger than any
2220        // fallback window a naive fix might reintroduce — with `shutdown_all`
2221        // still not called and the task still held open. A drain phase with any
2222        // independent deadline of its own would have already exited by now.
2223        tokio::time::advance(Duration::from_mins(2)).await;
2224
2225        // *Now* the real caller shows up.
2226        let sup2 = sup.clone();
2227        let shutdown_jh =
2228            tokio::spawn(async move { sup2.shutdown_all(Duration::from_secs(30)).await });
2229        for _ in 0..8 {
2230            tokio::task::yield_now().await;
2231        }
2232
2233        // Let the real OS thread actually finish.
2234        let _ = release_tx.send(());
2235
2236        // Await directly (no wrapping virtual-time timeout): under `start_paused`,
2237        // an artificial timeout here would itself be subject to auto-advance and
2238        // cannot reliably distinguish outcomes. The real signal is the registry
2239        // state checked below, not how long this await took.
2240        shutdown_jh.await.expect("shutdown_all task must not panic");
2241
2242        // NOTE: `handle.join()` reflects `spawn_blocking`'s own internal oneshot
2243        // completion channel, which is fed directly by the real OS thread finishing
2244        // — entirely independent of the reap driver / registry path this test is
2245        // targeting. It succeeds either way and cannot distinguish the bug, so the
2246        // real assertion is the supervisor's registry state, not `handle.join()`.
2247        let snapshot = sup.snapshot();
2248        let entry = snapshot.iter().find(|t| t.name.as_ref() == "held-task");
2249        assert!(
2250            entry.is_none(),
2251            "a task finishing after shutdown_all was (belatedly) invoked must be \
2252             reaped from the registry (the post-cancel path removes completed \
2253             entries rather than marking them), not force-aborted, no matter how \
2254             long the cancel-to-shutdown_all gap was: {snapshot:?} (#5926 S1)"
2255        );
2256
2257        handle
2258            .join()
2259            .await
2260            .expect("blocking task must complete without panicking");
2261    }
2262
2263    #[tokio::test]
2264    async fn test_blocking_spawner_task_appears_in_snapshot() {
2265        // Verify that tasks spawned via BlockingSpawner appear in supervisor.snapshot().
2266        use crate::BlockingSpawner;
2267
2268        let cancel = CancellationToken::new();
2269        let sup = TaskSupervisor::new(cancel);
2270
2271        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>();
2272        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
2273
2274        let handle = sup.spawn_blocking_named(
2275            Arc::from("chunk_file"),
2276            Box::new(move || {
2277                // Signal that the task has started.
2278                let _ = ready_tx.send(());
2279                // Block until test signals release.
2280                let _ = release_rx.blocking_recv();
2281            }),
2282        );
2283
2284        // Wait until the blocking task has actually started.
2285        ready_rx.await.expect("task should start");
2286
2287        let snapshot = sup.snapshot();
2288        assert!(
2289            snapshot.iter().any(|t| t.name.as_ref() == "chunk_file"),
2290            "chunk_file task must appear in supervisor snapshot"
2291        );
2292
2293        // Release the blocking task and await completion.
2294        let _ = release_tx.send(());
2295        handle.await.expect("task should complete");
2296    }
2297
2298    /// Verify that `measure_blocking` emits wall-time and CPU-time histograms.
2299    ///
2300    /// `measure_blocking` calls `metrics::histogram!` on the current thread.
2301    /// We test it directly using a `DebuggingRecorder` installed as the thread-local
2302    /// recorder via `metrics::with_local_recorder`.
2303    #[test]
2304    fn test_measure_blocking_emits_metrics() {
2305        use metrics_util::debugging::DebuggingRecorder;
2306
2307        let recorder = DebuggingRecorder::new();
2308        let snapshotter = recorder.snapshotter();
2309
2310        // Call measure_blocking inside the local recorder scope so histogram! calls
2311        // are captured. The closure runs synchronously on this thread.
2312        metrics::with_local_recorder(&recorder, || {
2313            measure_blocking("test_task", || std::hint::black_box(42_u64));
2314        });
2315
2316        let snapshot = snapshotter.snapshot();
2317        let metric_names: Vec<String> = snapshot
2318            .into_vec()
2319            .into_iter()
2320            .map(|(k, _, _, _)| k.key().name().to_owned())
2321            .collect();
2322
2323        assert!(
2324            metric_names.iter().any(|n| n == "zeph.task.wall_time_ms"),
2325            "expected zeph.task.wall_time_ms histogram; got: {metric_names:?}"
2326        );
2327        assert!(
2328            metric_names.iter().any(|n| n == "zeph.task.cpu_time_ms"),
2329            "expected zeph.task.cpu_time_ms histogram; got: {metric_names:?}"
2330        );
2331    }
2332
2333    /// Verify that `spawn_blocking` semaphore limits concurrent OS-thread tasks to 8.
2334    ///
2335    /// Spawns 16 tasks. Each holds a barrier until 8 are waiting; then releases in order.
2336    /// If more than 8 run concurrently the test would either deadlock (waiting for 9+ to reach
2337    /// the barrier) or the counter would exceed 8 — both are caught.
2338    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2339    async fn test_spawn_blocking_semaphore_cap() {
2340        let (sup, _cancel) = make_supervisor();
2341        let concurrent = Arc::new(AtomicU32::new(0));
2342        let max_concurrent = Arc::new(AtomicU32::new(0));
2343        let barrier = Arc::new(std::sync::Barrier::new(1)); // just a sync point
2344
2345        let mut handles = Vec::new();
2346        for i in 0u32..16 {
2347            let c = Arc::clone(&concurrent);
2348            let m = Arc::clone(&max_concurrent);
2349            let name: Arc<str> = Arc::from(format!("blocking-{i}").as_str());
2350            let h = sup.spawn_blocking(name, move || {
2351                let prev = c.fetch_add(1, Ordering::SeqCst);
2352                // Update observed maximum.
2353                let mut cur_max = m.load(Ordering::SeqCst);
2354                while prev + 1 > cur_max {
2355                    match m.compare_exchange(cur_max, prev + 1, Ordering::SeqCst, Ordering::SeqCst)
2356                    {
2357                        Ok(_) => break,
2358                        Err(x) => cur_max = x,
2359                    }
2360                }
2361                // Simulate work.
2362                std::thread::sleep(std::time::Duration::from_millis(20));
2363                c.fetch_sub(1, Ordering::SeqCst);
2364            });
2365            handles.push(h);
2366        }
2367
2368        for h in handles {
2369            h.join().await.expect("blocking task should succeed");
2370        }
2371        drop(barrier);
2372
2373        let observed = max_concurrent.load(Ordering::SeqCst);
2374        assert!(
2375            observed <= 8,
2376            "observed {observed} concurrent blocking tasks; expected ≤ 8 (semaphore cap)"
2377        );
2378    }
2379}