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", ®istry), "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", ®istry);
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", ®istry), "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(®istry);
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}