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