Skip to main content

oxios_kernel/resilience/
coordinator.rs

1//! Recovery coordinator (RFC-029 §3.4) — drives the OTP-style recovery
2//! policy on top of the Unix lifecycle.
3//!
4//! Wraps `AgentLifecycleManager::execute_directive` in an escalation
5//! ladder. On a provider/infra failure (signalled by
6//! `ExecutionResult.failure_class`), it retries with backoff (L1) and/or
7//! swaps to a fallback model (L2), recording each fallback for
8//! observability. Bounded by an `AttemptBudget`.
9//!
10//! # Current scope (P2)
11//!
12//! - L1 same-model backoff retry (Transient/Unknown).
13//! - L2 model/provider swap via `ExecEnv.model_override` (reads
14//!   `engine.fallback_models`). Records `FallbackEvent`.
15//! - L5 terminal — returns the best result with `failure_class` set.
16//!
17//! # Deferred (P2b / P3 / P4)
18//!
19//! - **Snapshot+restore state preservation (P2b):** L2 currently re-runs
20//!   from scratch (a fresh fork). This loses mid-execution conversation
21//!   state. The unified snapshot→restore-with-new-model operation (RFC
22//!   §3.2) will be wired once the `Agent::export_state` capture-on-failure
23//!   path lands. For now, the side-effect duplication risk is mitigated
24//!   by: (1) provider failures predominantly occur at connection/first-
25//!   call level where no tools ran, and (2) the AttemptBudget bounds
26//!   total attempts.
27//! - **Per-provider circuit breaker (P3):** `ProviderHealthRegistry`.
28//! - **A2A delegation (P4):** agent-specific failures only.
29
30use std::sync::Arc;
31
32use chrono::Utc;
33use oxios_ouroboros::{Directive, ExecEnv, ExecutionResult, FailureClass};
34use parking_lot::RwLock;
35use tracing::{info, warn};
36
37use crate::agent_lifecycle::AgentLifecycleManager;
38use crate::resilience::ProviderHealthRegistry;
39
40use crate::kernel_handle::{FallbackEvent, RoutingStats};
41
42use super::budget::AttemptBudget;
43
44/// Resilience configuration (RFC-029 §4). Mirrors a subset of
45/// `[intent.resilience]` from config.toml.
46#[derive(Debug, Clone)]
47pub struct ResilienceConfig {
48    /// Master switch. When `false`, `execute` is a passthrough to the
49    /// lifecycle with no recovery.
50    pub enabled: bool,
51    /// L1: max same-model backoff retries before escalating to L2.
52    pub max_same_model_retries: u32,
53    /// L1: base delay in ms for exponential backoff (`base * 2^attempt`).
54    pub backoff_base_ms: u64,
55    /// L1: cap on the backoff delay in ms.
56    pub backoff_max_ms: u64,
57    /// Global cap on total directive executions across the ladder. `0`
58    /// = unlimited (not recommended for production).
59    pub max_total_attempts: u32,
60}
61
62impl Default for ResilienceConfig {
63    fn default() -> Self {
64        Self {
65            enabled: true,
66            max_same_model_retries: 2,
67            backoff_base_ms: 1000,
68            backoff_max_ms: 30_000,
69            max_total_attempts: 8,
70        }
71    }
72}
73
74/// The recovery coordinator. Holds the shared routing stats (for
75/// `FallbackEvent` recording) and the live fallback-model list. The
76/// lifecycle is passed per-call (the orchestrator owns it).
77pub struct RecoveryCoordinator {
78    routing_stats: Arc<RoutingStats>,
79    /// Fallback model IDs (`"provider/model"`), tried left-to-right.
80    /// Updated live from `engine.fallback_models` via [`set_fallback_models`].
81    fallback_models: RwLock<Vec<String>>,
82    /// Per-provider health for smart skip in L2 (RFC-029 P3). `None` =
83    /// all providers assumed healthy (no health gating).
84    health: RwLock<Option<Arc<ProviderHealthRegistry>>>,
85    /// A2A circuit breaker for L4 delegation gating (RFC-029 P4). When
86    /// set, the L4 step checks `is_allowed` before attempting delegation.
87    a2a_breaker: RwLock<Option<Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>>>,
88    config: RwLock<ResilienceConfig>,
89}
90
91impl RecoveryCoordinator {
92    /// Create a coordinator. `routing_stats` is shared with `EngineApi`
93    /// / `AgentRuntime` so fallback events surface in the Web UI.
94    pub fn new(routing_stats: Arc<RoutingStats>, config: ResilienceConfig) -> Self {
95        Self {
96            routing_stats,
97            fallback_models: RwLock::new(Vec::new()),
98            health: RwLock::new(None),
99            a2a_breaker: RwLock::new(None),
100            config: RwLock::new(config),
101        }
102    }
103
104    /// Wire the per-provider circuit breaker (RFC-029 P3). When set,
105    /// L2 skips every model whose provider's breaker is open.
106    pub fn set_health(&self, health: Arc<ProviderHealthRegistry>) {
107        *self.health.write() = Some(health);
108    }
109
110    /// Wire the A2A circuit breaker for L4 delegation gating (RFC-029 P4).
111    /// When set, L4 checks `is_allowed` before attempting delegation.
112    pub fn set_a2a_breaker(&self, breaker: Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>) {
113        *self.a2a_breaker.write() = Some(breaker);
114    }
115    /// Update the fallback-model list (called when engine routing config
116    /// changes, e.g. `set_routing` / hot-reload).
117    pub fn set_fallback_models(&self, models: Vec<String>) {
118        *self.fallback_models.write() = models;
119    }
120
121    /// Update the resilience config (hot-reload).
122    pub fn set_config(&self, config: ResilienceConfig) {
123        *self.config.write() = config;
124    }
125
126    /// Execute a directive with the recovery ladder.
127    ///
128    /// On success or non-provider failure (cancellation/abort), returns
129    /// the result as-is. On a provider failure (`failure_class: Some`),
130    /// escalates through L1 (same-model backoff) → L2 (model swap) →
131    /// terminal.
132    pub async fn execute(
133        &self,
134        lifecycle: &AgentLifecycleManager,
135        directive: &Directive,
136        env: &ExecEnv,
137    ) -> anyhow::Result<ExecutionResult> {
138        let config = self.config.read().clone();
139        if !config.enabled {
140            // Passthrough — no recovery.
141            return lifecycle.execute_directive(directive, env).await;
142        }
143
144        let budget = AttemptBudget::new(config.max_total_attempts);
145        // L0 — initial attempt.
146        budget.try_consume();
147        let result = lifecycle.execute_directive(directive, env).await?;
148
149        // Decide whether to escalate based on the failure signal.
150        let class = match (result.success, result.failure_class) {
151            (true, _) => return Ok(result),
152            // Cancellation / abort (no classified failure) — not retryable.
153            (false, None) => return Ok(result),
154            (false, Some(class)) => class,
155        };
156
157        self.escalate(lifecycle, directive, env, &result, class, &budget, &config)
158            .await
159    }
160
161    #[allow(clippy::too_many_arguments)]
162    async fn escalate(
163        &self,
164        lifecycle: &AgentLifecycleManager,
165        directive: &Directive,
166        env: &ExecEnv,
167        initial: &ExecutionResult,
168        class: FailureClass,
169        budget: &AttemptBudget,
170        config: &ResilienceConfig,
171    ) -> anyhow::Result<ExecutionResult> {
172        let primary = env
173            .model_override
174            .clone()
175            .unwrap_or_else(|| initial.model_id.clone());
176        let mut best = initial.clone();
177
178        // L1 — same-model backoff retry (only Transient/Unknown benefit).
179        if class.benefits_from_same_model_retry() {
180            for attempt in 1..=config.max_same_model_retries {
181                if !budget.try_consume() {
182                    warn!(attempt, "attempt budget exhausted during L1 backoff");
183                    break;
184                }
185                let delay_ms = backoff(attempt, config.backoff_base_ms, config.backoff_max_ms);
186                info!(
187                    attempt,
188                    delay_ms,
189                    class = %class,
190                    model = %primary,
191                    "L1: same-model backoff retry"
192                );
193                tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
194                match lifecycle.execute_directive(directive, env).await {
195                    Ok(r) => {
196                        if r.success {
197                            return Ok(r);
198                        }
199                        // Capture the class before moving r into better_of.
200                        let new_class = r.failure_class;
201                        best = better_of(best, r);
202                        // If the class changed (e.g. now auth), re-evaluate.
203                        if let Some(c) = new_class
204                            && !c.benefits_from_same_model_retry()
205                        {
206                            info!(class = %c, "L1: failure class changed, escalating to L2");
207                            break;
208                        }
209                    }
210                    Err(e) => {
211                        warn!(error = %e, "L1: lifecycle error during retry");
212                        break;
213                    }
214                }
215            }
216        }
217
218        // L2 — model/provider swap via fallback chain.
219        let fallbacks = self.fallback_models.read().clone();
220        if fallbacks.is_empty() {
221            info!("L2: no fallback models configured — skipping to terminal");
222            return Ok(best);
223        }
224
225        for alt in fallbacks {
226            if alt == primary {
227                continue; // don't retry the model that just failed
228            }
229            if class.requires_provider_swap() && same_provider(&alt, &primary) {
230                info!(
231                    from = %primary,
232                    to = %alt,
233                    "L2: skipping same-provider fallback (quota/auth needs a different provider)"
234                );
235                continue;
236            }
237            // P3: skip models on providers whose circuit breaker is open.
238            {
239                let health_guard = self.health.read();
240                if let Some(ref health) = *health_guard {
241                    let provider = provider_of(&alt);
242                    if !health.is_healthy(provider) {
243                        info!(
244                            from = %primary,
245                            to = %alt,
246                            provider,
247                            "L2: skipping fallback — provider breaker open"
248                        );
249                        continue;
250                    }
251                }
252            }
253
254            if !budget.try_consume() {
255                warn!("attempt budget exhausted during L2 model swap");
256                break;
257            }
258
259            let mut env2 = env.clone();
260            env2.model_override = Some(alt.clone());
261            // RFC-029 P2b: carry the previous run's conversation state so
262            // the new agent continues from the checkpoint rather than
263            // restarting from scratch (snapshot → restore-with-new-model).
264            env2.restore_state.clone_from(&best.restore_state);
265
266            info!(from = %primary, to = %alt, class = %class, "L2: model swap retry");
267            match lifecycle.execute_directive(directive, &env2).await {
268                Ok(r) => {
269                    let success = r.success;
270                    // Record the fallback event (wires the dead record_fallback).
271                    self.routing_stats.record_fallback(FallbackEvent {
272                        timestamp: Utc::now(),
273                        from_model: primary.clone(),
274                        to_model: alt.clone(),
275                        reason: class.to_string(),
276                        success,
277                    });
278                    if success {
279                        info!(to = %alt, "L2: fallback model succeeded");
280                        return Ok(r);
281                    }
282                    best = better_of(best, r);
283                }
284                Err(e) => {
285                    warn!(error = %e, to = %alt, "L2: lifecycle error during fallback");
286                    self.routing_stats.record_fallback(FallbackEvent {
287                        timestamp: Utc::now(),
288                        from_model: primary.clone(),
289                        to_model: alt.clone(),
290                        reason: class.to_string(),
291                        success: false,
292                    });
293                }
294            }
295        }
296
297        // L4 — A2A delegation (agent-specific failures only).
298        // Gates via the A2A circuit breaker. When all model/provider options
299        // are exhausted, delegate to a fresh agent via A2A. Only helps for
300        // agent-specific failures (bad persona, missing tool); infra-wide
301        // outages make delegation pointless because the fresh agent uses the
302        // same dead providers.
303        {
304            let breaker_guard = self.a2a_breaker.read();
305            if let Some(ref breaker) = *breaker_guard {
306                if breaker.is_allowed() {
307                    info!(class = %class, "L4: A2A delegation attempted");
308                    // The actual delegation is handled by the orchestrator
309                    // (A2AProtocol). Here we just gate and record the
310                    // attempt. The result carries a best-effort terminal
311                    // that signals "no provider-based recovery worked."
312                    // On failure: the breaker records it.
313                    breaker.record_failure();
314                } else {
315                    info!("L4: A2A delegation blocked — circuit breaker open");
316                }
317            }
318        }
319
320        // L5 — terminal. Return the best result obtained.
321        info!(
322            class = %class,
323            success = best.success,
324            "L5: recovery exhausted, returning best result"
325        );
326        Ok(best)
327    }
328}
329
330/// Exponential backoff: `base * 2^(attempt-1)`, capped at `max`.
331fn backoff(attempt: u32, base_ms: u64, max_ms: u64) -> u64 {
332    let exp = attempt.saturating_sub(1).min(10);
333    base_ms.saturating_mul(2u64.saturating_pow(exp)).min(max_ms)
334}
335
336/// Extract the provider prefix from a `"provider/model"` id.
337fn provider_of(model_id: &str) -> &str {
338    model_id.split_once('/').map(|(p, _)| p).unwrap_or(model_id)
339}
340
341/// Whether two model IDs share the same provider.
342fn same_provider(a: &str, b: &str) -> bool {
343    provider_of(a) == provider_of(b)
344}
345
346/// Pick the "better" of two results: prefer success, then more steps.
347fn better_of(a: ExecutionResult, b: ExecutionResult) -> ExecutionResult {
348    if a.success {
349        return a;
350    }
351    if b.success {
352        return b;
353    }
354    if b.steps_completed > a.steps_completed {
355        b
356    } else {
357        a
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn backoff_grows_exponentially_and_caps() {
367        assert_eq!(backoff(1, 1000, 30_000), 1000);
368        assert_eq!(backoff(2, 1000, 30_000), 2000);
369        assert_eq!(backoff(3, 1000, 30_000), 4000);
370        assert_eq!(backoff(4, 1000, 30_000), 8000);
371        // capped
372        assert_eq!(backoff(10, 1000, 30_000), 30_000);
373    }
374
375    #[test]
376    fn provider_of_extracts_prefix() {
377        assert_eq!(provider_of("anthropic/claude-sonnet-4"), "anthropic");
378        assert_eq!(provider_of("openai/gpt-4o"), "openai");
379        assert_eq!(provider_of("bare-model"), "bare-model"); // no slash
380    }
381
382    #[test]
383    fn same_provider_detection() {
384        assert!(same_provider("anthropic/a", "anthropic/b"));
385        assert!(!same_provider("anthropic/a", "openai/b"));
386    }
387
388    #[test]
389    fn better_of_prefers_success() {
390        let ok = ExecutionResult {
391            output: "ok".into(),
392            steps_completed: 0,
393            success: true,
394            tool_calls: vec![],
395            tokens_input: 0,
396            tokens_output: 0,
397            model_id: "m".into(),
398            failure_class: None,
399            restore_state: None,
400        };
401        let fail = ExecutionResult {
402            success: false,
403            output: "fail".into(),
404            steps_completed: 5,
405            ..ok.clone()
406        };
407        assert!(better_of(fail.clone(), ok.clone()).success);
408        assert!(better_of(ok.clone(), fail.clone()).success);
409    }
410
411    #[test]
412    fn better_of_prefers_more_steps_on_tie() {
413        let base = ExecutionResult {
414            output: String::new(),
415            steps_completed: 0,
416            success: false,
417            tool_calls: vec![],
418            tokens_input: 0,
419            tokens_output: 0,
420            model_id: String::new(),
421            failure_class: Some(FailureClass::Transient),
422            restore_state: None,
423        };
424        let more = ExecutionResult {
425            steps_completed: 3,
426            ..base.clone()
427        };
428        assert_eq!(better_of(base, more).steps_completed, 3);
429    }
430
431    #[test]
432    fn budget_integration_with_coordinator_logic() {
433        // The coordinator delegates to the lifecycle, so we test the
434        // pure decision helpers here. Full integration is covered by
435        // the lifecycle mock in the integration tests.
436        let b = AttemptBudget::new(3);
437        assert!(b.try_consume());
438        assert!(b.try_consume());
439        assert!(b.try_consume());
440        assert!(!b.try_consume());
441    }
442}