use std::sync::Arc;
use chrono::Utc;
use oxios_ouroboros::{Directive, ExecEnv, ExecutionResult, FailureClass};
use parking_lot::RwLock;
use tracing::{info, warn};
use crate::agent_lifecycle::AgentLifecycleManager;
use crate::resilience::ProviderHealthRegistry;
use crate::kernel_handle::{FallbackEvent, RoutingStats};
use super::budget::AttemptBudget;
#[derive(Debug, Clone)]
pub struct ResilienceConfig {
pub enabled: bool,
pub max_same_model_retries: u32,
pub backoff_base_ms: u64,
pub backoff_max_ms: u64,
pub max_total_attempts: u32,
}
impl Default for ResilienceConfig {
fn default() -> Self {
Self {
enabled: true,
max_same_model_retries: 2,
backoff_base_ms: 1000,
backoff_max_ms: 30_000,
max_total_attempts: 8,
}
}
}
pub struct RecoveryCoordinator {
routing_stats: Arc<RoutingStats>,
fallback_models: RwLock<Vec<String>>,
health: RwLock<Option<Arc<ProviderHealthRegistry>>>,
a2a_breaker: RwLock<Option<Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>>>,
config: RwLock<ResilienceConfig>,
}
impl RecoveryCoordinator {
pub fn new(routing_stats: Arc<RoutingStats>, config: ResilienceConfig) -> Self {
Self {
routing_stats,
fallback_models: RwLock::new(Vec::new()),
health: RwLock::new(None),
a2a_breaker: RwLock::new(None),
config: RwLock::new(config),
}
}
pub fn set_health(&self, health: Arc<ProviderHealthRegistry>) {
*self.health.write() = Some(health);
}
pub fn set_a2a_breaker(&self, breaker: Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>) {
*self.a2a_breaker.write() = Some(breaker);
}
pub fn set_fallback_models(&self, models: Vec<String>) {
*self.fallback_models.write() = models;
}
pub fn set_config(&self, config: ResilienceConfig) {
*self.config.write() = config;
}
pub async fn execute(
&self,
lifecycle: &AgentLifecycleManager,
directive: &Directive,
env: &ExecEnv,
) -> anyhow::Result<ExecutionResult> {
let config = self.config.read().clone();
if !config.enabled {
return lifecycle.execute_directive(directive, env).await;
}
let budget = AttemptBudget::new(config.max_total_attempts);
budget.try_consume();
let result = lifecycle.execute_directive(directive, env).await?;
let class = match (result.success, result.failure_class) {
(true, _) => return Ok(result),
(false, None) => return Ok(result),
(false, Some(class)) => class,
};
self.escalate(lifecycle, directive, env, &result, class, &budget, &config)
.await
}
#[allow(clippy::too_many_arguments)]
async fn escalate(
&self,
lifecycle: &AgentLifecycleManager,
directive: &Directive,
env: &ExecEnv,
initial: &ExecutionResult,
class: FailureClass,
budget: &AttemptBudget,
config: &ResilienceConfig,
) -> anyhow::Result<ExecutionResult> {
let primary = env
.model_override
.clone()
.unwrap_or_else(|| initial.model_id.clone());
let mut best = initial.clone();
if class.benefits_from_same_model_retry() {
for attempt in 1..=config.max_same_model_retries {
if !budget.try_consume() {
warn!(attempt, "attempt budget exhausted during L1 backoff");
break;
}
let delay_ms = backoff(attempt, config.backoff_base_ms, config.backoff_max_ms);
info!(
attempt,
delay_ms,
class = %class,
model = %primary,
"L1: same-model backoff retry"
);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
match lifecycle.execute_directive(directive, env).await {
Ok(r) => {
if r.success {
return Ok(r);
}
let new_class = r.failure_class;
best = better_of(best, r);
if let Some(c) = new_class
&& !c.benefits_from_same_model_retry()
{
info!(class = %c, "L1: failure class changed, escalating to L2");
break;
}
}
Err(e) => {
warn!(error = %e, "L1: lifecycle error during retry");
break;
}
}
}
}
let fallbacks = self.fallback_models.read().clone();
if fallbacks.is_empty() {
info!("L2: no fallback models configured — skipping to terminal");
return Ok(best);
}
for alt in fallbacks {
if alt == primary {
continue; }
if class.requires_provider_swap() && same_provider(&alt, &primary) {
info!(
from = %primary,
to = %alt,
"L2: skipping same-provider fallback (quota/auth needs a different provider)"
);
continue;
}
{
let health_guard = self.health.read();
if let Some(ref health) = *health_guard {
let provider = provider_of(&alt);
if !health.is_healthy(provider) {
info!(
from = %primary,
to = %alt,
provider,
"L2: skipping fallback — provider breaker open"
);
continue;
}
}
}
if !budget.try_consume() {
warn!("attempt budget exhausted during L2 model swap");
break;
}
let mut env2 = env.clone();
env2.model_override = Some(alt.clone());
env2.restore_state.clone_from(&best.restore_state);
info!(from = %primary, to = %alt, class = %class, "L2: model swap retry");
match lifecycle.execute_directive(directive, &env2).await {
Ok(r) => {
let success = r.success;
self.routing_stats.record_fallback(FallbackEvent {
timestamp: Utc::now(),
from_model: primary.clone(),
to_model: alt.clone(),
reason: class.to_string(),
success,
});
if success {
info!(to = %alt, "L2: fallback model succeeded");
return Ok(r);
}
best = better_of(best, r);
}
Err(e) => {
warn!(error = %e, to = %alt, "L2: lifecycle error during fallback");
self.routing_stats.record_fallback(FallbackEvent {
timestamp: Utc::now(),
from_model: primary.clone(),
to_model: alt.clone(),
reason: class.to_string(),
success: false,
});
}
}
}
{
let breaker_guard = self.a2a_breaker.read();
if let Some(ref breaker) = *breaker_guard {
if breaker.is_allowed() {
info!(class = %class, "L4: A2A delegation attempted");
breaker.record_failure();
} else {
info!("L4: A2A delegation blocked — circuit breaker open");
}
}
}
info!(
class = %class,
success = best.success,
"L5: recovery exhausted, returning best result"
);
Ok(best)
}
}
fn backoff(attempt: u32, base_ms: u64, max_ms: u64) -> u64 {
let exp = attempt.saturating_sub(1).min(10);
base_ms.saturating_mul(2u64.saturating_pow(exp)).min(max_ms)
}
fn provider_of(model_id: &str) -> &str {
model_id.split_once('/').map(|(p, _)| p).unwrap_or(model_id)
}
fn same_provider(a: &str, b: &str) -> bool {
provider_of(a) == provider_of(b)
}
fn better_of(a: ExecutionResult, b: ExecutionResult) -> ExecutionResult {
if a.success {
return a;
}
if b.success {
return b;
}
if b.steps_completed > a.steps_completed {
b
} else {
a
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_grows_exponentially_and_caps() {
assert_eq!(backoff(1, 1000, 30_000), 1000);
assert_eq!(backoff(2, 1000, 30_000), 2000);
assert_eq!(backoff(3, 1000, 30_000), 4000);
assert_eq!(backoff(4, 1000, 30_000), 8000);
assert_eq!(backoff(10, 1000, 30_000), 30_000);
}
#[test]
fn provider_of_extracts_prefix() {
assert_eq!(provider_of("anthropic/claude-sonnet-4"), "anthropic");
assert_eq!(provider_of("openai/gpt-4o"), "openai");
assert_eq!(provider_of("bare-model"), "bare-model"); }
#[test]
fn same_provider_detection() {
assert!(same_provider("anthropic/a", "anthropic/b"));
assert!(!same_provider("anthropic/a", "openai/b"));
}
#[test]
fn better_of_prefers_success() {
let ok = ExecutionResult {
output: "ok".into(),
steps_completed: 0,
success: true,
tool_calls: vec![],
tokens_input: 0,
tokens_output: 0,
model_id: "m".into(),
failure_class: None,
restore_state: None,
reasoning_text: String::new(),
};
let fail = ExecutionResult {
success: false,
output: "fail".into(),
steps_completed: 5,
..ok.clone()
};
assert!(better_of(fail.clone(), ok.clone()).success);
assert!(better_of(ok.clone(), fail.clone()).success);
}
#[test]
fn better_of_prefers_more_steps_on_tie() {
let base = ExecutionResult {
output: String::new(),
steps_completed: 0,
success: false,
tool_calls: vec![],
tokens_input: 0,
tokens_output: 0,
model_id: String::new(),
failure_class: Some(FailureClass::Transient),
restore_state: None,
reasoning_text: String::new(),
};
let more = ExecutionResult {
steps_completed: 3,
..base.clone()
};
assert_eq!(better_of(base, more).steps_completed, 3);
}
#[test]
fn budget_integration_with_coordinator_logic() {
let b = AttemptBudget::new(3);
assert!(b.try_consume());
assert!(b.try_consume());
assert!(b.try_consume());
assert!(!b.try_consume());
}
}