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};
41use crate::scheduler::Priority;
42
43use super::budget::AttemptBudget;
44
45#[derive(Debug, Clone)]
48pub struct ResilienceConfig {
49 pub enabled: bool,
52 pub max_same_model_retries: u32,
54 pub backoff_base_ms: u64,
56 pub backoff_max_ms: u64,
58 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
75pub struct RecoveryCoordinator {
79 routing_stats: Arc<RoutingStats>,
80 fallback_models: RwLock<Vec<String>>,
83 health: RwLock<Option<Arc<ProviderHealthRegistry>>>,
86 a2a_breaker: RwLock<Option<Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>>>,
89 config: RwLock<ResilienceConfig>,
90}
91
92impl RecoveryCoordinator {
93 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 pub fn set_health(&self, health: Arc<ProviderHealthRegistry>) {
108 *self.health.write() = Some(health);
109 }
110
111 pub fn set_a2a_breaker(&self, breaker: Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>) {
114 *self.a2a_breaker.write() = Some(breaker);
115 }
116 pub fn set_fallback_models(&self, models: Vec<String>) {
119 *self.fallback_models.write() = models;
120 }
121
122 pub fn set_config(&self, config: ResilienceConfig) {
124 *self.config.write() = config;
125 }
126
127 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 return lifecycle
143 .execute_directive(directive, env, Priority::Normal)
144 .await;
145 }
146
147 let budget = AttemptBudget::new(config.max_total_attempts);
148 budget.try_consume();
150 let result = lifecycle
151 .execute_directive(directive, env, Priority::Normal)
152 .await?;
153
154 let class = match (result.success, result.failure_class) {
156 (true, _) => return Ok(result),
157 (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 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 let new_class = r.failure_class;
209 best = better_of(best, r);
210 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 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; }
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 {
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 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 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 {
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 breaker.record_failure();
325 } else {
326 info!("L4: A2A delegation blocked — circuit breaker open");
327 }
328 }
329 }
330
331 info!(
333 class = %class,
334 success = best.success,
335 "L5: recovery exhausted, returning best result"
336 );
337 Ok(best)
338 }
339}
340
341fn 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
347fn provider_of(model_id: &str) -> &str {
349 model_id.split_once('/').map(|(p, _)| p).unwrap_or(model_id)
350}
351
352fn same_provider(a: &str, b: &str) -> bool {
354 provider_of(a) == provider_of(b)
355}
356
357fn 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 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"); }
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 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}