Skip to main content

fno_agents/
supervisor.rs

1//! Restart policy state machine (design module `supervisor.rs`).
2//!
3//! After a PTY-managed agent's child crashes (post-spawn; pre-spawn validation
4//! failures never restart, LD32), the policy decides whether to re-spawn and
5//! how long to back off. LD36 imposes a hard ceiling: `consecutive_failures >=
6//! 10` triggers `permanent_dead` regardless of any provider-supplied policy.
7//! The provider's `default_restart_policy()` is capped at this ceiling so a
8//! buggy provider cannot request infinite restarts.
9
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12
13/// The hard ceiling from LD36. No provider policy may exceed it.
14pub const HARD_FAILURE_CEILING: u32 = 10;
15
16/// Backoff schedule between restart attempts.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Backoff {
19    /// Delay before the first restart.
20    pub base: Duration,
21    /// Multiplier applied per consecutive failure (exponential when > 1).
22    pub factor_milli: u32, // factor * 1000, to keep the struct serde-trivial
23    /// Cap on any single backoff delay.
24    pub max: Duration,
25}
26
27impl Backoff {
28    /// Delay for the Nth consecutive failure (1-based). `failures == 1` yields
29    /// `base`; each subsequent failure multiplies by `factor`, capped at `max`.
30    pub fn delay_for(&self, consecutive_failures: u32) -> Duration {
31        // Compute the raw (pre-floor) delay in ms for every path, then apply a
32        // single unconditional 1ms floor at the end. Flooring in ONE place
33        // covers both the first-failure path AND the exponential path; an
34        // earlier version floored only the latter, so a sub-millisecond `base`
35        // on the first failure slipped through.
36        let raw_ms = if consecutive_failures <= 1 {
37            self.base.min(self.max).as_millis() as f64
38        } else {
39            let factor = (self.factor_milli as f64) / 1000.0;
40            let exp = (consecutive_failures - 1) as i32;
41            let base_ms = self.base.as_millis() as f64;
42            let scaled_ms = base_ms * factor.powi(exp);
43            let capped_ms = scaled_ms.min(self.max.as_millis() as f64);
44            // Guard against NaN/inf from pathological factors.
45            if !capped_ms.is_finite() || capped_ms < 0.0 {
46                self.max.as_millis() as f64
47            } else {
48                capped_ms
49            }
50        };
51        // Floor at 1ms UNCONDITIONALLY so a misconfigured shrinking factor
52        // (`factor_milli < 1000`), a sub-millisecond `base`/`max`, or
53        // truncation of a sub-ms scaled value cannot yield a zero-delay restart
54        // and a hot re-spawn loop. A sub-ms config is itself a misconfiguration;
55        // honoring a 1ms minimum over it is the safe choice.
56        Duration::from_millis(raw_ms.max(1.0) as u64)
57    }
58}
59
60impl Default for Backoff {
61    fn default() -> Self {
62        // 500ms base, doubling, capped at 30s.
63        Backoff {
64            base: Duration::from_millis(500),
65            factor_milli: 2000,
66            max: Duration::from_secs(30),
67        }
68    }
69}
70
71/// Provider-supplied restart policy. `max_consecutive_failures` is the
72/// provider's request; the effective ceiling is `min(it, HARD_FAILURE_CEILING)`.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct RestartPolicy {
75    /// The provider's REQUESTED ceiling. This is not the enforced value: read
76    /// [`RestartPolicy::effective_ceiling`] for the value `decide` actually
77    /// uses, which is capped at [`HARD_FAILURE_CEILING`] (LD36). The raw
78    /// request is preserved here for display/logging.
79    pub max_consecutive_failures: u32,
80    pub backoff: Backoff,
81}
82
83impl RestartPolicy {
84    pub fn new(max_consecutive_failures: u32, backoff: Backoff) -> Self {
85        RestartPolicy {
86            max_consecutive_failures,
87            backoff,
88        }
89    }
90
91    /// The effective ceiling after applying the LD36 hard cap.
92    pub fn effective_ceiling(&self) -> u32 {
93        self.max_consecutive_failures.min(HARD_FAILURE_CEILING)
94    }
95
96    /// Decide what to do after `consecutive_failures` post-spawn crashes.
97    pub fn decide(&self, consecutive_failures: u32) -> RestartDecision {
98        if consecutive_failures >= self.effective_ceiling() {
99            RestartDecision::PermanentDead
100        } else {
101            RestartDecision::Restart {
102                after: self.backoff.delay_for(consecutive_failures),
103            }
104        }
105    }
106}
107
108impl Default for RestartPolicy {
109    fn default() -> Self {
110        // A provider that supplies nothing gets 5 retries with default backoff,
111        // still under the hard ceiling of 10.
112        RestartPolicy::new(5, Backoff::default())
113    }
114}
115
116/// The decision the supervisor acts on after a crash.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum RestartDecision {
119    /// Re-spawn the child after `after`.
120    Restart { after: Duration },
121    /// Hard ceiling reached; mark `permanent_dead`, never restart again.
122    PermanentDead,
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn hard_ceiling_overrides_generous_provider_policy() {
131        // Provider asks for 100 retries; LD36 caps it at 10.
132        let policy = RestartPolicy::new(100, Backoff::default());
133        assert_eq!(policy.effective_ceiling(), HARD_FAILURE_CEILING);
134        assert_eq!(
135            policy.decide(9),
136            RestartDecision::Restart {
137                after: policy.backoff.delay_for(9)
138            }
139        );
140        assert_eq!(policy.decide(10), RestartDecision::PermanentDead);
141        assert_eq!(policy.decide(50), RestartDecision::PermanentDead);
142    }
143
144    #[test]
145    fn conservative_provider_policy_triggers_permanent_dead_early() {
146        // Provider asks for only 3 retries; that is below the ceiling and wins.
147        let policy = RestartPolicy::new(3, Backoff::default());
148        assert_eq!(policy.effective_ceiling(), 3);
149        assert!(matches!(policy.decide(2), RestartDecision::Restart { .. }));
150        assert_eq!(policy.decide(3), RestartDecision::PermanentDead);
151    }
152
153    #[test]
154    fn backoff_grows_and_caps() {
155        let backoff = Backoff {
156            base: Duration::from_millis(100),
157            factor_milli: 2000, // x2
158            max: Duration::from_millis(800),
159        };
160        assert_eq!(backoff.delay_for(1), Duration::from_millis(100));
161        assert_eq!(backoff.delay_for(2), Duration::from_millis(200));
162        assert_eq!(backoff.delay_for(3), Duration::from_millis(400));
163        assert_eq!(backoff.delay_for(4), Duration::from_millis(800));
164        // Past the cap, stays capped.
165        assert_eq!(backoff.delay_for(10), Duration::from_millis(800));
166    }
167
168    #[test]
169    fn shrinking_factor_floors_above_zero() {
170        // A misconfigured factor < 1.0 shrinks the delay; truncation toward 0
171        // would otherwise produce a zero-delay hot restart loop.
172        let backoff = Backoff {
173            base: Duration::from_millis(2),
174            factor_milli: 100, // 0.1x: shrinks fast
175            max: Duration::from_secs(30),
176        };
177        // By the 5th failure the scaled value is well below 1ms; must floor.
178        assert!(
179            backoff.delay_for(5) >= Duration::from_millis(1),
180            "backoff must never floor to zero: got {:?}",
181            backoff.delay_for(5)
182        );
183    }
184
185    #[test]
186    fn sub_millisecond_max_still_floors_above_zero() {
187        // A degenerate sub-1ms max must not let the floor truncate to 0 and
188        // reintroduce a hot restart loop.
189        let backoff = Backoff {
190            base: Duration::from_micros(100),
191            factor_milli: 2000,
192            max: Duration::from_micros(500), // < 1ms; as_millis() truncates to 0
193        };
194        assert!(
195            backoff.delay_for(1) >= Duration::from_millis(1),
196            "sub-ms max must still floor to >=1ms, got {:?}",
197            backoff.delay_for(1)
198        );
199        assert!(backoff.delay_for(5) >= Duration::from_millis(1));
200    }
201
202    #[test]
203    fn first_failure_uses_base_not_zero() {
204        let policy = RestartPolicy::default();
205        match policy.decide(1) {
206            RestartDecision::Restart { after } => assert!(after >= Duration::from_millis(1)),
207            other => panic!("expected restart, got {other:?}"),
208        }
209    }
210}