Skip to main content

ravenclaws/
healing.rs

1//! Self-healing engine — automatic detection, retry, and recovery of failed agents
2//!
3//! # Dead code note
4//! Some fields and methods are `#[allow(dead_code)]` because they are used by
5//! library tests but not yet by the binary target. As healing is wired into
6//! more modules (swarm, heartbeat, background, patterns), these will become
7//! used by the binary as well.
8
9// Allow dead code for fields/methods used only by library tests
10#![allow(dead_code)]
11//!
12//! RavenClaws's self-healing system extends the v1.2.0 retry logic from individual
13//! LLM calls to the full agent, swarm, and task level. It provides:
14//!
15//! - **Failure tracking** — per-agent/worker failure records with timestamps
16//! - **Circuit breakers** — per-agent circuit state (Closed/Open/HalfOpen)
17//! - **Exponential backoff** — configurable retry delays for agent tasks
18//! - **Auto-replacement** — detect dead workers and trigger replacement
19//! - **Health checks** — verify agent health before delegation
20//!
21//! # Architecture
22//!
23//! ```text
24//! SelfHealingEngine
25//!   ├── HealingConfig — retry limits, backoff, circuit breaker settings
26//!   ├── failure_records: HashMap<String, FailureRecord> — per-agent failures
27//!   ├── circuit_breakers: HashMap<String, HealingCircuitBreaker> — per-agent circuits
28//!   └── Methods:
29//!       ├── record_failure() — track a failure for an agent
30//!       ├── record_success() — reset failure count for an agent
31//!       ├── is_healthy() — check if an agent is healthy (circuit breaker)
32//!       ├── retry_with_backoff() — execute with exponential backoff
33//!       ├── dead_workers() — get list of dead workers for replacement
34//!       └── reset() — clear all failure records
35//! ```
36
37use std::collections::HashMap;
38use std::time::{Duration, Instant};
39use tracing::{debug, info, warn};
40
41// ---------------------------------------------------------------------------
42// Configuration
43// ---------------------------------------------------------------------------
44
45/// Configuration for the self-healing engine.
46#[derive(Debug, Clone)]
47pub struct HealingConfig {
48    /// Maximum retries for a single agent task (default: 3)
49    pub max_retries: u32,
50    /// Base delay for exponential backoff in ms (default: 1000)
51    pub base_delay_ms: u64,
52    /// Maximum delay in ms (default: 30000)
53    pub max_delay_ms: u64,
54    /// Jitter factor (0.0-1.0, default: 0.3)
55    pub jitter: f64,
56    /// Circuit breaker: failures before opening (default: 5)
57    pub circuit_breaker_threshold: u32,
58    /// Circuit breaker: seconds before half-open (default: 30)
59    pub circuit_breaker_recovery_secs: u64,
60    /// Seconds before a worker is considered dead (default: 60)
61    pub worker_dead_after_secs: u64,
62    /// Maximum failed tasks before worker is marked dead (default: 10)
63    pub max_failed_tasks: u32,
64}
65
66impl Default for HealingConfig {
67    fn default() -> Self {
68        Self {
69            max_retries: 3,
70            base_delay_ms: 1000,
71            max_delay_ms: 30000,
72            jitter: 0.3,
73            circuit_breaker_threshold: 5,
74            circuit_breaker_recovery_secs: 30,
75            worker_dead_after_secs: 60,
76            max_failed_tasks: 10,
77        }
78    }
79}
80
81// ---------------------------------------------------------------------------
82// Circuit breaker
83// ---------------------------------------------------------------------------
84
85/// Circuit breaker state for a single agent/worker.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum HealingCircuitState {
88    /// Normal operation — requests allowed
89    Closed,
90    /// Failure threshold exceeded — requests blocked
91    Open,
92    /// Recovery period — one request allowed to test
93    HalfOpen,
94}
95
96/// Per-agent circuit breaker tracking failure counts and state transitions.
97#[derive(Debug, Clone)]
98pub struct HealingCircuitBreaker {
99    /// Current circuit state
100    pub state: HealingCircuitState,
101    /// Consecutive failure count
102    pub failure_count: u32,
103    /// Timestamp of last failure
104    pub last_failure_time: Option<Instant>,
105    /// Duration the circuit stays open before half-open
106    pub open_duration: Duration,
107    /// Threshold for opening the circuit
108    pub threshold: u32,
109}
110
111impl HealingCircuitBreaker {
112    /// Create a new circuit breaker with the given recovery duration and threshold.
113    pub fn new(open_duration_secs: u64, threshold: u32) -> Self {
114        Self {
115            state: HealingCircuitState::Closed,
116            failure_count: 0,
117            last_failure_time: None,
118            open_duration: Duration::from_secs(open_duration_secs),
119            threshold,
120        }
121    }
122
123    /// Record a success — resets failure count and closes the circuit.
124    pub fn record_success(&mut self) {
125        self.failure_count = 0;
126        self.state = HealingCircuitState::Closed;
127    }
128
129    /// Record a failure — increments count and opens circuit if threshold exceeded.
130    pub fn record_failure(&mut self) {
131        self.failure_count += 1;
132        self.last_failure_time = Some(Instant::now());
133        if self.failure_count >= self.threshold {
134            self.state = HealingCircuitState::Open;
135            warn!(
136                failure_count = self.failure_count,
137                threshold = self.threshold,
138                "Circuit breaker opened"
139            );
140        }
141    }
142
143    /// Check if requests are allowed.
144    /// Transitions from Open → HalfOpen after recovery duration.
145    pub fn can_execute(&mut self) -> bool {
146        match self.state {
147            HealingCircuitState::Closed => true,
148            HealingCircuitState::Open => {
149                if let Some(last) = self.last_failure_time {
150                    if last.elapsed() >= self.open_duration {
151                        self.state = HealingCircuitState::HalfOpen;
152                        info!("Circuit breaker half-open — allowing test request");
153                        true
154                    } else {
155                        false
156                    }
157                } else {
158                    false
159                }
160            }
161            HealingCircuitState::HalfOpen => true,
162        }
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Failure tracking
168// ---------------------------------------------------------------------------
169
170/// A record of a failure for a specific agent or worker.
171#[derive(Debug, Clone)]
172pub struct FailureRecord {
173    /// Agent/worker identifier
174    pub agent_id: String,
175    /// Number of consecutive failures
176    pub failure_count: u32,
177    /// Timestamp of first failure in this sequence
178    pub first_failure: Instant,
179    /// Timestamp of most recent failure
180    pub last_failure: Instant,
181    /// Error message from the most recent failure
182    pub last_error: String,
183    /// Whether this agent is considered dead
184    pub is_dead: bool,
185}
186
187impl FailureRecord {
188    /// Create a new failure record for an agent.
189    pub fn new(agent_id: &str, error: &str) -> Self {
190        Self {
191            agent_id: agent_id.to_string(),
192            failure_count: 1,
193            first_failure: Instant::now(),
194            last_failure: Instant::now(),
195            last_error: error.to_string(),
196            is_dead: false,
197        }
198    }
199
200    /// Record another failure.
201    pub fn add_failure(&mut self, error: &str) {
202        self.failure_count += 1;
203        self.last_failure = Instant::now();
204        self.last_error = error.to_string();
205    }
206
207    /// Record a success — resets the failure count.
208    pub fn record_success(&mut self) {
209        self.failure_count = 0;
210        self.is_dead = false;
211    }
212
213    /// Time since the last failure.
214    pub fn time_since_last_failure(&self) -> Duration {
215        self.last_failure.elapsed()
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Self-healing engine
221// ---------------------------------------------------------------------------
222
223/// The self-healing engine tracks failures, manages circuit breakers, and
224/// provides retry-with-backoff for agent tasks.
225///
226/// This is the central coordination point for all self-healing in RavenClaws.
227/// It is shared across the agent loop, swarm orchestrator, heartbeat agent,
228/// and background task manager via `Arc<RwLock<>>`.
229#[derive(Debug)]
230pub struct SelfHealingEngine {
231    /// Configuration
232    pub config: HealingConfig,
233    /// Per-agent failure records
234    failure_records: HashMap<String, FailureRecord>,
235    /// Per-agent circuit breakers
236    circuit_breakers: HashMap<String, HealingCircuitBreaker>,
237}
238
239impl SelfHealingEngine {
240    /// Create a new self-healing engine with default configuration.
241    pub fn new() -> Self {
242        Self {
243            config: HealingConfig::default(),
244            failure_records: HashMap::new(),
245            circuit_breakers: HashMap::new(),
246        }
247    }
248
249    /// Create a new self-healing engine with custom configuration.
250    pub fn with_config(config: HealingConfig) -> Self {
251        Self {
252            config,
253            failure_records: HashMap::new(),
254            circuit_breakers: HashMap::new(),
255        }
256    }
257
258    // ── Failure recording ──────────────────────────────────────────────
259
260    /// Record a failure for an agent.
261    ///
262    /// Returns the updated failure count for that agent.
263    pub fn record_failure(&mut self, agent_id: &str, error: &str) -> u32 {
264        // Update failure record
265        let record = self
266            .failure_records
267            .entry(agent_id.to_string())
268            .and_modify(|r| r.add_failure(error))
269            .or_insert_with(|| FailureRecord::new(agent_id, error));
270
271        // Check if agent should be marked dead
272        if record.failure_count >= self.config.max_failed_tasks {
273            record.is_dead = true;
274            warn!(
275                agent_id = %agent_id,
276                failure_count = record.failure_count,
277                max_failed = self.config.max_failed_tasks,
278                "Agent marked as dead"
279            );
280        }
281
282        // Update circuit breaker
283        let cb = self
284            .circuit_breakers
285            .entry(agent_id.to_string())
286            .or_insert_with(|| {
287                HealingCircuitBreaker::new(
288                    self.config.circuit_breaker_recovery_secs,
289                    self.config.circuit_breaker_threshold,
290                )
291            });
292        cb.record_failure();
293
294        record.failure_count
295    }
296
297    /// Record a success for an agent — resets failure tracking.
298    pub fn record_success(&mut self, agent_id: &str) {
299        if let Some(record) = self.failure_records.get_mut(agent_id) {
300            record.record_success();
301        }
302        if let Some(cb) = self.circuit_breakers.get_mut(agent_id) {
303            cb.record_success();
304        }
305    }
306
307    // ── Health checks ──────────────────────────────────────────────────
308
309    /// Check if an agent is healthy.
310    ///
311    /// An agent is healthy if:
312    /// 1. Its circuit breaker allows execution (Closed or HalfOpen)
313    /// 2. It's not marked as dead
314    pub fn is_healthy(&mut self, agent_id: &str) -> bool {
315        // Check circuit breaker
316        if let Some(cb) = self.circuit_breakers.get_mut(agent_id) {
317            if !cb.can_execute() {
318                debug!(
319                    agent_id = %agent_id,
320                    state = ?cb.state,
321                    "Agent blocked by circuit breaker"
322                );
323                return false;
324            }
325        }
326
327        // Check if marked dead
328        if let Some(record) = self.failure_records.get(agent_id) {
329            if record.is_dead {
330                // Check if enough time has passed to revive
331                if record.time_since_last_failure()
332                    > Duration::from_secs(self.config.worker_dead_after_secs)
333                {
334                    debug!(
335                        agent_id = %agent_id,
336                        "Agent revived after dead timeout"
337                    );
338                    return true;
339                }
340                return false;
341            }
342        }
343
344        true
345    }
346
347    /// Get the failure count for an agent.
348    pub fn failure_count(&self, agent_id: &str) -> u32 {
349        self.failure_records
350            .get(agent_id)
351            .map(|r| r.failure_count)
352            .unwrap_or(0)
353    }
354
355    /// Get the circuit state for an agent.
356    pub fn circuit_state(&self, agent_id: &str) -> Option<HealingCircuitState> {
357        self.circuit_breakers.get(agent_id).map(|cb| cb.state)
358    }
359
360    // ── Dead worker detection ──────────────────────────────────────────
361
362    /// Get a list of agent IDs that are marked as dead and past the replacement timeout.
363    pub fn dead_workers(&self) -> Vec<String> {
364        self.failure_records
365            .iter()
366            .filter(|(_, r)| {
367                r.is_dead
368                    && r.time_since_last_failure()
369                        > Duration::from_secs(self.config.worker_dead_after_secs)
370            })
371            .map(|(id, _)| id.clone())
372            .collect()
373    }
374
375    /// Get a list of all agent IDs that are currently dead (regardless of timeout).
376    pub fn all_dead_workers(&self) -> Vec<String> {
377        self.failure_records
378            .iter()
379            .filter(|(_, r)| r.is_dead)
380            .map(|(id, _)| id.clone())
381            .collect()
382    }
383
384    // ── Retry with backoff ─────────────────────────────────────────────
385
386    /// Calculate delay for a retry attempt using exponential backoff with jitter.
387    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
388        use rand::Rng;
389        let exp = 2u64.pow(attempt);
390        let base = self.config.base_delay_ms * exp;
391        let capped = base.min(self.config.max_delay_ms);
392        let jitter_range = (capped as f64) * self.config.jitter;
393        let jitter = rand::thread_rng().gen_range(-jitter_range..=jitter_range) as u64;
394        let delay = capped.saturating_add(jitter).max(self.config.base_delay_ms);
395        Duration::from_millis(delay)
396    }
397
398    /// Execute an async function with retry and exponential backoff.
399    ///
400    /// The function receives the attempt number (0-based) and should return
401    /// `Ok(T)` on success or `Err(String)` on failure.
402    /// Only transient errors should be retried — pass `is_transient` to classify.
403    ///
404    /// Returns `Ok(T)` on success, or `Err(String)` with the last error after
405    /// all retries are exhausted.
406    pub async fn retry_with_backoff<F, T>(
407        &self,
408        agent_id: &str,
409        is_transient: impl Fn(&str) -> bool,
410        mut operation: F,
411    ) -> Result<T, String>
412    where
413        F: FnMut(u32) -> Result<T, String>,
414    {
415        let max_attempts = self.config.max_retries + 1; // +1 for the initial attempt
416        let mut last_error = String::from("Unknown error");
417
418        for attempt in 0..max_attempts {
419            if attempt > 0 {
420                let delay = self.delay_for_attempt(attempt - 1);
421                debug!(
422                    agent_id = %agent_id,
423                    attempt = attempt,
424                    delay_ms = delay.as_millis(),
425                    "Retrying after backoff"
426                );
427                tokio::time::sleep(delay).await;
428            }
429
430            match operation(attempt) {
431                Ok(result) => {
432                    if attempt > 0 {
433                        info!(
434                            agent_id = %agent_id,
435                            attempt = attempt,
436                            "Operation succeeded after retry"
437                        );
438                    }
439                    return Ok(result);
440                }
441                Err(e) => {
442                    last_error = e.clone();
443                    if !is_transient(&e) {
444                        debug!(
445                            agent_id = %agent_id,
446                            attempt = attempt,
447                            error = %e,
448                            "Non-transient error — not retrying"
449                        );
450                        return Err(e);
451                    }
452                    warn!(
453                        agent_id = %agent_id,
454                        attempt = attempt,
455                        max_attempts = max_attempts,
456                        error = %e,
457                        "Transient error — will retry"
458                    );
459                }
460            }
461        }
462
463        Err(format!(
464            "All {} attempts failed for agent '{}': {}",
465            max_attempts, agent_id, last_error
466        ))
467    }
468
469    // ── Reset ──────────────────────────────────────────────────────────
470
471    /// Reset all failure records and circuit breakers.
472    pub fn reset(&mut self) {
473        self.failure_records.clear();
474        self.circuit_breakers.clear();
475        info!("Self-healing engine reset — all failure records cleared");
476    }
477
478    /// Reset failure records for a specific agent.
479    pub fn reset_agent(&mut self, agent_id: &str) {
480        self.failure_records.remove(agent_id);
481        self.circuit_breakers.remove(agent_id);
482        debug!(agent_id = %agent_id, "Self-healing record reset for agent");
483    }
484
485    // ── Metrics ────────────────────────────────────────────────────────
486
487    /// Get the total number of tracked agents.
488    pub fn tracked_agents(&self) -> usize {
489        self.failure_records.len()
490    }
491
492    /// Get the number of dead agents.
493    pub fn dead_agent_count(&self) -> usize {
494        self.all_dead_workers().len()
495    }
496
497    /// Get the number of agents with open circuit breakers.
498    pub fn open_circuit_count(&self) -> usize {
499        self.circuit_breakers
500            .values()
501            .filter(|cb| cb.state == HealingCircuitState::Open)
502            .count()
503    }
504}
505
506impl Default for SelfHealingEngine {
507    fn default() -> Self {
508        Self::new()
509    }
510}
511
512// ---------------------------------------------------------------------------
513// Tests
514// ---------------------------------------------------------------------------
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn test_healing_config_default() {
522        let config = HealingConfig::default();
523        assert_eq!(config.max_retries, 3);
524        assert_eq!(config.base_delay_ms, 1000);
525        assert_eq!(config.max_delay_ms, 30000);
526        assert_eq!(config.circuit_breaker_threshold, 5);
527        assert_eq!(config.circuit_breaker_recovery_secs, 30);
528    }
529
530    #[test]
531    fn test_circuit_breaker_initial_state() {
532        let mut cb = HealingCircuitBreaker::new(30, 5);
533        assert_eq!(cb.state, HealingCircuitState::Closed);
534        assert_eq!(cb.failure_count, 0);
535        assert!(cb.can_execute());
536    }
537
538    #[test]
539    fn test_circuit_breaker_opens_after_threshold() {
540        let mut cb = HealingCircuitBreaker::new(30, 3);
541        assert!(cb.can_execute());
542
543        cb.record_failure();
544        assert!(cb.can_execute());
545        assert_eq!(cb.failure_count, 1);
546
547        cb.record_failure();
548        assert!(cb.can_execute());
549        assert_eq!(cb.failure_count, 2);
550
551        cb.record_failure();
552        assert_eq!(cb.state, HealingCircuitState::Open);
553        assert!(!cb.can_execute());
554        assert_eq!(cb.failure_count, 3);
555    }
556
557    #[test]
558    fn test_circuit_breaker_success_resets() {
559        let mut cb = HealingCircuitBreaker::new(30, 3);
560        cb.record_failure();
561        cb.record_failure();
562        assert_eq!(cb.failure_count, 2);
563
564        cb.record_success();
565        assert_eq!(cb.state, HealingCircuitState::Closed);
566        assert_eq!(cb.failure_count, 0);
567    }
568
569    #[test]
570    fn test_failure_record_new() {
571        let record = FailureRecord::new("agent-1", "timeout");
572        assert_eq!(record.agent_id, "agent-1");
573        assert_eq!(record.failure_count, 1);
574        assert_eq!(record.last_error, "timeout");
575        assert!(!record.is_dead);
576    }
577
578    #[test]
579    fn test_failure_record_add_failure() {
580        let mut record = FailureRecord::new("agent-1", "timeout");
581        record.add_failure("connection refused");
582        assert_eq!(record.failure_count, 2);
583        assert_eq!(record.last_error, "connection refused");
584    }
585
586    #[test]
587    fn test_failure_record_success_resets() {
588        let mut record = FailureRecord::new("agent-1", "timeout");
589        record.add_failure("connection refused");
590        assert_eq!(record.failure_count, 2);
591
592        record.record_success();
593        assert_eq!(record.failure_count, 0);
594        assert!(!record.is_dead);
595    }
596
597    #[test]
598    fn test_self_healing_engine_new() {
599        let engine = SelfHealingEngine::new();
600        assert_eq!(engine.tracked_agents(), 0);
601        assert_eq!(engine.dead_agent_count(), 0);
602    }
603
604    #[test]
605    fn test_self_healing_engine_record_failure() {
606        let mut engine = SelfHealingEngine::new();
607        let count = engine.record_failure("agent-1", "timeout");
608        assert_eq!(count, 1);
609        assert_eq!(engine.failure_count("agent-1"), 1);
610        assert!(engine.is_healthy("agent-1"));
611    }
612
613    #[test]
614    fn test_self_healing_engine_marks_dead() {
615        let mut engine = SelfHealingEngine::new();
616        engine.config.max_failed_tasks = 3;
617
618        engine.record_failure("agent-1", "error 1");
619        engine.record_failure("agent-1", "error 2");
620        engine.record_failure("agent-1", "error 3");
621
622        assert!(!engine.is_healthy("agent-1"));
623        assert!(engine.all_dead_workers().contains(&"agent-1".to_string()));
624    }
625
626    #[test]
627    fn test_self_healing_engine_circuit_breaker() {
628        let mut engine = SelfHealingEngine::new();
629        engine.config.circuit_breaker_threshold = 3;
630
631        // 3 failures should open the circuit
632        engine.record_failure("agent-1", "error 1");
633        engine.record_failure("agent-1", "error 2");
634        engine.record_failure("agent-1", "error 3");
635
636        assert_eq!(
637            engine.circuit_state("agent-1"),
638            Some(HealingCircuitState::Open)
639        );
640        assert!(!engine.is_healthy("agent-1"));
641    }
642
643    #[test]
644    fn test_self_healing_engine_success_resets() {
645        let mut engine = SelfHealingEngine::new();
646        engine.config.circuit_breaker_threshold = 3;
647
648        engine.record_failure("agent-1", "error 1");
649        engine.record_failure("agent-1", "error 2");
650        assert_eq!(engine.failure_count("agent-1"), 2);
651
652        engine.record_success("agent-1");
653        assert_eq!(engine.failure_count("agent-1"), 0);
654        assert_eq!(
655            engine.circuit_state("agent-1"),
656            Some(HealingCircuitState::Closed)
657        );
658    }
659
660    #[test]
661    fn test_delay_for_attempt() {
662        let engine = SelfHealingEngine::new();
663        // Attempt 0: base * 2^0 = 1000ms + jitter
664        let d0 = engine.delay_for_attempt(0);
665        assert!(d0.as_millis() >= 700); // 1000 - 30% jitter
666        assert!(d0.as_millis() <= 1300); // 1000 + 30% jitter
667
668        // Attempt 1: base * 2^1 = 2000ms + jitter
669        let d1 = engine.delay_for_attempt(1);
670        assert!(d1.as_millis() >= 1400);
671        assert!(d1.as_millis() <= 2600);
672
673        // Attempt 4: base * 2^4 = 16000ms, capped at 30000ms
674        let d4 = engine.delay_for_attempt(4);
675        assert!(d4.as_millis() <= 30000 + 9000); // max + jitter
676    }
677
678    #[test]
679    fn test_reset() {
680        let mut engine = SelfHealingEngine::new();
681        engine.record_failure("agent-1", "error");
682        engine.record_failure("agent-2", "error");
683        assert_eq!(engine.tracked_agents(), 2);
684
685        engine.reset();
686        assert_eq!(engine.tracked_agents(), 0);
687    }
688
689    #[test]
690    fn test_reset_agent() {
691        let mut engine = SelfHealingEngine::new();
692        engine.record_failure("agent-1", "error");
693        engine.record_failure("agent-2", "error");
694        assert_eq!(engine.tracked_agents(), 2);
695
696        engine.reset_agent("agent-1");
697        assert_eq!(engine.tracked_agents(), 1);
698        assert_eq!(engine.failure_count("agent-1"), 0);
699    }
700
701    #[test]
702    fn test_dead_workers_empty_initially() {
703        let engine = SelfHealingEngine::new();
704        assert!(engine.dead_workers().is_empty());
705        assert!(engine.all_dead_workers().is_empty());
706    }
707
708    #[test]
709    fn test_open_circuit_count() {
710        let mut engine = SelfHealingEngine::new();
711        engine.config.circuit_breaker_threshold = 2;
712
713        engine.record_failure("agent-1", "error");
714        engine.record_failure("agent-1", "error");
715        engine.record_failure("agent-2", "error");
716        engine.record_failure("agent-2", "error");
717
718        assert_eq!(engine.open_circuit_count(), 2);
719    }
720
721    #[tokio::test]
722    async fn test_retry_with_backoff_succeeds_on_first_attempt() {
723        let engine = SelfHealingEngine::new();
724        let result = engine
725            .retry_with_backoff("agent-1", |_| true, |_| Ok::<_, String>(42))
726            .await;
727        assert_eq!(result, Ok(42));
728    }
729
730    #[tokio::test]
731    async fn test_retry_with_backoff_succeeds_after_retries() {
732        let engine = SelfHealingEngine::new();
733        let attempt = std::sync::atomic::AtomicU32::new(0);
734
735        let result = engine
736            .retry_with_backoff(
737                "agent-1",
738                |_| true,
739                |_| {
740                    let prev = attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
741                    if prev < 2 {
742                        Err("transient error".to_string())
743                    } else {
744                        Ok(42)
745                    }
746                },
747            )
748            .await;
749        assert_eq!(result, Ok(42));
750    }
751
752    #[tokio::test]
753    async fn test_retry_with_backoff_fails_on_non_transient() {
754        let engine = SelfHealingEngine::new();
755        let result: Result<i32, String> = engine
756            .retry_with_backoff(
757                "agent-1",
758                |e| !e.contains("non-transient"),
759                |_| Err("non-transient error".to_string()),
760            )
761            .await;
762        assert!(result.is_err());
763        assert!(result.unwrap_err().contains("non-transient"));
764    }
765
766    #[tokio::test]
767    async fn test_retry_with_backoff_exhausts_retries() {
768        let mut engine = SelfHealingEngine::new();
769        engine.config.max_retries = 2; // 3 total attempts (0, 1, 2)
770
771        let result: Result<i32, String> = engine
772            .retry_with_backoff(
773                "agent-1",
774                |_| true,
775                |_| Err("persistent transient error".to_string()),
776            )
777            .await;
778        assert!(result.is_err());
779        let err = result.unwrap_err();
780        assert!(err.contains("All 3 attempts failed"));
781        assert!(err.contains("persistent transient error"));
782    }
783}