Skip to main content

loopctl/tool/
health.rs

1//! Tool health monitoring, circuit breakers, and self-healing routing.
2//!
3//! Per-tool health tracking using lock-free atomic counters,
4//! circuit-breaker state machines to prevent repeated calls to failing tools,
5//! and a registry that combines both into a unified health picture. When a
6//! registry is configured on a [`BareLoop`](crate::engine::BareLoop), the
7//! engine consults it before every dispatch: a tool whose breaker is open is
8//! refused with a soft error the model can see and adapt to, and recovery
9//! (half-open probing, cooldown expiry) is automatic — no middleware
10//! required. Hosts wanting redirection *instead of* refusal can install
11//! their own routing middleware reading the same registry.
12//!
13//! # Quick Start
14//!
15//! ```
16//! use loopctl::tool::health::{ToolHealthRegistry, HealthStatus};
17//! use std::time::Duration;
18//!
19//! let registry = ToolHealthRegistry::new();
20//!
21//! // Record outcomes
22//! registry.record_success("bash", Duration::from_millis(150));
23//! registry.record_success("bash", Duration::from_millis(200));
24//! registry.record_failure("bash", Duration::from_secs(5));
25//!
26//! // Check health — mostly-successful tool stays at Degraded or better
27//! let status = registry.get_health_status("bash");
28//! assert!(status == HealthStatus::Healthy || status == HealthStatus::Degraded);
29//! assert!(registry.is_tool_available("bash"));
30//!
31//! // Get a snapshot for observability
32//! let summary = registry.health_summary();
33//! assert!(summary.contains_key("bash"));
34//! ```
35//!
36//! # Architecture
37//!
38//! The module has four layers:
39//!
40//! 1. **[`ToolStats`]** — Lock-free atomic counters for per-tool call counts,
41//!    success/failure rates, and latency tracking. Updated on every tool call
42//!    without blocking.
43//!
44//! 2. **[`ToolCircuitBreaker`]** — A three-state machine (`Closed` → `Open` → `HalfOpen`)
45//!    per tool. After `failure_threshold` consecutive failures the breaker opens,
46//!    blocking further calls until `recovery_duration` elapses. Then a single
47//!    "probe" call is allowed (`HalfOpen`). If it succeeds the breaker closes;
48//!    if it fails the breaker reopens.
49//!
50//! 3. **[`ToolHealthRegistry`]** — A concrete struct combining per-tool stats and
51//!    circuit breakers. Every agent uses the same health tracking mechanics,
52//!    so a trait boundary would add complexity without value.
53//!
54//! 4. **[`HealthRouter`]** — Inspects tool health before dispatch and
55//!    routes calls to healthy alternatives when the primary tool is degraded or
56//!    unhealthy.
57//!
58//! # Thread Safety
59//!
60//! All hot-path operations (recording success/failure, checking health) use
61//! lock-free atomics. The only `Mutex` is in the registry's name → stats/breaker
62//! maps, which are locked only when a new tool name is first seen (cold path).
63
64use std::collections::HashMap;
65use std::fmt;
66use std::sync::atomic::{AtomicU64, Ordering};
67use std::sync::{Arc, Mutex};
68use std::time::{Duration, Instant};
69
70/// Classified health status for a tool.
71///
72/// Determined by combining the tool's composite [`ToolStats::health_score`]
73/// with the [`ToolCircuitBreaker`] state. Used by [`HealthRouter`]
74/// to decide whether to route calls to an alternative.
75///
76/// # Classification Thresholds
77///
78/// | Score range | Circuit breaker | Status |
79/// |-------------|-----------------|--------|
80/// | ≥ 0.8 | Closed | [`Healthy`](HealthStatus::Healthy) |
81/// | 0.5–0.8 | Closed | [`Degraded`](HealthStatus::Degraded) |
82/// | < 0.5 | Any | [`Unhealthy`](HealthStatus::Unhealthy) |
83/// | Any | Open | [`Unhealthy`](HealthStatus::Unhealthy) |
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum HealthStatus {
86    /// Tool is operating normally.
87    ///
88    /// Assigned when the composite health score is ≥ 0.8 and the
89    /// circuit breaker is `Closed`. The router routes calls to this
90    /// tool without hesitation.
91    Healthy,
92
93    /// Tool is experiencing elevated errors or latency.
94    ///
95    /// Assigned when the health score is in the 0.5–0.8 band. The tool
96    /// is still called (the breaker has not tripped), but the router
97    /// may prefer a healthy alternative when one is available.
98    Degraded,
99
100    /// Tool is failing frequently or its circuit breaker is open.
101    ///
102    /// Assigned when the health score drops below 0.5, or whenever the
103    /// breaker is `Open` regardless of score. The router treats the
104    /// tool as unavailable and redirects to a fallback.
105    Unhealthy,
106}
107
108impl fmt::Display for HealthStatus {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            Self::Healthy => write!(f, "healthy"),
112            Self::Degraded => write!(f, "degraded"),
113            Self::Unhealthy => write!(f, "unhealthy"),
114        }
115    }
116}
117
118/// Fixed-point scale for the EWMA success rate.
119///
120/// Stored as `u64` where `1.0` = `1_000_000`. This avoids floating-point
121/// atomics (which don't exist in `std`) while maintaining sufficient
122/// precision for health scoring.
123const EWMA_SCALE: u64 = 1_000_000;
124
125/// Per-tool statistics using lock-free atomic counters.
126///
127/// All counters are `AtomicU64` so that recording success/failure never
128/// blocks. The [`health_score`](Self::health_score) computation reads all
129/// counters once; slight skew between reads is acceptable because the score
130/// is used for routing hints, not for exact correctness.
131///
132/// # Exponentially-Weighted Moving Average (EWMA)
133///
134/// The `ewma_success` counter tracks recent success rate with a decay
135/// factor of 0.7. On every call:
136///
137/// ```text
138/// ewma = 0.7 * ewma + 0.3 * (success ? 1.0 : 0.0)
139/// ```
140///
141/// This makes the health score respond quickly to degradation while
142/// retaining long-term history.
143///
144/// # Example
145///
146/// ```
147/// use loopctl::tool::health::ToolStats;
148/// use std::time::Duration;
149///
150/// let stats = ToolStats::new();
151///
152/// stats.record_success(Duration::from_millis(100));
153/// stats.record_success(Duration::from_millis(200));
154/// stats.record_failure(Duration::from_millis(5000));
155///
156/// assert_eq!(stats.total_calls(), 3);
157/// assert_eq!(stats.success_count(), 2);
158/// assert_eq!(stats.failure_count(), 1);
159/// assert!(stats.success_rate() > 0.0);
160/// assert!(stats.health_score() > 0.0);
161/// ```
162pub struct ToolStats {
163    /// Total number of calls recorded (successes + failures).
164    ///
165    /// Incremented once per `record_success` / `record_failure` call;
166    /// the denominator for [`success_rate`](Self::success_rate).
167    total_calls: AtomicU64,
168
169    /// Number of calls that completed successfully.
170    ///
171    /// Incremented by [`record_success`](Self::record_success); paired
172    /// with `total_calls` to compute the all-time success rate.
173    success_count: AtomicU64,
174
175    /// Number of calls that failed.
176    ///
177    /// Incremented by [`record_failure`](Self::record_failure). Kept
178    /// separately from `success_count` so both rates are available
179    /// without re-deriving from the total.
180    failure_count: AtomicU64,
181
182    /// Sum of per-call durations in nanoseconds, across all calls.
183    ///
184    /// Accumulated by both record methods; divided by `total_calls` in
185    /// [`avg_duration`](Self::avg_duration). Saturates at `u64::MAX` on
186    /// overflow rather than wrapping.
187    total_duration_ns: AtomicU64,
188
189    /// High-water mark for the longest single-call duration, in
190    /// nanoseconds.
191    ///
192    /// Updated via `fetch_max` so it only ever grows; exposed by
193    /// [`max_duration`](Self::max_duration). Useful for spotting
194    /// tail-latency outliers.
195    max_duration_ns: AtomicU64,
196
197    /// Exponentially-weighted moving average of success, in fixed-point.
198    ///
199    /// Stored as `u64` on a `1.0 = EWMA_SCALE` scale so it can be
200    /// updated atomically without floating-point atomics. Decays with a
201    /// 0.7 factor on every call, making the health score responsive to
202    /// recent degradation.
203    ewma_success: AtomicU64,
204}
205
206impl Default for ToolStats {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212impl ToolStats {
213    /// Create a new stats instance with all counters at zero.
214    ///
215    /// The EWMA starts at 1.0 (healthy) so that new tools are not
216    /// penalized before they have any call history.
217    #[must_use]
218    pub fn new() -> Self {
219        Self {
220            total_calls: AtomicU64::new(0),
221            success_count: AtomicU64::new(0),
222            failure_count: AtomicU64::new(0),
223            total_duration_ns: AtomicU64::new(0),
224            max_duration_ns: AtomicU64::new(0),
225            ewma_success: AtomicU64::new(EWMA_SCALE),
226        }
227    }
228
229    /// Record a successful tool execution.
230    ///
231    /// Increments the total and success counters, accumulates the
232    /// duration, updates the max-duration high-water mark, and pushes
233    /// the EWMA toward 1.0.
234    pub fn record_success(&self, duration: Duration) {
235        self.total_calls.fetch_add(1, Ordering::Relaxed);
236        self.success_count.fetch_add(1, Ordering::Relaxed);
237        let ns = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX);
238        saturating_add(&self.total_duration_ns, ns);
239        self.max_duration_ns.fetch_max(ns, Ordering::Relaxed);
240        self.ewma_success
241            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |prev| {
242                Some(update_ewma(prev, true))
243            })
244            .ok();
245    }
246
247    /// Record a failed tool execution.
248    ///
249    /// Increments the total and failure counters, accumulates the
250    /// duration, updates the max-duration high-water mark (a timed-out
251    /// call is a tail-latency outlier too), and pushes the EWMA toward
252    /// 0.0.
253    pub fn record_failure(&self, duration: Duration) {
254        self.total_calls.fetch_add(1, Ordering::Relaxed);
255        self.failure_count.fetch_add(1, Ordering::Relaxed);
256        let ns = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX);
257        saturating_add(&self.total_duration_ns, ns);
258        self.max_duration_ns.fetch_max(ns, Ordering::Relaxed);
259        self.ewma_success
260            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |prev| {
261                Some(update_ewma(prev, false))
262            })
263            .ok();
264    }
265
266    /// Total number of calls recorded (successes + failures).
267    ///
268    /// Lock-free relaxed load of the counter incremented on every
269    /// record call. Use as the denominator when computing custom rates.
270    #[must_use]
271    pub fn total_calls(&self) -> u64 {
272        self.total_calls.load(Ordering::Relaxed)
273    }
274
275    /// Number of calls that completed successfully.
276    ///
277    /// Lock-free relaxed load. Pair with [`total_calls`](Self::total_calls)
278    /// to derive the all-time success rate, or use
279    /// [`success_rate`](Self::success_rate) directly.
280    #[must_use]
281    pub fn success_count(&self) -> u64 {
282        self.success_count.load(Ordering::Relaxed)
283    }
284
285    /// Number of calls that failed.
286    ///
287    /// Lock-free relaxed load. Pair with [`total_calls`](Self::total_calls)
288    /// to derive the all-time failure rate.
289    #[must_use]
290    pub fn failure_count(&self) -> u64 {
291        self.failure_count.load(Ordering::Relaxed)
292    }
293
294    /// All-time success rate (0.0–1.0).
295    ///
296    /// Returns 1.0 when no calls have been recorded (new tools start
297    /// optimistic).
298    ///
299    /// The ratio is computed as `(successes * EWMA_SCALE) / total / EWMA_SCALE`
300    /// rather than `successes / total` directly so the intermediate result
301    /// keeps six digits of integer precision before the final narrowing to
302    /// `f64` — small success rates over very large call counts would otherwise
303    /// floor to zero in pure integer arithmetic.
304    #[must_use]
305    pub fn success_rate(&self) -> f64 {
306        let total = self.total_calls.load(Ordering::Relaxed);
307        if total == 0 {
308            return 1.0;
309        }
310        let successes = self.success_count.load(Ordering::Relaxed);
311        let rate = successes
312            .saturating_mul(EWMA_SCALE)
313            .checked_div(total)
314            .unwrap_or(0);
315        crate::numeric::unit_ratio(rate, EWMA_SCALE)
316    }
317
318    /// Composite health score (0.0–1.0) blending success rate with EWMA.
319    ///
320    /// The score weights the EWMA at 70% and the all-time success rate
321    /// at 30% so that recent failures have a larger impact than ancient
322    /// successes:
323    ///
324    /// ```text
325    /// health_score = 0.3 * success_rate + 0.7 * ewma_success
326    /// ```
327    #[must_use]
328    pub fn health_score(&self) -> f64 {
329        let v = self.ewma_success.load(Ordering::Relaxed).min(EWMA_SCALE);
330        let ewma = crate::numeric::unit_ratio(v, EWMA_SCALE);
331        0.3 * self.success_rate() + 0.7 * ewma
332    }
333
334    /// Average call duration across all recorded calls.
335    ///
336    /// Returns [`Duration::ZERO`] when no calls have been recorded.
337    #[must_use]
338    pub fn avg_duration(&self) -> Duration {
339        let total = self.total_calls.load(Ordering::Relaxed);
340        if total == 0 {
341            return Duration::ZERO;
342        }
343        let total_ns = self.total_duration_ns.load(Ordering::Relaxed);
344        // total is guaranteed > 0 (checked above), so checked_div always returns Some.
345        let avg_ns = u128::from(total_ns)
346            .checked_div(u128::from(total))
347            .unwrap_or(0);
348        Duration::from_nanos(u64::try_from(avg_ns).unwrap_or(u64::MAX))
349    }
350
351    /// Maximum single-call duration recorded so far.
352    ///
353    /// Returns [`Duration::ZERO`] when no calls have been recorded.
354    #[must_use]
355    pub fn max_duration(&self) -> Duration {
356        Duration::from_nanos(self.max_duration_ns.load(Ordering::Relaxed))
357    }
358}
359
360/// Update the EWMA in fixed-point representation.
361///
362/// - `prev` is the current EWMA value in `[0, EWMA_SCALE]`.
363/// - `is_success` determines whether the new sample is 1.0 or 0.0.
364/// - Returns the updated EWMA clamped to `[0, EWMA_SCALE]`.
365fn update_ewma(prev: u64, is_success: bool) -> u64 {
366    let sample = u64::from(is_success).saturating_mul(EWMA_SCALE);
367    // 0.7 * prev + 0.3 * sample
368    let next = (prev
369        .saturating_mul(7)
370        .saturating_add(sample.saturating_mul(3)))
371        / 10;
372    next.min(EWMA_SCALE)
373}
374
375/// Add `value` to `cell`, saturating at `u64::MAX` instead of wrapping.
376fn saturating_add(cell: &AtomicU64, value: u64) {
377    cell.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |prev| {
378        prev.checked_add(value).or(Some(u64::MAX))
379    })
380    .ok();
381}
382
383/// Circuit breaker state.
384///
385/// The breaker follows the standard three-state pattern. Starting from
386/// `Closed`, `failure_threshold` consecutive failures transition it to
387/// `Open`, where requests are blocked. Once `recovery_duration`
388/// elapses the next `allow_request` moves it to `HalfOpen` and lets a
389/// single probe call through. A successful probe closes the breaker; a
390/// failed probe reopens it.
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392#[repr(u32)]
393enum CircuitState {
394    /// Normal operation — requests are allowed.
395    ///
396    /// The resting state. Consecutive failures are counted toward
397    /// `failure_threshold`; reaching it transitions to [`Open`](Self::Open).
398    Closed = 0,
399
400    /// Too many failures — requests are blocked.
401    ///
402    /// Entered after `failure_threshold` consecutive failures (or when
403    /// a [`HalfOpen`](Self::HalfOpen) probe fails). Requests are
404    /// refused until `recovery_duration` elapses, after which the next
405    /// `allow_request` transitions to `HalfOpen`.
406    Open = 1,
407
408    /// Recovery probe — one request is allowed to test recovery.
409    ///
410    /// A single probe call runs; a success closes the breaker, a
411    /// failure reopens it. Additional probes are refused to avoid a
412    /// thundering herd.
413    HalfOpen = 2,
414}
415
416impl From<u32> for CircuitState {
417    fn from(value: u32) -> Self {
418        match value {
419            0 => Self::Closed,
420            1 => Self::Open,
421            _ => Self::HalfOpen,
422        }
423    }
424}
425
426impl fmt::Display for CircuitState {
427    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        match self {
429            Self::Closed => write!(f, "closed"),
430            Self::Open => write!(f, "open"),
431            Self::HalfOpen => write!(f, "half-open"),
432        }
433    }
434}
435
436/// Configuration for a [`ToolCircuitBreaker`].
437///
438/// Controls how many consecutive failures trigger the breaker and how
439/// long to wait before allowing a recovery probe.
440#[derive(Debug, Clone)]
441pub struct CircuitBreakerConfig {
442    /// Number of consecutive failures before the breaker opens.
443    ///
444    /// Defaults to 3. `0` disables tripping — the crate-wide "zero
445    /// disables" sentinel — so a breaker configured with `0` records
446    /// failures and telemetry but never opens.
447    pub failure_threshold: u64,
448
449    /// How long to wait in the `Open` state before transitioning to `HalfOpen`.
450    ///
451    /// Defaults to 30 seconds.
452    pub recovery_duration: Duration,
453
454    /// How long the single `HalfOpen` probe may stay in flight before the
455    /// breaker gives up on it.
456    ///
457    /// A probe whose result never arrives (the dispatch was cancelled or
458    /// the task died mid-flight) would otherwise strand the breaker in
459    /// `HalfOpen` forever — no probe result is ever recorded, so nothing
460    /// closes or reopens it. After the timeout the breaker returns to
461    /// `Open` with the cooldown anchored to the lease's end (backdated,
462    /// so a late-noticed expiry does not extend the wait), and the next
463    /// request past that cooldown probes again. Defaults to 30 seconds;
464    /// `0` disables the lease.
465    pub probe_timeout: Duration,
466}
467
468impl Default for CircuitBreakerConfig {
469    fn default() -> Self {
470        Self {
471            failure_threshold: 3,
472            recovery_duration: Duration::from_secs(30),
473            probe_timeout: Duration::from_secs(30),
474        }
475    }
476}
477
478/// Per-tool circuit breaker.
479///
480/// When a tool fails `failure_threshold` times consecutively the breaker
481/// opens. After `recovery_duration` it transitions to `HalfOpen` and allows
482/// one probe call. If the probe succeeds the breaker closes; if it fails
483/// the breaker reopens.
484///
485/// All mutable state is behind a single `Mutex`, making every method's
486/// read-modify-write atomic with respect to every other method — no
487/// window for a counter/state race between concurrent `record_success`
488/// and `record_failure` calls.
489///
490/// # Example
491///
492/// ```
493/// use loopctl::tool::health::ToolCircuitBreaker;
494/// use std::time::Duration;
495///
496/// let breaker = ToolCircuitBreaker::new(Duration::from_millis(100), 2);
497///
498/// // Initially closed — requests are allowed
499/// assert!(breaker.allow_request());
500///
501/// // Record failures until it opens
502/// breaker.record_failure();
503/// assert!(breaker.allow_request()); // 1 failure < threshold 2
504///
505/// breaker.record_failure();
506/// // Now open — requests blocked
507/// assert!(!breaker.allow_request());
508/// ```
509pub struct ToolCircuitBreaker {
510    /// Number of consecutive failures that trips the breaker.
511    ///
512    /// Compared against [`consecutive_failures`](BreakerState::consecutive_failures)
513    /// on each `record_failure` call; reaching this value while in
514    /// `Closed` transitions to `Open`. Set at construction and immutable
515    /// thereafter.
516    failure_threshold: u64,
517
518    /// How long the breaker stays `Open` before allowing a `HalfOpen` probe.
519    ///
520    /// Compared against the elapsed time since the last failure in
521    /// [`allow_request`](Self::allow_request). Set at construction and
522    /// immutable thereafter.
523    recovery_duration: Duration,
524
525    /// How long a `HalfOpen` probe may stay in flight before the breaker
526    /// re-arms recovery (see [`CircuitBreakerConfig::probe_timeout`]).
527    ///
528    /// Compared against the elapsed time since the probe was granted in
529    /// [`allow_request`](Self::allow_request). Set at construction and
530    /// immutable thereafter.
531    probe_timeout: Duration,
532
533    /// All mutable state, behind a single lock.
534    ///
535    /// Holding the counter, state, and last-failure timestamp together
536    /// prevents the TOCTOU race where a concurrent `record_success`
537    /// could reset the counter while a `record_failure` is mid-transition,
538    /// or vice versa.
539    state: Mutex<BreakerState>,
540}
541
542/// The complete mutable state of a [`ToolCircuitBreaker`].
543///
544/// Lives behind a single [`Mutex`] on the breaker so that every method's
545/// read-modify-write is atomic. All three fields are always observed
546/// together — no partial-state reads.
547struct BreakerState {
548    /// Current circuit-breaker phase.
549    ///
550    /// `Closed` at construction; transitions through `Open` (failures
551    /// exceeded threshold) and `HalfOpen` (recovery probe in flight) as
552    /// failures and successes are recorded.
553    circuit: CircuitState,
554
555    /// Consecutive failures since the last success.
556    ///
557    /// Reset to zero by success; reaching the threshold while in
558    /// `Closed` transitions to `Open`.
559    consecutive_failures: u64,
560
561    /// When the current `HalfOpen` probe was granted, or `None`.
562    ///
563    /// Set when [`allow_request`](ToolCircuitBreaker::allow_request)
564    /// transitions `Open`→`HalfOpen`; cleared by the probe's
565    /// `record_success`/`record_failure`. When the probe outlives
566    /// [`probe_timeout`](ToolCircuitBreaker::probe_timeout) without a
567    /// record, the next `allow_request` re-arms recovery instead of
568    /// stranding the breaker.
569    probe_started_at: Option<Instant>,
570
571    /// When the most recent failure occurred, or `None` if none yet.
572    ///
573    /// Compared against `recovery_duration` in `allow_request` to decide
574    /// whether enough time has passed to allow a `HalfOpen` probe.
575    last_failure_time: Option<Instant>,
576}
577
578impl Default for BreakerState {
579    fn default() -> Self {
580        Self {
581            circuit: CircuitState::Closed,
582            consecutive_failures: 0,
583            last_failure_time: None,
584            probe_started_at: None,
585        }
586    }
587}
588
589/// Whether the in-flight `HalfOpen` probe has outlived its timeout.
590///
591/// A zero timeout disables the lease (the crate's zero-disables
592/// sentinel): the probe never expires, and a slow probe's success still
593/// closes the breaker.
594///
595/// A probe whose result never arrives (cancelled dispatch, dead task)
596/// is stranded: no `record_*` will ever run for it, so the expiry check
597/// is what lets the breaker move on — re-arming the `Open` cooldown on
598/// the next `allow_request`, and refusing a late *success* that arrives
599/// after expiry (it describes a world the breaker has already left
600/// behind; a late *failure* is fresh bad news and is accepted, its
601/// cooldown starting when observed).
602fn probe_expired(state: &BreakerState, probe_timeout: Duration) -> bool {
603    if probe_timeout.is_zero() {
604        return false;
605    }
606    state
607        .probe_started_at
608        .is_some_and(|started| started.elapsed() >= probe_timeout)
609}
610
611/// The instant an in-flight `HalfOpen` probe's lease ends, if one is in flight.
612///
613/// The re-arm clock anchors here — not to the moment the expiry was
614/// noticed — so a real [`allow_request`](ToolCircuitBreaker::allow_request)
615/// and the pure availability reads agree on when the next probe becomes
616/// grantable, no matter when each first observes the expiry.
617fn probe_expires_at(state: &BreakerState, probe_timeout: Duration) -> Option<std::time::Instant> {
618    state
619        .probe_started_at
620        .and_then(|started| started.checked_add(probe_timeout))
621}
622
623/// Grant the `HalfOpen` probe slot if the `Open` cooldown has elapsed.
624///
625/// The single grant transition: `Open`→`HalfOpen` with the lease clock
626/// started, returning `true` to the caller that won the slot. Shared by
627/// the plain recovery path and the expired-probe re-arm so both grant
628/// identically.
629fn grant_probe(state: &mut BreakerState, recovery_duration: Duration) -> bool {
630    let recovered = state
631        .last_failure_time
632        .is_some_and(|t| t.elapsed() >= recovery_duration);
633    if recovered {
634        state.circuit = CircuitState::HalfOpen;
635        state.probe_started_at = Some(Instant::now());
636        true
637    } else {
638        false
639    }
640}
641
642impl ToolCircuitBreaker {
643    /// Create a new circuit breaker with the given recovery duration and
644    /// failure threshold.
645    ///
646    /// The breaker starts in the Closed state.
647    #[must_use]
648    pub fn new(recovery_duration: Duration, failure_threshold: u64) -> Self {
649        Self {
650            failure_threshold,
651            recovery_duration,
652            probe_timeout: recovery_duration,
653            state: Mutex::new(BreakerState::default()),
654        }
655    }
656
657    /// Override the `HalfOpen` probe timeout (builder style).
658    ///
659    /// [`new`](Self::new) defaults the probe timeout to the recovery
660    /// duration; use this when a probe should be given more (or less)
661    /// time than a full recovery window before the breaker re-arms.
662    #[must_use]
663    pub fn with_probe_timeout(mut self, probe_timeout: Duration) -> Self {
664        self.probe_timeout = probe_timeout;
665        self
666    }
667
668    /// Create a circuit breaker from a [`CircuitBreakerConfig`].
669    ///
670    /// Convenience constructor that unpacks the threshold and recovery
671    /// duration from a config struct, delegating to [`new`](Self::new).
672    /// Useful when many breakers share a single config.
673    #[must_use]
674    pub fn from_config(config: &CircuitBreakerConfig) -> Self {
675        Self {
676            failure_threshold: config.failure_threshold,
677            recovery_duration: config.recovery_duration,
678            probe_timeout: config.probe_timeout,
679            state: Mutex::new(BreakerState::default()),
680        }
681    }
682
683    /// Whether a request is allowed to proceed.
684    ///
685    /// - **Closed**: always allowed.
686    /// - **Open**: allowed only if `recovery_duration` has elapsed since
687    ///   the last failure, in which case the breaker transitions to
688    ///   `HalfOpen` and the caller becomes the sole probe.
689    /// - **`HalfOpen`**: already probing — no additional probes allowed
690    ///   (returns `false` to prevent thundering-herd). An *expired* probe
691    ///   is normalized to `Open` first (cooldown anchored to the lease's
692    ///   end), so a call arriving after that cooldown has also elapsed
693    ///   grants a fresh probe in this same call — the pure
694    ///   [`would_allow_request`](Self::would_allow_request) oracle and
695    ///   this method agree at every instant.
696    ///
697    /// The single-flight guarantee is bounded by `probe_timeout`: a probe
698    /// stalled past its lease admits a replacement only after the lease
699    /// end plus the recovery cooldown, so a call executing longer than
700    /// `probe_timeout + recovery_duration` can overlap its replacement —
701    /// recovery liveness beats strict single-flight for a wedged probe.
702    #[must_use]
703    pub fn allow_request(&self) -> bool {
704        let mut state = crate::error::recover_guard(self.state.lock());
705        match state.circuit {
706            CircuitState::Closed => true,
707            CircuitState::HalfOpen if probe_expired(&state, self.probe_timeout) => {
708                state.circuit = CircuitState::Open;
709                state.last_failure_time =
710                    probe_expires_at(&state, self.probe_timeout).or_else(|| Some(Instant::now()));
711                state.probe_started_at = None;
712                grant_probe(&mut state, self.recovery_duration)
713            }
714            CircuitState::HalfOpen => false,
715            CircuitState::Open => grant_probe(&mut state, self.recovery_duration),
716        }
717    }
718
719    /// Whether a request *would* be allowed, without the `Open`→`HalfOpen` side effect.
720    ///
721    /// Pure read mirroring [`allow_request`](Self::allow_request)'s decision
722    /// at the same instant: `true` for `Closed`; for `Open`, `true` once the
723    /// recovery duration has elapsed; for `HalfOpen`, `false` while the
724    /// probe's lease is live and `true` once the lease has expired *and* the
725    /// expiry-anchored cooldown has elapsed (the state an expired probe
726    /// occupies is exactly an `Open` cooldown — the next
727    /// [`allow_request`](Self::allow_request) grants). Crucially, this
728    /// performs **no** state transition — use it for availability checks
729    /// ([`is_tool_available`](ToolHealthRegistry::is_tool_available)) so a
730    /// read does not consume the single `HalfOpen` probe slot that belongs
731    /// to the real dispatch path.
732    #[must_use]
733    pub fn would_allow_request(&self) -> bool {
734        let state = crate::error::recover_guard(self.state.lock());
735        match state.circuit {
736            CircuitState::Closed => true,
737            CircuitState::HalfOpen => {
738                probe_expired(&state, self.probe_timeout)
739                    && probe_expires_at(&state, self.probe_timeout).is_some_and(|expired_at| {
740                        expired_at
741                            .checked_add(self.recovery_duration)
742                            .is_some_and(|available_at| available_at <= Instant::now())
743                    })
744            }
745            CircuitState::Open => state
746                .last_failure_time
747                .is_some_and(|t| t.elapsed() >= self.recovery_duration),
748        }
749    }
750
751    /// Whether the next [`allow_request`](Self::allow_request) call would
752    /// transition an `Open` breaker into `HalfOpen`.
753    ///
754    /// Pure read: `true` only when the next
755    /// [`allow_request`](Self::allow_request) would perform the
756    /// `Open`→`HalfOpen` transition and grant the probe slot — an `Open`
757    /// breaker past its cooldown, or a `HalfOpen` breaker whose probe lease
758    /// expired and whose expiry-anchored cooldown has elapsed (the next
759    /// call re-arms and grants in one step). `false` while a probe's lease
760    /// is live (the next call refuses to avoid a thundering herd) and for
761    /// `Closed`.
762    /// Complements [`would_allow_request`](Self::would_allow_request).
763    #[must_use]
764    pub fn would_be_half_open(&self) -> bool {
765        let state = crate::error::recover_guard(self.state.lock());
766        match state.circuit {
767            CircuitState::Open => state
768                .last_failure_time
769                .is_some_and(|t| t.elapsed() >= self.recovery_duration),
770            CircuitState::HalfOpen => {
771                probe_expired(&state, self.probe_timeout)
772                    && probe_expires_at(&state, self.probe_timeout).is_some_and(|expired_at| {
773                        expired_at
774                            .checked_add(self.recovery_duration)
775                            .is_some_and(|available_at| available_at <= Instant::now())
776                    })
777            }
778            CircuitState::Closed => false,
779        }
780    }
781
782    /// Record a successful call.
783    ///
784    /// Resets consecutive failures to zero and transitions the breaker to
785    /// Closed — except in two cases. A `HalfOpen` probe whose lease has
786    /// expired describes a probe the breaker has already abandoned, so it
787    /// re-arms recovery (cooldown anchored to the lease's end) instead of
788    /// closing. And a success observed while `Open` is ignored entirely:
789    /// the only calls that can complete in `Open` were admitted before
790    /// the trip, so their success is stale evidence — recovery is decided
791    /// by a probe, not by a pre-trip straggler.
792    pub fn record_success(&self) {
793        let mut state = crate::error::recover_guard(self.state.lock());
794        if matches!(state.circuit, CircuitState::HalfOpen)
795            && probe_expired(&state, self.probe_timeout)
796        {
797            state.circuit = CircuitState::Open;
798            state.last_failure_time =
799                probe_expires_at(&state, self.probe_timeout).or_else(|| Some(Instant::now()));
800            state.probe_started_at = None;
801            return;
802        }
803        if matches!(state.circuit, CircuitState::Open) {
804            return;
805        }
806        state.consecutive_failures = 0;
807        state.probe_started_at = None;
808        state.circuit = CircuitState::Closed;
809    }
810
811    /// Record a failed call.
812    ///
813    /// Increments the consecutive-failure counter. If the count reaches
814    /// `failure_threshold`, the breaker transitions to Open. In the
815    /// `HalfOpen` state, a single failure reopens the breaker.
816    pub fn record_failure(&self) {
817        let mut state = crate::error::recover_guard(self.state.lock());
818        state.consecutive_failures = state.consecutive_failures.saturating_add(1);
819        state.last_failure_time = Some(Instant::now());
820        state.probe_started_at = None;
821        match state.circuit {
822            CircuitState::Closed => {
823                if self.failure_threshold > 0
824                    && state.consecutive_failures >= self.failure_threshold
825                {
826                    state.circuit = CircuitState::Open;
827                }
828            }
829            CircuitState::HalfOpen => {
830                state.circuit = CircuitState::Open;
831            }
832            CircuitState::Open => {}
833        }
834    }
835
836    /// Current state of the breaker as a human-readable string.
837    ///
838    /// Returns `"closed"`, `"open"`, or `"half-open"`. Intended for
839    /// logs and metrics where a string label is preferable to the
840    /// numeric encoding.
841    #[must_use]
842    pub fn state_label(&self) -> &'static str {
843        let state = crate::error::recover_guard(self.state.lock());
844        match state.circuit {
845            CircuitState::Closed => "closed",
846            CircuitState::Open => "open",
847            CircuitState::HalfOpen => {
848                if probe_expired(&state, self.probe_timeout) {
849                    "open"
850                } else {
851                    "half-open"
852                }
853            }
854        }
855    }
856
857    /// Number of consecutive failures recorded since the last success.
858    ///
859    /// Reset to zero on every success; reaching `failure_threshold`
860    /// trips the breaker.
861    #[must_use]
862    pub fn consecutive_failures(&self) -> u64 {
863        crate::error::recover_guard(self.state.lock()).consecutive_failures
864    }
865
866    /// Whether the breaker is currently in the Closed (healthy) state.
867    ///
868    /// `true` when requests are allowed unconditionally.
869    #[must_use]
870    pub fn is_closed(&self) -> bool {
871        crate::error::recover_guard(self.state.lock()).circuit == CircuitState::Closed
872    }
873
874    /// Whether the breaker is currently in the Open (blocking) state.
875    ///
876    /// `true` when requests are refused outright (subject to the
877    /// recovery-duration transition handled inside
878    /// [`allow_request`](Self::allow_request)).
879    #[must_use]
880    pub fn is_open(&self) -> bool {
881        let state = crate::error::recover_guard(self.state.lock());
882        state.circuit == CircuitState::Open
883            || (state.circuit == CircuitState::HalfOpen
884                && probe_expired(&state, self.probe_timeout))
885    }
886
887    /// Whether the breaker is currently in the `HalfOpen` (probing)
888    /// state.
889    ///
890    /// `true` when a single probe call is in flight and additional
891    /// probes are refused to avoid a thundering herd.
892    #[must_use]
893    pub fn is_half_open(&self) -> bool {
894        let state = crate::error::recover_guard(self.state.lock());
895        state.circuit == CircuitState::HalfOpen && !probe_expired(&state, self.probe_timeout)
896    }
897}
898
899/// Global health registry for all tools.
900///
901/// A concrete struct (not a trait) because every agent uses the same health
902/// tracking mechanics. The registry provides:
903///
904/// - Per-tool [`ToolStats`] (lock-free atomic counters)
905/// - Per-tool [`ToolCircuitBreaker`] (atomic state machine)
906/// - [`is_tool_available`](Self::is_tool_available) — quick check combining
907///   health + breaker state
908/// - [`health_summary`](Self::health_summary) — snapshot for observability
909///
910/// Uses `Mutex<HashMap>` for the tool-name → stats/breaker maps. Only
911/// the cold path — locks are only taken when a new tool name is first seen.
912/// Poisoned mutex recovery follows the pattern: `unwrap_or_else(std::sync::PoisonError::into_inner)`.
913///
914/// # Thread Safety
915///
916/// All recording methods (`record_success`, `record_failure`) are `&self`
917/// and never block on each other. The internal `Mutex` is only taken to
918/// insert a new entry for a previously-unseen tool name.
919///
920/// # Example
921///
922/// ```
923/// use loopctl::tool::health::{ToolHealthRegistry, HealthStatus};
924/// use std::time::Duration;
925///
926/// let registry = ToolHealthRegistry::new();
927///
928/// // Simulate some calls
929/// registry.record_success("grep", Duration::from_millis(50));
930/// registry.record_success("grep", Duration::from_millis(75));
931///
932/// assert!(registry.is_tool_available("grep"));
933/// assert_eq!(registry.get_health_status("grep"), HealthStatus::Healthy);
934///
935/// // Observability snapshot
936/// let summary = registry.health_summary();
937/// assert!(summary.contains_key("grep"));
938/// let (status, score) = &summary["grep"];
939/// assert_eq!(*status, HealthStatus::Healthy);
940/// assert!(*score > 0.9);
941/// ```
942pub struct ToolHealthRegistry {
943    /// Per-tool statistics, keyed by tool name.
944    ///
945    /// Lazily populated on first sighting of a tool; entries hold
946    /// `Arc<ToolStats>` so callers can read counters without holding the
947    /// lock. The `Mutex` is only acquired on the cold path of inserting
948    /// a previously-unseen tool.
949    stats: Mutex<HashMap<String, Arc<ToolStats>>>,
950
951    /// Per-tool circuit breakers, keyed by tool name.
952    ///
953    /// Lazily populated alongside `stats`; each entry is configured from
954    /// `breaker_config` at insertion time. Same cold-path locking
955    /// strategy as `stats`.
956    breakers: Mutex<HashMap<String, Arc<ToolCircuitBreaker>>>,
957
958    /// Configuration applied to every newly-created circuit breaker.
959    ///
960    /// Set at construction (default 3 failures / 30 s recovery) and
961    /// cloned into each breaker on first sight of a tool; changing it
962    /// after the fact does not retroactively update existing breakers.
963    breaker_config: CircuitBreakerConfig,
964}
965
966impl Default for ToolHealthRegistry {
967    fn default() -> Self {
968        Self::new()
969    }
970}
971
972impl ToolHealthRegistry {
973    /// Create a new empty registry with default circuit-breaker settings.
974    ///
975    /// Default: 3 consecutive failures to open, 30-second recovery duration.
976    #[must_use]
977    pub fn new() -> Self {
978        Self {
979            stats: Mutex::new(HashMap::new()),
980            breakers: Mutex::new(HashMap::new()),
981            breaker_config: CircuitBreakerConfig::default(),
982        }
983    }
984
985    /// Set custom circuit-breaker configuration (builder style).
986    ///
987    /// Overrides the default (3 failures / 30 s recovery). Applied only
988    /// to breakers created *after* this call — existing per-tool
989    /// breakers keep their original thresholds.
990    #[must_use]
991    pub fn with_config(mut self, config: CircuitBreakerConfig) -> Self {
992        self.breaker_config = config;
993        self
994    }
995
996    /// Get or create stats for a tool.
997    ///
998    /// Auto-registers on first call. Returns a cloned `Arc<ToolStats>`
999    /// so the caller can read counters without holding any lock.
1000    #[must_use]
1001    pub fn get_stats(&self, tool_name: &str) -> Arc<ToolStats> {
1002        let guard = crate::error::recover_guard(self.stats.lock());
1003        if let Some(stats) = guard.get(tool_name) {
1004            return Arc::clone(stats);
1005        }
1006        drop(guard);
1007        let mut guard = crate::error::recover_guard(self.stats.lock());
1008        Arc::clone(
1009            guard
1010                .entry(tool_name.to_string())
1011                .or_insert_with(|| Arc::new(ToolStats::new())),
1012        )
1013    }
1014
1015    /// Get or create a circuit breaker for a tool.
1016    ///
1017    /// Auto-registers on first call. The breaker is configured with the
1018    /// registry's [`CircuitBreakerConfig`].
1019    #[must_use]
1020    pub fn get_circuit_breaker(&self, tool_name: &str) -> Arc<ToolCircuitBreaker> {
1021        let guard = crate::error::recover_guard(self.breakers.lock());
1022        if let Some(cb) = guard.get(tool_name) {
1023            return Arc::clone(cb);
1024        }
1025        drop(guard);
1026        let mut guard = crate::error::recover_guard(self.breakers.lock());
1027        Arc::clone(
1028            guard
1029                .entry(tool_name.to_string())
1030                .or_insert_with(|| Arc::new(ToolCircuitBreaker::from_config(&self.breaker_config))),
1031        )
1032    }
1033
1034    /// Quick health check: is this tool available for use?
1035    ///
1036    /// Whether the breaker for `tool_name` grants this request, and to
1037    /// grant it when it can.
1038    ///
1039    /// The mutating counterpart of
1040    /// [`is_tool_available`](Self::is_tool_available): an expired
1041    /// `Open` cooldown is normalized and the single recovery probe is
1042    /// granted here (a concurrent second caller is refused for the
1043    /// probe's lifetime — single-flight), so the dispatch gate that
1044    /// calls this executes at most one probe per cooldown. Callers that
1045    /// must not consume the probe (telemetry, routing hints) use the
1046    /// pure `is_tool_available` instead.
1047    #[must_use]
1048    pub fn allow_request(&self, tool_name: &str) -> bool {
1049        self.get_circuit_breaker(tool_name).allow_request()
1050    }
1051
1052    /// Whether `tool_name` is currently available for dispatch.
1053    ///
1054    /// Combines the circuit-breaker state (`Open` = unavailable) with the
1055    /// health score (`Unhealthy` = unavailable). Returns `true` when:
1056    /// - the breaker is not `Open` and the health score is not `Unhealthy`, or
1057    /// - the breaker would treat the next call as a `HalfOpen` recovery probe
1058    ///   (available even if the health score is still `Unhealthy`).
1059    ///
1060    /// This is a **pure read** — it observes whether a request would be
1061    /// allowed without performing the `Open`→`HalfOpen` transition, so a
1062    /// bare availability check does not consume the single probe slot that
1063    /// belongs to the real dispatch path
1064    /// ([`allow_request`](Self::allow_request)).
1065    #[must_use]
1066    pub fn is_tool_available(&self, tool_name: &str) -> bool {
1067        let breaker = self.get_circuit_breaker(tool_name);
1068        if !breaker.would_allow_request() {
1069            return false;
1070        }
1071        if breaker.would_be_half_open() {
1072            return true;
1073        }
1074        self.get_health_status(tool_name) != HealthStatus::Unhealthy
1075    }
1076
1077    /// Get classified health status for a tool.
1078    ///
1079    /// Classifies based on the composite health score and circuit-breaker
1080    /// state using the thresholds documented on [`HealthStatus`].
1081    #[must_use]
1082    pub fn get_health_status(&self, tool_name: &str) -> HealthStatus {
1083        let breaker = self.get_circuit_breaker(tool_name);
1084        if breaker.is_open() {
1085            return HealthStatus::Unhealthy;
1086        }
1087        let score = self.get_stats(tool_name).health_score();
1088        if score >= 0.8 {
1089            HealthStatus::Healthy
1090        } else if score >= 0.5 {
1091            HealthStatus::Degraded
1092        } else {
1093            HealthStatus::Unhealthy
1094        }
1095    }
1096
1097    /// Record a successful tool execution.
1098    ///
1099    /// Updates both the per-tool stats (success count, latency) and the
1100    /// circuit breaker (resets consecutive failures).
1101    pub fn record_success(&self, tool_name: &str, duration: Duration) {
1102        self.get_stats(tool_name).record_success(duration);
1103        self.get_circuit_breaker(tool_name).record_success();
1104    }
1105
1106    /// Record a failed tool execution.
1107    ///
1108    /// Updates both the per-tool stats (failure count, latency) and the
1109    /// circuit breaker (increments consecutive failures, may open the
1110    /// breaker).
1111    pub fn record_failure(&self, tool_name: &str, duration: Duration) {
1112        self.get_stats(tool_name).record_failure(duration);
1113        self.get_circuit_breaker(tool_name).record_failure();
1114    }
1115
1116    /// Snapshot of all tools' health for observability.
1117    ///
1118    /// Returns a map from tool name to `(HealthStatus, health_score)`.
1119    /// The snapshot is point-in-time and may be slightly inconsistent
1120    /// across tools (each tool's counters are read independently).
1121    #[must_use]
1122    pub fn health_summary(&self) -> HashMap<String, (HealthStatus, f64)> {
1123        let entries: Vec<(String, Arc<ToolStats>)> = {
1124            let guard = crate::error::recover_guard(self.stats.lock());
1125            guard
1126                .iter()
1127                .map(|(n, s)| (n.clone(), Arc::clone(s)))
1128                .collect()
1129        };
1130        entries
1131            .into_iter()
1132            .map(|(name, stats)| {
1133                let score = stats.health_score();
1134                let status = self.get_health_status(&name);
1135                (name, (status, score))
1136            })
1137            .collect()
1138    }
1139
1140    /// Number of distinct tools currently tracked.
1141    ///
1142    /// Lock-free in the sense that it briefly acquires the stats map's
1143    /// `Mutex` to read its length. Returns the count of tools seen at
1144    /// least once via `record_*` / `get_stats` / `get_circuit_breaker`.
1145    #[must_use]
1146    pub fn tool_count(&self) -> usize {
1147        crate::error::recover_guard(self.stats.lock()).len()
1148    }
1149}
1150
1151/// A mapping from primary tool names to alternative tool names.
1152///
1153/// When the primary tool is [`Unhealthy`](HealthStatus::Unhealthy) or
1154/// [`Degraded`](HealthStatus::Degraded), the router attempts to redirect
1155/// to an alternative in preference order. This allows the agent to
1156/// continue operating even when a preferred tool is malfunctioning.
1157///
1158/// # Example
1159///
1160/// ```
1161/// use loopctl::tool::health::{HealthRouter, HealthRouterBuilder};
1162///
1163/// let router = HealthRouterBuilder::new()
1164///     .add_fallback("bash", vec!["sh".to_string(), "python".to_string()])
1165///     .add_fallback("edit", vec!["write".to_string()])
1166///     .build();
1167///
1168/// // Find an alternative for "bash" (no registry, so returns the fallbacks)
1169/// let alternatives = router.fallbacks_for("bash");
1170/// assert_eq!(alternatives, vec!["sh", "python"]);
1171/// ```
1172#[derive(Debug, Clone, Default)]
1173pub struct HealthRouter {
1174    /// Primary tool name → ordered list of fallback tool names.
1175    ///
1176    /// Populated via [`HealthRouterBuilder`]; the router consults this
1177    /// map (in order) when the primary tool is unavailable, returning
1178    /// the first healthy alternative.
1179    fallbacks: HashMap<String, Vec<String>>,
1180}
1181
1182impl HealthRouter {
1183    /// Create an empty router with no fallback mappings.
1184    ///
1185    /// Equivalent to [`HealthRouter::default`]. An empty router always
1186    /// resolves to the primary tool name since no fallbacks are
1187    /// configured.
1188    #[must_use]
1189    pub fn new() -> Self {
1190        Self {
1191            fallbacks: HashMap::new(),
1192        }
1193    }
1194
1195    /// Get the ordered list of fallback tool names for a primary tool.
1196    ///
1197    /// Returns an empty slice if no fallbacks are configured for the
1198    /// given tool name. The order matches the order supplied to
1199    /// [`HealthRouterBuilder::add_fallback`].
1200    #[must_use]
1201    pub fn fallbacks_for(&self, tool_name: &str) -> &[String] {
1202        self.fallbacks.get(tool_name).map_or(&[], Vec::as_slice)
1203    }
1204
1205    /// Choose the best available tool from a primary name and its fallbacks.
1206    ///
1207    /// Returns the primary name if [`is_tool_available`](ToolHealthRegistry::is_tool_available)
1208    /// reports it as available. Otherwise iterates through the fallbacks and
1209    /// returns the first one that is available. If no alternative is available,
1210    /// returns the primary name (letting the caller decide how to handle the
1211    /// failure).
1212    #[must_use]
1213    pub fn resolve_tool(&self, tool_name: &str, registry: &ToolHealthRegistry) -> String {
1214        if registry.is_tool_available(tool_name) {
1215            return tool_name.to_string();
1216        }
1217
1218        for fallback in self.fallbacks_for(tool_name) {
1219            if registry.is_tool_available(fallback) {
1220                return fallback.clone();
1221            }
1222        }
1223
1224        // No healthy alternative — return the original and let the caller handle it
1225        tool_name.to_string()
1226    }
1227}
1228
1229/// Builder for [`HealthRouter`] with a fluent API.
1230///
1231/// # Example
1232///
1233/// ```
1234/// use loopctl::tool::health::HealthRouterBuilder;
1235///
1236/// let router = HealthRouterBuilder::new()
1237///     .add_fallback("bash", vec!["sh".to_string()])
1238///     .add_fallback("edit", vec!["write".to_string(), "sed".to_string()])
1239///     .build();
1240///
1241/// assert_eq!(router.fallbacks_for("edit"), vec!["write", "sed"]);
1242/// assert!(router.fallbacks_for("unknown").is_empty());
1243/// ```
1244#[derive(Debug, Clone, Default)]
1245pub struct HealthRouterBuilder {
1246    /// In-progress fallback map, mutated by each `add_fallback` call.
1247    ///
1248    /// Consumed by [`build`](Self::build) to produce the immutable
1249    /// [`HealthRouter`].
1250    fallbacks: HashMap<String, Vec<String>>,
1251}
1252
1253impl HealthRouterBuilder {
1254    /// Create an empty builder.
1255    ///
1256    /// Equivalent to [`HealthRouterBuilder::default`]; every primary
1257    /// starts with no fallbacks until `add_fallback` is called.
1258    #[must_use]
1259    pub fn new() -> Self {
1260        Self {
1261            fallbacks: HashMap::new(),
1262        }
1263    }
1264
1265    /// Register a list of fallback tools for a primary tool name.
1266    ///
1267    /// Replaces any previously-registered fallbacks for `primary`.
1268    /// Fallbacks are tried in the order provided when
1269    /// [`HealthRouter::resolve_tool`] walks the list.
1270    #[must_use]
1271    pub fn add_fallback(mut self, primary: &str, alternatives: Vec<String>) -> Self {
1272        self.fallbacks.insert(primary.to_string(), alternatives);
1273        self
1274    }
1275
1276    /// Build the [`HealthRouter`].
1277    ///
1278    /// Consumes the builder and freezes the fallback map into an
1279    /// immutable router. The returned router is cheap to clone for
1280    /// sharing across dispatch paths.
1281    #[must_use]
1282    pub fn build(self) -> HealthRouter {
1283        HealthRouter {
1284            fallbacks: self.fallbacks,
1285        }
1286    }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use super::*;
1292
1293    #[test]
1294    fn a_stale_success_cannot_untrip_a_freshly_opened_breaker() {
1295        // A success arriving while the breaker is Open can only come
1296        // from a call admitted before the trip — stale evidence.
1297        // Recovery is decided by a probe, not by a pre-trip straggler.
1298        let breaker = ToolCircuitBreaker::new(Duration::from_millis(50), 2);
1299        breaker.record_failure();
1300        breaker.record_failure();
1301        assert!(breaker.is_open());
1302        breaker.record_success();
1303        assert!(
1304            breaker.is_open(),
1305            "a pre-trip straggler's success must not close a freshly \
1306             opened breaker"
1307        );
1308    }
1309
1310    #[test]
1311    fn duration_accumulation_saturates_rather_than_wraps() {
1312        let stats = ToolStats::new();
1313        stats.record_success(Duration::from_nanos(u64::MAX - 10));
1314        stats.record_failure(Duration::from_nanos(u64::MAX - 10));
1315        let avg = stats.avg_duration();
1316        assert_eq!(
1317            avg,
1318            Duration::from_nanos(u64::MAX / 2),
1319            "two near-max durations must saturate, not wrap to a tiny average"
1320        );
1321    }
1322
1323    #[test]
1324    fn tool_stats_starts_healthy() {
1325        let stats = ToolStats::new();
1326        assert_eq!(stats.total_calls(), 0);
1327        assert_eq!(stats.success_count(), 0);
1328        assert_eq!(stats.failure_count(), 0);
1329        assert!((stats.success_rate() - 1.0).abs() < f64::EPSILON);
1330        assert!(stats.health_score() > 0.9);
1331        assert_eq!(stats.avg_duration(), Duration::ZERO);
1332        assert_eq!(stats.max_duration(), Duration::ZERO);
1333    }
1334
1335    #[test]
1336    fn tool_stats_records_success() {
1337        let stats = ToolStats::new();
1338        stats.record_success(Duration::from_millis(100));
1339        stats.record_success(Duration::from_millis(200));
1340
1341        assert_eq!(stats.total_calls(), 2);
1342        assert_eq!(stats.success_count(), 2);
1343        assert_eq!(stats.failure_count(), 0);
1344        assert!(stats.success_rate() > 0.99);
1345        assert_eq!(stats.max_duration(), Duration::from_millis(200));
1346    }
1347
1348    #[test]
1349    fn tool_stats_records_failure() {
1350        let stats = ToolStats::new();
1351        stats.record_failure(Duration::from_secs(5));
1352
1353        assert_eq!(stats.total_calls(), 1);
1354        assert_eq!(stats.success_count(), 0);
1355        assert_eq!(stats.failure_count(), 1);
1356        assert!(stats.success_rate() < 0.01);
1357    }
1358
1359    #[test]
1360    fn tool_stats_avg_duration() {
1361        let stats = ToolStats::new();
1362        stats.record_success(Duration::from_millis(100));
1363        stats.record_success(Duration::from_millis(300));
1364
1365        let avg = stats.avg_duration();
1366        assert!(avg >= Duration::from_millis(199) && avg <= Duration::from_millis(201));
1367    }
1368
1369    #[test]
1370    fn tool_stats_ewma_responds_to_failures() {
1371        let stats = ToolStats::new();
1372
1373        // Start healthy
1374        let initial = stats.health_score();
1375        assert!(initial > 0.9);
1376
1377        // Pound with failures
1378        for _ in 0..10 {
1379            stats.record_failure(Duration::from_millis(100));
1380        }
1381
1382        let after_failures = stats.health_score();
1383        assert!(
1384            after_failures < 0.3,
1385            "expected score < 0.3, got {after_failures}"
1386        );
1387    }
1388
1389    #[test]
1390    fn tool_stats_ewma_recovers_on_success() {
1391        let stats = ToolStats::new();
1392
1393        // Drive EWMA down
1394        for _ in 0..10 {
1395            stats.record_failure(Duration::from_millis(100));
1396        }
1397        let low = stats.health_score();
1398        assert!(low < 0.3);
1399
1400        // Recover with successes
1401        for _ in 0..20 {
1402            stats.record_success(Duration::from_millis(100));
1403        }
1404        let recovered = stats.health_score();
1405        assert!(
1406            recovered > low,
1407            "expected recovery: {recovered} should be > {low}"
1408        );
1409    }
1410
1411    #[test]
1412    fn update_ewma_function() {
1413        // Start at 1.0
1414        let mut ewma = EWMA_SCALE;
1415
1416        // One failure: 0.7 * 1.0 + 0.3 * 0.0 = 0.7
1417        ewma = update_ewma(ewma, false);
1418        assert_eq!(ewma, 700_000);
1419
1420        // Another failure: 0.7 * 0.7 + 0.0 = 0.49
1421        ewma = update_ewma(ewma, false);
1422        assert_eq!(ewma, 490_000);
1423
1424        // One success: 0.7 * 0.49 + 0.3 * 1.0 = 0.643
1425        ewma = update_ewma(ewma, true);
1426        assert_eq!(ewma, 643_000);
1427    }
1428
1429    #[test]
1430    fn circuit_state_from_u32() {
1431        assert_eq!(CircuitState::from(0u32), CircuitState::Closed);
1432        assert_eq!(CircuitState::from(1u32), CircuitState::Open);
1433        assert_eq!(CircuitState::from(2u32), CircuitState::HalfOpen);
1434        assert_eq!(CircuitState::from(99u32), CircuitState::HalfOpen);
1435    }
1436
1437    #[test]
1438    fn circuit_state_display() {
1439        assert_eq!(format!("{}", CircuitState::Closed), "closed");
1440        assert_eq!(format!("{}", CircuitState::Open), "open");
1441        assert_eq!(format!("{}", CircuitState::HalfOpen), "half-open");
1442    }
1443
1444    #[test]
1445    fn circuit_breaker_starts_closed() {
1446        let cb = ToolCircuitBreaker::new(Duration::from_secs(30), 3);
1447        assert!(cb.is_closed());
1448        assert!(!cb.is_open());
1449        assert_eq!(cb.state_label(), "closed");
1450        assert!(cb.allow_request());
1451        assert_eq!(cb.consecutive_failures(), 0);
1452    }
1453
1454    #[test]
1455    fn circuit_breaker_opens_after_threshold() {
1456        let cb = ToolCircuitBreaker::new(Duration::from_secs(30), 3);
1457
1458        cb.record_failure();
1459        cb.record_failure();
1460        assert!(cb.is_closed(), "2 failures < threshold 3");
1461
1462        cb.record_failure();
1463        assert!(cb.is_open(), "3 failures should open breaker");
1464        assert!(!cb.allow_request());
1465        assert_eq!(cb.state_label(), "open");
1466    }
1467
1468    #[test]
1469    fn circuit_breaker_success_resets() {
1470        let cb = ToolCircuitBreaker::new(Duration::from_secs(30), 2);
1471
1472        cb.record_failure();
1473        assert_eq!(cb.consecutive_failures(), 1);
1474
1475        cb.record_success();
1476        assert_eq!(cb.consecutive_failures(), 0);
1477        assert!(cb.is_closed());
1478    }
1479
1480    #[test]
1481    fn circuit_breaker_half_open_probe() {
1482        let cb = ToolCircuitBreaker::new(Duration::from_millis(50), 1);
1483
1484        cb.record_failure();
1485        assert!(cb.is_open());
1486
1487        // Wait for recovery duration
1488        std::thread::sleep(Duration::from_millis(60));
1489
1490        // Should transition to HalfOpen and allow probe
1491        assert!(cb.allow_request());
1492        assert_eq!(cb.state_label(), "half-open");
1493    }
1494
1495    #[test]
1496    fn circuit_breaker_half_open_success_closes() {
1497        let cb = ToolCircuitBreaker::new(Duration::from_millis(50), 1);
1498
1499        cb.record_failure();
1500        assert!(cb.is_open());
1501
1502        // Wait for recovery
1503        std::thread::sleep(Duration::from_millis(60));
1504        assert!(cb.allow_request()); // HalfOpen
1505
1506        // Probe succeeds
1507        cb.record_success();
1508        assert!(cb.is_closed());
1509    }
1510
1511    #[test]
1512    fn circuit_breaker_half_open_failure_reopens() {
1513        let cb = ToolCircuitBreaker::new(Duration::from_millis(50), 1);
1514
1515        cb.record_failure();
1516        assert!(cb.is_open());
1517
1518        // Wait for recovery
1519        std::thread::sleep(Duration::from_millis(60));
1520        assert!(cb.allow_request()); // HalfOpen
1521
1522        // Probe fails — reopen
1523        cb.record_failure();
1524        assert!(cb.is_open());
1525        assert!(!cb.allow_request());
1526    }
1527
1528    #[test]
1529    fn availability_recovers_after_a_stranded_probe_without_a_dispatch() {
1530        let cb = ToolCircuitBreaker::new(Duration::from_millis(40), 1)
1531            .with_probe_timeout(Duration::from_millis(60));
1532        cb.record_failure();
1533        assert!(!cb.would_allow_request());
1534        std::thread::sleep(Duration::from_millis(50));
1535        assert!(
1536            cb.allow_request(),
1537            "after the recovery window the probe is granted"
1538        );
1539        assert!(
1540            !cb.would_allow_request(),
1541            "an in-flight probe holds availability"
1542        );
1543
1544        std::thread::sleep(Duration::from_millis(140));
1545        assert!(
1546            cb.would_allow_request(),
1547            "after the probe times out and the re-armed cooldown elapses, \
1548             availability recovers without any allow_request call re-arming it"
1549        );
1550    }
1551
1552    #[test]
1553    fn late_success_after_probe_expiry_does_not_close_the_breaker() {
1554        let cb = ToolCircuitBreaker::new(Duration::from_millis(40), 1)
1555            .with_probe_timeout(Duration::from_millis(60));
1556        cb.record_failure();
1557        std::thread::sleep(Duration::from_millis(50));
1558        assert!(cb.allow_request(), "the probe is granted");
1559
1560        std::thread::sleep(Duration::from_millis(80));
1561        cb.record_success();
1562        assert_eq!(
1563            cb.state_label(),
1564            "open",
1565            "a success arriving after the probe lease expired describes a stale \
1566             probe — it re-arms recovery instead of closing the breaker"
1567        );
1568        assert!(!cb.would_allow_request(), "the re-armed cooldown holds");
1569
1570        std::thread::sleep(Duration::from_millis(50));
1571        assert!(
1572            cb.allow_request(),
1573            "a fresh probe is granted after the re-armed cooldown"
1574        );
1575    }
1576
1577    #[test]
1578    fn would_allow_true_means_the_next_call_grants() {
1579        let cb = ToolCircuitBreaker::new(Duration::from_millis(40), 1)
1580            .with_probe_timeout(Duration::from_millis(60));
1581        cb.record_failure();
1582        std::thread::sleep(Duration::from_millis(50));
1583        assert!(
1584            cb.allow_request(),
1585            "the probe is granted after the cooldown"
1586        );
1587
1588        std::thread::sleep(Duration::from_millis(140));
1589        assert!(
1590            cb.would_allow_request(),
1591            "after the lease and the re-armed cooldown elapse, availability is true"
1592        );
1593        assert!(
1594            cb.allow_request(),
1595            "a would-be-allowed request is granted on the very next call — \
1596             the oracle and the mutator agree at the same instant"
1597        );
1598    }
1599
1600    #[test]
1601    fn zero_probe_timeout_disables_the_lease() {
1602        let cb = ToolCircuitBreaker::new(Duration::from_millis(40), 1)
1603            .with_probe_timeout(Duration::ZERO);
1604        cb.record_failure();
1605        std::thread::sleep(Duration::from_millis(50));
1606        assert!(
1607            cb.allow_request(),
1608            "the probe is granted after the cooldown"
1609        );
1610        std::thread::sleep(Duration::from_millis(30));
1611        cb.record_success();
1612        assert_eq!(
1613            cb.state_label(),
1614            "closed",
1615            "a zero probe timeout means the lease never expires — a slow \
1616             probe's success still closes the breaker"
1617        );
1618    }
1619
1620    #[test]
1621    fn stranded_probe_recovers_availability_in_the_registry() {
1622        let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
1623            failure_threshold: 3,
1624            recovery_duration: Duration::from_millis(40),
1625            probe_timeout: Duration::from_millis(60),
1626        });
1627        for _ in 0..3 {
1628            registry.record_failure("tool", Duration::from_millis(1));
1629        }
1630        assert!(
1631            !registry.is_tool_available("tool"),
1632            "a tripped tool is unavailable"
1633        );
1634
1635        std::thread::sleep(Duration::from_millis(50));
1636        let cb = registry.get_circuit_breaker("tool");
1637        assert!(cb.allow_request(), "the probe is granted");
1638        assert!(
1639            !registry.is_tool_available("tool"),
1640            "an in-flight probe holds availability"
1641        );
1642
1643        std::thread::sleep(Duration::from_millis(140));
1644        assert!(
1645            registry.is_tool_available("tool"),
1646            "after the lease expires and the re-armed cooldown elapses, the \
1647             registry reports the tool available again — a stranded probe \
1648             cannot wedge routing forever"
1649        );
1650    }
1651
1652    #[test]
1653    fn zero_failure_threshold_disables_tripping() {
1654        let cb = ToolCircuitBreaker::new(Duration::from_millis(10), 0);
1655        for _ in 0..10 {
1656            cb.record_failure();
1657        }
1658        assert!(
1659            cb.allow_request(),
1660            "a zero threshold follows the crate-wide zero-disables sentinel: \
1661             failures are counted but the breaker never opens"
1662        );
1663        assert_eq!(cb.consecutive_failures(), 10);
1664    }
1665
1666    #[test]
1667    fn stranded_half_open_probe_re_arms_recovery_after_the_timeout() {
1668        let cb = ToolCircuitBreaker::new(Duration::from_millis(40), 1)
1669            .with_probe_timeout(Duration::from_millis(60));
1670        cb.record_failure();
1671        assert!(!cb.allow_request());
1672        std::thread::sleep(Duration::from_millis(50));
1673        assert!(
1674            cb.allow_request(),
1675            "after the recovery window the probe is granted"
1676        );
1677        // The probe never records (cancelled or lost). Without the probe
1678        // timeout the breaker would answer false forever.
1679        std::thread::sleep(Duration::from_millis(80));
1680        assert!(
1681            !cb.allow_request(),
1682            "an expired probe re-arms the Open cooldown instead of stranding HalfOpen"
1683        );
1684        std::thread::sleep(Duration::from_millis(50));
1685        assert!(
1686            cb.allow_request(),
1687            "a fresh probe is granted after the re-armed cooldown"
1688        );
1689        cb.record_success();
1690        assert!(cb.allow_request(), "the recorded probe closes the breaker");
1691    }
1692
1693    #[test]
1694    fn circuit_breaker_from_config() {
1695        let config = CircuitBreakerConfig {
1696            failure_threshold: 5,
1697            recovery_duration: Duration::from_mins(1),
1698            probe_timeout: Duration::from_mins(1),
1699        };
1700        let cb = ToolCircuitBreaker::from_config(&config);
1701
1702        // Should need 5 failures
1703        for _ in 0..4 {
1704            cb.record_failure();
1705        }
1706        assert!(cb.is_closed(), "4 failures < threshold 5");
1707
1708        cb.record_failure();
1709        assert!(cb.is_open(), "5 failures should open breaker");
1710    }
1711
1712    #[test]
1713    fn health_status_display() {
1714        assert_eq!(format!("{}", HealthStatus::Healthy), "healthy");
1715        assert_eq!(format!("{}", HealthStatus::Degraded), "degraded");
1716        assert_eq!(format!("{}", HealthStatus::Unhealthy), "unhealthy");
1717    }
1718
1719    #[test]
1720    fn registry_starts_empty() {
1721        let registry = ToolHealthRegistry::new();
1722        assert_eq!(registry.tool_count(), 0);
1723    }
1724
1725    #[test]
1726    fn registry_auto_registers_on_record() {
1727        let registry = ToolHealthRegistry::new();
1728
1729        registry.record_success("bash", Duration::from_millis(100));
1730        assert_eq!(registry.tool_count(), 1);
1731
1732        registry.record_failure("grep", Duration::from_millis(50));
1733        assert_eq!(registry.tool_count(), 2);
1734    }
1735
1736    #[test]
1737    fn registry_tracks_per_tool_stats() {
1738        let registry = ToolHealthRegistry::new();
1739
1740        registry.record_success("bash", Duration::from_millis(100));
1741        registry.record_success("bash", Duration::from_millis(200));
1742        registry.record_failure("bash", Duration::from_secs(5));
1743
1744        let stats = registry.get_stats("bash");
1745        assert_eq!(stats.total_calls(), 3);
1746        assert_eq!(stats.success_count(), 2);
1747        assert_eq!(stats.failure_count(), 1);
1748    }
1749
1750    #[test]
1751    fn registry_health_status_classification() {
1752        let registry = ToolHealthRegistry::new();
1753
1754        // Fresh tool — healthy (EWMA starts at 1.0)
1755        registry.record_success("tool_a", Duration::from_millis(10));
1756        assert_eq!(registry.get_health_status("tool_a"), HealthStatus::Healthy);
1757
1758        // Drive tool_b down with failures (with low threshold)
1759        let low_threshold_registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
1760            failure_threshold: 2,
1761            recovery_duration: Duration::from_secs(30),
1762            probe_timeout: Duration::from_secs(30),
1763        });
1764        for _ in 0..5 {
1765            low_threshold_registry.record_failure("tool_b", Duration::from_millis(10));
1766        }
1767        let status = low_threshold_registry.get_health_status("tool_b");
1768        assert!(
1769            status == HealthStatus::Unhealthy,
1770            "expected Unhealthy, got {status}"
1771        );
1772    }
1773
1774    #[test]
1775    fn registry_is_tool_available() {
1776        let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
1777            failure_threshold: 2,
1778            recovery_duration: Duration::from_secs(30),
1779            probe_timeout: Duration::from_secs(30),
1780        });
1781
1782        // Healthy tool — available
1783        registry.record_success("tool_a", Duration::from_millis(10));
1784        assert!(registry.is_tool_available("tool_a"));
1785
1786        // Open breaker — unavailable
1787        registry.record_failure("tool_b", Duration::from_millis(10));
1788        registry.record_failure("tool_b", Duration::from_millis(10));
1789        assert!(!registry.is_tool_available("tool_b"));
1790    }
1791
1792    #[test]
1793    fn is_tool_available_does_not_consume_half_open_probe() {
1794        let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
1795            failure_threshold: 1,
1796            recovery_duration: Duration::from_millis(40),
1797            probe_timeout: Duration::from_secs(30),
1798        });
1799        registry.record_failure("tool", Duration::from_millis(1));
1800        assert!(
1801            !registry.is_tool_available("tool"),
1802            "Open breaker must be unavailable"
1803        );
1804        std::thread::sleep(Duration::from_millis(50));
1805
1806        // An availability check on the recovered-Open breaker must report
1807        // available (the next dispatch would probe) WITHOUT performing the
1808        // Open→HalfOpen transition — otherwise the read consumes the probe
1809        // and the real dispatch is blocked.
1810        assert!(
1811            registry.is_tool_available("tool"),
1812            "recovered breaker must report available"
1813        );
1814        let breaker = registry.get_circuit_breaker("tool");
1815        assert!(
1816            breaker.allow_request(),
1817            "is_tool_available must not consume the HalfOpen probe slot; \
1818             the real dispatch path must still get it"
1819        );
1820        assert_eq!(breaker.state_label(), "half-open");
1821    }
1822
1823    #[test]
1824    fn registry_health_summary() {
1825        let registry = ToolHealthRegistry::new();
1826
1827        registry.record_success("bash", Duration::from_millis(100));
1828        registry.record_failure("grep", Duration::from_millis(50));
1829
1830        let summary = registry.health_summary();
1831        assert_eq!(summary.len(), 2);
1832        assert!(summary.contains_key("bash"));
1833        assert!(summary.contains_key("grep"));
1834
1835        let (bash_status, bash_score) = &summary["bash"];
1836        assert_eq!(*bash_status, HealthStatus::Healthy);
1837        assert!(*bash_score > 0.5);
1838    }
1839
1840    #[test]
1841    fn health_router_no_fallbacks() {
1842        let router = HealthRouter::new();
1843        assert!(router.fallbacks_for("unknown").is_empty());
1844    }
1845
1846    #[test]
1847    fn health_router_with_fallbacks() {
1848        let router = HealthRouterBuilder::new()
1849            .add_fallback("bash", vec!["sh".to_string(), "python".to_string()])
1850            .build();
1851
1852        assert_eq!(router.fallbacks_for("bash"), vec!["sh", "python"]);
1853        assert!(router.fallbacks_for("unknown").is_empty());
1854    }
1855
1856    #[test]
1857    fn health_router_resolve_healthy_primary() {
1858        let registry = ToolHealthRegistry::new();
1859        registry.record_success("bash", Duration::from_millis(10));
1860
1861        let router = HealthRouterBuilder::new()
1862            .add_fallback("bash", vec!["sh".to_string()])
1863            .build();
1864
1865        // Primary is healthy — should return primary
1866        assert_eq!(router.resolve_tool("bash", &registry), "bash");
1867    }
1868
1869    #[test]
1870    fn health_router_resolve_falls_back_to_alternative() {
1871        let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
1872            failure_threshold: 1,
1873            recovery_duration: Duration::from_secs(30),
1874            probe_timeout: Duration::from_secs(30),
1875        });
1876
1877        // Make "bash" unhealthy
1878        registry.record_failure("bash", Duration::from_millis(10));
1879        // Make "sh" healthy
1880        registry.record_success("sh", Duration::from_millis(10));
1881
1882        let router = HealthRouterBuilder::new()
1883            .add_fallback("bash", vec!["sh".to_string()])
1884            .build();
1885
1886        let resolved = router.resolve_tool("bash", &registry);
1887        assert_eq!(resolved, "sh", "should fall back to 'sh'");
1888    }
1889
1890    #[test]
1891    fn health_router_resolve_returns_primary_when_no_healthy_alternative() {
1892        let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
1893            failure_threshold: 1,
1894            recovery_duration: Duration::from_secs(30),
1895            probe_timeout: Duration::from_secs(30),
1896        });
1897
1898        // Both unhealthy
1899        registry.record_failure("bash", Duration::from_millis(10));
1900        registry.record_failure("sh", Duration::from_millis(10));
1901
1902        let router = HealthRouterBuilder::new()
1903            .add_fallback("bash", vec!["sh".to_string()])
1904            .build();
1905
1906        // No healthy alternative — returns primary
1907        assert_eq!(router.resolve_tool("bash", &registry), "bash");
1908    }
1909
1910    #[test]
1911    fn registry_concurrent_access() {
1912        use std::sync::Arc;
1913        use std::thread;
1914
1915        let registry = Arc::new(ToolHealthRegistry::new());
1916        let mut handles = vec![];
1917
1918        for i in 0..4 {
1919            let reg = Arc::clone(&registry);
1920            handles.push(thread::spawn(move || {
1921                let tool_name = format!("tool_{i}");
1922                for j in 0..100 {
1923                    if j % 3 == 0 {
1924                        reg.record_failure(&tool_name, Duration::from_millis(j));
1925                    } else {
1926                        reg.record_success(&tool_name, Duration::from_millis(j));
1927                    }
1928                }
1929            }));
1930        }
1931
1932        for handle in handles {
1933            handle.join().unwrap();
1934        }
1935
1936        // All 4 tools registered
1937        assert_eq!(registry.tool_count(), 4);
1938
1939        // Each tool should have 100 calls
1940        for i in 0..4u64 {
1941            let tool_name = format!("tool_{i}");
1942            let stats = registry.get_stats(&tool_name);
1943            assert_eq!(stats.total_calls(), 100);
1944            // 33 failures (j % 3 == 0 for j in 0..100 → indices 0,3,6,...,99 = 34 failures)
1945            let failures = stats.failure_count();
1946            assert!(
1947                (33..=35).contains(&failures),
1948                "tool_{i}: expected ~34 failures, got {failures}"
1949            );
1950        }
1951    }
1952
1953    #[test]
1954    fn tool_stats_max_duration_counts_failed_calls() {
1955        let stats = ToolStats::new();
1956        stats.record_success(Duration::from_millis(100));
1957        stats.record_failure(Duration::from_secs(5));
1958        assert_eq!(
1959            stats.max_duration(),
1960            Duration::from_secs(5),
1961            "doc: maximum single-call duration recorded so far"
1962        );
1963    }
1964}