1use 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#[derive(Debug, Clone)]
47pub struct ResilienceConfig {
48 pub enabled: bool,
51 pub max_same_model_retries: u32,
53 pub backoff_base_ms: u64,
55 pub backoff_max_ms: u64,
57 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
74pub struct RecoveryCoordinator {
78 routing_stats: Arc<RoutingStats>,
79 fallback_models: RwLock<Vec<String>>,
82 health: RwLock<Option<Arc<ProviderHealthRegistry>>>,
85 a2a_breaker: RwLock<Option<Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>>>,
88 config: RwLock<ResilienceConfig>,
89}
90
91impl RecoveryCoordinator {
92 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 pub fn set_health(&self, health: Arc<ProviderHealthRegistry>) {
107 *self.health.write() = Some(health);
108 }
109
110 pub fn set_a2a_breaker(&self, breaker: Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>) {
113 *self.a2a_breaker.write() = Some(breaker);
114 }
115 pub fn set_fallback_models(&self, models: Vec<String>) {
118 *self.fallback_models.write() = models;
119 }
120
121 pub fn set_config(&self, config: ResilienceConfig) {
123 *self.config.write() = config;
124 }
125
126 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 return lifecycle.execute_directive(directive, env).await;
142 }
143
144 let budget = AttemptBudget::new(config.max_total_attempts);
145 budget.try_consume();
147 let result = lifecycle.execute_directive(directive, env).await?;
148
149 let class = match (result.success, result.failure_class) {
151 (true, _) => return Ok(result),
152 (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 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 let new_class = r.failure_class;
201 best = better_of(best, r);
202 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 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; }
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 {
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 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 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 {
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 breaker.record_failure();
314 } else {
315 info!("L4: A2A delegation blocked — circuit breaker open");
316 }
317 }
318 }
319
320 info!(
322 class = %class,
323 success = best.success,
324 "L5: recovery exhausted, returning best result"
325 );
326 Ok(best)
327 }
328}
329
330fn 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
336fn provider_of(model_id: &str) -> &str {
338 model_id.split_once('/').map(|(p, _)| p).unwrap_or(model_id)
339}
340
341fn same_provider(a: &str, b: &str) -> bool {
343 provider_of(a) == provider_of(b)
344}
345
346fn 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 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"); }
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 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}