Skip to main content

agentd/supervisor/
liveness.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Dead/stuck detection — the three-detector model + the EOF x pong classifier.
3//!
4//! This is the *pure* heart of supervision: given timestamps and flags, decide
5//! whether a child is healthy, legitimately busy, stuck-alive, or dead. The
6//! supervisor feeds it events (`on_event`/`on_pong`/`on_eof`) and asks
7//! `classify(now)` on each reactor tick; the kill ladder (`kill.rs`) acts on a
8//! teardown verdict. No processes or signals here — those are `spawn.rs`,
9//! `reap.rs`, `kill.rs`.
10//!
11//! The three detectors:
12//! - **A — hard deadline** (always on, no child cooperation).
13//! - **B — no-progress watchdog**: substantive events (loop.step, tool.call,
14//!   usage) stamp `last_event_at`; silence past `progress_timeout` is suspicious.
15//! - **C — ping/pong**: pongs (answered by the child's *control thread*, which
16//!   is separate from its agentic loop) stamp `last_pong_at`. Pongs continuing
17//!   while events have stopped means "busy in a long legitimate tool call";
18//!   pongs *also* stopping means the process is wedged.
19
20use std::time::{Duration, Instant};
21
22/// A child's liveness verdict on a given tick.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Health {
25    /// Substantive events are flowing — making progress.
26    Healthy,
27    /// No recent events, but pongs still arrive — a long legitimate tool/model
28    /// call. Leave it alone.
29    Busy,
30    /// No events *and* no pongs past their timeouts — wedged. Tear down.
31    Stuck,
32    /// The control channel hit EOF — the child likely exited. Confirm via
33    /// `waitpid` (`reap.rs`), then remove.
34    Dead,
35    /// The hard wall-clock deadline passed. Tear down; a one-shot run reports
36    /// exit code 124.
37    DeadlineExceeded,
38}
39
40impl Health {
41    /// Does this verdict require the kill ladder to run?
42    pub fn needs_teardown(self) -> bool {
43        matches!(
44            self,
45            Health::Stuck | Health::Dead | Health::DeadlineExceeded
46        )
47    }
48}
49
50/// Sensible default timeouts. `progress_timeout` is generous because a single
51/// tool/model call can legitimately take a while; `pong_timeout` is tight
52/// because the control thread answers a ping immediately regardless of what
53/// the loop is doing.
54#[derive(Debug, Clone, Copy)]
55pub struct LivenessConfig {
56    pub progress_timeout: Duration,
57    pub pong_timeout: Duration,
58    /// How often the supervisor pings each live child. Kept well below
59    /// `pong_timeout` so a *responsive* child answers within the window (its
60    /// control thread replies regardless of what the loop is doing) and stays
61    /// `Busy` rather than reading `Stuck`. Derived, not configured directly.
62    pub ping_interval: Duration,
63}
64
65impl Default for LivenessConfig {
66    fn default() -> Self {
67        Self::new(Duration::from_secs(120), Duration::from_secs(10))
68    }
69}
70
71impl LivenessConfig {
72    /// Build from the two timeouts, deriving a ping cadence (≈ a third of the
73    /// pong window, clamped to a sane band).
74    pub fn new(progress_timeout: Duration, pong_timeout: Duration) -> LivenessConfig {
75        let ping_interval =
76            (pong_timeout / 3).clamp(Duration::from_millis(50), Duration::from_secs(5));
77        LivenessConfig {
78            progress_timeout,
79            pong_timeout,
80            ping_interval,
81        }
82    }
83
84    /// Optional operator/test tuning via `AGENTD_PROGRESS_TIMEOUT_MS` /
85    /// `AGENTD_PONG_TIMEOUT_MS` (a niche knob — defaults are the production
86    /// values). Used by the chaos suite to exercise stuck-kill quickly.
87    pub fn from_env() -> LivenessConfig {
88        let d = LivenessConfig::default();
89        let ms = |k: &str, fallback: Duration| {
90            std::env::var(k)
91                .ok()
92                .and_then(|v| v.parse::<u64>().ok())
93                .map(Duration::from_millis)
94                .unwrap_or(fallback)
95        };
96        LivenessConfig::new(
97            ms("AGENTD_PROGRESS_TIMEOUT_MS", d.progress_timeout),
98            ms("AGENTD_PONG_TIMEOUT_MS", d.pong_timeout),
99        )
100    }
101}
102
103/// Per-child liveness tracker. Construct at spawn with the child's absolute
104/// deadline; feed it events as they arrive; `classify(now)` each tick.
105#[derive(Debug)]
106pub struct Liveness {
107    deadline: Instant,
108    cfg: LivenessConfig,
109    last_event_at: Instant,
110    last_pong_at: Instant,
111    eof: bool,
112}
113
114impl Liveness {
115    pub fn new(now: Instant, deadline: Instant, cfg: LivenessConfig) -> Liveness {
116        Liveness {
117            deadline,
118            cfg,
119            last_event_at: now,
120            last_pong_at: now,
121            eof: false,
122        }
123    }
124
125    /// A substantive progress frame arrived (Event/Usage/Result). Also counts
126    /// as liveness, so it refreshes the pong clock too.
127    pub fn on_event(&mut self, now: Instant) {
128        self.last_event_at = now;
129        self.last_pong_at = now;
130    }
131
132    /// A `Pong` arrived (liveness only — not progress).
133    pub fn on_pong(&mut self, now: Instant) {
134        self.last_pong_at = now;
135    }
136
137    /// The control channel closed.
138    pub fn on_eof(&mut self) {
139        self.eof = true;
140    }
141
142    pub fn deadline(&self) -> Instant {
143        self.deadline
144    }
145
146    /// The 2x2 classifier. Order matters: EOF and the hard deadline dominate —
147    /// a child that has already closed its channel or blown its deadline must
148    /// not be reported `Healthy` just because an event landed a moment earlier.
149    /// Otherwise recent events = Healthy, else recent pongs = Busy, else Stuck.
150    pub fn classify(&self, now: Instant) -> Health {
151        if self.eof {
152            return Health::Dead;
153        }
154        if now >= self.deadline {
155            return Health::DeadlineExceeded;
156        }
157        if now.duration_since(self.last_event_at) <= self.cfg.progress_timeout {
158            Health::Healthy
159        } else if now.duration_since(self.last_pong_at) <= self.cfg.pong_timeout {
160            Health::Busy
161        } else {
162            Health::Stuck
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn cfg() -> LivenessConfig {
172        LivenessConfig::new(Duration::from_secs(100), Duration::from_secs(10))
173    }
174
175    #[test]
176    fn ping_interval_is_derived_below_the_pong_window_and_clamped() {
177        // ~ a third of the pong window, so a responsive child answers in time.
178        assert_eq!(
179            LivenessConfig::new(Duration::from_secs(100), Duration::from_secs(9)).ping_interval,
180            Duration::from_secs(3)
181        );
182        // clamped low: a tiny pong window still pings no faster than 50ms.
183        assert_eq!(
184            LivenessConfig::new(Duration::from_secs(1), Duration::from_millis(30)).ping_interval,
185            Duration::from_millis(50)
186        );
187        // clamped high: a huge pong window still pings at least every 5s.
188        assert_eq!(
189            LivenessConfig::new(Duration::from_secs(600), Duration::from_secs(60)).ping_interval,
190            Duration::from_secs(5)
191        );
192        // the production default keeps the ping interval under the pong window.
193        let d = LivenessConfig::default();
194        assert!(d.ping_interval < d.pong_timeout);
195    }
196
197    #[test]
198    fn recent_events_are_healthy() {
199        let t0 = Instant::now();
200        let l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
201        assert_eq!(l.classify(t0 + Duration::from_secs(50)), Health::Healthy);
202    }
203
204    #[test]
205    fn no_events_but_pongs_is_busy() {
206        let t0 = Instant::now();
207        let mut l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
208        // 150s with no events (> progress_timeout) but a pong at 145s.
209        l.on_pong(t0 + Duration::from_secs(145));
210        assert_eq!(l.classify(t0 + Duration::from_secs(150)), Health::Busy);
211    }
212
213    #[test]
214    fn no_events_no_pongs_is_stuck() {
215        let t0 = Instant::now();
216        let l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
217        // 200s of silence: past both progress (100s) and pong (10s) timeouts.
218        assert_eq!(l.classify(t0 + Duration::from_secs(200)), Health::Stuck);
219        assert!(l.classify(t0 + Duration::from_secs(200)).needs_teardown());
220    }
221
222    #[test]
223    fn eof_is_dead_and_dominates() {
224        let t0 = Instant::now();
225        let mut l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
226        l.on_event(t0 + Duration::from_secs(1)); // even with recent progress...
227        l.on_eof();
228        assert_eq!(l.classify(t0 + Duration::from_secs(2)), Health::Dead); // ...EOF wins
229    }
230
231    #[test]
232    fn deadline_exceeded() {
233        let t0 = Instant::now();
234        let mut l = Liveness::new(t0, t0 + Duration::from_secs(60), cfg());
235        l.on_event(t0 + Duration::from_secs(59)); // busy right up to the wire
236        assert_eq!(
237            l.classify(t0 + Duration::from_secs(61)),
238            Health::DeadlineExceeded
239        );
240    }
241
242    #[test]
243    fn on_event_refreshes_both_clocks() {
244        let t0 = Instant::now();
245        let mut l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
246        l.on_event(t0 + Duration::from_secs(300));
247        // right after an event → healthy again
248        assert_eq!(l.classify(t0 + Duration::from_secs(301)), Health::Healthy);
249    }
250}