1use std::num::NonZeroU64;
15
16use kernel::event::record::{FiredAction, WatchdogFired};
17use kernel::{AxCode, AxError, Payload, Retries, Retry, RunId, StallVerdict, TimeMs};
18
19#[derive(Debug, Default)]
22pub struct Watchdog {
23 corrections: u32,
24 provider_failures: u32,
25 streak: u32,
31 retries: Retries,
32 jitter_seed: u64,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Disposal {
41 Proceed,
42 CorrectiveSteer {
43 text: String,
44 },
45 BackOff {
51 until: TimeMs,
52 code: AxCode,
53 subject: String,
54 },
55 Freeze {
56 reason: FreezeReason,
57 },
58}
59
60impl Disposal {
61 fn backing_off(until: TimeMs, failure: &AxError) -> Disposal {
63 Disposal::BackOff {
64 until,
65 code: *failure.code(),
66 subject: failure.subject().to_owned(),
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum FreezeReason {
73 Stall,
74 ProviderRefused,
75}
76
77impl FreezeReason {
78 fn as_str(self) -> &'static str {
79 match self {
80 FreezeReason::Stall => "stall",
81 FreezeReason::ProviderRefused => "provider_refused",
82 }
83 }
84}
85
86impl Watchdog {
87 #[must_use]
88 pub fn new(retries: Retries, run: RunId) -> Watchdog {
89 Watchdog {
90 retries,
91 jitter_seed: seed_of(run),
92 ..Watchdog::default()
93 }
94 }
95
96 pub fn on_stall(&mut self, verdict: &StallVerdict) -> Disposal {
99 match verdict {
100 StallVerdict::Ok => Disposal::Proceed,
101 StallVerdict::Stall { repeats } => {
102 if self.corrections == 0 {
103 self.corrections = 1;
104 Disposal::CorrectiveSteer {
105 text: format!(
106 "You have repeated the same call {repeats} times in a row. \
107 Change the approach or report why the goal cannot be met."
108 ),
109 }
110 } else {
111 Disposal::Freeze {
112 reason: FreezeReason::Stall,
113 }
114 }
115 }
116 }
117 }
118
119 pub fn on_provider_failure(&mut self, failure: &AxError, now: TimeMs) -> Disposal {
138 self.provider_failures = self.provider_failures.saturating_add(1);
139 self.streak = self.streak.saturating_add(1);
140 let own = self.backoff_ms();
141 let wait = failure.retry_after_ms().map_or(own, |told| own.max(told));
142 let not_before = TimeMs::new(now.value().saturating_add(wait));
143 let refused = Disposal::Freeze {
144 reason: FreezeReason::ProviderRefused,
145 };
146 if failure.retry() == Retry::No {
147 return refused;
148 }
149 match self.retries {
150 Retries::UntilHalted => Disposal::backing_off(not_before, failure),
151 Retries::AtMost(ceiling) if self.streak <= ceiling => {
154 Disposal::backing_off(not_before, failure)
155 }
156 Retries::AtMost(_) => refused,
157 }
158 }
159
160 pub fn on_provider_answered(&mut self) {
162 self.streak = 0;
163 }
164
165 fn backoff_ms(&self) -> u64 {
173 const FIRST_MS: u64 = 500;
174 const CEILING_MS: u64 = 60_000;
175 let doublings = self.streak.saturating_sub(1).min(16);
176 let base = FIRST_MS
177 .checked_shl(doublings)
178 .map_or(CEILING_MS, |wait| wait.min(CEILING_MS));
179 let spread = NonZeroU64::MIN.saturating_add(base >> 1);
180 base.saturating_add(mixed(self.jitter_seed ^ u64::from(self.streak)) % spread)
181 }
182
183 pub fn fired_payload(&self, disposal: &Disposal) -> Result<Payload, AxError> {
190 let action = match disposal {
191 Disposal::Proceed => {
192 return Err(AxError::failure(
193 AxCode::InvalidArgs,
194 "encode watchdog_fired",
195 "Proceed does not fire",
196 )
197 .with_recovery(
198 "report this against runtime::watchdog: `Proceed` is the verdict that records nothing, and the caller asked it for a payload",
199 ));
200 }
201 Disposal::CorrectiveSteer { text } => FiredAction::Steer { text: text.clone() },
202 Disposal::BackOff {
203 until,
204 code,
205 subject,
206 } => FiredAction::BackOff {
207 until_ms: until.value(),
208 code: code.as_str().to_owned(),
209 subject: subject.clone(),
210 },
211 Disposal::Freeze { reason } => FiredAction::Freeze {
212 reason: reason.as_str().to_owned(),
213 },
214 };
215 Payload::of(&WatchdogFired {
216 action,
217 corrections: self.corrections,
218 provider_failures: self.provider_failures,
219 })
220 }
221}
222
223fn seed_of(run: RunId) -> u64 {
227 run.as_bytes()
228 .iter()
229 .fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
230 (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
231 })
232}
233
234fn mixed(word: u64) -> u64 {
237 let word = (word ^ (word >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
238 let word = (word ^ (word >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
239 word ^ (word >> 31)
240}
241
242#[cfg(test)]
243#[allow(
244 clippy::unwrap_used,
245 clippy::expect_used,
246 clippy::panic,
247 clippy::indexing_slicing,
248 clippy::wildcard_enum_match_arm,
249 clippy::let_underscore_must_use,
250 clippy::let_underscore_untyped,
251 clippy::arithmetic_side_effects,
252 reason = "test code"
253)]
254mod tests {
255 use super::*;
256 use kernel::ActionFingerprint;
257 use kernel::stall::observe;
258
259 const BASES: [u64; 9] = [
261 500, 1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000,
262 ];
263
264 fn run(tail: u8) -> RunId {
267 let mut bytes = [
268 0x01, 0x93, 0x2a, 0x7c, 0x10, 0x00, 0x70, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0,
269 ];
270 bytes[15] = tail;
271 RunId::from_bytes(bytes)
272 }
273
274 fn waits(dog: &mut Watchdog, count: usize) -> Vec<u64> {
277 (0..count)
278 .map(
279 |_| match dog.on_provider_failure(&provider_error(true), TimeMs::new(0)) {
280 Disposal::BackOff { until, .. } => until.value(),
281 other => panic!("a retriable failure under its ceiling backs off: {other:?}"),
282 },
283 )
284 .collect()
285 }
286
287 fn within_schedule(waits: &[u64]) -> bool {
288 waits
289 .iter()
290 .zip(BASES)
291 .all(|(wait, base)| (base..=base + base / 2).contains(wait))
292 }
293
294 #[test]
298 fn each_run_waits_its_own_jittered_schedule() {
299 let schedule = |tail: u8| waits(&mut Watchdog::new(Retries::UntilHalted, run(tail)), 9);
300 let (first, second) = (schedule(1), schedule(2));
301 assert!(
302 within_schedule(&first) && within_schedule(&second),
303 "{first:?} {second:?}"
304 );
305 assert_eq!(first, schedule(1), "the same run waits the same schedule");
306 assert_ne!(first, second, "two runs do not ask again in step");
307 }
308
309 #[test]
310 fn disposal_is_graded_steer_first_freeze_second() {
311 let mut dog = Watchdog::new(Retries::UntilHalted, run(1));
312 let same = ActionFingerprint::derive(b"exec identical");
313 let sample = vec![same, same, same];
314 let verdict = observe(&sample);
315 let first = dog.on_stall(&verdict);
316 match &first {
317 Disposal::CorrectiveSteer { text } => {
318 assert!(text.contains("repeated the same call 3 times"))
319 }
320 other => panic!("first hit must steer, got {other:?}"),
321 }
322 let second = dog.on_stall(&verdict);
323 assert_eq!(
324 second,
325 Disposal::Freeze {
326 reason: FreezeReason::Stall
327 }
328 );
329 let payload = serde_json::to_value(dog.fired_payload(&second).unwrap()).unwrap();
331 assert_eq!(payload["action"], "freeze");
332 assert_eq!(payload["reason"], "stall");
333 assert!(dog.fired_payload(&Disposal::Proceed).is_err());
334 }
335
336 #[test]
337 fn ok_verdicts_never_dispose() {
338 let mut dog = Watchdog::new(Retries::UntilHalted, run(1));
339 assert_eq!(dog.on_stall(&StallVerdict::Ok), Disposal::Proceed);
340 assert_eq!(dog.on_stall(&StallVerdict::Ok), Disposal::Proceed);
341 }
342
343 fn provider_error(retriable: bool) -> AxError {
344 let draft = AxError::failure(AxCode::Provider, "call the model", "the provider said no");
345 let draft = if retriable { draft.retriable() } else { draft };
346 draft.with_recovery("dispatch again, or attach a second endpoint")
347 }
348
349 #[test]
350 fn a_failure_the_provider_will_repeat_stops_after_one() {
351 let mut dog = Watchdog::new(Retries::UntilHalted, run(1));
352 assert_eq!(
353 dog.on_provider_failure(&provider_error(false), TimeMs::new(9_000)),
354 Disposal::Freeze {
355 reason: FreezeReason::ProviderRefused
356 },
357 "a request the provider rejected on its shape buys the same rejection again"
358 );
359 }
360
361 #[test]
362 fn an_answer_ends_the_streak_the_backoff_counts() {
363 let mut dog = Watchdog::new(Retries::AtMost(2), run(1));
364 let before = waits(&mut dog, 2);
365 assert!(within_schedule(&before), "{before:?}");
366 dog.on_provider_answered();
367 assert_eq!(
368 waits(&mut dog, 2),
369 before,
370 "an outage after a recovery starts from the first wait, under a fresh ceiling"
371 );
372 }
373
374 #[test]
375 fn a_retriable_failure_backs_off_and_never_freezes_by_itself() {
376 let mut dog = Watchdog::new(Retries::UntilHalted, run(1));
377 let waits: Vec<u64> = (0..64u64)
378 .map(
379 |_| match dog.on_provider_failure(&provider_error(true), TimeMs::new(1_000)) {
380 Disposal::BackOff { until, .. } => until.value().saturating_sub(1_000),
381 other => {
382 panic!("only Halt stops a run that is waiting out a provider: {other:?}")
383 }
384 },
385 )
386 .collect();
387 assert!(
388 within_schedule(&waits),
389 "each failure in a row doubles the wait, up to a minute: {waits:?}"
390 );
391 assert!(
392 waits[9..]
393 .iter()
394 .all(|wait| (60_000..=90_000).contains(wait))
395 );
396 let payload = serde_json::to_value(
397 dog.fired_payload(&Disposal::BackOff {
398 until: TimeMs::new(1_000),
399 code: AxCode::Provider,
400 subject: "the provider said no".to_owned(),
401 })
402 .unwrap(),
403 )
404 .unwrap();
405 assert_eq!(payload["action"], "back_off");
406 assert_eq!(payload["until_ms"], 1_000);
407 assert_eq!(payload["provider_failures"], 64);
408 assert_eq!(
409 payload["subject"], "the provider said no",
410 "a line saying it backed off says what it backed off from"
411 );
412 }
413
414 fn waits_of_fresh_run() -> Vec<u64> {
415 waits(&mut Watchdog::new(Retries::UntilHalted, run(1)), 2)
416 }
417
418 #[test]
419 fn a_provider_that_names_its_wait_is_not_asked_sooner() {
420 let mut dog = Watchdog::new(Retries::UntilHalted, run(1));
421 let told = |wait_ms: u64| {
422 AxError::failure(AxCode::Provider, "call the model", "answered 429")
423 .retriable_after(wait_ms)
424 .with_recovery("wait, then ask again")
425 };
426 let waits: Vec<u64> = [30_000, 10]
427 .map(
428 |wait_ms| match dog.on_provider_failure(&told(wait_ms), TimeMs::new(1_000)) {
429 Disposal::BackOff { until, .. } => until.value().saturating_sub(1_000),
430 other => {
431 panic!("a provider that says when to come back is asked again: {other:?}")
432 }
433 },
434 )
435 .to_vec();
436 let own = waits_of_fresh_run();
437 assert_eq!(
438 waits,
439 [30_000, own[1]],
440 "the longer of the provider's word and the schedule's own wait"
441 );
442 }
443}