Skip to main content

agentos_runtime/
supervision.rs

1//! Bounded task ownership, terminal accounting, and owner notification.
2
3use std::collections::{BTreeMap, VecDeque};
4use std::fmt;
5use std::panic::{catch_unwind, AssertUnwindSafe};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8
9use crate::accounting::{LimitError, Reservation, ResourceClass, ResourceLedger};
10use crate::metrics::{
11    RuntimeMetrics, TelemetryFallback, TelemetryFallbackCode, TelemetrySeverity, TelemetrySubsystem,
12};
13
14#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
15pub enum TaskClass {
16    Runtime,
17    Dns,
18    Socket,
19    Listener,
20    Udp,
21    Tls,
22    Http2,
23    Timer,
24    Vm,
25    Plugin,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum TaskOwner {
30    Process,
31    Vm { generation: u64 },
32    Capability { id: u64, generation: u64 },
33    Connection { id: u64, generation: u64 },
34    Background { name: &'static str },
35}
36
37impl fmt::Display for TaskOwner {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::Process => formatter.write_str("process"),
41            Self::Vm { generation } => write!(formatter, "vm-generation={generation}"),
42            Self::Capability { id, generation } => {
43                write!(formatter, "capability={id} generation={generation}")
44            }
45            Self::Connection { id, generation } => {
46                write!(formatter, "connection={id} generation={generation}")
47            }
48            Self::Background { name } => write!(formatter, "background={name}"),
49        }
50    }
51}
52
53#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
54pub enum TaskTerminalReason {
55    Completed,
56    Cancelled,
57    Failed,
58    Panicked,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct TaskTerminalReport {
63    pub class: TaskClass,
64    pub owner: TaskOwner,
65    pub scope: String,
66    pub reason: TaskTerminalReason,
67}
68
69#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
70pub struct TaskClassSnapshot {
71    pub active: usize,
72    pub completed: u64,
73    pub cancelled: u64,
74    pub failed: u64,
75    pub panicked: u64,
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub enum TaskSpawnError {
80    ResourceLimit(LimitError),
81    AdmissionClosed { scope: String },
82}
83
84impl fmt::Display for TaskSpawnError {
85    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::ResourceLimit(error) => error.fmt(formatter),
88            Self::AdmissionClosed { scope } => write!(
89                formatter,
90                "ERR_AGENTOS_TASK_ADMISSION_CLOSED: scope={scope} is closing; new task admission is disabled"
91            ),
92        }
93    }
94}
95
96impl std::error::Error for TaskSpawnError {}
97
98impl From<LimitError> for TaskSpawnError {
99    fn from(error: LimitError) -> Self {
100        Self::ResourceLimit(error)
101    }
102}
103
104impl TaskClassSnapshot {
105    fn record(&mut self, reason: TaskTerminalReason) {
106        let counter = match reason {
107            TaskTerminalReason::Completed => &mut self.completed,
108            TaskTerminalReason::Cancelled => &mut self.cancelled,
109            TaskTerminalReason::Failed => &mut self.failed,
110            TaskTerminalReason::Panicked => &mut self.panicked,
111        };
112        *counter = counter.saturating_add(1);
113    }
114}
115
116#[derive(Debug, Default)]
117struct TaskSupervisorState {
118    classes: BTreeMap<TaskClass, TaskClassSnapshot>,
119    active_scopes: BTreeMap<String, usize>,
120    reports: VecDeque<TaskTerminalReport>,
121    dropped_reports: u64,
122    report_overflow_warned: bool,
123}
124
125type TerminalHandler = Arc<dyn Fn(&TaskTerminalReport) + Send + Sync>;
126
127#[derive(Clone, Debug)]
128pub struct TaskSupervisor {
129    ledger: Arc<ResourceLedger>,
130    metrics: RuntimeMetrics,
131    state: Arc<Mutex<TaskSupervisorState>>,
132    settled: Arc<tokio::sync::Notify>,
133    admission_open: Arc<AtomicBool>,
134    admission_gate: Arc<Mutex<()>>,
135    report_capacity: usize,
136}
137
138impl TaskSupervisor {
139    pub(crate) fn new(
140        ledger: Arc<ResourceLedger>,
141        metrics: RuntimeMetrics,
142        admission_open: Arc<AtomicBool>,
143        admission_gate: Arc<Mutex<()>>,
144        report_capacity: usize,
145    ) -> Self {
146        Self::with_report_capacity(
147            ledger,
148            metrics,
149            admission_open,
150            admission_gate,
151            report_capacity,
152        )
153    }
154
155    fn with_report_capacity(
156        ledger: Arc<ResourceLedger>,
157        metrics: RuntimeMetrics,
158        admission_open: Arc<AtomicBool>,
159        admission_gate: Arc<Mutex<()>>,
160        report_capacity: usize,
161    ) -> Self {
162        Self {
163            ledger,
164            metrics,
165            state: Arc::new(Mutex::new(TaskSupervisorState::default())),
166            settled: Arc::new(tokio::sync::Notify::new()),
167            admission_open,
168            admission_gate,
169            report_capacity: report_capacity.max(1),
170        }
171    }
172
173    pub(crate) fn admit(
174        &self,
175        class: TaskClass,
176        owner: TaskOwner,
177        handler: Option<TerminalHandler>,
178    ) -> Result<TaskGuard, TaskSpawnError> {
179        // Linearize insertion against close so teardown cannot observe an
180        // empty scope and then have a stale clone insert a new task.
181        let _admission = self.admission_gate.lock().unwrap_or_else(|poisoned| {
182            eprintln!("ERR_AGENTOS_TASK_ADMISSION_GATE_POISONED: recovering task admission");
183            poisoned.into_inner()
184        });
185        self.ensure_admission_open()?;
186        let reservation = self
187            .ledger
188            .reserve(ResourceClass::Tasks, 1)
189            .map_err(TaskSpawnError::ResourceLimit)?;
190        let scope = self.ledger.scope().to_owned();
191        let mut state = self.state.lock().unwrap_or_else(|poisoned| {
192            eprintln!("ERR_AGENTOS_TASK_SUPERVISOR_POISONED: recovering task admission");
193            poisoned.into_inner()
194        });
195        state.classes.entry(class).or_default().active += 1;
196        *state.active_scopes.entry(scope.clone()).or_default() += 1;
197        drop(state);
198        self.metrics.task_started(class);
199        Ok(TaskGuard {
200            class,
201            owner,
202            scope,
203            supervisor: self.clone(),
204            handler,
205            reservation: Some(reservation),
206            terminal: None,
207        })
208    }
209
210    pub fn snapshot(&self, class: TaskClass) -> TaskClassSnapshot {
211        self.state
212            .lock()
213            .map(|state| state.classes.get(&class).copied().unwrap_or_default())
214            .unwrap_or_else(|_| {
215                eprintln!("ERR_AGENTOS_TASK_SUPERVISOR_POISONED: failed to read task census");
216                TaskClassSnapshot::default()
217            })
218    }
219
220    pub fn active_total(&self) -> usize {
221        self.state
222            .lock()
223            .map(|state| state.classes.values().map(|stats| stats.active).sum())
224            .unwrap_or_else(|_| {
225                eprintln!("ERR_AGENTOS_TASK_SUPERVISOR_POISONED: failed to read active census");
226                0
227            })
228    }
229
230    pub fn active_scoped(&self) -> usize {
231        let scope = self.ledger.scope();
232        self.state
233            .lock()
234            .map(|state| state.active_scopes.get(scope).copied().unwrap_or(0))
235            .unwrap_or_else(|_| {
236                eprintln!(
237                    "ERR_AGENTOS_TASK_SUPERVISOR_POISONED: failed to read scoped task census"
238                );
239                usize::MAX
240            })
241    }
242
243    /// Wait until this RuntimeContext's accounting scope owns no supervised
244    /// tasks. The notification is armed before observation so the final task
245    /// cannot exit between the check and await.
246    pub async fn wait_empty(&self) {
247        loop {
248            let settled = self.settled.notified();
249            if self.active_scoped() == 0 {
250                return;
251            }
252            settled.await;
253        }
254    }
255
256    pub(crate) fn close_admission(&self) {
257        let _admission = self.admission_gate.lock().unwrap_or_else(|poisoned| {
258            eprintln!("ERR_AGENTOS_TASK_ADMISSION_GATE_POISONED: recovering task close");
259            poisoned.into_inner()
260        });
261        self.admission_open.store(false, Ordering::Release);
262    }
263
264    fn ensure_admission_open(&self) -> Result<(), TaskSpawnError> {
265        if self.admission_open.load(Ordering::Acquire) {
266            Ok(())
267        } else {
268            Err(TaskSpawnError::AdmissionClosed {
269                scope: self.ledger.scope().to_owned(),
270            })
271        }
272    }
273
274    pub fn drain_terminal_reports(&self) -> Vec<TaskTerminalReport> {
275        self.state
276            .lock()
277            .map(|mut state| {
278                state.report_overflow_warned = false;
279                state.reports.drain(..).collect()
280            })
281            .unwrap_or_else(|_| {
282                eprintln!("ERR_AGENTOS_TASK_SUPERVISOR_POISONED: failed to drain terminal reports");
283                Vec::new()
284            })
285    }
286
287    pub fn dropped_terminal_reports(&self) -> u64 {
288        self.state
289            .lock()
290            .map(|state| state.dropped_reports)
291            .unwrap_or(u64::MAX)
292    }
293
294    pub(crate) fn scoped(
295        &self,
296        ledger: Arc<ResourceLedger>,
297        admission_open: Arc<AtomicBool>,
298        admission_gate: Arc<Mutex<()>>,
299    ) -> Self {
300        Self {
301            ledger,
302            metrics: self.metrics.clone(),
303            state: Arc::clone(&self.state),
304            settled: Arc::clone(&self.settled),
305            admission_open,
306            admission_gate,
307            report_capacity: self.report_capacity,
308        }
309    }
310
311    fn terminal(&self, report: TaskTerminalReport, handler: Option<&TerminalHandler>) {
312        {
313            let mut state = self.state.lock().unwrap_or_else(|poisoned| {
314                eprintln!("ERR_AGENTOS_TASK_SUPERVISOR_POISONED: recovering terminal task report");
315                poisoned.into_inner()
316            });
317            let stats = state.classes.entry(report.class).or_default();
318            if stats.active == 0 {
319                eprintln!(
320                    "ERR_AGENTOS_TASK_ACCOUNTING_UNDERFLOW: class={:?} owner={}",
321                    report.class, report.owner
322                );
323            } else {
324                stats.active -= 1;
325            }
326            stats.record(report.reason);
327            match state.active_scopes.get_mut(&report.scope) {
328                Some(active) if *active > 1 => *active -= 1,
329                Some(_) => {
330                    state.active_scopes.remove(&report.scope);
331                }
332                None => eprintln!(
333                    "ERR_AGENTOS_TASK_ACCOUNTING_UNDERFLOW: scope={} class={:?} owner={}",
334                    report.scope, report.class, report.owner
335                ),
336            }
337            // Normal completion and owner-driven cancellation are already
338            // handled by the class census, low-cardinality metrics, and the
339            // terminal callback. Retaining every successful report would make
340            // a healthy long-lived process inevitably fill this diagnostic
341            // buffer even though no failure was unobserved.
342            if matches!(
343                report.reason,
344                TaskTerminalReason::Failed | TaskTerminalReason::Panicked
345            ) {
346                if state.reports.len() == self.report_capacity {
347                    state.reports.pop_front();
348                    state.dropped_reports = state.dropped_reports.saturating_add(1);
349                    if !state.report_overflow_warned {
350                        state.report_overflow_warned = true;
351                        eprintln!(
352                            "ERR_AGENTOS_TASK_REPORT_LIMIT: failed task report buffer exceeded {}; latest_class={:?} latest_owner={}; oldest reports will be dropped until drained; raise runtime.tasks.maxTerminalReports",
353                            self.report_capacity, report.class, report.owner
354                        );
355                    }
356                }
357                state.reports.push_back(report.clone());
358            }
359        }
360        self.metrics.task_finished(report.class, report.reason);
361        self.settled.notify_waiters();
362
363        if matches!(
364            report.reason,
365            TaskTerminalReason::Failed | TaskTerminalReason::Panicked
366        ) {
367            let message = format!(
368                "class={:?} owner={} reason={:?}",
369                report.class, report.owner, report.reason
370            );
371            self.metrics.emit_stderr_fallback(TelemetryFallback {
372                severity: TelemetrySeverity::Fatal,
373                code: TelemetryFallbackCode::SupervisedTaskExit,
374                subsystem: TelemetrySubsystem::Runtime,
375                message: &message,
376            });
377        }
378        if let Some(handler) = handler {
379            if catch_unwind(AssertUnwindSafe(|| handler(&report))).is_err() {
380                eprintln!(
381                    "ERR_AGENTOS_TASK_TERMINAL_HANDLER_PANIC: class={:?} owner={} reason={:?}",
382                    report.class, report.owner, report.reason
383                );
384            }
385        }
386    }
387}
388
389pub(crate) struct TaskGuard {
390    class: TaskClass,
391    owner: TaskOwner,
392    scope: String,
393    supervisor: TaskSupervisor,
394    handler: Option<TerminalHandler>,
395    reservation: Option<Reservation>,
396    terminal: Option<TaskTerminalReason>,
397}
398
399impl TaskGuard {
400    pub(crate) fn complete(&mut self) {
401        self.terminal = Some(TaskTerminalReason::Completed);
402    }
403
404    pub(crate) fn fail(&mut self) {
405        self.terminal = Some(TaskTerminalReason::Failed);
406    }
407}
408
409impl Drop for TaskGuard {
410    fn drop(&mut self) {
411        let reason = if std::thread::panicking() {
412            TaskTerminalReason::Panicked
413        } else {
414            self.terminal.unwrap_or(TaskTerminalReason::Cancelled)
415        };
416        let report = TaskTerminalReport {
417            class: self.class,
418            owner: self.owner.clone(),
419            scope: self.scope.clone(),
420            reason,
421        };
422        // Release task admission before publishing terminal completion. A
423        // waiter that observes an empty scope must also observe zero task
424        // reservations in the VM ledger.
425        self.reservation.take();
426        self.supervisor.terminal(report, self.handler.as_ref());
427    }
428}
429
430pub(crate) fn terminal_handler(
431    handler: impl Fn(&TaskTerminalReport) + Send + Sync + 'static,
432) -> TerminalHandler {
433    Arc::new(handler)
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::accounting::ResourceLimit;
440
441    fn supervisor(report_capacity: usize) -> TaskSupervisor {
442        let ledger = Arc::new(ResourceLedger::root(
443            "process",
444            [(
445                ResourceClass::Tasks,
446                ResourceLimit::new(4, "runtime.resources.maxTasks"),
447            )],
448        ));
449        TaskSupervisor::with_report_capacity(
450            ledger,
451            RuntimeMetrics::new(),
452            Arc::new(AtomicBool::new(true)),
453            Arc::new(Mutex::new(())),
454            report_capacity,
455        )
456    }
457
458    #[test]
459    fn failed_reports_are_bounded_and_metrics_are_low_cardinality() {
460        let supervisor = supervisor(2);
461        for generation in 1..=4 {
462            let mut guard = supervisor
463                .admit(TaskClass::Vm, TaskOwner::Vm { generation }, None)
464                .expect("admit");
465            guard.fail();
466        }
467        assert_eq!(supervisor.snapshot(TaskClass::Vm).failed, 4);
468        assert_eq!(supervisor.dropped_terminal_reports(), 2);
469        assert!(
470            supervisor
471                .state
472                .lock()
473                .expect("supervisor state")
474                .report_overflow_warned,
475            "repeated drops should retain one edge-triggered warning state"
476        );
477        let reports = supervisor.drain_terminal_reports();
478        assert_eq!(reports.len(), 2);
479        assert_eq!(reports[0].owner, TaskOwner::Vm { generation: 3 });
480        assert!(
481            !supervisor
482                .state
483                .lock()
484                .expect("supervisor state")
485                .report_overflow_warned,
486            "draining reports should re-arm the next overflow warning"
487        );
488    }
489
490    #[test]
491    fn successful_and_cancelled_tasks_do_not_consume_failure_report_capacity() {
492        let supervisor = supervisor(1);
493        for generation in 1..=4 {
494            let mut completed = supervisor
495                .admit(TaskClass::Vm, TaskOwner::Vm { generation }, None)
496                .expect("admit completed task");
497            completed.complete();
498
499            let cancelled = supervisor
500                .admit(TaskClass::Vm, TaskOwner::Vm { generation }, None)
501                .expect("admit cancelled task");
502            drop(cancelled);
503        }
504
505        let snapshot = supervisor.snapshot(TaskClass::Vm);
506        assert_eq!(snapshot.completed, 4);
507        assert_eq!(snapshot.cancelled, 4);
508        assert_eq!(supervisor.dropped_terminal_reports(), 0);
509        assert!(supervisor.drain_terminal_reports().is_empty());
510    }
511
512    #[test]
513    fn owner_handler_runs_after_terminal_accounting() {
514        let supervisor = supervisor(2);
515        let observed = Arc::new(Mutex::new(None));
516        let observed_for_handler = Arc::clone(&observed);
517        let handler = terminal_handler(move |report| {
518            *observed_for_handler.lock().expect("handler state") = Some(report.clone());
519        });
520        let guard = supervisor
521            .admit(
522                TaskClass::Socket,
523                TaskOwner::Capability {
524                    id: 9,
525                    generation: 4,
526                },
527                Some(handler),
528            )
529            .expect("admit");
530        drop(guard);
531        assert_eq!(supervisor.snapshot(TaskClass::Socket).cancelled, 1);
532        assert_eq!(
533            observed.lock().expect("observed").as_ref().unwrap().reason,
534            TaskTerminalReason::Cancelled
535        );
536    }
537
538    #[tokio::test]
539    async fn scoped_wait_ignores_other_scopes_and_cannot_miss_final_exit() {
540        let process = Arc::new(ResourceLedger::root(
541            "process",
542            [(
543                ResourceClass::Tasks,
544                ResourceLimit::new(4, "runtime.resources.maxTasks"),
545            )],
546        ));
547        let base = TaskSupervisor::new(
548            Arc::clone(&process),
549            RuntimeMetrics::new(),
550            Arc::new(AtomicBool::new(true)),
551            Arc::new(Mutex::new(())),
552            4_096,
553        );
554        let vm_1_ledger = Arc::new(ResourceLedger::child(
555            "vm=1 generation=1",
556            [(
557                ResourceClass::Tasks,
558                ResourceLimit::new(2, "limits.reactor.maxTasks"),
559            )],
560            Arc::clone(&process),
561        ));
562        let vm_2_ledger = Arc::new(ResourceLedger::child(
563            "vm=2 generation=1",
564            [(
565                ResourceClass::Tasks,
566                ResourceLimit::new(2, "limits.reactor.maxTasks"),
567            )],
568            Arc::clone(&process),
569        ));
570        let vm_1 = base.scoped(
571            Arc::clone(&vm_1_ledger),
572            Arc::new(AtomicBool::new(true)),
573            Arc::new(Mutex::new(())),
574        );
575        let vm_2 = base.scoped(
576            Arc::clone(&vm_2_ledger),
577            Arc::new(AtomicBool::new(true)),
578            Arc::new(Mutex::new(())),
579        );
580        let vm_1_guard = vm_1
581            .admit(TaskClass::Socket, TaskOwner::Vm { generation: 1 }, None)
582            .expect("vm 1 task");
583        let _vm_2_guard = vm_2
584            .admit(TaskClass::Socket, TaskOwner::Vm { generation: 1 }, None)
585            .expect("vm 2 task");
586        let waiter = tokio::spawn({
587            let vm_1 = vm_1.clone();
588            async move { vm_1.wait_empty().await }
589        });
590        tokio::task::yield_now().await;
591        assert!(!waiter.is_finished());
592        drop(vm_1_guard);
593        tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
594            .await
595            .expect("vm 1 waiter")
596            .expect("wait task");
597        assert_eq!(vm_1.active_scoped(), 0);
598        assert_eq!(vm_2.active_scoped(), 1);
599        assert_eq!(vm_1_ledger.usage(ResourceClass::Tasks).used, 0);
600    }
601}