Skip to main content

orch/engine/
cooldown.rs

1//! Per-agent and per-model cooldown tracking.
2//!
3//! Shared by `engine::runner::response` (recording failures) and
4//! `engine::router::config` (avoiding cooled models during pool selection).
5//!
6//! # Persistence
7//!
8//! All cooldowns are persisted to the KV store in SQLite so they survive
9//! service restarts. Call [`init_cooldown_store`] once at startup (after
10//! the `TaskStore` is open) to register the store and pre-load unexpired
11//! cooldowns.
12//!
13//! When a rate limit includes a "try again at {date}" message, the cooldown
14//! is set to that specific timestamp instead of the default duration.
15//! Relative usage windows (e.g. "5-hour usage limit") are also detected and
16//! used to set a model-specific cooldown matching the advertised window.
17
18use chrono::TimeZone;
19use regex::Regex;
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex, OnceLock};
22
23/// Default fallback cooldown for agents when no store is available: 30 minutes.
24///
25/// Used in contexts that need a concrete duration and cannot compute exponential
26/// backoff (e.g. computing how long to wait before retrying when a cooldown
27/// expiry timestamp is unknown). New code should use [`BACKOFF_BASE_SECS`] instead.
28pub const AGENT_COOLDOWN_SECS: i64 = 30 * 60;
29
30/// Base backoff for generic agent/model failures: 5 minutes.
31pub const BACKOFF_BASE_SECS: i64 = 5 * 60;
32
33/// Maximum backoff for generic agent/model failures: 4 hours.
34pub const BACKOFF_MAX_SECS: i64 = 4 * 60 * 60;
35
36/// Base backoff for persistent model failures (silent exit/provider errors): 4 hours.
37pub const PERSISTENT_MODEL_BACKOFF_BASE_SECS: i64 = 4 * 60 * 60;
38
39/// Maximum backoff for persistent model failures: 7 days.
40pub const PERSISTENT_MODEL_BACKOFF_MAX_SECS: i64 = 7 * 24 * 60 * 60;
41
42/// Base backoff for a one-off model failure that does not mean the model is gone: 5 minutes.
43pub const TRANSIENT_MODEL_BACKOFF_BASE_SECS: i64 = 5 * 60;
44
45/// Maximum backoff for a one-off model failure: 15 minutes.
46pub const TRANSIENT_MODEL_BACKOFF_MAX_SECS: i64 = 15 * 60;
47
48/// Base backoff for credit exhaustion (out_of_credits): 1 hour.
49pub const CREDIT_BACKOFF_BASE_SECS: i64 = 60 * 60;
50
51/// Base backoff for org-level disabling: 2 hours.
52pub const ORG_BACKOFF_BASE_SECS: i64 = 2 * 60 * 60;
53
54/// Standard cap for credit exhaustion and org-level disabling: 8 hours.
55/// Repeated failures at the cap move to the extended tiers (48h, then 96h)
56pub const CREDIT_BACKOFF_MAX_SECS: i64 = 8 * 60 * 60;
57
58/// Base cooldown for billing cycle exhaustion: 24 hours.
59///
60/// Unlike per-request rate limits, billing cycle exhaustion is a calendar event
61/// that won't self-resolve for days or weeks. The first failure starts at 24h;
62/// subsequent failures escalate via `compute_backoff()` up to
63/// `BILLING_CYCLE_MAX_SECS` (7 days). This prevents daily retry-and-fail cycles
64/// when a monthly billing quota is exhausted.
65pub const BILLING_CYCLE_COOLDOWN_SECS: i64 = 24 * 60 * 60;
66
67/// Maximum cooldown for billing cycle exhaustion: 7 days.
68///
69/// Monthly billing cycles typically reset on a fixed date. 7 days is a
70/// reasonable cap — the cooldown will expire before the next monthly reset
71/// in most cases, allowing a single probe to re-check availability.
72pub const BILLING_CYCLE_MAX_SECS: i64 = 7 * 24 * 60 * 60;
73
74/// Credit exhaustion reason detected from error message.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum CreditExhaustionReason {
77    /// Per-model credit exhaustion (out_of_credits).
78    OutOfCredits,
79    /// Organization-level disabling (org_level_disabled).
80    OrgLevelDisabled,
81    /// Monthly billing cycle exhaustion — escalating cooldown (24h base, 7d cap).
82    BillingCycleExhausted,
83}
84
85impl CreditExhaustionReason {
86    /// Stable string for `task_runs.outcome`, distinct from the cooldown reason
87    /// string used by `record_credit_exhaustion`. Lets callers persist the actual
88    /// failure mode instead of collapsing it into a generic "rate_limit"/"failed".
89    pub fn outcome_str(self) -> &'static str {
90        match self {
91            CreditExhaustionReason::OutOfCredits => "credit_exhausted",
92            CreditExhaustionReason::OrgLevelDisabled => "org_level_disabled",
93            CreditExhaustionReason::BillingCycleExhausted => "billing_cycle_exhausted",
94        }
95    }
96}
97
98/// Short agent cooldown applied on silence detection (120 seconds).
99///
100/// Forces the router to pick a different agent immediately on re-route,
101/// without long-term blocking. The model cooldown (exponential, starting at
102/// 5 min) keeps the dead model out while this short cooldown just breaks
103/// the same-agent loop.
104pub const SILENCE_AGENT_COOLDOWN_SECS: u64 = 120;
105
106/// Silence detections before applying an extended cooldown (rolling window).
107pub const SILENCE_COUNT_THRESHOLD: usize = 5;
108
109/// Rolling window for silence detections (24 hours).
110pub const SILENCE_COUNT_WINDOW_SECS: i64 = 24 * 60 * 60;
111
112/// Extended cooldown applied after repeated silence detections (4 hours).
113pub const SILENCE_EXTENDED_COOLDOWN_SECS: u64 = 4 * 60 * 60;
114
115/// KV key prefix for persisted cooldowns (both agent and model).
116const KV_PREFIX: &str = "cooldown:";
117
118/// KV key prefix for rolling silence counters.
119const SILENCE_COUNT_PREFIX: &str = "silence_count:";
120
121/// KV key prefix for per-agent and per-agent:model failure counts (drives exponential backoff).
122const FAILURE_COUNT_PREFIX: &str = "failure_count:";
123
124/// KV key prefix for credit-exhaustion failure counts (separate from generic failures to prevent
125/// cross-contamination of backoff escalation — credit exhaustion uses different base/max durations).
126const CREDIT_FAILURE_COUNT_PREFIX: &str = "credit_failure_count:";
127
128/// KV key prefix for persistent model failure counts (separate from [`FAILURE_COUNT_PREFIX`] so
129/// `record_agent_success()` resetting the generic counter on an intervening success doesn't erase
130/// escalation progress for structurally unreliable models — see `record_persistent_model_failure`).
131const PERSISTENT_FAILURE_COUNT_PREFIX: &str = "persistent_failure_count:";
132
133/// KV key prefix for the unix timestamp after which `rate_limits` rows count toward degraded
134const RATE_LIMITS_CLEARED_AT_PREFIX: &str = "rate_limits_cleared_at:";
135
136/// Agent name from a cooldown key (`claude:sonnet` -> `claude`)
137fn agent_from_key(key: &str) -> &str {
138    key.split_once(':').map_or(key, |(agent, _)| agent)
139}
140
141struct CooldownEntry {
142    /// Unix timestamp when the cooldown expires.
143    cooldown_until: i64,
144    reason: String,
145}
146
147#[derive(Debug)]
148pub struct SilenceCountResult {
149    pub count: usize,
150    pub extended_cooldown_applied: bool,
151}
152
153/// Global in-memory cooldown map, protected by a Mutex.
154///
155/// Uses `std::sync::Mutex` because this map is also read from sync helper
156/// functions (`is_in_cooldown`, `is_model_in_cooldown`, etc.).  All lock
157/// holders perform only in-memory HashMap operations — **no `.await` is ever
158/// called while a guard is held**.  This invariant must be preserved: if a
159/// future caller needs to await while holding this lock, switch to
160/// `tokio::sync::Mutex` and make the helpers async.
161fn cooldowns() -> &'static Mutex<HashMap<String, CooldownEntry>> {
162    static COOLDOWNS: OnceLock<Mutex<HashMap<String, CooldownEntry>>> = OnceLock::new();
163    COOLDOWNS.get_or_init(|| Mutex::new(HashMap::new()))
164}
165
166/// Global store reference used for background KV writes.
167///
168/// Uses `tokio::sync::Mutex` because this value is only ever accessed from
169/// async functions. A `tokio::sync::Mutex` yields the Tokio task instead of
170/// blocking the worker thread if the lock is contended.
171fn cooldown_store() -> &'static tokio::sync::Mutex<Option<Arc<crate::store::TaskStore>>> {
172    static STORE: OnceLock<tokio::sync::Mutex<Option<Arc<crate::store::TaskStore>>>> =
173        OnceLock::new();
174    STORE.get_or_init(|| tokio::sync::Mutex::new(None))
175}
176
177/// Initialise persistent cooldowns.
178///
179/// Must be called once at engine startup, after the `TaskStore` is opened.
180/// Loads unexpired cooldowns from KV and registers the store for future writes.
181/* Parses the `{"until": N, "reason": "..."}` cooldown KV format, falling back
182to the legacy bare-integer format (reason unknown) so existing rows keep
183working across the upgrade without a migration. */
184fn parse_cooldown_value(value: &str) -> Option<(i64, String)> {
185    if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(value) {
186        let until = map.get("until")?.as_i64()?;
187        let reason = map
188            .get("reason")
189            .and_then(|r| r.as_str())
190            .unwrap_or("persisted")
191            .to_string();
192        return Some((until, reason));
193    }
194    value
195        .parse::<i64>()
196        .ok()
197        .map(|until| (until, "persisted".to_string()))
198}
199
200pub async fn init_cooldown_store(store: Arc<crate::store::TaskStore>) {
201    match store.kv_list_prefix(KV_PREFIX).await {
202        Ok(rows) => {
203            let now = chrono::Utc::now().timestamp();
204            let mut map = cooldown_lock();
205            let mut loaded = 0;
206            for (key, value) in rows {
207                let cooldown_key = key.trim_start_matches(KV_PREFIX);
208                if let Some((cooldown_until, reason)) = parse_cooldown_value(&value)
209                    && now < cooldown_until
210                {
211                    map.insert(
212                        cooldown_key.to_string(),
213                        CooldownEntry {
214                            cooldown_until,
215                            reason,
216                        },
217                    );
218                    loaded += 1;
219                }
220                // Expired entries are simply not loaded — they'll be overwritten
221                // on the next failure or cleaned up by future writes.
222            }
223            if loaded > 0 {
224                tracing::info!(loaded, "loaded persisted cooldowns from KV store");
225            }
226        }
227        Err(e) => {
228            tracing::warn!(err = %e, "failed to load cooldowns from KV store");
229        }
230    }
231
232    // Load GitHub 5xx circuit breaker state if it was persisted before restart.
233    if let Ok(Some(raw)) = store.kv_get("cooldown:github:5xx").await
234        && let Ok(cooldown_until) = raw.parse::<i64>()
235    {
236        let now = chrono::Utc::now().timestamp();
237        if now < cooldown_until {
238            let mut open = github_5xx_circuit_open_lock();
239            *open = Some(cooldown_until);
240            let remaining = cooldown_until - now;
241            tracing::warn!(
242                remaining_secs = remaining,
243                "GitHub 5xx circuit breaker restored from KV (still open)"
244            );
245        }
246    }
247
248    *cooldown_store().lock().await = Some(store);
249}
250
251/// Reconcile the in-memory cooldown map with the persisted KV state.
252///
253/// KV is the source of truth. This is called once per tick by
254/// `router.refresh_health` so the running service notices state mutated by a
255/// separate process (e.g. `orch cooldown clear`). Without this sync, the CLI
256/// clears KV but the engine's in-memory map keeps the stale entries until they
257/// expire on their own, producing the "all agents cooled" + "No active
258/// cooldowns" inconsistency.
259///
260/// Reconciliation rules (KV authoritative for every key it has):
261/// - KV row missing for an in-memory key: leave memory alone — likely a
262///   `set_cooldown_async` race where memory was written but the KV row isn't
263///   yet visible. The next tick will catch up.
264/// - KV row present with `cooldown_until <= now` (including the "0" tombstone
265///   written by `clear_cooldown`): drop from memory.
266/// - KV row present with `cooldown_until > now`: insert/update memory to match.
267pub async fn sync_from_kv(store: &Arc<crate::store::TaskStore>) {
268    let rows = match store.kv_list_prefix(KV_PREFIX).await {
269        Ok(rows) => rows,
270        Err(e) => {
271            tracing::warn!(err = %e, "cooldown sync_from_kv: KV list failed");
272            return;
273        }
274    };
275    let now = chrono::Utc::now().timestamp();
276
277    // Some((ts, reason)) for active cooldowns, None for cleared/expired rows.
278    // Missing key means no KV row at all, left alone below.
279    let mut explicit_kv: HashMap<String, Option<(i64, String)>> =
280        HashMap::with_capacity(rows.len());
281    for (kv_key, value) in &rows {
282        let cooldown_key = kv_key.trim_start_matches(KV_PREFIX);
283        if let Some((ts, reason)) = parse_cooldown_value(value) {
284            let normalized = if ts > now { Some((ts, reason)) } else { None };
285            explicit_kv.insert(cooldown_key.to_string(), normalized);
286        }
287    }
288
289    // The 5xx circuit breaker lives in its own statics, so mirror its KV row there
290    match explicit_kv.get("github:5xx") {
291        Some(None) => {
292            let mut open = github_5xx_circuit_open_lock();
293            if open.take().is_some() {
294                tracing::info!("GitHub 5xx circuit breaker closed by KV clear");
295                github_5xx_timestamps_lock().clear();
296            }
297        }
298        Some(Some((ts, _))) => {
299            *github_5xx_circuit_open_lock() = Some(*ts);
300        }
301        None => {}
302    }
303
304    let mut map = cooldown_lock();
305    let mut dropped = 0usize;
306    let mut updated = 0usize;
307
308    // Drop in-memory entries that KV explicitly marks cleared/expired.
309    map.retain(|key, _| {
310        if matches!(explicit_kv.get(key), Some(None)) {
311            dropped += 1;
312            false
313        } else {
314            true
315        }
316    });
317
318    // Adopt KV's authoritative timestamp for active cooldowns.
319    for (key, normalized) in explicit_kv {
320        if let Some((ts, reason)) = normalized {
321            if let Some(entry) = map.get_mut(&key) {
322                if entry.cooldown_until != ts {
323                    entry.cooldown_until = ts;
324                    updated += 1;
325                }
326            } else {
327                map.insert(
328                    key,
329                    CooldownEntry {
330                        cooldown_until: ts,
331                        reason,
332                    },
333                );
334                updated += 1;
335            }
336        }
337    }
338
339    if dropped > 0 || updated > 0 {
340        tracing::info!(
341            dropped,
342            updated,
343            "cooldown sync_from_kv: reconciled in-memory map with KV"
344        );
345    }
346}
347
348/// Failure count threshold (times at max backoff) for first extended tier (24h).
349///
350/// After a model has been at the max cap this many times, apply a 6x multiplier.
351const EXTENDED_TIER_1_THRESHOLD: u64 = 2;
352
353/// Multiplier for first extended tier (6x = 24h when max=4h).
354const EXTENDED_TIER_1_MULTIPLIER: i64 = 6;
355
356/// Failure count threshold (times at max backoff) for second extended tier (48h).
357///
358/// After a model has been at the max cap this many times, apply a 12x multiplier.
359const EXTENDED_TIER_2_THRESHOLD: u64 = 5;
360
361/// Multiplier for second extended tier (12x = 48h when max=4h).
362const EXTENDED_TIER_2_MULTIPLIER: i64 = 12;
363
364/// Compute exponential backoff duration using base-3 growth.
365///
366/// Returns `min(base * 3^(count-1), max)` for count >= 1.
367/// For count == 0 or 1 (first failure), returns `base`.
368///
369/// | count | base=300s | result |
370/// |-------|-----------|--------|
371/// | 0     | 300       | 300    |
372/// | 1     | 300       | 300    |
373/// | 2     | 300       | 900    |
374/// | 3     | 300       | 2700   |
375/// | 4     | 300       | 8100   |
376/// | 5-6   | 14400     | 14400 (max, not yet extended) |
377/// | 7-9   | 86400     | 86400 (24h extended tier) |
378/// | 10+   | 172800    | 172800 (48h extended tier) |
379///
380/// The extended tier applies when the model has hit the max cap multiple times,
381/// indicating a persistently broken model that won't self-resolve quickly.
382pub fn compute_backoff(count: u64, base: i64, max: i64, allow_extended: bool) -> i64 {
383    if count <= 1 {
384        return base;
385    }
386    let exp = u32::try_from(count.saturating_sub(1)).unwrap_or(u32::MAX);
387    let factor = 3_i64.saturating_pow(exp);
388    let standard = base.saturating_mul(factor).min(max);
389
390    // Extended tier: after N failures at max backoff, apply longer window
391    // to reduce noise from persistently broken models.
392    //
393    // First find the count at which exponential growth first hits max:
394    // solve: base * 3^(first_max-1) >= max
395    // => 3^(first_max-1) >= max / base
396    // => first_max-1 >= log_3(max / base)
397    // => first_max >= 1 + log_3(max / base)
398    let first_max = if base <= 0 {
399        0
400    } else {
401        // Use integer approximation: count where base * 3^(count-1) would first exceed max
402        let mut accumulated = base;
403        let mut first_max_count = 1u64;
404        while accumulated < max && first_max_count < count {
405            accumulated = accumulated.saturating_mul(3);
406            first_max_count += 1;
407        }
408        first_max_count
409    };
410
411    // Times we've been at the max cap (excluding the first time we hit it)
412    let times_at_max = count.saturating_sub(first_max);
413
414    if !allow_extended {
415        return standard;
416    }
417
418    if standard == max && times_at_max >= EXTENDED_TIER_2_THRESHOLD {
419        max.saturating_mul(EXTENDED_TIER_2_MULTIPLIER)
420    } else if standard == max && times_at_max >= EXTENDED_TIER_1_THRESHOLD {
421        max.saturating_mul(EXTENDED_TIER_1_MULTIPLIER)
422    } else {
423        standard
424    }
425}
426
427/// Read the failure count for a key from KV, increment it, write it back, and return the new count.
428///
429/// Always increments — the backoff formula's `max` parameter prevents the
430/// cooldown duration from growing beyond the cap, so unbounded counts are safe.
431///
432/// Returns 1 when no store is configured (unit-test contexts without a store).
433/// Returns `u64::MAX` on store errors (e.g. lock contention) so backoff applies the cap duration
434/// instead of resetting to the base, preventing rapid re-dispatch during store outages.
435async fn read_and_increment_failure_count(
436    store_opt: Option<&Arc<crate::store::TaskStore>>,
437    key: &str,
438) -> u64 {
439    read_and_increment_failure_count_with_prefix(store_opt, FAILURE_COUNT_PREFIX, key).await
440}
441
442/// Like [`read_and_increment_failure_count`] but with an explicit KV prefix.
443///
444/// Credit exhaustion uses [`CREDIT_FAILURE_COUNT_PREFIX`] so its backoff escalation
445/// is independent of generic agent failures (which use [`FAILURE_COUNT_PREFIX`]).
446async fn read_and_increment_failure_count_with_prefix(
447    store_opt: Option<&Arc<crate::store::TaskStore>>,
448    prefix: &str,
449    key: &str,
450) -> u64 {
451    let kv_key = format!("{prefix}{key}");
452    if let Some(store) = store_opt {
453        match store.kv_increment(&kv_key).await {
454            Ok(n) => n,
455            Err(e) => {
456                tracing::warn!(key = %kv_key, err = %e, "failed to increment failure count — treating as high count for safe backoff");
457                u64::MAX
458            }
459        }
460    } else {
461        1
462    }
463}
464
465/// Reset failure counts for an agent and agent:model combo after a successful run.
466///
467/// Call this from the runner's success path so that the next failure starts
468/// backoff from the base duration again, not from wherever it left off.
469/// Resets both generic and credit-exhaustion failure counters.
470pub async fn record_agent_success(agent_name: &str, model: &str) {
471    let store_opt = cooldown_store().lock().await.clone();
472    if let Some(store) = store_opt {
473        let agent_key = format!("{FAILURE_COUNT_PREFIX}{agent_name}");
474        let credit_key = format!("{CREDIT_FAILURE_COUNT_PREFIX}{agent_name}");
475        if let Err(e) = store.kv_set(&agent_key, "0").await {
476            tracing::warn!(key = agent_key, err = %e, "failed to reset failure count");
477        }
478        if !model.is_empty() {
479            let model_key = format!("{FAILURE_COUNT_PREFIX}{agent_name}:{model}");
480            if let Err(e) = store.kv_set(&model_key, "0").await {
481                tracing::warn!(key = model_key, err = %e, "failed to reset failure count");
482            }
483        }
484        if let Err(e) = store.kv_set(&credit_key, "0").await {
485            tracing::warn!(key = credit_key, err = %e, "failed to reset failure count");
486        }
487    }
488}
489
490/// Record that an agent has failed and should be temporarily avoided.
491///
492/// Applies exponential backoff based on the agent's failure count in KV.
493/// If `error_message` contains "try again at {date}", that vendor date is used
494/// instead of the computed backoff (vendor dates are always authoritative).
495pub async fn record_agent_failure_with_message(agent_name: &str, error_message: &str) {
496    // Vendor-specified retry date takes priority over backoff — but only
497    // if it is in the present or future. If the parsed timestamp is in the
498    // past (stale vendor message), fall through to the exponential backoff
499    // path instead of returning early and bypassing backoff entirely.
500    if let Some(cooldown_until) = parse_retry_at(error_message) {
501        let now = chrono::Utc::now().timestamp();
502        if cooldown_until > now {
503            set_cooldown_async(agent_name, cooldown_until, "agent_error").await;
504            return;
505        }
506        tracing::debug!(
507            agent = agent_name,
508            cooldown_until,
509            "vendor retry_at is in the past — using backoff instead"
510        );
511    }
512
513    let store_opt = cooldown_store().lock().await.clone();
514    let count = read_and_increment_failure_count(store_opt.as_ref(), agent_name).await;
515    let base = crate::engine::router::config::get_agent_backoff_base(agent_name);
516    let cooldown_until =
517        chrono::Utc::now().timestamp() + compute_backoff(count, base, BACKOFF_MAX_SECS, true);
518    set_cooldown_async(agent_name, cooldown_until, "agent_error").await;
519}
520
521/// Detect credit exhaustion from an error message and return the stable
522/// `task_runs.outcome` string for it (see `CreditExhaustionReason::outcome_str`).
523///
524/// Callers that classify `AgentError::RateLimit`/`AgentError::Auth` into an
525/// outcome string should consult this first — generic rate-limit patterns like
526/// "usage limit" also match billing-cycle exhaustion messages (e.g. Kimi's
527/// "You've reached your usage limit for this billing cycle"), which would
528/// otherwise be stored as a plain "rate_limit" outcome.
529pub fn detect_credit_exhaustion_outcome(error_message: &str) -> Option<&'static str> {
530    detect_credit_exhaustion(error_message).map(CreditExhaustionReason::outcome_str)
531}
532
533/// Detect credit exhaustion reasons from error message.
534///
535/// Returns `Some(CreditExhaustionReason)` if the error indicates
536/// out_of_credits or org_level_disabled, which require longer agent-wide cooldowns.
537pub fn detect_credit_exhaustion(error_message: &str) -> Option<CreditExhaustionReason> {
538    let lower = error_message.to_lowercase();
539
540    let overage_patterns = [
541        "overagedisabledreason",
542        "overage_disabled_reason",
543        "overage disabled reason",
544    ];
545
546    if overage_patterns.iter().any(|p| lower.contains(p)) {
547        if lower.contains("out_of_credits") || lower.contains("outofcredits") {
548            return Some(CreditExhaustionReason::OutOfCredits);
549        }
550        if lower.contains("org_level_disabled") || lower.contains("orgdisabled") {
551            return Some(CreditExhaustionReason::OrgLevelDisabled);
552        }
553    }
554
555    let billing_patterns = [
556        "credit balance too low",
557        "credit balance insufficient",
558        "insufficient credit balance",
559        "billing quota exceeded",
560        "billing limit exceeded",
561        "organization has been disabled",
562        "org disabled",
563    ];
564
565    if billing_patterns.iter().any(|p| lower.contains(p)) {
566        return Some(CreditExhaustionReason::OrgLevelDisabled);
567    }
568
569    // Billing cycle exhaustion: monthly quota reset — check before generic quota
570    // patterns because "monthly quota exceeded" would otherwise match "quota exceeded".
571    let billing_cycle_patterns = [
572        "billing cycle",
573        "quota refreshed next cycle",
574        "monthly quota",
575        "quota will be refreshed",
576        "next billing cycle",
577        "refreshed in the next cycle",
578        "weekly/monthly limit exhausted",
579        "limit will reset at",
580        "upgrade to pro", // codex/chatgpt plan usage cap: requires billing upgrade
581        "upgrade your token plan", // minimax: Token Plan usage limit reached
582        "token plan usage limit", // minimax: Token Plan usage limit reached
583        "purchase credits for more", // minimax: ...purchase Credits for more usage
584    ];
585
586    if billing_cycle_patterns.iter().any(|p| lower.contains(p)) {
587        return Some(CreditExhaustionReason::BillingCycleExhausted);
588    }
589
590    if lower.contains("out of credits")
591        || lower.contains("outofcredits")
592        || lower.contains("insufficient funds")
593        || lower.contains("no credits remaining")
594        || lower.contains("insufficient_quota")
595        || lower.contains("quota exceeded")
596        || lower.contains("insufficient balance")
597        || lower.contains("please recharge")
598        || lower.contains("no resource package")
599        || lower.contains("balance exhausted")
600        || lower.contains("402 payment required")
601    {
602        return Some(CreditExhaustionReason::OutOfCredits);
603    }
604
605    None
606}
607
608/// Record an agent-level cooldown for credit exhaustion.
609///
610/// Applies exponential backoff based on the agent's failure count.
611/// All variants use escalating backoff:
612/// - `OutOfCredits`: 1h, 3h, then 8h (credits can be refilled any time), 48h from the 5th failure, 96h from the 8th
613/// - `OrgLevelDisabled`: 2h, 6h, then 8h, 48h from the 5th failure, 96h from the 8th
614/// - `BillingCycleExhausted`: starts at 24h, caps at 7 days (monthly event)
615///
616/// Billing cycle exhaustion was previously a flat 24h, but this caused daily
617/// retry-and-fail cycles when a monthly quota was exhausted (the 24h cooldown
618/// would expire, kimi would be retried, fail immediately, and get another 24h).
619/// Now each recurrence escalates: 24h → 72h → 7d (capped).
620pub async fn record_credit_exhaustion(agent_name: &str, reason: CreditExhaustionReason) {
621    let reason_str = match reason {
622        CreditExhaustionReason::OutOfCredits => "credit_exhaustion_out_of_credits",
623        CreditExhaustionReason::OrgLevelDisabled => "credit_exhaustion_org_level_disabled",
624        CreditExhaustionReason::BillingCycleExhausted => "billing_cycle_exhausted",
625    };
626
627    // A failure from a run that started before the cooldown is the same event
628    if is_in_cooldown_with_reason(agent_name, reason_str) {
629        tracing::debug!(
630            agent = agent_name,
631            reason = reason_str,
632            "credit exhaustion already cooled, not escalating"
633        );
634        return;
635    }
636
637    let store_opt = cooldown_store().lock().await.clone();
638    // Use a distinct failure counter (CREDIT_FAILURE_COUNT_PREFIX) so credit-
639    // exhaustion backoff escalation is independent of generic agent failures.
640    // Previously both paths shared FAILURE_COUNT_PREFIX, causing generic failures
641    // to inflate credit-exhaustion backoff (and vice versa).
642    let count = read_and_increment_failure_count_with_prefix(
643        store_opt.as_ref(),
644        CREDIT_FAILURE_COUNT_PREFIX,
645        agent_name,
646    )
647    .await;
648
649    let (base, max) = match reason {
650        CreditExhaustionReason::OutOfCredits => (CREDIT_BACKOFF_BASE_SECS, CREDIT_BACKOFF_MAX_SECS),
651        CreditExhaustionReason::OrgLevelDisabled => {
652            (ORG_BACKOFF_BASE_SECS, CREDIT_BACKOFF_MAX_SECS)
653        }
654        CreditExhaustionReason::BillingCycleExhausted => {
655            (BILLING_CYCLE_COOLDOWN_SECS, BILLING_CYCLE_MAX_SECS)
656        }
657    };
658    let cooldown_secs = compute_backoff(
659        count,
660        base,
661        max,
662        !matches!(reason, CreditExhaustionReason::BillingCycleExhausted),
663    );
664    let cooldown_until = chrono::Utc::now().timestamp() + cooldown_secs;
665    set_cooldown_async(agent_name, cooldown_until, reason_str).await;
666    tracing::warn!(
667        agent = agent_name,
668        reason = reason_str,
669        attempt = count,
670        cooldown_secs,
671        "credit exhaustion detected: applying exponential agent-wide cooldown"
672    );
673}
674
675// Returns false when the message is no credit exhaustion. Model-scoped billing cycle cools the model only
676pub async fn record_credit_exhaustion_from_message(
677    agent_name: &str,
678    model: Option<&str>,
679    message: &str,
680) -> bool {
681    let Some(reason) = detect_credit_exhaustion(message) else {
682        return false;
683    };
684    match (reason, model) {
685        (CreditExhaustionReason::BillingCycleExhausted, Some(model)) => {
686            record_persistent_model_failure(agent_name, model).await;
687        }
688        _ => record_credit_exhaustion(agent_name, reason).await,
689    }
690    true
691}
692
693/// Record that a specific agent+model combo has failed.
694///
695/// Applies exponential backoff based on the model's failure count in KV.
696pub async fn record_model_failure(agent_name: &str, model: &str) {
697    let key = format!("{agent_name}:{model}");
698    let store_opt = cooldown_store().lock().await.clone();
699    let count = read_and_increment_failure_count(store_opt.as_ref(), &key).await;
700    let base = crate::engine::router::config::get_agent_backoff_base(agent_name);
701    let cooldown_until =
702        chrono::Utc::now().timestamp() + compute_backoff(count, base, BACKOFF_MAX_SECS, true);
703    set_cooldown_async(&key, cooldown_until, "model_error").await;
704}
705
706/// Record a rate-limit event for an agent+model.
707///
708/// If the error message advertises a relative usage window (e.g.
709/// "5-hour usage limit"), the cooldown is applied to the specific model for
710/// that window length. The quota is model-scoped, so the whole agent is not
711/// cooled. If no window is detected, fall back to the generic agent+model
712/// exponential backoff path.
713pub async fn record_rate_limit(agent_name: &str, model: Option<&str>, error_message: &str) {
714    if let Some(window_secs) = parse_relative_usage_window(error_message)
715        && let Some(m) = model
716    {
717        let cooldown_until = chrono::Utc::now()
718            .timestamp()
719            .saturating_add(i64::try_from(window_secs).unwrap_or(i64::MAX));
720        set_model_cooldown(agent_name, m, window_secs, "rate_limit_usage_window").await;
721        tracing::info!(
722            agent = agent_name,
723            model = m,
724            window_secs,
725            cooldown_until,
726            "relative usage window detected: applying model-specific cooldown"
727        );
728        return;
729    }
730
731    record_agent_failure_with_message(agent_name, error_message).await;
732    if let Some(m) = model {
733        record_model_failure(agent_name, m).await;
734    }
735}
736
737/// Record a persistent model-specific failure and apply longer exponential backoff.
738///
739/// Used for failure modes that are commonly broken for long periods (for example
740/// silent exit-0 or opaque provider errors). Starts at 4h and escalates with
741/// the generic backoff formula up to 7 days.
742///
743/// Uses [`PERSISTENT_FAILURE_COUNT_PREFIX`], a counter independent of the generic
744/// `failure_count:` key that `record_agent_success()` resets on every successful
745/// run. Otherwise a single intervening success between persistent failures would
746/// reset escalation back to the 4h base, letting structurally unreliable models
747/// (intermittent parse errors, truncated runs) stay in rotation indefinitely.
748pub async fn record_persistent_model_failure(agent_name: &str, model: &str) {
749    let key = format!("{agent_name}:{model}");
750    let store_opt = cooldown_store().lock().await.clone();
751    let count = read_and_increment_failure_count_with_prefix(
752        store_opt.as_ref(),
753        PERSISTENT_FAILURE_COUNT_PREFIX,
754        &key,
755    )
756    .await;
757    let cooldown_secs = compute_backoff(
758        count,
759        PERSISTENT_MODEL_BACKOFF_BASE_SECS,
760        PERSISTENT_MODEL_BACKOFF_MAX_SECS,
761        false,
762    );
763    let cooldown_until = chrono::Utc::now().timestamp() + cooldown_secs;
764    set_cooldown_async(&key, cooldown_until, "persistent_model_error").await;
765}
766
767/// Record a `ModelUnavailable` failure for an agent+model.
768///
769/// "Not found", "not supported" and region blocks never lift on their own, so they get the
770/// persistent backoff (4h base, 7d max). Any other message gets [`record_model_failure`]
771pub async fn record_model_unavailable(agent_name: &str, model: &str, message: &str) {
772    if is_model_permanently_gone(message) {
773        record_persistent_model_failure(agent_name, model).await;
774    } else {
775        record_model_failure(agent_name, model).await;
776    }
777}
778
779fn is_model_permanently_gone(message: &str) -> bool {
780    let lower = message.to_lowercase();
781    lower.contains("not found")
782        || lower.contains("not supported")
783        || lower.contains("not available in your country")
784        || lower.contains("not available in your region")
785}
786
787/// Record a run that failed without showing the model is gone: empty or cut off output, an
788/// unparseable review, an opaque provider error.
789///
790/// A stopped session or one odd answer causes these, so the backoff is 5 then 15 minutes. Billing
791/// exhaustion and "model not found" keep [`record_persistent_model_failure`]
792pub async fn record_transient_model_failure(agent_name: &str, model: &str) {
793    let key = format!("{agent_name}:{model}");
794    let store_opt = cooldown_store().lock().await.clone();
795    let count = read_and_increment_failure_count(store_opt.as_ref(), &key).await;
796    let cooldown_secs = compute_backoff(
797        count,
798        TRANSIENT_MODEL_BACKOFF_BASE_SECS,
799        TRANSIENT_MODEL_BACKOFF_MAX_SECS,
800        false,
801    );
802    let cooldown_until = chrono::Utc::now().timestamp() + cooldown_secs;
803    set_cooldown_async(&key, cooldown_until, "transient_model_error").await;
804}
805
806/// Set a model cooldown with a custom duration (in seconds) and reason.
807///
808/// Generic model-cooldown setter shared by silence detection and the
809/// rate-limit relative-usage-window path. The reason is persisted with
810/// the cooldown (KV + in-memory entry) so operators can tell the two
811/// apart in `orch cooldown list`.
812pub async fn set_model_cooldown(agent_name: &str, model: &str, duration_secs: u64, reason: &str) {
813    let key = format!("{agent_name}:{model}");
814    let cooldown_until = chrono::Utc::now()
815        .timestamp()
816        .saturating_add(i64::try_from(duration_secs).unwrap_or(i64::MAX));
817    set_cooldown_async(&key, cooldown_until, reason).await;
818}
819
820/// Set a model cooldown that ends at a vendor-provided Unix timestamp.
821pub async fn set_model_cooldown_until(agent_name: &str, model: &str, until: i64) {
822    let key = format!("{agent_name}:{model}");
823    set_cooldown_async(&key, until, "vendor_reset_at").await;
824}
825
826/// Set a short agent-level cooldown (in seconds).
827///
828/// Used by silence detection to temporarily block the whole agent so the
829/// router picks a different one on re-route. Unlike `record_agent_failure`
830/// (exponential backoff), this uses a short duration (typically 120s) —
831/// just enough to force one re-route cycle to a different agent.
832pub async fn set_agent_cooldown(agent_name: &str, duration_secs: u64) {
833    let cooldown_until = chrono::Utc::now()
834        .timestamp()
835        .saturating_add(i64::try_from(duration_secs).unwrap_or(i64::MAX));
836    set_cooldown_async(agent_name, cooldown_until, "silence_agent_cooldown").await;
837}
838
839/// Record a silence detection for an agent+model and apply extended cooldowns
840/// when repeated silences exceed the threshold within the rolling window.
841pub async fn record_silence_detection(agent_name: &str, model: &str) -> Option<SilenceCountResult> {
842    let store_opt = cooldown_store().lock().await.clone();
843    let Some(store) = store_opt else {
844        tracing::debug!(
845            agent = agent_name,
846            model,
847            "skipping silence count record (no KV store)"
848        );
849        return None;
850    };
851
852    let key = format!("{SILENCE_COUNT_PREFIX}{agent_name}:{model}");
853    let now = chrono::Utc::now().timestamp();
854    let window_start = now - SILENCE_COUNT_WINDOW_SECS;
855
856    let mut timestamps = match store.kv_get(&key).await {
857        Ok(Some(raw)) => match serde_json::from_str::<Vec<i64>>(&raw) {
858            Ok(ts) => ts,
859            Err(e) => {
860                tracing::warn!(
861                    kv_key = %key,
862                    err = %e,
863                    "failed to parse silence timestamps from KV — resetting window"
864                );
865                Vec::new()
866            }
867        },
868        Ok(None) => Vec::new(),
869        Err(err) => {
870            tracing::warn!(
871                kv_key = %key,
872                err = %err,
873                "failed to load silence count from KV"
874            );
875            Vec::new()
876        }
877    };
878
879    timestamps.retain(|ts| *ts >= window_start);
880    timestamps.push(now);
881
882    let count = timestamps.len();
883    let mut extended_cooldown_applied = false;
884    if count >= SILENCE_COUNT_THRESHOLD {
885        set_model_cooldown(
886            agent_name,
887            model,
888            SILENCE_EXTENDED_COOLDOWN_SECS,
889            "silence_detected",
890        )
891        .await;
892        extended_cooldown_applied = true;
893        timestamps.clear();
894    }
895
896    let value = serde_json::to_string(&timestamps).unwrap_or_else(|_| "[]".to_string());
897    if let Err(err) = store.kv_set(&key, &value).await {
898        tracing::warn!(kv_key = key, err = %err, "failed to persist silence count");
899    }
900
901    Some(SilenceCountResult {
902        count,
903        extended_cooldown_applied,
904    })
905}
906
907/// Check if a specific agent+model combo is in cooldown.
908pub fn is_model_in_cooldown(agent_name: &str, model: &str) -> bool {
909    let key = format!("{agent_name}:{model}");
910    is_in_cooldown(&key)
911}
912
913/// Check if an agent is currently in cooldown period.
914pub fn is_agent_in_cooldown(agent_name: &str) -> bool {
915    is_in_cooldown(agent_name)
916}
917
918/// Return the cooldown expiry timestamp for a key (agent or agent:model), if active.
919pub fn cooldown_until(key: &str) -> Option<i64> {
920    let map = cooldown_lock();
921    map.get(key).and_then(|entry| {
922        let now = chrono::Utc::now().timestamp();
923        if now < entry.cooldown_until {
924            Some(entry.cooldown_until)
925        } else {
926            None
927        }
928    })
929}
930
931/// Return the reason string recorded when a cooldown was set, if the cooldown is still active.
932pub fn cooldown_reason(key: &str) -> Option<String> {
933    let map = cooldown_lock();
934    map.get(key).and_then(|entry| {
935        let now = chrono::Utc::now().timestamp();
936        if now < entry.cooldown_until {
937            Some(entry.reason.clone())
938        } else {
939            None
940        }
941    })
942}
943
944/// Return all currently active cooldowns as `(key, cooldown_until_unix, reason)`.
945///
946/// Reads from the in-memory map only — always fast, no async needed.
947/// Expired entries are filtered out. Used by `orch cooldown list`.
948pub fn list_all_cooldowns() -> Vec<(String, i64, String)> {
949    let now = chrono::Utc::now().timestamp();
950    let map = cooldown_lock();
951    let mut result: Vec<(String, i64, String)> = map
952        .iter()
953        .filter(|(_, entry)| now < entry.cooldown_until)
954        .map(|(key, entry)| (key.clone(), entry.cooldown_until, entry.reason.clone()))
955        .collect();
956    result.sort_by(|a, b| a.0.cmp(&b.0));
957    result
958}
959
960/// Write `rate_limits_cleared_at:{agent}` = now so the next health check
961/// ignores older `rate_limits` rows. Rows stay in the table for metrics
962pub async fn mark_rate_limits_cleared(store: &Arc<crate::store::TaskStore>, agents: &[String]) {
963    let now = chrono::Utc::now().timestamp().to_string();
964    let mut seen = std::collections::HashSet::new();
965    for key in agents {
966        let agent = agent_from_key(key);
967        if agent.is_empty() || !seen.insert(agent.to_string()) {
968            continue;
969        }
970        let kv_key = format!("{RATE_LIMITS_CLEARED_AT_PREFIX}{agent}");
971        if let Err(e) = store.kv_set(&kv_key, &now).await {
972            tracing::warn!(agent, err = %e, "failed to mark rate limits cleared");
973        }
974    }
975}
976
977/// Clear a specific cooldown by key, or all cooldowns when `key == "*"`.
978///
979/// Removes from the in-memory map, writes a past timestamp to KV, and resets
980/// the corresponding `failure_count:` entries so the next failure starts
981/// backoff from the base duration. Also writes `rate_limits_cleared_at:{agent}`
982/// so a degraded agent held only by rate rows recovers on the next health check
983pub async fn clear_cooldown(key: &str, store: &Arc<crate::store::TaskStore>) {
984    let mut agents_to_mark: Vec<String> = Vec::new();
985    if key == "*" {
986        let keys: Vec<String> = {
987            let mut map = cooldown_lock();
988            let keys: Vec<String> = map.keys().cloned().collect();
989            map.clear();
990            keys
991        };
992        for k in &keys {
993            let kv_key = format!("{KV_PREFIX}{k}");
994            if let Err(e) = store.kv_set(&kv_key, "0").await {
995                tracing::warn!(key = kv_key, err = %e, "failed to clear cooldown");
996            }
997            // Reset failure count so backoff restarts from base.
998            let fc_key = format!("{FAILURE_COUNT_PREFIX}{k}");
999            if let Err(e) = store.kv_set(&fc_key, "0").await {
1000                tracing::warn!(key = fc_key, err = %e, "failed to reset failure count");
1001            }
1002            // Also reset credit-specific failure count.
1003            let credit_fc_key = format!("{CREDIT_FAILURE_COUNT_PREFIX}{k}");
1004            if let Err(e) = store.kv_set(&credit_fc_key, "0").await {
1005                tracing::warn!(key = credit_fc_key, err = %e, "failed to reset credit failure count");
1006            }
1007            // Also reset the persistent-model-failure counter.
1008            let persistent_fc_key = format!("{PERSISTENT_FAILURE_COUNT_PREFIX}{k}");
1009            if let Err(e) = store.kv_set(&persistent_fc_key, "0").await {
1010                tracing::warn!(key = persistent_fc_key, err = %e, "failed to reset persistent failure count");
1011            }
1012        }
1013        // Also reset any persisted failure_count keys that are not in the
1014        // in-memory map (e.g. survived a restart or were set without a
1015        // corresponding in-memory cooldown entry).
1016        if let Ok(fc_entries) = store.kv_list_prefix(FAILURE_COUNT_PREFIX).await {
1017            for (fc_key, _) in fc_entries {
1018                if let Err(e) = store.kv_set(&fc_key, "0").await {
1019                    tracing::warn!(key = fc_key, err = %e, "failed to reset failure count");
1020                }
1021            }
1022        }
1023        if let Ok(cfc_entries) = store.kv_list_prefix(CREDIT_FAILURE_COUNT_PREFIX).await {
1024            for (cfc_key, _) in cfc_entries {
1025                if let Err(e) = store.kv_set(&cfc_key, "0").await {
1026                    tracing::warn!(key = cfc_key, err = %e, "failed to reset credit failure count");
1027                }
1028            }
1029        }
1030        if let Ok(pfc_entries) = store.kv_list_prefix(PERSISTENT_FAILURE_COUNT_PREFIX).await {
1031            for (pfc_key, _) in pfc_entries {
1032                if let Err(e) = store.kv_set(&pfc_key, "0").await {
1033                    tracing::warn!(key = pfc_key, err = %e, "failed to reset persistent failure count");
1034                }
1035            }
1036        }
1037        tracing::info!(
1038            count = keys.len(),
1039            "cleared all cooldowns and failure counts"
1040        );
1041        agents_to_mark.extend(keys.iter().map(|k| agent_from_key(k).to_string()));
1042        match store.rate_limit_agents().await {
1043            Ok(rl_agents) => agents_to_mark.extend(rl_agents),
1044            Err(e) => tracing::warn!(err = %e, "failed to list rate_limits agents while clearing"),
1045        }
1046        match store.kv_list_prefix(RATE_LIMITS_CLEARED_AT_PREFIX).await {
1047            Ok(marks) => {
1048                for (k, _) in marks {
1049                    let agent = k.trim_start_matches(RATE_LIMITS_CLEARED_AT_PREFIX);
1050                    if !agent.is_empty() {
1051                        agents_to_mark.push(agent.to_string());
1052                    }
1053                }
1054            }
1055            Err(e) => tracing::warn!(err = %e, "failed to list rate_limits_cleared_at keys"),
1056        }
1057    } else {
1058        {
1059            let mut map = cooldown_lock();
1060            map.remove(key);
1061        }
1062        let kv_key = format!("{KV_PREFIX}{key}");
1063        if let Err(e) = store.kv_set(&kv_key, "0").await {
1064            tracing::warn!(key = kv_key, err = %e, "failed to clear cooldown");
1065        }
1066        // Reset failure count so backoff restarts from base.
1067        let fc_key = format!("{FAILURE_COUNT_PREFIX}{key}");
1068        if let Err(e) = store.kv_set(&fc_key, "0").await {
1069            tracing::warn!(key = fc_key, err = %e, "failed to reset failure count");
1070        }
1071        // Also reset credit-specific failure count.
1072        let credit_fc_key = format!("{CREDIT_FAILURE_COUNT_PREFIX}{key}");
1073        if let Err(e) = store.kv_set(&credit_fc_key, "0").await {
1074            tracing::warn!(key = credit_fc_key, err = %e, "failed to reset credit failure count");
1075        }
1076        // Also reset the persistent-model-failure counter.
1077        let persistent_fc_key = format!("{PERSISTENT_FAILURE_COUNT_PREFIX}{key}");
1078        if let Err(e) = store.kv_set(&persistent_fc_key, "0").await {
1079            tracing::warn!(key = persistent_fc_key, err = %e, "failed to reset persistent failure count");
1080        }
1081        tracing::info!(key, "cleared cooldown and failure count");
1082        agents_to_mark.push(agent_from_key(key).to_string());
1083    }
1084    mark_rate_limits_cleared(store, &agents_to_mark).await;
1085}
1086
1087/// Set a cooldown with a specific expiry timestamp. Updates in-memory state only.
1088///
1089/// Returns `true` if the cooldown was applied, `false` if an existing longer
1090/// cooldown prevented the update (never-shorten rule).
1091///
1092/// **Callers are responsible for persisting to KV.** Use [`set_cooldown_async`]
1093/// for critical cooldowns that must survive a crash, or this function combined
1094/// with a fire-and-forget `tokio::spawn` for non-critical short cooldowns.
1095fn set_cooldown_in_memory(key: &str, cooldown_until: i64, reason: &str) -> bool {
1096    let mut map = cooldown_lock();
1097    if let Some(existing) = map.get(key) {
1098        // Never shorten an existing cooldown. This prevents a short
1099        // retry window (e.g., generic rate limit) from overriding a longer
1100        // billing-cycle cooldown.
1101        if existing.cooldown_until >= cooldown_until {
1102            return false;
1103        }
1104    }
1105    map.insert(
1106        key.to_string(),
1107        CooldownEntry {
1108            cooldown_until,
1109            reason: reason.to_string(),
1110        },
1111    );
1112    true
1113}
1114
1115/// Set a cooldown with a specific expiry timestamp and persist to KV inline.
1116///
1117/// This is the preferred function for critical cooldowns (credit exhaustion,
1118/// billing cycle, agent/model failures) that must survive a service crash.
1119/// The KV write is awaited before returning, so the caller can be confident
1120/// the cooldown is durable once this function returns `true`.
1121///
1122/// Returns `true` if the cooldown was applied, `false` if an existing longer
1123/// cooldown prevented the update (never-shorten rule).
1124async fn set_cooldown_async(key: &str, cooldown_until: i64, reason: &str) -> bool {
1125    if !set_cooldown_in_memory(key, cooldown_until, reason) {
1126        return false;
1127    }
1128    let store_opt = cooldown_store().lock().await.clone();
1129    if let Some(store) = store_opt {
1130        let kv_key = format!("{KV_PREFIX}{key}");
1131        let value = serde_json::json!({"until": cooldown_until, "reason": reason}).to_string();
1132        if let Err(e) = store.kv_set(&kv_key, &value).await {
1133            tracing::warn!(
1134                kv_key,
1135                err = %e,
1136                "failed to persist cooldown to KV store — rolling back in-memory entry"
1137            );
1138            let mut map = cooldown_lock();
1139            // Only roll back if nothing has superseded our write.
1140            // A racing thread may have set a longer cooldown in the meantime;
1141            // removing unconditionally would erase that valid entry.
1142            if map.get(key).map(|e| e.cooldown_until) == Some(cooldown_until) {
1143                map.remove(key);
1144            }
1145            return false;
1146        }
1147    }
1148    true
1149}
1150
1151/// Helper to acquire the cooldown map lock, logging and recovering on poison.
1152fn cooldown_lock() -> std::sync::MutexGuard<'static, HashMap<String, CooldownEntry>> {
1153    cooldowns().lock().unwrap_or_else(|e| {
1154        tracing::error!("cooldown map lock poisoned — recovering with partial state; backoff and degraded-agent decisions may be incorrect");
1155        e.into_inner()
1156    })
1157}
1158
1159/// Helper to acquire the degraded agents lock, logging and recovering on poison.
1160fn degraded_agents_lock() -> std::sync::MutexGuard<'static, HashMap<String, String>> {
1161    degraded_agents().lock().unwrap_or_else(|e| {
1162        tracing::error!("degraded agents lock poisoned — recovering with partial state; health check decisions may be incorrect");
1163        e.into_inner()
1164    })
1165}
1166
1167/// Helper to acquire the GitHub 5xx timestamps lock, logging and recovering on poison.
1168fn github_5xx_timestamps_lock() -> std::sync::MutexGuard<'static, Vec<i64>> {
1169    github_5xx_timestamps().lock().unwrap_or_else(|e| {
1170        tracing::error!("github 5xx timestamps lock poisoned — recovering with partial state; circuit breaker may operate incorrectly");
1171        e.into_inner()
1172    })
1173}
1174
1175/// Helper to acquire the GitHub 5xx circuit open lock, logging and recovering on poison.
1176fn github_5xx_circuit_open_lock() -> std::sync::MutexGuard<'static, Option<i64>> {
1177    github_5xx_circuit_open().lock().unwrap_or_else(|e| {
1178        tracing::error!("github 5xx circuit open lock poisoned — recovering with partial state; circuit breaker may operate incorrectly");
1179        e.into_inner()
1180    })
1181}
1182
1183/// Check if a key is in cooldown with the given reason.
1184fn is_in_cooldown_with_reason(key: &str, reason: &str) -> bool {
1185    let map = cooldown_lock();
1186    map.get(key).is_some_and(|entry| {
1187        entry.reason == reason && chrono::Utc::now().timestamp() < entry.cooldown_until
1188    })
1189}
1190
1191/// Check if a key is currently in cooldown.
1192fn is_in_cooldown(key: &str) -> bool {
1193    let map = cooldown_lock();
1194    if let Some(entry) = map.get(key) {
1195        let now = chrono::Utc::now().timestamp();
1196        return now < entry.cooldown_until;
1197    }
1198    false
1199}
1200
1201// ---------------------------------------------------------------------------
1202// Degraded-agent tracking (pre-emptive health check)
1203// ---------------------------------------------------------------------------
1204
1205/// Default health check window for recent rate-limit events (hours).
1206pub const DEFAULT_HEALTH_CHECK_WINDOW_HOURS: u32 = 6;
1207
1208/// Default threshold: minimum rate-limit events within the window to consider
1209/// an agent degraded.  A single transient 429 shouldn't mark an agent; we need
1210/// a pattern of repeated failures.
1211pub const DEFAULT_DEGRADED_RATE_LIMIT_THRESHOLD: i64 = 3;
1212
1213/// In-memory map of agents currently flagged as degraded, keyed by agent
1214/// with the reason that holds now
1215fn degraded_agents() -> &'static Mutex<HashMap<String, String>> {
1216    static DEGRADED: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
1217    DEGRADED.get_or_init(|| Mutex::new(HashMap::new()))
1218}
1219
1220/// Check if an agent is currently flagged as degraded by the health check.
1221///
1222/// Degraded means: all configured models are cooled **or** the agent has
1223/// exceeded the rate-limit threshold within the configured lookback window.
1224pub fn is_agent_degraded(agent: &str) -> bool {
1225    let set = degraded_agents_lock();
1226    set.contains_key(agent)
1227}
1228
1229/// Reason the agent is currently degraded, if it is
1230pub fn agent_degraded_reason(agent: &str) -> Option<String> {
1231    degraded_agents_lock().get(agent).cloned()
1232}
1233
1234/// Active degraded agents as `(agent, reason)`, sorted by agent name
1235pub fn list_degraded_agents() -> Vec<(String, String)> {
1236    let mut entries: Vec<(String, String)> = degraded_agents_lock()
1237        .iter()
1238        .map(|(agent, reason)| (agent.clone(), reason.clone()))
1239        .collect();
1240    entries.sort_by(|a, b| a.0.cmp(&b.0));
1241    entries
1242}
1243
1244/// Mark an agent as degraded (called by [`refresh_degraded_agents`]).
1245pub fn mark_agent_degraded(agent: &str) {
1246    mark_agent_degraded_with_reason(agent, "degraded");
1247}
1248
1249fn mark_agent_degraded_with_reason(agent: &str, reason: &str) {
1250    let mut set = degraded_agents_lock();
1251    set.insert(agent.to_string(), reason.to_string());
1252}
1253
1254/// Clear the degraded flag for an agent.
1255pub fn clear_agent_degraded(agent: &str) {
1256    let mut set = degraded_agents_lock();
1257    set.remove(agent);
1258}
1259
1260/// Refresh the degraded-agent set from the rate_limits table and cooldown state.
1261///
1262/// An agent is marked degraded when:
1263/// 1. It is in agent-level cooldown (explicit cooldown KV key exists and hasn't expired), **or**
1264/// 2. All its configured models are in cooldown, **or**
1265/// 3. It has >= `threshold` `limit_type = 'rate'` events within `window_hours`.
1266///
1267/// Failures keep their own cooldowns and do not count toward the threshold
1268/// Agents that no longer meet these criteria are cleared.
1269///
1270/// Note: This function calls `clear_expired_cooldowns()` first to ensure the
1271/// in-memory cooldown map reflects current state. An agent should NOT be marked
1272/// degraded simply because an expired cooldown entry hasn't been cleaned up yet.
1273pub async fn refresh_degraded_agents(
1274    store: &Arc<crate::store::TaskStore>,
1275    available_agents: &[String],
1276    model_checker: &(dyn Fn(&str) -> bool + Send + Sync),
1277    window_hours: u32,
1278    threshold: i64,
1279) {
1280    // Clear expired cooldowns from in-memory map before evaluating agent state.
1281    // This ensures we don't incorrectly mark agents as degraded due to stale
1282    // in-memory entries that should have expired but weren't cleaned up.
1283    clear_expired_cooldowns();
1284
1285    // Time the DB query to surface slow health-checks that can dominate the
1286    // engine tick latency. If the query fails, log and return early.
1287    let start = chrono::Utc::now();
1288    let counts = match store.recent_rate_limit_counts(window_hours).await {
1289        Ok(c) => {
1290            let dur_ms = (chrono::Utc::now() - start).num_milliseconds();
1291            tracing::info!(
1292                duration_ms = dur_ms,
1293                window_hours,
1294                "recent_rate_limit_counts query completed"
1295            );
1296            c
1297        }
1298        Err(e) => {
1299            let dur_ms = (chrono::Utc::now() - start).num_milliseconds();
1300            tracing::warn!(
1301                err = %e,
1302                duration_ms = dur_ms,
1303                "failed to query recent rate limit counts for health check"
1304            );
1305            return;
1306        }
1307    };
1308
1309    for agent in available_agents {
1310        let in_cooldown = is_agent_in_cooldown(agent);
1311        let no_models = !model_checker(agent);
1312        let rate_limit_count = counts.get(agent.as_str()).copied().unwrap_or(0);
1313        let over_threshold = rate_limit_count >= threshold;
1314
1315        let degraded = in_cooldown || no_models || over_threshold;
1316
1317        if degraded {
1318            let reason = if in_cooldown {
1319                "agent in cooldown"
1320            } else if no_models {
1321                "all models cooled"
1322            } else {
1323                "rate limit threshold exceeded"
1324            };
1325            let previous = agent_degraded_reason(agent);
1326            match previous.as_deref() {
1327                None => tracing::warn!(
1328                    agent,
1329                    reason,
1330                    rate_limit_count,
1331                    window_hours,
1332                    threshold,
1333                    "pre-emptive health check: marking agent as degraded"
1334                ),
1335                Some(prev) if prev != reason => tracing::warn!(
1336                    agent,
1337                    reason,
1338                    previous = prev,
1339                    rate_limit_count,
1340                    window_hours,
1341                    threshold,
1342                    "pre-emptive health check: degraded reason changed"
1343                ),
1344                Some(_) => {}
1345            }
1346            mark_agent_degraded_with_reason(agent, reason);
1347        } else {
1348            if is_agent_degraded(agent) {
1349                tracing::info!(
1350                    agent,
1351                    rate_limit_count,
1352                    "pre-emptive health check: agent recovered, clearing degraded flag"
1353                );
1354            }
1355            clear_agent_degraded(agent);
1356        }
1357    }
1358}
1359
1360/// Clear expired cooldowns from the in-memory map.
1361pub fn clear_expired_cooldowns() {
1362    let mut map = cooldown_lock();
1363    let now = chrono::Utc::now().timestamp();
1364    map.retain(|_key, entry| now < entry.cooldown_until);
1365}
1366
1367// ---------------------------------------------------------------------------
1368// GitHub 5xx circuit breaker
1369// ---------------------------------------------------------------------------
1370
1371/// Sliding window for counting GitHub 5xx errors (seconds).
1372const GITHUB_5XX_WINDOW_SECS: i64 = 120;
1373
1374/// Number of 5xx errors within the window to trip the circuit breaker.
1375const GITHUB_5XX_THRESHOLD: usize = 5;
1376
1377/// Duration the circuit breaker stays open after tripping (seconds).
1378const GITHUB_5XX_COOLDOWN_SECS: i64 = 180;
1379
1380/// Global sliding window of 5xx error timestamps.
1381fn github_5xx_timestamps() -> &'static Mutex<Vec<i64>> {
1382    static TS: OnceLock<Mutex<Vec<i64>>> = OnceLock::new();
1383    TS.get_or_init(|| Mutex::new(Vec::new()))
1384}
1385
1386/// Whether the GitHub 5xx circuit breaker is currently open (tripped).
1387fn github_5xx_circuit_open() -> &'static Mutex<Option<i64>> {
1388    static OPEN: OnceLock<Mutex<Option<i64>>> = OnceLock::new();
1389    OPEN.get_or_init(|| Mutex::new(None))
1390}
1391
1392/// Record a GitHub 5xx server error and trip the circuit breaker if the
1393/// sliding window threshold is exceeded.
1394///
1395/// Emits a single high-level log entry when the circuit transitions to open.
1396pub async fn record_github_5xx() {
1397    let now = chrono::Utc::now().timestamp();
1398    let window_start = now - GITHUB_5XX_WINDOW_SECS;
1399
1400    let should_trip = {
1401        let mut ts = github_5xx_timestamps_lock();
1402        ts.retain(|t| *t >= window_start);
1403        ts.push(now);
1404        ts.len() >= GITHUB_5XX_THRESHOLD
1405    };
1406
1407    if should_trip {
1408        let cooldown_until = {
1409            let mut open = github_5xx_circuit_open_lock();
1410            if open.is_none() {
1411                let cd = now + GITHUB_5XX_COOLDOWN_SECS;
1412                *open = Some(cd);
1413                tracing::warn!(
1414                    errors_in_window = GITHUB_5XX_THRESHOLD,
1415                    window_secs = GITHUB_5XX_WINDOW_SECS,
1416                    cooldown_secs = GITHUB_5XX_COOLDOWN_SECS,
1417                    "GitHub 5xx circuit breaker OPEN — throttling non-critical work"
1418                );
1419                Some(cd)
1420            } else {
1421                None
1422            }
1423        };
1424        // Persist to KV (outside the mutex lock so the future is Send).
1425        if let Some(cd) = cooldown_until {
1426            let store_opt = cooldown_store().lock().await.clone();
1427            if let Some(store) = store_opt {
1428                let _ = store.kv_set("cooldown:github:5xx", &cd.to_string()).await;
1429            }
1430            // Also set the generic cooldown so is_agent_in_cooldown("github:5xx")
1431            // returns true immediately, avoiding a race window where concurrent
1432            // requests bypass the circuit breaker.
1433            set_agent_cooldown("github:5xx", GITHUB_5XX_COOLDOWN_SECS as u64).await;
1434        }
1435        // Clear the sliding window after tripping so we don't re-trip immediately
1436        let mut ts = github_5xx_timestamps_lock();
1437        ts.clear();
1438    }
1439}
1440
1441/// Check if the GitHub 5xx circuit breaker is currently open.
1442///
1443/// When open, non-critical background work (cleanup, polling, mention scans)
1444/// should be skipped. Critical operations (PR creation) should still attempt
1445/// with their existing exponential backoff.
1446///
1447/// Automatically recovers (logs a recovery message) when the cooldown expires.
1448pub fn is_github_circuit_open() -> bool {
1449    // Check the dedicated in-memory circuit flag first. If present, honour it
1450    // and perform auto-recovery when it expires (logging once on close).
1451    {
1452        let mut open = github_5xx_circuit_open_lock();
1453        if let Some(until) = *open {
1454            let now = chrono::Utc::now().timestamp();
1455            if now >= until {
1456                *open = None;
1457                tracing::info!("GitHub 5xx circuit breaker CLOSED — resuming normal operations");
1458                return false;
1459            }
1460            return true;
1461        }
1462    }
1463
1464    // If the dedicated flag is not set, consult the generic cooldown map so
1465    // callers see a consistent view regardless of which code path set the
1466    // persisted cooldown (e.g. send_with_retries -> set_agent_cooldown).
1467    if let Some(until) = cooldown_until("github:5xx") {
1468        // Populate the dedicated in-memory flag so future checks and the
1469        // auto-recovery path go through the same code and logging.
1470        let mut open = github_5xx_circuit_open_lock();
1471        if open.is_none() {
1472            *open = Some(until);
1473            let now = chrono::Utc::now().timestamp();
1474            if now < until {
1475                let remaining = until - now;
1476                tracing::warn!(
1477                    remaining_secs = remaining,
1478                    "GitHub 5xx circuit breaker OPEN (discovered via generic cooldown) — throttling non-critical work"
1479                );
1480            }
1481        }
1482        return true;
1483    }
1484
1485    false
1486}
1487
1488/// Returns the remaining seconds of the GitHub 5xx circuit breaker cooldown,
1489/// or 0 if the circuit is closed.
1490pub fn github_circuit_remaining_secs() -> u64 {
1491    // Prefer the dedicated in-memory value when present.
1492    {
1493        let open = github_5xx_circuit_open_lock();
1494        if let Some(until) = *open {
1495            let now = chrono::Utc::now().timestamp();
1496            if now < until {
1497                return u64::try_from(until - now).unwrap_or(0);
1498            }
1499            return 0;
1500        }
1501    }
1502
1503    // Fall back to the generic cooldown map if the dedicated flag isn't set.
1504    if let Some(until) = cooldown_until("github:5xx") {
1505        let now = chrono::Utc::now().timestamp();
1506        if now < until {
1507            return u64::try_from(until - now).unwrap_or(0);
1508        }
1509    }
1510    0
1511}
1512
1513/// Parse a "try again at {date}" or "reset at {date}" from an error message.
1514///
1515/// Returns a Unix timestamp if a retry-at date is found.
1516pub(crate) fn parse_retry_at(error_message: &str) -> Option<i64> {
1517    if error_message.is_empty() {
1518        return None;
1519    }
1520
1521    let lower = error_message.to_lowercase();
1522    // Accept a wider set of vendor-provided markers. Claude commonly uses
1523    // variations like "resets 10:30am" (no explicit "at"), so include
1524    // both "resets" and the older "reset at"/"try again at" markers.
1525    for retry_marker in [
1526        "try again at ",
1527        "reset at ",
1528        "resets at ",
1529        "resets ",
1530        "reset ",
1531    ] {
1532        if let Some(idx) = lower.find(retry_marker) {
1533            let date_str = &lower[idx + retry_marker.len()..];
1534            // Take until period, newline, or end
1535            let date_str = date_str
1536                .split(['.', '\n'])
1537                .next()
1538                .unwrap_or(date_str)
1539                .trim();
1540
1541            // Try common date formats
1542            // "Mar 26th, 2026 5:55 AM" → strip ordinal suffixes
1543            let cleaned = date_str
1544                .replace("st,", ",")
1545                .replace("nd,", ",")
1546                .replace("rd,", ",")
1547                .replace("th,", ",");
1548
1549            for fmt in &[
1550                "%b %d, %Y %I:%M %p", // Mar 26, 2026 5:55 AM
1551                "%b %d, %Y %I:%M%p",  // Mar 26, 2026 5:55AM
1552                "%B %d, %Y %I:%M %p", // March 26, 2026 5:55 AM
1553                "%Y-%m-%dT%H:%M:%S",  // 2026-03-26T05:55:00
1554                "%Y-%m-%d %H:%M:%S",  // 2026-03-26 05:55:00
1555                "%Y-%m-%d %H:%M",     // 2026-03-26 05:55
1556            ] {
1557                if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, fmt) {
1558                    // Vendor messages (e.g. Codex/GLM) emit times in the machine's
1559                    // local timezone, not UTC. Interpret naive datetime as local time.
1560                    use chrono::TimeZone;
1561                    let utc = match chrono::Local.from_local_datetime(&dt) {
1562                        chrono::LocalResult::Single(local_dt) => {
1563                            local_dt.with_timezone(&chrono::Utc)
1564                        }
1565                        chrono::LocalResult::Ambiguous(earliest, _) => {
1566                            // DST fold: pick the earlier (conservative) interpretation
1567                            earliest.with_timezone(&chrono::Utc)
1568                        }
1569                        chrono::LocalResult::None => {
1570                            // DST gap: fall back to treating as UTC to avoid panic
1571                            dt.and_utc()
1572                        }
1573                    };
1574                    tracing::info!(
1575                        retry_at = %utc,
1576                        raw = date_str,
1577                        marker = retry_marker,
1578                        "parsed rate limit retry-at date"
1579                    );
1580                    return Some(utc.timestamp());
1581                }
1582            }
1583            tracing::debug!(
1584                raw = date_str,
1585                marker = retry_marker,
1586                "could not parse retry-at date"
1587            );
1588
1589            // If the vendor provided only a time (e.g. "resets 10:30am"),
1590            // attempt to parse a time-only and interpret it as the next
1591            // occurrence of that time in the local timezone.
1592            // Strip parenthesised timezone hints like "(America/Sao_Paulo)".
1593            let time_only = date_str.split_once('(').map_or(date_str, |(t, _)| t).trim();
1594            // Hour-only values like "8am" have no minute field, which chrono needs, so insert ":00"
1595            let mut compact = time_only.replace(" am", "am").replace(" pm", "pm");
1596            let split = compact.rfind(' ').map_or(0, |i| i + 1);
1597            let last = &compact[split..];
1598            if !last.contains(':')
1599                && (last.ends_with("am") || last.ends_with("pm"))
1600                && last.len() > 2
1601            {
1602                let at = split + last.len() - 2;
1603                compact.insert_str(at, ":00");
1604            }
1605            // "Jun 9 at 1am" has no year, so take the next future occurrence
1606            let md = compact.replace(" at ", " ");
1607            let now_local = chrono::Local::now();
1608            let this_year = chrono::Datelike::year(&now_local);
1609            for year in [this_year, this_year + 1] {
1610                let with_year = format!("{year} {md}");
1611                if let Ok(dt) =
1612                    chrono::NaiveDateTime::parse_from_str(&with_year, "%Y %b %d %I:%M%p")
1613                {
1614                    use chrono::TimeZone;
1615                    if let Some(local_dt) = chrono::Local.from_local_datetime(&dt).earliest()
1616                        && local_dt > now_local
1617                    {
1618                        return Some(local_dt.timestamp());
1619                    }
1620                }
1621            }
1622            let time_only = compact.as_str();
1623            for tf in &["%I:%M %p", "%I:%M%p", "%I %p", "%H:%M"] {
1624                if let Ok(t) = chrono::NaiveTime::parse_from_str(time_only, tf) {
1625                    // Build a NaiveDateTime for today at that time, then choose
1626                    // today or tomorrow depending on whether the time has passed.
1627                    let local_now = chrono::Local::now();
1628                    let today = local_now.date_naive();
1629                    let candidate = chrono::NaiveDateTime::new(today, t);
1630                    // Use the TimeZone trait methods; annotate types so the
1631                    // compiler can infer the concrete DateTime types.
1632                    let candidate_local: chrono::DateTime<chrono::Local> =
1633                        match chrono::Local.from_local_datetime(&candidate) {
1634                            chrono::LocalResult::Single(dt) => dt,
1635                            chrono::LocalResult::Ambiguous(earliest, _) => earliest,
1636                            chrono::LocalResult::None => {
1637                                // DST gap — fall back to UTC interpretation
1638                                let utc_dt = candidate.and_utc();
1639                                return Some(utc_dt.timestamp());
1640                            }
1641                        };
1642                    let chosen = if candidate_local <= local_now {
1643                        // Use tomorrow
1644                        let tomorrow = today.succ_opt().unwrap_or(today);
1645                        let ndt = chrono::NaiveDateTime::new(tomorrow, t);
1646                        match chrono::Local.from_local_datetime(&ndt) {
1647                            chrono::LocalResult::Single(dt) => dt.with_timezone(&chrono::Utc),
1648                            chrono::LocalResult::Ambiguous(earliest, _) => {
1649                                earliest.with_timezone(&chrono::Utc)
1650                            }
1651                            chrono::LocalResult::None => ndt.and_utc(),
1652                        }
1653                    } else {
1654                        candidate_local.with_timezone(&chrono::Utc)
1655                    };
1656                    tracing::info!(
1657                        retry_at = %chosen,
1658                        raw = date_str,
1659                        marker = retry_marker,
1660                        "parsed rate limit retry-at time-only; interpreted as next occurrence"
1661                    );
1662                    return Some(chosen.timestamp());
1663                }
1664            }
1665        }
1666    }
1667
1668    // Without a specific retry/reset date, return None and let the caller use
1669    // its default cooldown (exponential backoff). A blanket long fallback here
1670    // would cause false billing-cycle cooldowns on agents without those limits.
1671    None
1672}
1673
1674/// Parse a relative usage window (e.g. "5-hour usage limit") from an error
1675/// message.
1676///
1677/// Returns the window length in seconds. Only matches in usage/quota/limit
1678/// contexts ("usage limit", "quota", "window", "limit") to avoid interpreting
1679/// unrelated numbers as cooldown durations.
1680///
1681/// `pub(crate)` so `engine::runner::fallback::handle_error()` can apply the
1682/// same window-detection the `needs_review` call site
1683/// (`record_rate_limit()`, below) already uses — see issue #3580.
1684pub(crate) fn parse_relative_usage_window(error_message: &str) -> Option<u64> {
1685    static WINDOW_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
1686        Regex::new(
1687            r"(?i)(?:^|\s|[\(\)\.,;:!?])(?:(\d+)\s*-?\s*(hour|hours|hr|hrs|minute|minutes|min|mins|day|days)|(hourly|daily|weekly|monthly))(?:$|\s|[\(\)\.,;:!?])"
1688        )
1689        .expect("usage window regex is valid")
1690    });
1691
1692    if error_message.is_empty() {
1693        return None;
1694    }
1695
1696    let lower = error_message.to_lowercase();
1697    let has_context = lower.contains("usage limit")
1698        || lower.contains("quota")
1699        || lower.contains("window")
1700        || lower.contains("limit");
1701    if !has_context {
1702        return None;
1703    }
1704
1705    let mut max_secs: Option<u64> = None;
1706    for caps in WINDOW_RE.captures_iter(&lower) {
1707        let secs = if let Some(num) = caps.get(1) {
1708            let n: u64 = num.as_str().parse().ok()?;
1709            let unit = caps.get(2)?.as_str().to_lowercase();
1710            let multiplier = match unit.as_str() {
1711                "hour" | "hours" | "hr" | "hrs" => 3600,
1712                "minute" | "minutes" | "min" | "mins" => 60,
1713                "day" | "days" => 86400,
1714                _ => return None,
1715            };
1716            n.checked_mul(multiplier)?
1717        } else if let Some(word) = caps.get(3) {
1718            match word.as_str().to_lowercase().as_str() {
1719                "hourly" => 3600,
1720                "daily" => 86400,
1721                "weekly" => 604_800,
1722                "monthly" => 2_592_000,
1723                _ => return None,
1724            }
1725        } else {
1726            continue;
1727        };
1728        max_secs = Some(max_secs.map_or(secs, |m| m.max(secs)));
1729    }
1730
1731    max_secs
1732}
1733
1734#[cfg(test)]
1735pub(crate) async fn reset_global_state() {
1736    cooldowns().lock().unwrap().clear();
1737    *cooldown_store().lock().await = None;
1738    github_5xx_timestamps().lock().unwrap().clear();
1739    *github_5xx_circuit_open().lock().unwrap() = None;
1740    degraded_agents().lock().unwrap().clear();
1741    crate::engine::runner::agents::opencode::reset_model_caches_for_test();
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746    use super::*;
1747    use serial_test::serial;
1748
1749    async fn test_store() -> Arc<crate::store::TaskStore> {
1750        Arc::new(
1751            crate::store::TaskStore::open_memory()
1752                .await
1753                .expect("in-memory store"),
1754        )
1755    }
1756
1757    fn sqlite_datetime_offset(secs: i64) -> String {
1758        (chrono::Utc::now() + chrono::Duration::seconds(secs))
1759            .format("%Y-%m-%d %H:%M:%S")
1760            .to_string()
1761    }
1762
1763    #[serial(cooldown_state)]
1764    #[tokio::test]
1765    async fn init_loads_unexpired_cooldowns_from_kv() {
1766        super::reset_global_state().await;
1767        let store = test_store().await;
1768        let cooldown_until = chrono::Utc::now().timestamp() + 3600; // 1 hour from now
1769
1770        store
1771            .kv_set(
1772                &format!("{KV_PREFIX}kimi:k2p5"),
1773                &cooldown_until.to_string(),
1774            )
1775            .await
1776            .unwrap();
1777
1778        // Also add an already-expired entry
1779        let expired = chrono::Utc::now().timestamp() - 1;
1780        store
1781            .kv_set(
1782                &format!("{KV_PREFIX}opencode:old-free"),
1783                &expired.to_string(),
1784            )
1785            .await
1786            .unwrap();
1787
1788        {
1789            let mut map = cooldown_lock();
1790            map.clear();
1791        }
1792
1793        init_cooldown_store(store).await;
1794
1795        assert!(
1796            is_model_in_cooldown("kimi", "k2p5"),
1797            "unexpired cooldown should be loaded"
1798        );
1799        assert!(
1800            !is_model_in_cooldown("opencode", "old-free"),
1801            "expired cooldown should not be loaded"
1802        );
1803    }
1804
1805    #[serial(cooldown_state)]
1806    #[tokio::test]
1807    async fn credit_exhaustion_inside_active_cooldown_keeps_counter_at_one() {
1808        super::reset_global_state().await;
1809        let store = test_store().await;
1810        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1811
1812        let msg =
1813            "API error (status 402 Payment Required): usage balance exhausted, out of credits";
1814        assert!(record_credit_exhaustion_from_message("credit_dup_agent", Some("m"), msg).await);
1815        assert!(record_credit_exhaustion_from_message("credit_dup_agent", Some("m"), msg).await);
1816
1817        assert!(is_agent_in_cooldown("credit_dup_agent"));
1818        let count = store
1819            .kv_get(&format!("{CREDIT_FAILURE_COUNT_PREFIX}credit_dup_agent"))
1820            .await
1821            .unwrap();
1822        assert_eq!(count.as_deref(), Some("1"));
1823    }
1824
1825    #[serial(cooldown_state)]
1826    #[tokio::test]
1827    async fn non_credit_message_is_not_recorded_as_credit_exhaustion() {
1828        super::reset_global_state().await;
1829        assert!(!record_credit_exhaustion_from_message("x_agent", Some("m"), "boom").await);
1830        assert!(!is_agent_in_cooldown("x_agent"));
1831    }
1832
1833    #[serial(cooldown_state)]
1834    #[tokio::test]
1835    async fn record_model_failure_writes_to_kv() {
1836        super::reset_global_state().await;
1837        let store = test_store().await;
1838
1839        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1840
1841        record_model_failure("testagent_persist", "testmodel_persist").await;
1842
1843        // Give the async KV write a moment to settle before querying.
1844        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1845
1846        let kv_key = format!("{KV_PREFIX}testagent_persist:testmodel_persist");
1847        let stored = store.kv_get(&kv_key).await.unwrap();
1848        assert!(stored.is_some(), "model failure should be persisted to KV");
1849        let (ts, reason) = parse_cooldown_value(&stored.unwrap()).expect("cooldown value");
1850        let now = chrono::Utc::now().timestamp();
1851        // Should be in the future (cooldown_until, not failed_at)
1852        assert!(ts > now, "persisted timestamp should be in the future");
1853        assert_eq!(reason, "model_error");
1854    }
1855
1856    #[serial(cooldown_state)]
1857    #[tokio::test]
1858    async fn cooldown_reason_survives_restart_via_kv() {
1859        super::reset_global_state().await;
1860        let store = test_store().await;
1861        let agent = "test_reason_restart";
1862        let future = chrono::Utc::now().timestamp() + 600;
1863
1864        init_cooldown_store(std::sync::Arc::clone(&store)).await;
1865        set_cooldown_async(agent, future, "billing_cycle_exhausted").await;
1866
1867        // Simulate a service restart: drop the in-memory map, reload from KV.
1868        {
1869            let mut map = cooldown_lock();
1870            map.clear();
1871        }
1872        init_cooldown_store(store).await;
1873
1874        let entries = list_all_cooldowns();
1875        let entry = entries.iter().find(|(key, ..)| key == agent);
1876        assert_eq!(
1877            entry.map(|(_, _, reason)| reason.as_str()),
1878            Some("billing_cycle_exhausted"),
1879            "cooldown reason must survive a restart, not collapse to 'persisted'"
1880        );
1881    }
1882
1883    #[serial(cooldown_state)]
1884    #[tokio::test]
1885    async fn record_persistent_model_failure_escalates_and_increments_failure_count() {
1886        super::reset_global_state().await;
1887        let store = test_store().await;
1888        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1889
1890        let agent = "testagent_persistent";
1891        let model = "testmodel_persistent";
1892        let key = format!("{agent}:{model}");
1893
1894        // First failure -> base 4h.
1895        record_persistent_model_failure(agent, model).await;
1896        let now = chrono::Utc::now().timestamp();
1897        let first_until = cooldown_until(&key).expect("first cooldown should exist");
1898        let first_remaining = first_until.saturating_sub(now);
1899        assert!(
1900            first_remaining >= PERSISTENT_MODEL_BACKOFF_BASE_SECS - 5,
1901            "first persistent model cooldown should be ~4h, got {first_remaining}s"
1902        );
1903
1904        // Second failure -> 12h (3x growth).
1905        record_persistent_model_failure(agent, model).await;
1906        let now2 = chrono::Utc::now().timestamp();
1907        let second_until = cooldown_until(&key).expect("second cooldown should exist");
1908        let second_remaining = second_until.saturating_sub(now2);
1909        assert!(
1910            second_remaining >= (PERSISTENT_MODEL_BACKOFF_BASE_SECS * 3) - 5,
1911            "second persistent model cooldown should be ~12h, got {second_remaining}s"
1912        );
1913
1914        let count_key = format!("{PERSISTENT_FAILURE_COUNT_PREFIX}{agent}:{model}");
1915        let stored_count = store
1916            .kv_get(&count_key)
1917            .await
1918            .expect("kv read should succeed")
1919            .expect("failure count should be written");
1920        assert_eq!(stored_count, "2");
1921    }
1922
1923    #[serial(cooldown_state)]
1924    #[tokio::test]
1925    async fn record_agent_success_does_not_reset_persistent_failure_count() {
1926        super::reset_global_state().await;
1927        let store = test_store().await;
1928        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1929
1930        let agent = "testagent_persistent_success";
1931        let model = "testmodel_persistent_success";
1932        let key = format!("{agent}:{model}");
1933
1934        // Two persistent failures escalate the counter to 2 (~12h cooldown).
1935        record_persistent_model_failure(agent, model).await;
1936        record_persistent_model_failure(agent, model).await;
1937
1938        let count_key = format!("{PERSISTENT_FAILURE_COUNT_PREFIX}{agent}:{model}");
1939        let stored_count = store
1940            .kv_get(&count_key)
1941            .await
1942            .expect("kv read should succeed")
1943            .expect("failure count should be written");
1944        assert_eq!(stored_count, "2");
1945
1946        // A successful run resets the generic failure_count but must NOT touch
1947        // the persistent counter — otherwise an intervening success would erase
1948        // escalation progress for a structurally unreliable model.
1949        record_agent_success(agent, model).await;
1950
1951        let stored_count_after_success = store
1952            .kv_get(&count_key)
1953            .await
1954            .expect("kv read should succeed")
1955            .expect("persistent failure count should still exist");
1956        assert_eq!(
1957            stored_count_after_success, "2",
1958            "persistent failure count must survive an intervening success"
1959        );
1960
1961        // The next persistent failure should escalate from 3 (~36h), not reset to base (4h).
1962        record_persistent_model_failure(agent, model).await;
1963        let now = chrono::Utc::now().timestamp();
1964        let third_until = cooldown_until(&key).expect("third cooldown should exist");
1965        let third_remaining = third_until.saturating_sub(now);
1966        assert!(
1967            third_remaining > PERSISTENT_MODEL_BACKOFF_BASE_SECS,
1968            "third persistent model failure should escalate past the 4h base, got {third_remaining}s"
1969        );
1970    }
1971
1972    #[serial(cooldown_state)]
1973    #[tokio::test]
1974    async fn record_persistent_model_failure_stays_capped_at_7_days() {
1975        super::reset_global_state().await;
1976        let store = test_store().await;
1977        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1978
1979        let agent = "testagent_persistent_capped";
1980        let model = "testmodel_persistent_capped";
1981        let key = format!("{agent}:{model}");
1982
1983        for _ in 0..9 {
1984            record_persistent_model_failure(agent, model).await;
1985        }
1986
1987        let remaining = cooldown_until(&key)
1988            .expect("cooldown should exist")
1989            .saturating_sub(chrono::Utc::now().timestamp());
1990        assert!(
1991            remaining >= PERSISTENT_MODEL_BACKOFF_MAX_SECS - 5,
1992            "persistent model cooldown should stay capped at 7 days, got {remaining}s"
1993        );
1994        assert!(
1995            remaining <= PERSISTENT_MODEL_BACKOFF_MAX_SECS + 5,
1996            "persistent model cooldown should not exceed 7 days, got {remaining}s"
1997        );
1998    }
1999
2000    #[serial(cooldown_state)]
2001    #[test]
2002    fn parse_retry_at_codex_format() {
2003        use chrono::Datelike;
2004        use chrono::TimeZone;
2005
2006        let msg = "You've hit your usage limit. Upgrade to Pro, visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Mar 26th, 2026 5:55 AM.";
2007        let ts = parse_retry_at(msg);
2008        assert!(ts.is_some(), "should parse codex retry-at date");
2009
2010        // The parsed timestamp must equal "Mar 26 2026 05:55 local" in UTC.
2011        // We reconstruct what the function should produce and compare directly.
2012        let naive = chrono::NaiveDateTime::new(
2013            chrono::NaiveDate::from_ymd_opt(2026, 3, 26).unwrap(),
2014            chrono::NaiveTime::from_hms_opt(5, 55, 0).unwrap(),
2015        );
2016        let expected_utc = match chrono::Local.from_local_datetime(&naive) {
2017            chrono::LocalResult::Single(local_dt) => local_dt.with_timezone(&chrono::Utc),
2018            chrono::LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&chrono::Utc),
2019            chrono::LocalResult::None => naive.and_utc(),
2020        };
2021        assert_eq!(
2022            ts.unwrap(),
2023            expected_utc.timestamp(),
2024            "timestamp should reflect local-timezone interpretation of the vendor date"
2025        );
2026
2027        // Sanity: the UTC date must still be March 26 (true for all timezones within ±14h)
2028        let dt = chrono::DateTime::from_timestamp(ts.unwrap(), 0).unwrap();
2029        assert_eq!(dt.month(), 3);
2030        assert_eq!(dt.day(), 26);
2031    }
2032
2033    #[serial(cooldown_state)]
2034    #[test]
2035    fn parse_retry_at_hour_only_and_month_day() {
2036        let now = chrono::Utc::now().timestamp();
2037        for msg in [
2038            "You've hit your session limit · resets 8am (America/Sao_Paulo)",
2039            "You've hit your session limit · resets 6pm (America/Sao_Paulo)",
2040            "You've hit your session limit · resets 12am (America/Sao_Paulo)",
2041            "You've hit your weekly limit · resets Jun 9 at 1am (America/Sao_Paulo)",
2042        ] {
2043            let ts = parse_retry_at(msg).unwrap_or_else(|| panic!("should parse: {msg}"));
2044            assert!(ts > now, "{msg}");
2045        }
2046    }
2047
2048    #[serial(cooldown_state)]
2049    #[test]
2050    fn parse_retry_at_weekly_resets_without_year() {
2051        use chrono::Datelike;
2052        let t = chrono::Local::now() + chrono::Duration::days(2);
2053        let msg = format!(
2054            "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
2055            t.format("%b"),
2056            t.day()
2057        );
2058        let ts = parse_retry_at(&msg).expect("should parse year-less reset date");
2059        let remaining = ts - chrono::Utc::now().timestamp();
2060        assert!(
2061            remaining > 3600 && remaining < 3 * 86400,
2062            "expected ~2d retry-at, got {remaining}s"
2063        );
2064    }
2065
2066    #[serial(cooldown_state)]
2067    #[test]
2068    fn parse_retry_at_no_date() {
2069        assert!(parse_retry_at("generic rate limit error").is_none());
2070        assert!(parse_retry_at("").is_none());
2071    }
2072
2073    #[serial(cooldown_state)]
2074    #[test]
2075    fn parse_retry_at_glm_reset_format_with_seconds() {
2076        use chrono::TimeZone;
2077
2078        let msg = "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-04-23 18:33:48";
2079        let ts = parse_retry_at(msg).expect("should parse GLM reset-at date");
2080
2081        let naive = chrono::NaiveDateTime::new(
2082            chrono::NaiveDate::from_ymd_opt(2026, 4, 23).expect("valid date"),
2083            chrono::NaiveTime::from_hms_opt(18, 33, 48).expect("valid time"),
2084        );
2085        let expected_utc = match chrono::Local.from_local_datetime(&naive) {
2086            chrono::LocalResult::Single(local_dt) => local_dt.with_timezone(&chrono::Utc),
2087            chrono::LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&chrono::Utc),
2088            chrono::LocalResult::None => naive.and_utc(),
2089        };
2090
2091        assert_eq!(
2092            ts,
2093            expected_utc.timestamp(),
2094            "timestamp should reflect local-timezone interpretation of vendor reset date"
2095        );
2096    }
2097
2098    #[serial(cooldown_state)]
2099    #[test]
2100    fn parse_retry_at_billing_cycle_without_date_returns_none() {
2101        // Without a specific "try again at" date, parse_retry_at should return
2102        // None so callers use their default cooldown (30m agent / 1h model).
2103        let msg = "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle. Upgrade to get more.";
2104        assert!(
2105            parse_retry_at(msg).is_none(),
2106            "billing cycle without date should return None"
2107        );
2108    }
2109
2110    #[serial(cooldown_state)]
2111    #[test]
2112    fn parse_relative_usage_window_extracts_kimi_five_hour_window() {
2113        let msg = "Failed to authenticate. API Error: 403 You've reached your 5-hour usage limit. Your quota will reset when the current 5-hour window expires.";
2114        assert_eq!(
2115            parse_relative_usage_window(msg),
2116            Some(5 * 3600),
2117            "should extract the 5-hour window as seconds"
2118        );
2119    }
2120
2121    #[serial(cooldown_state)]
2122    #[test]
2123    fn parse_relative_usage_window_handles_common_durations() {
2124        assert_eq!(
2125            parse_relative_usage_window("You've reached your hourly usage limit."),
2126            Some(3600)
2127        );
2128        assert_eq!(
2129            parse_relative_usage_window("You've reached your daily usage limit."),
2130            Some(86400)
2131        );
2132        assert_eq!(
2133            parse_relative_usage_window("You've reached your weekly limit."),
2134            Some(604_800)
2135        );
2136        assert_eq!(
2137            parse_relative_usage_window("You've reached your 30-minute usage limit."),
2138            Some(1800)
2139        );
2140        assert_eq!(
2141            parse_relative_usage_window("You've reached your 2-day quota."),
2142            Some(172_800)
2143        );
2144    }
2145
2146    #[serial(cooldown_state)]
2147    #[test]
2148    fn parse_relative_usage_window_returns_none_without_usage_context() {
2149        // A bare rate-limit message with a number should not be interpreted as a
2150        // relative usage window unless usage/quota/window context is present.
2151        assert!(parse_relative_usage_window("429 Too Many Requests").is_none());
2152        assert!(parse_relative_usage_window("rate limit exceeded").is_none());
2153        assert!(parse_relative_usage_window("").is_none());
2154    }
2155
2156    #[serial(cooldown_state)]
2157    #[tokio::test]
2158    async fn record_rate_limit_uses_model_specific_window_cooldown() {
2159        super::reset_global_state().await;
2160        let store = test_store().await;
2161        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2162
2163        let agent = "test_record_rl_window_agent";
2164        let model = "test_record_rl_window_model";
2165        let msg = "You've reached your 5-hour usage limit. Your quota will reset when the current 5-hour window expires.";
2166
2167        record_rate_limit(agent, Some(model), msg).await;
2168
2169        assert!(
2170            is_model_in_cooldown(agent, model),
2171            "model should be cooled for the relative usage window"
2172        );
2173        assert!(
2174            !is_agent_in_cooldown(agent),
2175            "agent should not be cooled for a model-scoped quota"
2176        );
2177
2178        let key = format!("{agent}:{model}");
2179        let remaining = cooldown_until(&key)
2180            .expect("model cooldown should exist")
2181            .saturating_sub(chrono::Utc::now().timestamp());
2182        assert!(
2183            remaining >= 5 * 3600 - 5,
2184            "model cooldown should be at least ~5 hours, got {remaining}s"
2185        );
2186        assert_eq!(
2187            cooldown_reason(&key).as_deref(),
2188            Some("rate_limit_usage_window"),
2189            "relative usage-window cooldown must be labeled as a rate limit, not silence"
2190        );
2191    }
2192
2193    #[serial(cooldown_state)]
2194    #[tokio::test]
2195    async fn record_rate_limit_without_window_falls_back_to_agent_and_model() {
2196        super::reset_global_state().await;
2197        let store = test_store().await;
2198        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2199
2200        let agent = "test_record_rl_fallback_agent";
2201        let model = "test_record_rl_fallback_model";
2202        let msg = "429 Too Many Requests";
2203
2204        record_rate_limit(agent, Some(model), msg).await;
2205
2206        assert!(
2207            is_agent_in_cooldown(agent),
2208            "agent should be cooled when no relative window is detected"
2209        );
2210        assert!(
2211            is_model_in_cooldown(agent, model),
2212            "model should be cooled when no relative window is detected"
2213        );
2214    }
2215
2216    #[serial(cooldown_state)]
2217    #[tokio::test]
2218    async fn agent_cooldown_persists_to_kv() {
2219        super::reset_global_state().await;
2220        // Agent-level cooldowns persist to KV via set_cooldown_async
2221        let agent = "test_agent_persist_check";
2222        record_agent_failure_with_message(agent, "").await;
2223        assert!(is_agent_in_cooldown(agent));
2224    }
2225
2226    #[serial(cooldown_state)]
2227    #[tokio::test]
2228    async fn cooldown_never_shortens() {
2229        super::reset_global_state().await;
2230        let agent = "test_agent_no_shorten";
2231        set_agent_cooldown(agent, 24 * 60 * 60).await;
2232        let initial = {
2233            let map = cooldowns().lock().unwrap();
2234            map.get(agent)
2235                .expect("cooldown entry should exist")
2236                .cooldown_until
2237        };
2238
2239        // Shorter cooldown attempt should not override the existing one.
2240        record_agent_failure_with_message(agent, "rate limit").await;
2241        let after = {
2242            let map = cooldowns().lock().unwrap();
2243            map.get(agent)
2244                .expect("cooldown entry should exist")
2245                .cooldown_until
2246        };
2247        assert_eq!(
2248            initial, after,
2249            "cooldown should not be shortened by later failures"
2250        );
2251    }
2252
2253    #[serial(cooldown_state)]
2254    #[tokio::test]
2255    async fn silence_agent_cooldown_is_short_lived() {
2256        super::reset_global_state().await;
2257        let agent = "test_silence_agent_cd";
2258        assert!(!is_agent_in_cooldown(agent));
2259
2260        set_agent_cooldown(agent, SILENCE_AGENT_COOLDOWN_SECS).await;
2261        assert!(is_agent_in_cooldown(agent));
2262
2263        // Verify it's a short cooldown (120s), not the exponential backoff one
2264        let map = cooldowns().lock().unwrap();
2265        let entry = map.get(agent).expect("should have cooldown entry");
2266        let now = chrono::Utc::now().timestamp();
2267        let remaining = entry.cooldown_until - now;
2268        assert!(
2269            remaining <= i64::try_from(SILENCE_AGENT_COOLDOWN_SECS).unwrap(),
2270            "silence agent cooldown should be <= {SILENCE_AGENT_COOLDOWN_SECS}s, got {remaining}s"
2271        );
2272        assert!(remaining > 0, "cooldown should still be active");
2273    }
2274
2275    #[serial(cooldown_state)]
2276    #[tokio::test]
2277    async fn record_silence_detection_applies_extended_cooldown() {
2278        super::reset_global_state().await;
2279        let store = test_store().await;
2280        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2281
2282        let agent = "test_silence_count_agent";
2283        let model = "test_silence_count_model";
2284        assert!(!is_model_in_cooldown(agent, model));
2285
2286        for _ in 0..SILENCE_COUNT_THRESHOLD {
2287            let result = record_silence_detection(agent, model).await;
2288            assert!(result.is_some());
2289        }
2290
2291        assert!(is_model_in_cooldown(agent, model));
2292        let key = format!("{agent}:{model}");
2293        let remaining = {
2294            let map = cooldowns().lock().unwrap();
2295            let entry = map.get(&key).expect("cooldown entry should exist");
2296            entry.cooldown_until - chrono::Utc::now().timestamp()
2297        };
2298        assert!(
2299            remaining >= i64::try_from(SILENCE_EXTENDED_COOLDOWN_SECS).unwrap() - 5,
2300            "extended cooldown should be applied, got {remaining}s"
2301        );
2302
2303        let kv_key = format!("{SILENCE_COUNT_PREFIX}{agent}:{model}");
2304        let stored = store.kv_get(&kv_key).await.unwrap().unwrap();
2305        assert_eq!(stored, "[]");
2306    }
2307
2308    #[serial(cooldown_state)]
2309    #[test]
2310    fn detect_credit_exhaustion_out_of_credits() {
2311        let msg = "Error: API error - {\"error\":{\"message\":\"You exceeded your current quota\",\"type\":\"insufficient_quota\",\"param\":null,\"code\":\"insufficient_quota\"}}";
2312        assert_eq!(
2313            detect_credit_exhaustion(msg),
2314            Some(CreditExhaustionReason::OutOfCredits)
2315        );
2316    }
2317
2318    #[serial(cooldown_state)]
2319    #[test]
2320    fn detect_credit_exhaustion_out_of_credits_variations() {
2321        assert_eq!(
2322            detect_credit_exhaustion("out of credits"),
2323            Some(CreditExhaustionReason::OutOfCredits)
2324        );
2325        assert_eq!(
2326            detect_credit_exhaustion("outofcredits"),
2327            Some(CreditExhaustionReason::OutOfCredits)
2328        );
2329        assert_eq!(
2330            detect_credit_exhaustion("no credits remaining"),
2331            Some(CreditExhaustionReason::OutOfCredits)
2332        );
2333        assert_eq!(
2334            detect_credit_exhaustion("insufficient funds"),
2335            Some(CreditExhaustionReason::OutOfCredits)
2336        );
2337    }
2338
2339    #[serial(cooldown_state)]
2340    #[test]
2341    fn detect_credit_exhaustion_org_level_disabled() {
2342        let msg = "Error: API error - {\"error\":{\"message\":\"Organization has been disabled\",\"type\":\"org_disabled\",\"code\":\"overageDisabledReason:org_level_disabled\"}}";
2343        assert_eq!(
2344            detect_credit_exhaustion(msg),
2345            Some(CreditExhaustionReason::OrgLevelDisabled)
2346        );
2347    }
2348
2349    #[serial(cooldown_state)]
2350    #[test]
2351    fn detect_credit_exhaustion_org_level_disabled_variations() {
2352        assert_eq!(
2353            detect_credit_exhaustion("organization has been disabled"),
2354            Some(CreditExhaustionReason::OrgLevelDisabled)
2355        );
2356        assert_eq!(
2357            detect_credit_exhaustion("org disabled"),
2358            Some(CreditExhaustionReason::OrgLevelDisabled)
2359        );
2360        assert_eq!(
2361            detect_credit_exhaustion("billing quota exceeded"),
2362            Some(CreditExhaustionReason::OrgLevelDisabled)
2363        );
2364        assert_eq!(
2365            detect_credit_exhaustion("credit balance too low"),
2366            Some(CreditExhaustionReason::OrgLevelDisabled)
2367        );
2368    }
2369
2370    #[serial(cooldown_state)]
2371    #[test]
2372    fn detect_credit_exhaustion_overage_disabled_reason_parsing() {
2373        let msg = "overageDisabledReason: out_of_credits";
2374        assert_eq!(
2375            detect_credit_exhaustion(msg),
2376            Some(CreditExhaustionReason::OutOfCredits)
2377        );
2378
2379        let msg = "overageDisabledReason: org_level_disabled";
2380        assert_eq!(
2381            detect_credit_exhaustion(msg),
2382            Some(CreditExhaustionReason::OrgLevelDisabled)
2383        );
2384    }
2385
2386    #[serial(cooldown_state)]
2387    #[test]
2388    fn detect_credit_exhaustion_glm_insufficient_balance() {
2389        // GLM/ZhipuAI 429: "Insufficient balance or no resource package. Please recharge."
2390        assert_eq!(
2391            detect_credit_exhaustion(
2392                "API Error: Request rejected (429) · Insufficient balance or no resource package. Please recharge."
2393            ),
2394            Some(CreditExhaustionReason::OutOfCredits)
2395        );
2396        assert_eq!(
2397            detect_credit_exhaustion("Insufficient balance"),
2398            Some(CreditExhaustionReason::OutOfCredits)
2399        );
2400        assert_eq!(
2401            detect_credit_exhaustion("please recharge your account"),
2402            Some(CreditExhaustionReason::OutOfCredits)
2403        );
2404        assert_eq!(
2405            detect_credit_exhaustion("no resource package available"),
2406            Some(CreditExhaustionReason::OutOfCredits)
2407        );
2408    }
2409
2410    #[serial(cooldown_state)]
2411    #[test]
2412    fn detect_credit_exhaustion_none_for_regular_rate_limit() {
2413        assert!(detect_credit_exhaustion("rate limit exceeded").is_none());
2414        assert!(detect_credit_exhaustion("too many requests").is_none());
2415        assert!(detect_credit_exhaustion("429 Too Many Requests").is_none());
2416        assert!(detect_credit_exhaustion("you've hit your usage limit").is_none());
2417        assert!(
2418            detect_credit_exhaustion("You've hit your usage limit, try again in a few minutes.")
2419                .is_none()
2420        );
2421    }
2422
2423    #[serial(cooldown_state)]
2424    #[tokio::test]
2425    async fn record_credit_exhaustion_applies_agent_cooldown() {
2426        super::reset_global_state().await;
2427        let agent = "test_credit_exhaust_agent";
2428        assert!(!is_agent_in_cooldown(agent));
2429
2430        record_credit_exhaustion(agent, CreditExhaustionReason::OutOfCredits).await;
2431        assert!(is_agent_in_cooldown(agent));
2432
2433        let remaining = {
2434            let map = cooldowns().lock().unwrap();
2435            let entry = map.get(agent).expect("cooldown entry should exist");
2436            entry.cooldown_until - chrono::Utc::now().timestamp()
2437        };
2438        // First failure: base = 1h
2439        assert!(
2440            remaining >= CREDIT_BACKOFF_BASE_SECS - 5,
2441            "first out_of_credits cooldown should be ~1 hour, got {remaining}s"
2442        );
2443        assert!(
2444            remaining <= CREDIT_BACKOFF_MAX_SECS,
2445            "cooldown should not exceed cap of 8h, got {remaining}s"
2446        );
2447    }
2448
2449    #[serial(cooldown_state)]
2450    #[tokio::test]
2451    async fn record_credit_exhaustion_org_level_applies_longer_cooldown() {
2452        super::reset_global_state().await;
2453        let agent = "test_org_disabled_agent";
2454        assert!(!is_agent_in_cooldown(agent));
2455
2456        record_credit_exhaustion(agent, CreditExhaustionReason::OrgLevelDisabled).await;
2457        assert!(is_agent_in_cooldown(agent));
2458
2459        let remaining = {
2460            let map = cooldowns().lock().unwrap();
2461            let entry = map.get(agent).expect("cooldown entry should exist");
2462            entry.cooldown_until - chrono::Utc::now().timestamp()
2463        };
2464        // First failure: base = 2h (ORG_BACKOFF_BASE_SECS)
2465        assert!(
2466            remaining >= ORG_BACKOFF_BASE_SECS - 5,
2467            "first org-level disabled cooldown should be ~2 hours, got {remaining}s"
2468        );
2469        assert!(
2470            remaining <= CREDIT_BACKOFF_MAX_SECS,
2471            "cooldown should not exceed cap of 8h, got {remaining}s"
2472        );
2473    }
2474
2475    #[serial(cooldown_state)]
2476    #[tokio::test]
2477    async fn record_credit_exhaustion_billing_cycle_applies_24h() {
2478        super::reset_global_state().await;
2479        let store = test_store().await;
2480        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2481
2482        let agent = "test_billing_cycle_agent";
2483        assert!(!is_agent_in_cooldown(agent));
2484
2485        record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2486        assert!(is_agent_in_cooldown(agent));
2487
2488        let remaining = {
2489            let map = cooldowns().lock().unwrap();
2490            let entry = map.get(agent).expect("cooldown entry should exist");
2491            entry.cooldown_until - chrono::Utc::now().timestamp()
2492        };
2493        // First failure: base = 24h
2494        assert!(
2495            remaining >= BILLING_CYCLE_COOLDOWN_SECS - 5,
2496            "first billing cycle cooldown should be ~24 hours, got {remaining}s"
2497        );
2498        assert!(
2499            remaining <= BILLING_CYCLE_MAX_SECS,
2500            "cooldown should not exceed cap of 7 days, got {remaining}s"
2501        );
2502    }
2503
2504    #[serial(cooldown_state)]
2505    #[tokio::test]
2506    async fn record_credit_exhaustion_billing_cycle_escalates() {
2507        super::reset_global_state().await;
2508        let store = test_store().await;
2509        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2510
2511        let agent = "test_billing_cycle_escalation";
2512
2513        // First failure: 24h
2514        record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2515        let remaining_1 = {
2516            let map = cooldowns().lock().unwrap();
2517            let entry = map.get(agent).expect("cooldown entry should exist");
2518            entry.cooldown_until - chrono::Utc::now().timestamp()
2519        };
2520        assert!(
2521            (BILLING_CYCLE_COOLDOWN_SECS - 5..=BILLING_CYCLE_COOLDOWN_SECS + 5)
2522                .contains(&remaining_1),
2523            "first billing cycle cooldown should be ~24h, got {remaining_1}s"
2524        );
2525
2526        // Clear in-memory cooldown (but NOT failure count) to allow next set_cooldown_async
2527        {
2528            let mut map = cooldowns().lock().unwrap();
2529            map.remove(agent);
2530        }
2531
2532        // Second failure: 24h * 3 = 72h (3 days)
2533        record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2534        let remaining_2 = {
2535            let map = cooldowns().lock().unwrap();
2536            let entry = map.get(agent).expect("cooldown entry should exist");
2537            entry.cooldown_until - chrono::Utc::now().timestamp()
2538        };
2539        let expected_2 = BILLING_CYCLE_COOLDOWN_SECS * 3; // 72h
2540        assert!(
2541            remaining_2 >= expected_2 - 5,
2542            "second billing cycle cooldown should be ~72h, got {remaining_2}s"
2543        );
2544
2545        // Clear in-memory cooldown again
2546        {
2547            let mut map = cooldowns().lock().unwrap();
2548            map.remove(agent);
2549        }
2550
2551        // Third failure: 24h * 9 = 216h → capped at 7 days (168h)
2552        record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2553        let remaining_3 = {
2554            let map = cooldowns().lock().unwrap();
2555            let entry = map.get(agent).expect("cooldown entry should exist");
2556            entry.cooldown_until - chrono::Utc::now().timestamp()
2557        };
2558        assert!(
2559            remaining_3 >= BILLING_CYCLE_MAX_SECS - 5,
2560            "third billing cycle cooldown should be capped at 7 days, got {remaining_3}s"
2561        );
2562        assert!(
2563            remaining_3 <= BILLING_CYCLE_MAX_SECS + 5,
2564            "third billing cycle cooldown should not exceed 7 days, got {remaining_3}s"
2565        );
2566    }
2567
2568    #[serial(cooldown_state)]
2569    #[tokio::test]
2570    async fn record_credit_exhaustion_billing_cycle_stays_capped_after_repeats() {
2571        super::reset_global_state().await;
2572        let store = test_store().await;
2573        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2574
2575        let agent = "test_billing_cycle_agent_capped";
2576
2577        for i in 0..5 {
2578            if i > 0 {
2579                // Each repeat is a new exhaustion after the previous cooldown ended
2580                cooldowns().lock().unwrap().remove(agent);
2581            }
2582            record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2583        }
2584
2585        let remaining = {
2586            let map = cooldowns().lock().unwrap();
2587            let entry = map.get(agent).expect("cooldown entry should exist");
2588            entry.cooldown_until - chrono::Utc::now().timestamp()
2589        };
2590        assert!(
2591            remaining >= BILLING_CYCLE_MAX_SECS - 5,
2592            "billing cycle cooldown should stay capped at 7 days, got {remaining}s"
2593        );
2594        assert!(
2595            remaining <= BILLING_CYCLE_MAX_SECS + 5,
2596            "billing cycle cooldown should not exceed 7 days, got {remaining}s"
2597        );
2598    }
2599
2600    #[serial(cooldown_state)]
2601    #[test]
2602    fn credit_exhaustion_backoff_constants() {
2603        // OutOfCredits first failure: 1h base
2604        assert_eq!(
2605            compute_backoff(1, CREDIT_BACKOFF_BASE_SECS, CREDIT_BACKOFF_MAX_SECS, true),
2606            CREDIT_BACKOFF_BASE_SECS
2607        );
2608        // OrgLevelDisabled first failure: 2h base
2609        assert_eq!(
2610            compute_backoff(1, ORG_BACKOFF_BASE_SECS, CREDIT_BACKOFF_MAX_SECS, true),
2611            ORG_BACKOFF_BASE_SECS
2612        );
2613        // BillingCycleExhausted: 24h base, 7d cap
2614        assert_eq!(BILLING_CYCLE_COOLDOWN_SECS, 24 * 60 * 60);
2615        assert_eq!(BILLING_CYCLE_MAX_SECS, 7 * 24 * 60 * 60);
2616    }
2617
2618    #[serial(cooldown_state)]
2619    #[test]
2620    fn compute_backoff_long_cap_without_extended_stays_capped() {
2621        assert_eq!(
2622            compute_backoff(
2623                3,
2624                BILLING_CYCLE_COOLDOWN_SECS,
2625                BILLING_CYCLE_MAX_SECS,
2626                false
2627            ),
2628            BILLING_CYCLE_MAX_SECS,
2629            "7-day capped paths should stop at the documented max"
2630        );
2631        assert_eq!(
2632            compute_backoff(
2633                9,
2634                PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2635                PERSISTENT_MODEL_BACKOFF_MAX_SECS,
2636                false,
2637            ),
2638            PERSISTENT_MODEL_BACKOFF_MAX_SECS,
2639            "persistent model failures should not extend past 7 days"
2640        );
2641    }
2642
2643    // ---- Degraded-agent tracking tests ----
2644
2645    #[serial(cooldown_state)]
2646    #[test]
2647    fn mark_and_check_agent_degraded() {
2648        let agent = "test_degraded_mark";
2649        assert!(!is_agent_degraded(agent));
2650        mark_agent_degraded(agent);
2651        assert!(is_agent_degraded(agent));
2652        clear_agent_degraded(agent);
2653        assert!(!is_agent_degraded(agent));
2654    }
2655
2656    #[serial(cooldown_state)]
2657    #[tokio::test]
2658    async fn sync_from_kv_drops_in_memory_entry_cleared_externally() {
2659        super::reset_global_state().await;
2660        let store = test_store().await;
2661        let agent = "test_sync_external_clear";
2662        let future = chrono::Utc::now().timestamp() + 600;
2663
2664        // Register the store and put a cooldown in both memory and KV.
2665        init_cooldown_store(std::sync::Arc::clone(&store)).await;
2666        set_cooldown_async(agent, future, "test").await;
2667        assert!(is_agent_in_cooldown(agent));
2668
2669        // Simulate `orch cooldown clear --all` from a separate CLI process: it
2670        // can only write KV (to "0"); the running engine's in-memory map is
2671        // untouched.
2672        store
2673            .kv_set(&format!("{KV_PREFIX}{agent}"), "0")
2674            .await
2675            .expect("kv_set tombstone");
2676        assert!(
2677            is_agent_in_cooldown(agent),
2678            "in-memory cooldown should still be present before sync"
2679        );
2680
2681        sync_from_kv(&store).await;
2682
2683        assert!(
2684            !is_agent_in_cooldown(agent),
2685            "sync_from_kv must drop in-memory entries the KV tombstoned"
2686        );
2687    }
2688
2689    #[serial(cooldown_state)]
2690    #[tokio::test]
2691    async fn sync_from_kv_closes_github_circuit_when_row_cleared() {
2692        super::reset_global_state().await;
2693        let store = test_store().await;
2694        {
2695            *github_5xx_circuit_open_lock() = Some(chrono::Utc::now().timestamp() + 600);
2696            github_5xx_timestamps_lock().push(chrono::Utc::now().timestamp());
2697        }
2698        assert!(is_github_circuit_open());
2699
2700        store
2701            .kv_set("cooldown:github:5xx", "0")
2702            .await
2703            .expect("kv_set tombstone");
2704        sync_from_kv(&store).await;
2705
2706        assert!(!is_github_circuit_open());
2707        assert!(github_5xx_timestamps_lock().is_empty());
2708    }
2709
2710    #[serial(cooldown_state)]
2711    #[tokio::test]
2712    async fn sync_from_kv_keeps_5xx_window_when_expired_row_and_breaker_closed() {
2713        super::reset_global_state().await;
2714        let store = test_store().await;
2715        let now = chrono::Utc::now().timestamp();
2716        {
2717            github_5xx_timestamps_lock().push(now);
2718            github_5xx_timestamps_lock().push(now);
2719        }
2720
2721        store
2722            .kv_set("cooldown:github:5xx", &(now - 600).to_string())
2723            .await
2724            .expect("kv_set past");
2725        sync_from_kv(&store).await;
2726
2727        assert!(!is_github_circuit_open());
2728        assert_eq!(github_5xx_timestamps_lock().len(), 2);
2729
2730        github_5xx_timestamps_lock().clear();
2731    }
2732
2733    #[serial(cooldown_state)]
2734    #[tokio::test]
2735    async fn sync_from_kv_keeps_github_circuit_open_for_future_row() {
2736        super::reset_global_state().await;
2737        let store = test_store().await;
2738        let future = chrono::Utc::now().timestamp() + 600;
2739        *github_5xx_circuit_open_lock() = Some(future);
2740
2741        store
2742            .kv_set("cooldown:github:5xx", &future.to_string())
2743            .await
2744            .expect("kv_set future");
2745        sync_from_kv(&store).await;
2746
2747        assert!(is_github_circuit_open());
2748
2749        *github_5xx_circuit_open_lock() = None;
2750    }
2751
2752    #[serial(cooldown_state)]
2753    #[tokio::test]
2754    async fn sync_from_kv_keeps_in_memory_entry_when_kv_row_missing() {
2755        // Race window: set_cooldown_async writes memory first, then KV. If a
2756        // sync runs between those two writes, the KV row is missing — we must
2757        // leave the in-memory entry alone, not drop it.
2758        super::reset_global_state().await;
2759        let store = test_store().await;
2760        let agent = "test_sync_race_window";
2761        let future = chrono::Utc::now().timestamp() + 600;
2762
2763        // Simulate the race: memory has the entry, KV does not.
2764        set_cooldown_in_memory(agent, future, "raced");
2765        assert!(is_agent_in_cooldown(agent));
2766
2767        sync_from_kv(&store).await;
2768
2769        assert!(
2770            is_agent_in_cooldown(agent),
2771            "sync_from_kv must NOT drop entries whose KV row is missing — that is the set_cooldown_async race window"
2772        );
2773    }
2774
2775    #[serial(cooldown_state)]
2776    #[tokio::test]
2777    async fn sync_from_kv_adopts_kv_timestamp() {
2778        // KV is the source of truth: if KV has a different active timestamp,
2779        // memory adopts it (covers external manual KV edits).
2780        super::reset_global_state().await;
2781        let store = test_store().await;
2782        let agent = "test_sync_adopt_kv";
2783        let original = chrono::Utc::now().timestamp() + 600;
2784        let updated = chrono::Utc::now().timestamp() + 1200;
2785
2786        init_cooldown_store(std::sync::Arc::clone(&store)).await;
2787        set_cooldown_async(agent, original, "initial").await;
2788
2789        store
2790            .kv_set(&format!("{KV_PREFIX}{agent}"), &updated.to_string())
2791            .await
2792            .expect("kv_set updated");
2793
2794        sync_from_kv(&store).await;
2795
2796        let until = cooldown_until(agent).expect("cooldown still active");
2797        assert_eq!(
2798            until, updated,
2799            "sync_from_kv must adopt KV's authoritative timestamp"
2800        );
2801    }
2802
2803    #[serial(cooldown_state)]
2804    #[tokio::test]
2805    async fn refresh_degraded_agents_marks_cooled_agent() {
2806        super::reset_global_state().await;
2807        let store = test_store().await;
2808        let agent = "test_refresh_cooled";
2809
2810        // Put agent in cooldown
2811        record_agent_failure_with_message(agent, "").await;
2812        assert!(is_agent_in_cooldown(agent));
2813
2814        // model_checker always returns true (agent has models)
2815        let agents = vec![agent.to_string()];
2816        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
2817
2818        assert!(
2819            is_agent_degraded(agent),
2820            "agent in cooldown should be marked degraded"
2821        );
2822
2823        // Cleanup
2824        clear_agent_degraded(agent);
2825    }
2826
2827    #[serial(cooldown_state)]
2828    #[tokio::test]
2829    async fn refresh_degraded_agents_marks_no_models_agent() {
2830        super::reset_global_state().await;
2831        let store = test_store().await;
2832        let agent = "test_refresh_no_models";
2833
2834        // model_checker returns false (no available models)
2835        let agents = vec![agent.to_string()];
2836        refresh_degraded_agents(&store, &agents, &|_| false, 6, 3).await;
2837
2838        assert!(
2839            is_agent_degraded(agent),
2840            "agent with no available models should be marked degraded"
2841        );
2842
2843        // Cleanup
2844        clear_agent_degraded(agent);
2845    }
2846
2847    #[serial(cooldown_state)]
2848    #[tokio::test]
2849    async fn refresh_degraded_agents_marks_rate_limited_agent() {
2850        super::reset_global_state().await;
2851        let store = test_store().await;
2852        let agent = "test_refresh_rate_limited";
2853
2854        // Insert rate limit events exceeding threshold
2855        for _ in 0..4 {
2856            store.record_rate_limit(agent, "rate", None).await.unwrap();
2857        }
2858
2859        let agents = vec![agent.to_string()];
2860        // threshold=3, window=6h — 4 events should trigger degraded
2861        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
2862
2863        assert!(
2864            is_agent_degraded(agent),
2865            "agent with rate limit count >= threshold should be marked degraded"
2866        );
2867
2868        // Cleanup
2869        clear_agent_degraded(agent);
2870    }
2871
2872    #[serial(cooldown_state)]
2873    #[tokio::test]
2874    async fn refresh_degraded_agents_clears_healthy_agent() {
2875        super::reset_global_state().await;
2876        let store = test_store().await;
2877        let agent = "test_refresh_healthy";
2878
2879        // Pre-mark as degraded
2880        mark_agent_degraded(agent);
2881        assert!(is_agent_degraded(agent));
2882
2883        // No cooldown, has models, no rate limit events
2884        let agents = vec![agent.to_string()];
2885        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
2886
2887        assert!(
2888            !is_agent_degraded(agent),
2889            "healthy agent should have degraded flag cleared"
2890        );
2891    }
2892
2893    #[serial(cooldown_state)]
2894    #[tokio::test]
2895    async fn refresh_degraded_agents_ignores_expired_cooldowns() {
2896        super::reset_global_state().await;
2897        let store = test_store().await;
2898        let agent = "test_refresh_expired_cooldown";
2899
2900        // Manually insert an already-expired cooldown entry directly into the map
2901        // (simulating a stale entry that wasn't cleaned up)
2902        let expired_ts = chrono::Utc::now().timestamp() - 100; // 100 seconds ago
2903        {
2904            let mut map = cooldowns().lock().unwrap();
2905            map.insert(
2906                agent.to_string(),
2907                CooldownEntry {
2908                    cooldown_until: expired_ts,
2909                    reason: "expired_test".to_string(),
2910                },
2911            );
2912        }
2913
2914        // Verify the expired entry is in the map but should not cause degraded status
2915        assert!(
2916            !is_agent_in_cooldown(agent),
2917            "expired cooldown should not be active"
2918        );
2919
2920        // Pre-mark as degraded to verify it gets cleared
2921        mark_agent_degraded(agent);
2922        assert!(is_agent_degraded(agent));
2923
2924        // Run refresh — should clear degraded because cooldown is expired
2925        let agents = vec![agent.to_string()];
2926        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
2927
2928        assert!(
2929            !is_agent_degraded(agent),
2930            "agent with only expired cooldown should NOT be marked degraded"
2931        );
2932
2933        // Cleanup
2934        clear_agent_degraded(agent);
2935        {
2936            let mut map = cooldowns().lock().unwrap();
2937            map.remove(agent);
2938        }
2939    }
2940
2941    #[serial(cooldown_state)]
2942    #[test]
2943    fn compute_backoff_grows_exponentially() {
2944        // count=0 or 1: base
2945        assert_eq!(compute_backoff(0, 300, 14400, true), 300);
2946        assert_eq!(compute_backoff(1, 300, 14400, true), 300);
2947        // count=2: base * 3
2948        assert_eq!(compute_backoff(2, 300, 14400, true), 900);
2949        // count=3: base * 9
2950        assert_eq!(compute_backoff(3, 300, 14400, true), 2700);
2951        // count=4: base * 27 = 8100 < 14400
2952        assert_eq!(compute_backoff(4, 300, 14400, true), 8100);
2953        // count=5: base * 81 = 24300 → capped at 14400
2954        assert_eq!(compute_backoff(5, 300, 14400, true), 14400);
2955    }
2956
2957    #[serial(cooldown_state)]
2958    #[test]
2959    fn compute_backoff_extended_tier_1_applies_24h() {
2960        // With base=300, max=14400:
2961        // count=4: 8100s (not yet at max)
2962        // count=5: 14400s (first time at max, times_at_max=0, no extension)
2963        // count=6: 14400s (times_at_max=1, no extension yet, need >=2)
2964        // count=7: 86400s (times_at_max=2, extended tier 1: 6x = 24h)
2965        assert_eq!(
2966            compute_backoff(5, 300, 14400, true),
2967            14400,
2968            "first at max, not extended"
2969        );
2970        assert_eq!(
2971            compute_backoff(6, 300, 14400, true),
2972            14400,
2973            "times_at_max=1, not extended"
2974        );
2975        assert_eq!(
2976            compute_backoff(7, 300, 14400, true),
2977            86400,
2978            "times_at_max=2, extended 6x"
2979        );
2980        assert_eq!(
2981            compute_backoff(8, 300, 14400, true),
2982            86400,
2983            "times_at_max=3, still extended 6x"
2984        );
2985    }
2986
2987    #[serial(cooldown_state)]
2988    #[test]
2989    fn compute_backoff_extended_tier_2_applies_48h() {
2990        // With base=300, max=14400:
2991        // count=10: times_at_max=5 (first_max=5, count=10), extended tier 2: 12x = 48h
2992        assert_eq!(
2993            compute_backoff(9, 300, 14400, true),
2994            86400,
2995            "times_at_max=4, still tier 1"
2996        );
2997        assert_eq!(
2998            compute_backoff(10, 300, 14400, true),
2999            172_800,
3000            "times_at_max=5, extended 12x"
3001        );
3002        assert_eq!(
3003            compute_backoff(15, 300, 14400, true),
3004            172_800,
3005            "times_at_max=10, still tier 2"
3006        );
3007        assert_eq!(
3008            compute_backoff(20, 300, 14400, true),
3009            172_800,
3010            "times_at_max=15, still tier 2"
3011        );
3012    }
3013
3014    #[serial(cooldown_state)]
3015    #[test]
3016    fn compute_backoff_extended_tier_with_different_base_max() {
3017        // With base=60, max=3600:
3018        // count=3: 60*9=540 < 3600
3019        // count=4: 60*27=1620 < 3600
3020        // count=5: 60*81=4860 → capped at 3600
3021        // count=6: times_at_max=1, still 3600
3022        // count=7: times_at_max=2, extended 6x = 21600 (6h)
3023        assert_eq!(compute_backoff(5, 60, 3600, true), 3600, "first at max");
3024        assert_eq!(compute_backoff(6, 60, 3600, true), 3600, "times_at_max=1");
3025        assert_eq!(
3026            compute_backoff(7, 60, 3600, true),
3027            21600,
3028            "times_at_max=2, extended 6x"
3029        );
3030    }
3031
3032    #[serial(cooldown_state)]
3033    #[test]
3034    fn compute_backoff_extended_tier_persistent_fails_example() {
3035        // Real-world example from issue: glm:haiku with 21 failures
3036        // With base=300, max=14400:
3037        // count=21 → times_at_max=16 (first_max=5), extended tier 2: 12x = 48h
3038        assert_eq!(
3039            compute_backoff(21, 300, 14400, true),
3040            172_800,
3041            "21 failures should get 48h extended cooldown"
3042        );
3043
3044        // kimi:haiku with 36 failures
3045        assert_eq!(
3046            compute_backoff(36, 300, 14400, true),
3047            172_800,
3048            "36 failures should also get 48h extended cooldown"
3049        );
3050    }
3051
3052    #[serial(cooldown_state)]
3053    #[test]
3054    fn compute_backoff_extended_tier_zero_base() {
3055        // Zero base should still work without panicking
3056        assert_eq!(compute_backoff(1, 0, 14400, true), 0);
3057        assert_eq!(compute_backoff(5, 0, 14400, true), 0);
3058        assert_eq!(compute_backoff(10, 0, 14400, true), 0);
3059    }
3060
3061    #[serial(cooldown_state)]
3062    #[test]
3063    fn detect_credit_exhaustion_billing_cycle() {
3064        assert_eq!(
3065            detect_credit_exhaustion("billing cycle"),
3066            Some(CreditExhaustionReason::BillingCycleExhausted)
3067        );
3068        assert_eq!(
3069            detect_credit_exhaustion("quota refreshed next cycle"),
3070            Some(CreditExhaustionReason::BillingCycleExhausted)
3071        );
3072        assert_eq!(
3073            detect_credit_exhaustion(
3074                "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle."
3075            ),
3076            Some(CreditExhaustionReason::BillingCycleExhausted)
3077        );
3078        assert_eq!(
3079            detect_credit_exhaustion("monthly quota exceeded"),
3080            Some(CreditExhaustionReason::BillingCycleExhausted)
3081        );
3082        assert_eq!(
3083            detect_credit_exhaustion(
3084                "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-04-23 18:33:48"
3085            ),
3086            Some(CreditExhaustionReason::BillingCycleExhausted)
3087        );
3088    }
3089
3090    #[serial(cooldown_state)]
3091    #[test]
3092    fn detect_credit_exhaustion_billing_cycle_minimax() {
3093        assert_eq!(
3094            detect_credit_exhaustion(
3095                "minimax rate limit: API Error: Request rejected (429) · Token Plan usage limit reached: Upgrade your Token Plan or purchase Credits for more usage."
3096            ),
3097            Some(CreditExhaustionReason::BillingCycleExhausted)
3098        );
3099        assert_eq!(
3100            detect_credit_exhaustion("Upgrade your Token Plan"),
3101            Some(CreditExhaustionReason::BillingCycleExhausted)
3102        );
3103        assert_eq!(
3104            detect_credit_exhaustion("Token Plan usage limit reached"),
3105            Some(CreditExhaustionReason::BillingCycleExhausted)
3106        );
3107        assert_eq!(
3108            detect_credit_exhaustion("purchase Credits for more usage"),
3109            Some(CreditExhaustionReason::BillingCycleExhausted)
3110        );
3111        assert_eq!(
3112            detect_credit_exhaustion(
3113                "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits"
3114            ),
3115            Some(CreditExhaustionReason::BillingCycleExhausted)
3116        );
3117    }
3118
3119    #[serial(cooldown_state)]
3120    #[test]
3121    fn detect_credit_exhaustion_outcome_kimi_billing_cycle() {
3122        // Regression test for issue #3529: the exact Kimi billing-cycle exhaustion
3123        // message from task_runs (tasks 156734/156678) must resolve to the
3124        // "billing_cycle_exhausted" outcome string, not fall through to a generic
3125        // rate-limit/auth classification.
3126        let msg = "Failed to authenticate. API Error: 403 You've reached your usage limit for \
3127                    this billing cycle. Your quota will be refreshed soon.";
3128        assert_eq!(
3129            detect_credit_exhaustion_outcome(msg),
3130            Some("billing_cycle_exhausted")
3131        );
3132    }
3133
3134    #[serial(cooldown_state)]
3135    #[test]
3136    fn detect_credit_exhaustion_outcome_maps_all_reasons() {
3137        assert_eq!(
3138            detect_credit_exhaustion_outcome("out of credits"),
3139            Some("credit_exhausted")
3140        );
3141        assert_eq!(
3142            detect_credit_exhaustion_outcome("organization has been disabled"),
3143            Some("org_level_disabled")
3144        );
3145        assert_eq!(
3146            detect_credit_exhaustion_outcome("monthly quota exceeded"),
3147            Some("billing_cycle_exhausted")
3148        );
3149    }
3150
3151    #[test]
3152    fn detect_credit_exhaustion_outcome_returns_none_for_unrelated_errors() {
3153        assert_eq!(
3154            detect_credit_exhaustion_outcome("429 Too Many Requests"),
3155            None
3156        );
3157        assert_eq!(detect_credit_exhaustion_outcome("connection reset"), None);
3158    }
3159
3160    #[serial(cooldown_state)]
3161    #[test]
3162    fn detect_credit_exhaustion_overage_takes_priority_over_billing_cycle() {
3163        // A message containing both overage and billing cycle language should resolve
3164        // to OrgLevelDisabled/OutOfCredits (detected first) not BillingCycleExhausted.
3165        let msg = "overageDisabledReason: org_level_disabled for billing cycle";
3166        assert_eq!(
3167            detect_credit_exhaustion(msg),
3168            Some(CreditExhaustionReason::OrgLevelDisabled)
3169        );
3170    }
3171
3172    #[serial(cooldown_state)]
3173    #[tokio::test]
3174    async fn refresh_degraded_agents_below_threshold_not_degraded() {
3175        super::reset_global_state().await;
3176        let store = test_store().await;
3177        let agent = "test_refresh_below_threshold";
3178
3179        // Insert rate limit events below threshold
3180        for _ in 0..2 {
3181            store.record_rate_limit(agent, "rate", None).await.unwrap();
3182        }
3183
3184        let agents = vec![agent.to_string()];
3185        // threshold=3, only 2 events — should NOT be degraded
3186        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3187
3188        assert!(
3189            !is_agent_degraded(agent),
3190            "agent with rate limit count < threshold should not be degraded"
3191        );
3192    }
3193
3194    #[serial(cooldown_state)]
3195    #[tokio::test]
3196    async fn refresh_degraded_agents_ignores_error_limit_type() {
3197        super::reset_global_state().await;
3198        let store = test_store().await;
3199        let agent = "test_refresh_error_rows";
3200
3201        for _ in 0..3 {
3202            store.record_rate_limit(agent, "error", None).await.unwrap();
3203        }
3204
3205        let agents = vec![agent.to_string()];
3206        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3207
3208        assert!(
3209            !is_agent_degraded(agent),
3210            "three error rows in 6 hours must not mark an agent degraded"
3211        );
3212    }
3213
3214    #[serial(cooldown_state)]
3215    #[tokio::test]
3216    async fn refresh_degraded_agents_marks_three_rate_rows() {
3217        super::reset_global_state().await;
3218        let store = test_store().await;
3219        let agent = "test_refresh_three_rate_rows";
3220
3221        for _ in 0..3 {
3222            store.record_rate_limit(agent, "rate", None).await.unwrap();
3223        }
3224
3225        let agents = vec![agent.to_string()];
3226        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3227
3228        assert!(
3229            is_agent_degraded(agent),
3230            "three rate rows in 6 hours must mark an agent degraded"
3231        );
3232        assert_eq!(
3233            agent_degraded_reason(agent).as_deref(),
3234            Some("rate limit threshold exceeded")
3235        );
3236        let listed = list_degraded_agents();
3237        assert!(
3238            listed
3239                .iter()
3240                .any(|(a, r)| a == agent && r == "rate limit threshold exceeded"),
3241            "orch cooldown list must show the degraded reason, got {listed:?}"
3242        );
3243
3244        clear_agent_degraded(agent);
3245    }
3246
3247    #[serial(cooldown_state)]
3248    #[tokio::test]
3249    async fn refresh_degraded_agents_logs_reason_change() {
3250        super::reset_global_state().await;
3251        let store = test_store().await;
3252        let agent = "test_refresh_reason_change";
3253        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3254
3255        set_agent_cooldown(agent, 600).await;
3256        let agents = vec![agent.to_string()];
3257        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3258        assert_eq!(
3259            agent_degraded_reason(agent).as_deref(),
3260            Some("agent in cooldown")
3261        );
3262
3263        clear_cooldown(agent, &store).await;
3264        let after_clear = sqlite_datetime_offset(60);
3265        for _ in 0..3 {
3266            store
3267                .record_rate_limit_at(agent, "rate", None, &after_clear)
3268                .await
3269                .unwrap();
3270        }
3271        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3272        assert_eq!(
3273            agent_degraded_reason(agent).as_deref(),
3274            Some("rate limit threshold exceeded"),
3275            "degraded reason must update when the hold changes"
3276        );
3277
3278        clear_agent_degraded(agent);
3279    }
3280
3281    #[serial(cooldown_state)]
3282    #[tokio::test]
3283    async fn clear_un_degrades_on_next_health_check() {
3284        super::reset_global_state().await;
3285        let store = test_store().await;
3286        let agent = "test_clear_un_degrades";
3287        let before = sqlite_datetime_offset(-30);
3288        let after = sqlite_datetime_offset(60);
3289
3290        for _ in 0..3 {
3291            store
3292                .record_rate_limit_at(agent, "rate", None, &before)
3293                .await
3294                .unwrap();
3295        }
3296
3297        let agents = vec![agent.to_string()];
3298        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3299        assert!(
3300            is_agent_degraded(agent),
3301            "three rate rows must mark an agent degraded"
3302        );
3303
3304        clear_cooldown(agent, &store).await;
3305        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3306        assert!(
3307            !is_agent_degraded(agent),
3308            "clear must un-degrade on the next health check"
3309        );
3310
3311        store
3312            .record_rate_limit_at(agent, "rate", None, &after)
3313            .await
3314            .unwrap();
3315        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3316        assert!(
3317            !is_agent_degraded(agent),
3318            "one rate row after the mark must not degrade again"
3319        );
3320
3321        for _ in 0..2 {
3322            store
3323                .record_rate_limit_at(agent, "rate", None, &after)
3324                .await
3325                .unwrap();
3326        }
3327        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3328        assert!(
3329            is_agent_degraded(agent),
3330            "three rate rows after the mark must degrade again"
3331        );
3332
3333        clear_agent_degraded(agent);
3334    }
3335
3336    #[serial(cooldown_state)]
3337    #[tokio::test]
3338    async fn clear_all_un_degrades_rate_limited_agent() {
3339        super::reset_global_state().await;
3340        let store = test_store().await;
3341        let agent = "test_clear_all_un_degrades";
3342        let before = sqlite_datetime_offset(-30);
3343
3344        for _ in 0..3 {
3345            store
3346                .record_rate_limit_at(agent, "rate", None, &before)
3347                .await
3348                .unwrap();
3349        }
3350
3351        let agents = vec![agent.to_string()];
3352        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3353        assert!(is_agent_degraded(agent));
3354
3355        clear_cooldown("*", &store).await;
3356        refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3357        assert!(
3358            !is_agent_degraded(agent),
3359            "clear --all must un-degrade an agent held only by rate rows"
3360        );
3361    }
3362
3363    // ---- Exponential backoff integration tests ----
3364
3365    #[serial(cooldown_state)]
3366    #[tokio::test]
3367    async fn repeated_agent_failures_escalate_backoff() {
3368        super::reset_global_state().await;
3369        let store = test_store().await;
3370        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3371
3372        let agent = "test_escalation_agent";
3373
3374        // First failure: should be ~5 min (BACKOFF_BASE_SECS)
3375        record_agent_failure_with_message(agent, "").await;
3376        let remaining_1 = {
3377            let map = cooldowns().lock().unwrap();
3378            let entry = map.get(agent).expect("should have cooldown");
3379            entry.cooldown_until - chrono::Utc::now().timestamp()
3380        };
3381        assert!(
3382            (BACKOFF_BASE_SECS - 5..=BACKOFF_BASE_SECS + 5).contains(&remaining_1),
3383            "first failure should be ~5 min, got {remaining_1}s"
3384        );
3385
3386        // Clear the cooldown (but NOT failure count) to allow the next set_cooldown_async to apply
3387        {
3388            let mut map = cooldowns().lock().unwrap();
3389            map.remove(agent);
3390        }
3391
3392        // Second failure: should be ~15 min (5 * 3)
3393        record_agent_failure_with_message(agent, "").await;
3394        let remaining_2 = {
3395            let map = cooldowns().lock().unwrap();
3396            let entry = map.get(agent).expect("should have cooldown");
3397            entry.cooldown_until - chrono::Utc::now().timestamp()
3398        };
3399        assert!(
3400            remaining_2 >= BACKOFF_BASE_SECS * 3 - 5,
3401            "second failure should be ~15 min, got {remaining_2}s"
3402        );
3403    }
3404
3405    #[serial(cooldown_state)]
3406    #[tokio::test]
3407    async fn vendor_past_retry_falls_back_to_backoff() {
3408        super::reset_global_state().await;
3409        // If a vendor-provided "try again at" date is in the past, the
3410        // code must NOT early-return and must instead apply the normal
3411        // exponential backoff (increment failure count and set a future cooldown).
3412        let store = test_store().await;
3413        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3414
3415        let agent = "test_vendor_past_retry";
3416
3417        // Ensure no pre-existing cooldown
3418        {
3419            let mut map = cooldowns().lock().unwrap();
3420            map.remove(agent);
3421        }
3422
3423        // A clearly past date that parse_retry_at can parse
3424        let past_msg = "You've hit the limit; try again at Jan 01, 2000 01:00 AM.";
3425
3426        record_agent_failure_with_message(agent, past_msg).await;
3427
3428        // After handling a past vendor timestamp, we should have applied
3429        // exponential backoff (base = BACKOFF_BASE_SECS) and a persisted
3430        // failure count of 1.
3431        assert!(
3432            is_agent_in_cooldown(agent),
3433            "agent should be in cooldown after failure"
3434        );
3435
3436        let remaining = {
3437            let map = cooldowns().lock().unwrap();
3438            let entry = map.get(agent).expect("cooldown entry should exist");
3439            entry.cooldown_until - chrono::Utc::now().timestamp()
3440        };
3441
3442        assert!(
3443            remaining >= BACKOFF_BASE_SECS - 5,
3444            "expected backoff of ~{BACKOFF_BASE_SECS}s when vendor timestamp is past, got {remaining}s"
3445        );
3446
3447        // Verify the failure count was incremented and persisted to KV
3448        let kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3449        let stored = store
3450            .kv_get(&kv_key)
3451            .await
3452            .unwrap()
3453            .expect("failure count should be stored");
3454        assert_eq!(stored, "1");
3455    }
3456
3457    #[serial(cooldown_state)]
3458    #[tokio::test]
3459    async fn record_agent_success_resets_backoff() {
3460        super::reset_global_state().await;
3461        let store = test_store().await;
3462        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3463
3464        let agent = "test_success_reset_agent";
3465        let model = "test_model";
3466
3467        // Accumulate 3 failures to escalate the backoff
3468        for _ in 0..3 {
3469            let _ = read_and_increment_failure_count(Some(&store), agent).await;
3470        }
3471        // Verify count is 3
3472        let kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3473        let count: u32 = store
3474            .kv_get(&kv_key)
3475            .await
3476            .unwrap()
3477            .unwrap()
3478            .parse()
3479            .unwrap();
3480        assert_eq!(count, 3);
3481
3482        // Success should reset to 0
3483        record_agent_success(agent, model).await;
3484
3485        let count_after: u32 = store
3486            .kv_get(&kv_key)
3487            .await
3488            .unwrap()
3489            .unwrap()
3490            .parse()
3491            .unwrap();
3492        assert_eq!(count_after, 0, "success should reset failure count to 0");
3493
3494        // Model-specific count should also be reset
3495        let model_kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}:{model}");
3496        let model_count: u32 = store
3497            .kv_get(&model_kv_key)
3498            .await
3499            .unwrap()
3500            .unwrap()
3501            .parse()
3502            .unwrap();
3503        assert_eq!(
3504            model_count, 0,
3505            "success should reset model failure count to 0"
3506        );
3507    }
3508
3509    #[serial(cooldown_state)]
3510    #[tokio::test]
3511    async fn record_agent_success_without_model_skips_empty_model_key() {
3512        super::reset_global_state().await;
3513        let store = test_store().await;
3514        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3515
3516        let agent = "test_success_without_model";
3517
3518        record_agent_success(agent, "").await;
3519
3520        let agent_kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3521        let agent_count = store
3522            .kv_get(&agent_kv_key)
3523            .await
3524            .unwrap()
3525            .expect("agent failure count should be stored");
3526        assert_eq!(agent_count, "0");
3527
3528        let empty_model_kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}:");
3529        let empty_model_count = store.kv_get(&empty_model_kv_key).await.unwrap();
3530        assert!(
3531            empty_model_count.is_none(),
3532            "success without model should not create an empty model failure count key"
3533        );
3534    }
3535
3536    #[serial(cooldown_state)]
3537    #[tokio::test]
3538    async fn clear_cooldown_resets_failure_counts() {
3539        super::reset_global_state().await;
3540        let store = test_store().await;
3541        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3542
3543        let agent = "test_clear_fc_agent";
3544
3545        // Accumulate failures
3546        for _ in 0..5 {
3547            let _ = read_and_increment_failure_count(Some(&store), agent).await;
3548        }
3549        // Set a cooldown so clear_cooldown has something to clear
3550        set_agent_cooldown(agent, 3600).await;
3551
3552        // Verify failure count is 5
3553        let kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3554        let count: u32 = store
3555            .kv_get(&kv_key)
3556            .await
3557            .unwrap()
3558            .unwrap()
3559            .parse()
3560            .unwrap();
3561        assert_eq!(count, 5);
3562
3563        // Clear the cooldown
3564        clear_cooldown(agent, &store).await;
3565
3566        // Failure count should be reset to 0
3567        let count_after: u32 = store
3568            .kv_get(&kv_key)
3569            .await
3570            .unwrap()
3571            .unwrap()
3572            .parse()
3573            .unwrap();
3574        assert_eq!(
3575            count_after, 0,
3576            "clear_cooldown should reset failure count to 0"
3577        );
3578
3579        // Cooldown should be removed
3580        assert!(
3581            !is_agent_in_cooldown(agent),
3582            "agent should not be in cooldown after clear"
3583        );
3584    }
3585
3586    #[serial(cooldown_state)]
3587    #[tokio::test]
3588    async fn clear_all_cooldowns_resets_all_failure_counts() {
3589        super::reset_global_state().await;
3590        let store = test_store().await;
3591        *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3592
3593        let agents = ["test_clear_all_a", "test_clear_all_b"];
3594        for agent in &agents {
3595            let _ = read_and_increment_failure_count(Some(&store), agent).await;
3596            set_agent_cooldown(agent, 3600).await;
3597        }
3598
3599        clear_cooldown("*", &store).await;
3600
3601        for agent in &agents {
3602            assert!(!is_agent_in_cooldown(agent));
3603            let kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3604            let count: u32 = store
3605                .kv_get(&kv_key)
3606                .await
3607                .unwrap()
3608                .unwrap()
3609                .parse()
3610                .unwrap();
3611            assert_eq!(
3612                count, 0,
3613                "clear --all should reset failure count for {agent}"
3614            );
3615        }
3616    }
3617
3618    #[serial(cooldown_state)]
3619    #[test]
3620    fn compute_backoff_saturates_on_large_counts() {
3621        // Very large count should not overflow — extended tier applies 48h
3622        assert_eq!(
3623            compute_backoff(100, BACKOFF_BASE_SECS, BACKOFF_MAX_SECS, true),
3624            BACKOFF_MAX_SECS * 12
3625        );
3626        assert_eq!(
3627            compute_backoff(u64::from(u32::MAX), 300, 14400, true),
3628            172_800
3629        );
3630        assert_eq!(compute_backoff(u64::MAX, 300, 14400, true), 172_800);
3631    }
3632
3633    #[serial(cooldown_state)]
3634    #[test]
3635    fn compute_backoff_zero_base_returns_zero() {
3636        assert_eq!(compute_backoff(1, 0, 14400, true), 0);
3637        assert_eq!(compute_backoff(5, 0, 14400, true), 0);
3638    }
3639
3640    // ---- GitHub 5xx circuit breaker tests ----
3641
3642    #[serial(cooldown_state)]
3643    #[tokio::test]
3644    async fn github_circuit_stays_closed_below_threshold() {
3645        super::reset_global_state().await;
3646        // Reset circuit breaker state
3647        {
3648            let mut open = github_5xx_circuit_open().lock().unwrap();
3649            *open = None;
3650            let mut ts = github_5xx_timestamps().lock().unwrap();
3651            ts.clear();
3652            // Also clear any generic cooldown map entry for the github 5xx
3653            // circuit so tests are isolated from other tests that may have
3654            // set the generic cooldown via set_agent_cooldown("github:5xx", ...).
3655            let mut map = cooldown_lock();
3656            map.remove("github:5xx");
3657        }
3658
3659        // Record fewer errors than threshold
3660        for _ in 0..GITHUB_5XX_THRESHOLD - 1 {
3661            record_github_5xx().await;
3662        }
3663
3664        assert!(
3665            !is_github_circuit_open(),
3666            "circuit should stay closed below threshold"
3667        );
3668        assert_eq!(github_circuit_remaining_secs(), 0);
3669    }
3670
3671    #[serial(cooldown_state)]
3672    #[tokio::test]
3673    async fn github_circuit_trips_at_threshold() {
3674        super::reset_global_state().await;
3675        // Reset circuit breaker state
3676        {
3677            let mut open = github_5xx_circuit_open_lock();
3678            *open = None;
3679            let mut ts = github_5xx_timestamps_lock();
3680            ts.clear();
3681            let mut map = cooldown_lock();
3682            map.remove("github:5xx");
3683        }
3684
3685        // Record exactly threshold errors
3686        for _ in 0..GITHUB_5XX_THRESHOLD {
3687            record_github_5xx().await;
3688        }
3689
3690        assert!(is_github_circuit_open(), "circuit should trip at threshold");
3691        assert!(
3692            github_circuit_remaining_secs() > 0,
3693            "remaining should be > 0 when open"
3694        );
3695        assert!(
3696            github_circuit_remaining_secs() <= GITHUB_5XX_COOLDOWN_SECS as u64,
3697            "remaining should not exceed cooldown duration"
3698        );
3699
3700        // Clean up
3701        {
3702            let mut open = github_5xx_circuit_open().lock().unwrap();
3703            *open = None;
3704        }
3705    }
3706
3707    #[serial(cooldown_state)]
3708    #[tokio::test]
3709    async fn github_circuit_cannot_double_trip() {
3710        super::reset_global_state().await;
3711        // Reset circuit breaker state
3712        {
3713            let mut open = github_5xx_circuit_open_lock();
3714            *open = None;
3715            let mut ts = github_5xx_timestamps_lock();
3716            ts.clear();
3717            let mut map = cooldown_lock();
3718            map.remove("github:5xx");
3719        }
3720
3721        // Trip the circuit
3722        for _ in 0..GITHUB_5XX_THRESHOLD {
3723            record_github_5xx().await;
3724        }
3725        assert!(is_github_circuit_open());
3726
3727        let first_remaining = github_circuit_remaining_secs();
3728
3729        // More 5xx errors should NOT reset the cooldown (never-shorten)
3730        for _ in 0..GITHUB_5XX_THRESHOLD {
3731            record_github_5xx().await;
3732        }
3733
3734        let second_remaining = github_circuit_remaining_secs();
3735        assert!(
3736            second_remaining <= first_remaining,
3737            "additional errors should not extend cooldown"
3738        );
3739
3740        // Clean up
3741        {
3742            let mut open = github_5xx_circuit_open().lock().unwrap();
3743            *open = None;
3744        }
3745    }
3746
3747    #[serial(cooldown_state)]
3748    #[tokio::test]
3749    async fn github_circuit_recovers_after_cooldown() {
3750        super::reset_global_state().await;
3751        // Reset circuit breaker state
3752        {
3753            let mut open = github_5xx_circuit_open_lock();
3754            *open = None;
3755            let mut ts = github_5xx_timestamps_lock();
3756            ts.clear();
3757            let mut map = cooldown_lock();
3758            map.remove("github:5xx");
3759        }
3760
3761        // Set the circuit to an expired cooldown
3762        {
3763            let mut open = github_5xx_circuit_open().lock().unwrap();
3764            *open = Some(chrono::Utc::now().timestamp() - 1);
3765        }
3766
3767        // Should be closed now (auto-recovery)
3768        assert!(
3769            !is_github_circuit_open(),
3770            "circuit should auto-recover after cooldown"
3771        );
3772    }
3773
3774    #[serial(cooldown_state)]
3775    #[tokio::test]
3776    async fn github_circuit_sliding_window_ages_out() {
3777        super::reset_global_state().await;
3778        // Reset circuit breaker state
3779        {
3780            let mut open = github_5xx_circuit_open_lock();
3781            *open = None;
3782            let mut ts = github_5xx_timestamps_lock();
3783            ts.clear();
3784            let mut map = cooldown_lock();
3785            map.remove("github:5xx");
3786        }
3787
3788        // Record errors that are outside the window
3789        {
3790            let mut ts = github_5xx_timestamps().lock().unwrap();
3791            let old = chrono::Utc::now().timestamp() - GITHUB_5XX_WINDOW_SECS - 10;
3792            for _ in 0..GITHUB_5XX_THRESHOLD {
3793                ts.push(old);
3794            }
3795        }
3796
3797        // A new error should clear old ones and not trip the circuit
3798        record_github_5xx().await;
3799
3800        assert!(
3801            !is_github_circuit_open(),
3802            "old errors outside window should not trip circuit"
3803        );
3804    }
3805}