Skip to main content

agentd/supervisor/
liveness.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Dead/stuck detection — the three-detector model + the EOF×pong classifier.
3//! RFC 0003 §dead-stuck.
4//!
5//! This is the *pure* heart of supervision: given timestamps and flags, decide
6//! whether a child is healthy, legitimately busy, stuck-alive, or dead. The
7//! supervisor feeds it events (`on_event`/`on_pong`/`on_eof`) and asks
8//! `classify(now)` on each reactor tick; the kill ladder (`kill.rs`) acts on a
9//! teardown verdict. No processes or signals here — those are `spawn.rs`,
10//! `reap.rs`, `kill.rs`.
11//!
12//! The three detectors:
13//! - **A — hard deadline** (always on, no child cooperation).
14//! - **B — no-progress watchdog**: substantive events (loop.step, tool.call,
15//!   usage) stamp `last_event_at`; silence past `progress_timeout` is suspicious.
16//! - **C — ping/pong**: pongs (answered by the child's *control thread*, which
17//!   is separate from its agentic loop) stamp `last_pong_at`. Pongs continuing
18//!   while events have stopped means "busy in a long legitimate tool call";
19//!   pongs *also* stopping means the process is wedged.
20
21use std::time::{Duration, Instant};
22
23/// A child's liveness verdict on a given tick.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Health {
26    /// Substantive events are flowing — making progress.
27    Healthy,
28    /// No recent events, but pongs still arrive — a long legitimate tool/model
29    /// call. Leave it alone.
30    Busy,
31    /// No events *and* no pongs past their timeouts — wedged. Tear down.
32    Stuck,
33    /// The control channel hit EOF — the child likely exited. Confirm via
34    /// `waitpid` (`reap.rs`), then remove.
35    Dead,
36    /// The hard wall-clock deadline passed. Tear down (RFC 0011 → exit 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 2×2 classifier (RFC 0003 §2.8). Order matters: EOF and the hard
147    /// deadline dominate; otherwise recent events = Healthy, else recent pongs
148    /// = Busy, else Stuck.
149    pub fn classify(&self, now: Instant) -> Health {
150        if self.eof {
151            return Health::Dead;
152        }
153        if now >= self.deadline {
154            return Health::DeadlineExceeded;
155        }
156        if now.duration_since(self.last_event_at) <= self.cfg.progress_timeout {
157            Health::Healthy
158        } else if now.duration_since(self.last_pong_at) <= self.cfg.pong_timeout {
159            Health::Busy
160        } else {
161            Health::Stuck
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn cfg() -> LivenessConfig {
171        LivenessConfig::new(Duration::from_secs(100), Duration::from_secs(10))
172    }
173
174    #[test]
175    fn ping_interval_is_derived_below_the_pong_window_and_clamped() {
176        // ~ a third of the pong window, so a responsive child answers in time.
177        assert_eq!(
178            LivenessConfig::new(Duration::from_secs(100), Duration::from_secs(9)).ping_interval,
179            Duration::from_secs(3)
180        );
181        // clamped low: a tiny pong window still pings no faster than 50ms.
182        assert_eq!(
183            LivenessConfig::new(Duration::from_secs(1), Duration::from_millis(30)).ping_interval,
184            Duration::from_millis(50)
185        );
186        // clamped high: a huge pong window still pings at least every 5s.
187        assert_eq!(
188            LivenessConfig::new(Duration::from_secs(600), Duration::from_secs(60)).ping_interval,
189            Duration::from_secs(5)
190        );
191        // the production default keeps the ping interval under the pong window.
192        let d = LivenessConfig::default();
193        assert!(d.ping_interval < d.pong_timeout);
194    }
195
196    #[test]
197    fn recent_events_are_healthy() {
198        let t0 = Instant::now();
199        let l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
200        assert_eq!(l.classify(t0 + Duration::from_secs(50)), Health::Healthy);
201    }
202
203    #[test]
204    fn no_events_but_pongs_is_busy() {
205        let t0 = Instant::now();
206        let mut l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
207        // 150s with no events (> progress_timeout) but a pong at 145s.
208        l.on_pong(t0 + Duration::from_secs(145));
209        assert_eq!(l.classify(t0 + Duration::from_secs(150)), Health::Busy);
210    }
211
212    #[test]
213    fn no_events_no_pongs_is_stuck() {
214        let t0 = Instant::now();
215        let l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
216        // 200s of silence: past both progress (100s) and pong (10s) timeouts.
217        assert_eq!(l.classify(t0 + Duration::from_secs(200)), Health::Stuck);
218        assert!(l.classify(t0 + Duration::from_secs(200)).needs_teardown());
219    }
220
221    #[test]
222    fn eof_is_dead_and_dominates() {
223        let t0 = Instant::now();
224        let mut l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
225        l.on_event(t0 + Duration::from_secs(1)); // even with recent progress...
226        l.on_eof();
227        assert_eq!(l.classify(t0 + Duration::from_secs(2)), Health::Dead); // ...EOF wins
228    }
229
230    #[test]
231    fn deadline_exceeded() {
232        let t0 = Instant::now();
233        let mut l = Liveness::new(t0, t0 + Duration::from_secs(60), cfg());
234        l.on_event(t0 + Duration::from_secs(59)); // busy right up to the wire
235        assert_eq!(
236            l.classify(t0 + Duration::from_secs(61)),
237            Health::DeadlineExceeded
238        );
239    }
240
241    #[test]
242    fn on_event_refreshes_both_clocks() {
243        let t0 = Instant::now();
244        let mut l = Liveness::new(t0, t0 + Duration::from_secs(3600), cfg());
245        l.on_event(t0 + Duration::from_secs(300));
246        // right after an event → healthy again
247        assert_eq!(l.classify(t0 + Duration::from_secs(301)), Health::Healthy);
248    }
249}