agentd/supervisor/
liveness.rs1use std::time::{Duration, Instant};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Health {
25 Healthy,
27 Busy,
30 Stuck,
32 Dead,
35 DeadlineExceeded,
38}
39
40impl Health {
41 pub fn needs_teardown(self) -> bool {
43 matches!(
44 self,
45 Health::Stuck | Health::Dead | Health::DeadlineExceeded
46 )
47 }
48}
49
50#[derive(Debug, Clone, Copy)]
55pub struct LivenessConfig {
56 pub progress_timeout: Duration,
57 pub pong_timeout: Duration,
58 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 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 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#[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 pub fn on_event(&mut self, now: Instant) {
128 self.last_event_at = now;
129 self.last_pong_at = now;
130 }
131
132 pub fn on_pong(&mut self, now: Instant) {
134 self.last_pong_at = now;
135 }
136
137 pub fn on_eof(&mut self) {
139 self.eof = true;
140 }
141
142 pub fn deadline(&self) -> Instant {
143 self.deadline
144 }
145
146 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 assert_eq!(
179 LivenessConfig::new(Duration::from_secs(100), Duration::from_secs(9)).ping_interval,
180 Duration::from_secs(3)
181 );
182 assert_eq!(
184 LivenessConfig::new(Duration::from_secs(1), Duration::from_millis(30)).ping_interval,
185 Duration::from_millis(50)
186 );
187 assert_eq!(
189 LivenessConfig::new(Duration::from_secs(600), Duration::from_secs(60)).ping_interval,
190 Duration::from_secs(5)
191 );
192 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 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 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)); l.on_eof();
228 assert_eq!(l.classify(t0 + Duration::from_secs(2)), Health::Dead); }
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)); 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 assert_eq!(l.classify(t0 + Duration::from_secs(301)), Health::Healthy);
249 }
250}