agentd/supervisor/
liveness.rs1use std::time::{Duration, Instant};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Health {
26 Healthy,
28 Busy,
31 Stuck,
33 Dead,
36 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 {
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 assert_eq!(
178 LivenessConfig::new(Duration::from_secs(100), Duration::from_secs(9)).ping_interval,
179 Duration::from_secs(3)
180 );
181 assert_eq!(
183 LivenessConfig::new(Duration::from_secs(1), Duration::from_millis(30)).ping_interval,
184 Duration::from_millis(50)
185 );
186 assert_eq!(
188 LivenessConfig::new(Duration::from_secs(600), Duration::from_secs(60)).ping_interval,
189 Duration::from_secs(5)
190 );
191 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 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 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)); l.on_eof();
227 assert_eq!(l.classify(t0 + Duration::from_secs(2)), Health::Dead); }
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)); 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 assert_eq!(l.classify(t0 + Duration::from_secs(301)), Health::Healthy);
248 }
249}