Skip to main content

orch/engine/runner/
fallback.rs

1//! Error recovery and fallback strategies.
2//!
3//! Extracted from `runner/mod.rs`. Handles the `Err(agent_err)` arm of the
4//! parse result: error classification, model failover, free-model fallback,
5//! and agent rerouting.
6
7use crate::store;
8use crate::store::TaskStore;
9use std::sync::Arc;
10
11use super::{agents, response};
12
13/// Summarizes a rate limit error by extracting key information from Claude's API retry JSON.
14///
15/// Looks for the last `{"type":"system","subtype":"api_retry",...}` line (or a partial
16/// fragment containing `"subtype":"api_retry"`) and extracts `error_status`, `attempt`,
17/// `retry_delay_ms` to return a compact summary.
18/// Returns a compact fallback if no api_retry JSON is found.
19///
20/// Exposed `pub(crate)` so the review pipeline (`engine::review`) reuses the same
21/// summariser as the task runner. Without this, review runs persisted raw
22/// api_retry JSON fragments into `task_runs.error` (issue #3149).
23pub(crate) fn summarize_rate_limit_error(raw_output: &str) -> String {
24    // Find the last line containing "subtype":"api_retry"
25    // This handles both complete JSON objects and truncated fragments that begin
26    // mid-object (e.g. `,"subtype":"api_retry","attempt":5...`).
27    let last_api_retry_line = raw_output
28        .lines()
29        .rev()
30        .find(|line| line.contains("\"subtype\":\"api_retry\""));
31
32    if let Some(line) = last_api_retry_line {
33        return summarise_api_retry_line(line);
34    }
35
36    // No `"subtype":"api_retry"` marker but the payload may still be a truncated
37    // api_retry NDJSON fragment that lost the `"subtype"` key earlier in the
38    // stream. If any line contains the characteristic numeric fields
39    // (`error_status`, `attempt`, `retry_delay_ms`), summarise from that line
40    // instead of echoing the raw JSON into `task_runs.error` (issue #3149).
41    let api_retry_field_markers = ["\"error_status\":", "\"attempt\":", "\"retry_delay_ms\":"];
42    let field_line = raw_output
43        .lines()
44        .rev()
45        .find(|line| api_retry_field_markers.iter().any(|m| line.contains(m)));
46    if let Some(line) = field_line {
47        return summarise_api_retry_line(line);
48    }
49
50    // No api_retry JSON or field markers found — emit a compact first-line
51    // summary, never raw passthrough. Truncate to a tight byte cap so even
52    // long single-line provider payloads cannot bloat `task_runs.error`.
53    const FIRST_LINE_CAP: usize = 120;
54    let first_line = raw_output.lines().next().unwrap_or(raw_output);
55    if first_line.len() > FIRST_LINE_CAP {
56        let cutoff = agents::truncate_at_char_boundary(first_line, FIRST_LINE_CAP);
57        let remaining = raw_output.len().saturating_sub(cutoff);
58        format!("{}... (+{} more chars)", &first_line[..cutoff], remaining)
59    } else {
60        first_line.to_string()
61    }
62}
63
64/// Extract `error_status`, `attempt`, and `retry_delay_ms` from a single
65/// `api_retry`-shaped JSON line (or fragment) and return a compact summary
66/// such as `"status=429 after 7 attempts (last delay 35s)"`. Falls back to
67/// `"status=… (api_retry fragment)"` when no numeric field is recoverable.
68fn summarise_api_retry_line(line: &str) -> String {
69    let error_status =
70        extract_json_field(line, "\"error_status\":").unwrap_or_else(|| "unknown".to_string());
71    let attempt = extract_json_field(line, "\"attempt\":").unwrap_or_else(|| "unknown".to_string());
72    let retry_delay_ms =
73        extract_json_field(line, "\"retry_delay_ms\":").unwrap_or_else(|| "0".to_string());
74
75    let has_field = error_status != "unknown"
76        || attempt != "unknown"
77        || (retry_delay_ms != "0" && retry_delay_ms != "unknown");
78
79    if has_field {
80        let retry_delay_s = retry_delay_ms.parse::<f64>().unwrap_or(0.0) / 1000.0;
81        let retry_delay_s = retry_delay_s as u64;
82        format!(
83            "status={} after {} attempts (last delay {}s)",
84            error_status, attempt, retry_delay_s
85        )
86    } else {
87        let status_hint = extract_json_field(line, "\"status\":")
88            .or_else(|| extract_json_field(line, "\"error\":"))
89            .unwrap_or_else(|| "429".to_string());
90        format!("status={} (api_retry fragment)", status_hint)
91    }
92}
93
94/// Extracts a JSON field value as a string, handling both quoted strings and raw numbers.
95fn extract_json_field(json: &str, field: &str) -> Option<String> {
96    json.find(field).and_then(|start| {
97        let value_start = start + field.len();
98        let value_str = &json[value_start..];
99
100        // Handle quoted strings
101        if let Some(rest) = value_str.strip_prefix('"') {
102            let end = rest.find('"')?; // find closing quote
103            Some(rest[..end].to_string())
104        }
105        // Handle raw numbers (integers or floats)
106        else {
107            let end = value_str
108                .find(|c: char| !c.is_ascii_digit() && c != '.')
109                .unwrap_or(value_str.len());
110            let num_str = &value_str[..end];
111            num_str
112                .strip_suffix(',')
113                .map(|s| s.to_string())
114                .or_else(|| Some(num_str.to_string()))
115        }
116    })
117}
118
119/// How `run()` should proceed after error handling.
120pub enum ErrorHandleResult {
121    /// Task was rerouted — `run()` should record metrics then return early.
122    EarlyReturn { status: String },
123    /// Normal failover applied (or task marked needs_review) — proceed to cleanup + metrics.
124    ///
125    /// `error` is the current run's attributed error message (e.g. "kimi timed out after 1801s").
126    /// Callers should pass this as `error_override` to `build_run_audit` so that stale
127    /// `last_error` values from a previous agent's run are not mis-attributed.
128    Continue { status: String, error: String },
129}
130
131/// Try to reroute the task to an untried free model.
132///
133/// Returns `Some(EarlyReturn { status: "new" })` if a free model was found and the
134/// task was rerouted, `None` otherwise (caller should fall through to normal failover).
135///
136/// - `exclude_models`: models to skip in addition to already-tried ones
137/// - `check_cooldowns`: whether to skip models currently in cooldown
138/// - `set_agent`: if `Some`, also overwrite the task's `agent` field
139/// - `apply_backoff`: whether to pace the reroute with `wait_for_fallback_backoff`
140/// - `reason_prefix`: prefix for the `last_error` message (free model name is appended)
141#[allow(clippy::too_many_arguments)]
142async fn try_free_model_reroute(
143    task_id: &str,
144    agent_name: &str,
145    agent_runner: &dyn agents::AgentRunner,
146    exclude_models: &[&str],
147    check_cooldowns: bool,
148    set_agent: Option<&str>,
149    apply_backoff: bool,
150    reason_prefix: &str,
151    store: &Option<Arc<TaskStore>>,
152    repo: &str,
153) -> Option<ErrorHandleResult> {
154    let free = agent_runner.free_models();
155    if free.is_empty() {
156        return None;
157    }
158    let tried_models: String = store::opt_store_get_task(store, repo, task_id)
159        .await
160        .map(|t| t.model_reroute_chain)
161        .unwrap_or_default();
162    let tried_set: std::collections::HashSet<&str> =
163        tried_models.split(',').filter(|s| !s.is_empty()).collect();
164    let next_free = free.iter().find(|m| {
165        !exclude_models.contains(&m.as_str())
166            && !tried_set.contains(m.as_str())
167            && (!check_cooldowns
168                || (!crate::engine::cooldown::is_agent_in_cooldown(agent_name)
169                    && !response::is_model_in_cooldown(agent_name, m)))
170    })?;
171    tracing::info!(task_id, model = %next_free, "{}", reason_prefix);
172    let new_tried = if tried_models.is_empty() {
173        next_free.clone()
174    } else {
175        format!("{tried_models},{next_free}")
176    };
177    let msg = format!("{reason_prefix} {next_free}");
178    let mut fields: Vec<(&str, serde_json::Value)> = vec![
179        ("model", serde_json::json!(next_free.to_string())),
180        ("model_reroute_chain", serde_json::json!(new_tried)),
181        ("last_error", serde_json::json!(msg)),
182    ];
183    if let Some(agent) = set_agent {
184        fields.push(("agent", serde_json::json!(agent)));
185    }
186    if let Err(e) = store::store_set_result(store, repo, task_id, &fields).await {
187        tracing::warn!(task_id, err = %e, "try_free_model_reroute: failed to persist model update — not rerouting");
188        return None; // let caller fall through to normal failover
189    }
190    if apply_backoff {
191        response::wait_for_fallback_backoff(task_id, store, repo).await;
192    }
193    Some(ErrorHandleResult::EarlyReturn {
194        status: "new".to_string(),
195    })
196}
197
198/// Apply a vendor-provided future reset date as the model cooldown.
199///
200/// Skipped for credit exhaustion reasons other than billing cycle, which keep their agent-wide cooldowns.
201/// Returns true when the cooldown was set.
202async fn apply_vendor_reset_date(
203    agent_name: &str,
204    model_name: Option<&str>,
205    message: &str,
206) -> bool {
207    use crate::engine::cooldown::CreditExhaustionReason;
208    let Some(model) = model_name else {
209        return false;
210    };
211    match crate::engine::cooldown::detect_credit_exhaustion(message) {
212        None | Some(CreditExhaustionReason::BillingCycleExhausted) => {}
213        Some(_) => return false,
214    }
215    match crate::engine::cooldown::parse_retry_at(message) {
216        Some(until) if until > chrono::Utc::now().timestamp() => {
217            crate::engine::cooldown::set_model_cooldown_until(agent_name, model, until).await;
218            true
219        }
220        _ => false,
221    }
222}
223
224/// Handle an agent error: classify it, attempt recovery strategies, and update store.
225///
226/// Returns `Ok(ErrorHandleResult::EarlyReturn)` when the task was rerouted and
227/// `run()` should record metrics and return immediately (skipping tmux cleanup).
228/// Returns `Ok(ErrorHandleResult::Continue)` when `run()` should proceed to cleanup.
229#[allow(clippy::too_many_arguments)]
230pub async fn handle_error(
231    task_id: &str,
232    agent_err: &agents::AgentError,
233    agent_name: &str,
234    agent_runner: &dyn agents::AgentRunner,
235    model_name: Option<&str>,
236    complexity: Option<&str>,
237    new_attempts: u32,
238    store: &Option<Arc<TaskStore>>,
239    repo: &str,
240) -> anyhow::Result<ErrorHandleResult> {
241    tracing::warn!(
242        task_id,
243        agent = agent_name,
244        error = %agent_err,
245        "agent error, attempting recovery"
246    );
247
248    // Map AgentError to RetryableError for handle_failover(). credit_recorded
249    // skips the generic failure_count bump for already-recorded credit errors
250    let mut credit_recorded = false;
251    let (retryable, error_msg) = match agent_err {
252        agents::AgentError::RateLimit { message, .. } => {
253            let is_provider_returned_error = message
254                .to_ascii_lowercase()
255                .contains("provider returned error");
256            // Check for credit exhaustion - these require longer agent-wide cooldowns.
257            // For all other rate limits, record both the agent-level and model-level
258            // cooldowns immediately so the router can observe them before any concurrent
259            // tasks start their runs. handle_failover() also records the cooldown later,
260            // but setting it here means the in-memory map is updated right away — without
261            // this, concurrent dispatches that check cooldowns between now and
262            // handle_failover() will see no cooldown and waste another run against the
263            // same rate-limited model.
264            //
265            // Setting BOTH cooldowns is critical: the router's model selection
266            // (model_for_complexity) checks is_model_in_cooldown(agent, model) which
267            // uses the agent:model key, so a model-level cooldown is needed to prevent
268            // the same model from being re-selected even when the agent-level cooldown
269            // is set. Without the model-level cooldown, the model continues to be
270            // picked and fails repeatedly (issue #2153).
271            // Track whether the quota is model-scoped (BillingCycleExhausted + known model).
272            // When true we return ModelUnavailable so handle_failover skips the
273            // agent-level failure counter — the agent itself is healthy, only the
274            // specific model's plan quota was exhausted (issue #3382).
275            let mut billing_cycle_model_scoped = false;
276
277            if apply_vendor_reset_date(agent_name, model_name, message).await {
278                let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
279                credit_recorded = reason.is_some();
280                billing_cycle_model_scoped = reason
281                    == Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
282            } else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
283            {
284                credit_recorded = true;
285                // For billing cycle exhaustion where we know the specific model, apply
286                // model-level persistent cooldown only. The quota is scoped to that
287                // provider sub-model (e.g. github-copilot/gpt-5-mini), not the entire
288                // agent account, so an agent-wide cooldown would incorrectly block
289                // unrelated models on the same agent.
290                if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
291                {
292                    if let Some(model) = model_name {
293                        crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
294                            .await;
295                        billing_cycle_model_scoped = true;
296                    } else {
297                        crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
298                    }
299                } else {
300                    crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
301                }
302            } else if let (Some(model), Some(window_secs)) = (
303                model_name,
304                crate::engine::cooldown::parse_relative_usage_window(message),
305            ) {
306                // Vendor-advertised relative usage window (e.g. "weekly (7-day)
307                // usage limit") is authoritative — apply a model-specific cooldown
308                // for that exact duration instead of the generic exponential
309                // backoff. This is the same window-detection the `needs_review`
310                // call site (cooldown::record_rate_limit()) already applies; this
311                // in-task failover path bypassed it entirely, undoing the
312                // #3572/#3574 fix for the most common rate-limit path (issue #3580).
313                crate::engine::cooldown::set_model_cooldown(
314                    agent_name,
315                    model,
316                    window_secs,
317                    "rate_limit_usage_window",
318                )
319                .await;
320                tracing::info!(
321                    task_id,
322                    agent = agent_name,
323                    model,
324                    window_secs,
325                    "relative usage window detected: applying model-specific cooldown"
326                );
327            } else {
328                crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
329                    .await;
330                // Also record the model-specific cooldown so model_for_complexity skips it.
331                if let Some(model) = model_name {
332                    if is_provider_returned_error {
333                        // Opaque provider failures can remain unstable for hours.
334                        // Use longer exponential model backoff to avoid repeated runs.
335                        crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
336                            .await;
337                    } else {
338                        response::record_model_failure(agent_name, model).await;
339                    }
340                }
341            }
342            // Use ModelUnavailable when billing cycle is exhausted for a known model so
343            // handle_failover does not call record_agent_failure_with_message (which would
344            // set an unwanted agent-level cooldown). The agent's other models remain available.
345            let retryable = if billing_cycle_model_scoped {
346                response::RetryableError::ModelUnavailable
347            } else {
348                response::RetryableError::UsageLimit
349            };
350            (
351                retryable,
352                format!(
353                    "{agent_name} rate limit: {}",
354                    summarize_rate_limit_error(message)
355                ),
356            )
357        }
358        agents::AgentError::Auth { message } => {
359            // Check for credit exhaustion first — those need longer agent-wide cooldowns.
360            // For plain auth failures (expired key, wrong token, etc.) record both the
361            // agent-level and model-level cooldowns so the router can observe them before
362            // concurrent tasks start another run against the same unavailable model.
363            let mut billing_cycle_model_scoped = false;
364
365            if apply_vendor_reset_date(agent_name, model_name, message).await {
366                let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
367                credit_recorded = reason.is_some();
368                billing_cycle_model_scoped = reason
369                    == Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
370            } else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
371            {
372                credit_recorded = true;
373                // Billing cycle exhaustion scoped to a known model gets model-level
374                // persistent cooldown only — agent-wide cooldown would block unrelated models.
375                if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
376                {
377                    if let Some(model) = model_name {
378                        crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
379                            .await;
380                        billing_cycle_model_scoped = true;
381                    } else {
382                        crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
383                    }
384                } else {
385                    crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
386                }
387            } else {
388                crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
389                    .await;
390                if let Some(model) = model_name {
391                    response::record_model_failure(agent_name, model).await;
392                }
393            }
394            // Use ModelUnavailable for model-scoped billing exhaustion so handle_failover
395            // does not set an agent-level failure cooldown (issue #3382).
396            let retryable = if billing_cycle_model_scoped {
397                response::RetryableError::ModelUnavailable
398            } else {
399                response::RetryableError::AuthError
400            };
401            (retryable, format!("{agent_name} auth error: {message}"))
402        }
403        agents::AgentError::Timeout { elapsed } => {
404            // A task time limit says the work was long, not that the model is
405            // unavailable. Retry without a model cooldown or rate_limits row
406            (
407                response::RetryableError::Timeout,
408                format!("{agent_name} timed out after {}s", elapsed.as_secs()),
409            )
410        }
411        agents::AgentError::Interrupted { message } => (
412            response::RetryableError::Interrupted,
413            format!("{agent_name} interrupted: {message}"),
414        ),
415        agents::AgentError::MissingTool { tool } => (
416            response::RetryableError::MissingTooling,
417            format!("missing tool: {tool}"),
418        ),
419        agents::AgentError::ModelUnavailable { model, message } => {
420            // "Not found", "not supported" and region blocks never lift on their own, so they
421            // get the persistent model backoff (4h base → 7d max), not the transient 5min one
422            let lower = message.to_lowercase();
423            let is_permanently_gone = lower.contains("not found")
424                || lower.contains("not supported")
425                || lower.contains("not available in your country")
426                || lower.contains("not available in your region");
427            if is_permanently_gone {
428                // Persistent model failure backoff (starts at 4h, escalates to 7d)
429                crate::engine::cooldown::record_persistent_model_failure(agent_name, model).await;
430            } else {
431                // Generic model failure uses the standard model failure backoff (5m base → 4h max)
432                response::record_model_failure(agent_name, model).await;
433            }
434
435            // Try next model before switching agent, unless the agent is cooled
436            // (an agent-wide cooldown must not be defeated by model substitution)
437            let agent_cooled = crate::engine::cooldown::is_agent_in_cooldown(agent_name);
438            let models = agent_runner.available_models();
439            let current_model = model_name.unwrap_or("");
440            let next_model = if agent_cooled {
441                None
442            } else {
443                models.iter().find(|m| {
444                    m.as_str() != current_model
445                        && m.as_str() != model
446                        && !response::is_model_in_cooldown(agent_name, m)
447                })
448            };
449            if let Some(next) = next_model {
450                tracing::info!(task_id, model = %next, "retrying with different model");
451                let msg = format!("model {model} unavailable, trying {next}");
452                if let Err(e) = store::store_set_result(
453                    store,
454                    repo,
455                    task_id,
456                    &[
457                        ("model", serde_json::json!(next.to_string())),
458                        ("last_error", serde_json::json!(msg)),
459                    ],
460                )
461                .await
462                {
463                    tracing::warn!(task_id, err = %e, "failed to write model failover to store");
464                }
465                // Skip normal failover — we're retrying same agent with different model.
466                // Use "routed" (not "new") since agent/model are already stored;
467                // this avoids a redundant LLM re-routing cycle.
468                response::wait_for_fallback_backoff(task_id, store, repo).await;
469                return Ok(ErrorHandleResult::EarlyReturn {
470                    status: "routed".to_string(),
471                });
472            }
473
474            // No other standard models available — try free models for simple tasks
475            let is_simple = matches!(complexity, None | Some("simple"));
476            if is_simple {
477                if let Some(result) = try_free_model_reroute(
478                    task_id,
479                    agent_name,
480                    agent_runner,
481                    &[current_model, model.as_str()],
482                    true,
483                    None,
484                    true,
485                    &format!("model {model} unavailable, trying free model"),
486                    store,
487                    repo,
488                )
489                .await
490                {
491                    return Ok(result);
492                }
493            }
494
495            (
496                response::RetryableError::ModelUnavailable,
497                format!("model {model} unavailable"),
498            )
499        }
500        agents::AgentError::ContextOverflow { message } => {
501            // Context overflow is deterministic — the task's prompt/context is too large
502            // for this model's context window. Cycling through other agents would hit the
503            // same limit and waste API calls. Skip failover entirely and escalate
504            // immediately to needs_review so a human can intervene (e.g., split the task,
505            // reduce context, or manually assign a model with a larger context window).
506            let msg = format!("{agent_name} context overflow: {message}");
507            if let Err(e) = store::store_set_result(
508                store,
509                repo,
510                task_id,
511                &[("last_error", serde_json::json!(msg))],
512            )
513            .await
514            {
515                tracing::warn!(task_id, err = %e, "failed to write context_overflow last_error to store");
516            }
517            return Ok(ErrorHandleResult::EarlyReturn {
518                status: "needs_review".to_string(),
519            });
520        }
521        agents::AgentError::WaitingForInput { message } => {
522            // Requires human — skip failover, go straight to needs_review
523            let msg = format!("waiting for input: {message}");
524            if let Err(e) = store::store_set_result(
525                store,
526                repo,
527                task_id,
528                &[("last_error", serde_json::json!(msg))],
529            )
530            .await
531            {
532                tracing::warn!(task_id, err = %e, "failed to write waiting_for_input last_error to store");
533            }
534            return Ok(ErrorHandleResult::EarlyReturn {
535                status: "needs_review".to_string(),
536            });
537        }
538        agents::AgentError::PermissionDenied { message } => (
539            response::RetryableError::Failed,
540            format!("permission denied: {message}"),
541        ),
542        agents::AgentError::InvalidResponse { .. } => {
543            // Record model-specific cooldown so the same model that produced an
544            // unparseable response isn't immediately retried on the next task.
545            if let Some(model) = model_name {
546                response::record_model_failure(agent_name, model).await;
547            }
548            (
549                response::RetryableError::Failed,
550                format!("{agent_name} invalid response"),
551            )
552        }
553        agents::AgentError::AgentFailed { message } => {
554            // "Provider returned error" (opencode) and similar upstream failures.
555            // Record model+agent cooldowns so the router skips this model on retry,
556            // then route through handle_failover() to get proper reroute-chain
557            // tracking, exhaustion checks, and fallback pacing.
558            // Credit-shaped errors that escaped the Auth/RateLimit classifiers still
559            // need the credit cooldown and counter, not the generic 5 min backoff.
560            if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
561                credit_recorded = true;
562                match model_name {
563                    Some(model)
564                        if reason
565                            == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted =>
566                    {
567                        crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
568                            .await;
569                    }
570                    _ => crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await,
571                }
572            } else {
573                if let Some(model) = model_name {
574                    response::record_model_failure(agent_name, model).await;
575                }
576                crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
577                    .await;
578            }
579            (
580                response::RetryableError::Failed,
581                format!("{agent_name} failed: {message}"),
582            )
583        }
584        agents::AgentError::NetworkError { message } => {
585            // Transient connectivity failure — retry same agent without rerouting,
586            // but grow a dedicated streak counter so backoff keeps increasing.
587            // Use "routed" so it re-dispatches without a full re-routing cycle.
588            // After MAX_NETWORK_RETRIES consecutive network errors, escalate to
589            // needs_review so a human is notified rather than looping forever.
590            const MAX_NETWORK_RETRIES: u64 = 8;
591            let msg = format!("{agent_name} network error: {message}");
592            let retry_count = match store::store_increment(store, repo, task_id, "network_retries")
593                .await
594            {
595                Ok(count) => {
596                    tracing::info!(
597                        task_id,
598                        retry_count = count,
599                        agent = agent_name,
600                        "network retry scheduled"
601                    );
602                    count
603                }
604                Err(e) => {
605                    tracing::warn!(
606                        task_id,
607                        error = %e,
608                        "failed to increment network_retries — escalating to needs_review"
609                    );
610                    return Ok(ErrorHandleResult::Continue {
611                        status: "needs_review".to_string(),
612                        error: format!("{agent_name} network error (store unavailable): {message}"),
613                    });
614                }
615            };
616            if let Err(e) = store::store_set_result(
617                store,
618                repo,
619                task_id,
620                &[("last_error", serde_json::json!(msg))],
621            )
622            .await
623            {
624                tracing::warn!(task_id, err = %e, "failed to write network_error last_error to store");
625            }
626            if retry_count >= MAX_NETWORK_RETRIES {
627                tracing::warn!(
628                    task_id,
629                    retry_count,
630                    agent = agent_name,
631                    "network retries exhausted, escalating to needs_review"
632                );
633                return Ok(ErrorHandleResult::Continue {
634                    status: "needs_review".to_string(),
635                    error: msg,
636                });
637            }
638            // Apply backoff to pace retries
639            response::wait_for_fallback_backoff(task_id, store, repo).await;
640            return Ok(ErrorHandleResult::EarlyReturn {
641                status: "routed".to_string(),
642            });
643        }
644        agents::AgentError::StaleSession { session_id } => {
645            // Stale session errors only occur in the orch chat control session, not
646            // in task runners (tasks don't use --session-id / --resume). Treat as a
647            // generic agent failure so the task is retried from scratch.
648            (
649                response::RetryableError::Failed,
650                format!("{agent_name} stale session: {session_id}"),
651            )
652        }
653        agents::AgentError::ThinkingBlockConflict { message } => {
654            // Transient Anthropic API 400 — thinking blocks modified in multi-turn
655            // tool-use. Not a model capacity or rate-limit problem.
656            // Do NOT record failure counts or model/agent cooldowns.
657            // Pass ModelUnavailable so handle_failover skips agent failure recording
658            // while still updating the reroute chain to avoid re-selecting claude.
659            (
660                response::RetryableError::ModelUnavailable,
661                format!("{agent_name} thinking block conflict: {message}"),
662            )
663        }
664        agents::AgentError::Unknown { exit_code, message } => {
665            // Exit 0 with empty output is a silent failure (common with GitHub
666            // Copilot models in opencode).  Record a model-specific cooldown so
667            // the same model is not retried on every subsequent task.
668            // Also matches when message == "empty-output-exit0" (from classify_from_text
669            // or the direct empty-output branch in mod.rs).
670            if *exit_code == 0
671                && (message.is_empty()
672                    || message == "empty-output-exit0"
673                    || message.starts_with("empty-output-exit0:"))
674            {
675                if let Some(m) = model_name {
676                    // Silent exits can remain broken for long periods. Start with 4h,
677                    // then escalate using exponential backoff for persistent failures.
678                    crate::engine::cooldown::record_persistent_model_failure(agent_name, m).await;
679                }
680                // Before falling through to handle_failover() (which tries claude/codex),
681                // check whether this agent has any free models that haven't been tried yet.
682                // Silent exit-0 is usually model/provider-specific; retrying other free
683                // models first avoids wasting paid claude/codex failovers.
684                let current = model_name.unwrap_or("");
685                if let Some(result) = try_free_model_reroute(
686                    task_id,
687                    agent_name,
688                    agent_runner,
689                    &[current],
690                    true,
691                    Some("opencode"),
692                    true,
693                    "silent exit 0, retrying with free model",
694                    store,
695                    repo,
696                )
697                .await
698                {
699                    return Ok(result);
700                }
701            }
702
703            // Handle JSON cost telemetry in stdout for non-zero exits
704            // If the message looks like Claude's cost JSON, replace with a generic message
705            let error_message = if *exit_code != 0 && message.contains("\"costUSD\":") {
706                // Extract just the essential info: exit code and that cost telemetry was present
707                format!(
708                    "{} exited with non-zero status (cost telemetry in stdout, no error message)",
709                    agent_name
710                )
711            } else {
712                format!("{agent_name} exit {exit_code}: {message}")
713            };
714
715            (response::RetryableError::Failed, error_message)
716        }
717    };
718
719    // Timeouts and interrupts clear the agent and skip the rate_limits row
720    if matches!(
721        retryable,
722        response::RetryableError::Timeout | response::RetryableError::Interrupted
723    ) {
724        if let Err(e) = store::store_set_result(
725            store,
726            repo,
727            task_id,
728            &[
729                ("agent", serde_json::json!("")),
730                ("last_error", serde_json::json!(error_msg.clone())),
731            ],
732        )
733        .await
734        {
735            tracing::warn!(task_id, err = %e, "failed to write timeout agent clear to store");
736        }
737    }
738
739    // Record rate limit in store (sqlx). Timeouts and interrupted runs are
740    // not usage limits, so they must not write a row
741    {
742        let error_type_str = match retryable {
743            response::RetryableError::UsageLimit => Some("rate"),
744            response::RetryableError::AuthError => Some("auth"),
745            response::RetryableError::Timeout | response::RetryableError::Interrupted => None,
746            _ => Some("error"),
747        };
748        if let Some(limit_type) = error_type_str {
749            if let Some(ref s) = store {
750                if let Err(e) = s
751                    .record_rate_limit(agent_name, limit_type, Some(task_id))
752                    .await
753                {
754                    tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
755                }
756            }
757        }
758    }
759
760    // Try free models as last resort before giving up
761    let chain = response::get_reroute_chain(task_id, store, repo).await;
762    let available: Vec<String> = crate::engine::configured_agents()
763        .into_iter()
764        .filter(|a| crate::cmd_cache::command_exists(a))
765        .collect();
766
767    let all_agents_tried = {
768        let chain_set: std::collections::HashSet<&str> = if chain.is_empty() {
769            std::collections::HashSet::new()
770        } else {
771            chain.split(',').collect()
772        };
773        !available
774            .iter()
775            .any(|a| a.as_str() != agent_name && !chain_set.contains(a.as_str()))
776    };
777
778    let is_simple = matches!(complexity, None | Some("simple"));
779    if all_agents_tried && is_simple {
780        // All agents exhausted — try free models via opencode (only for simple tasks)
781        if let Some(result) = try_free_model_reroute(
782            task_id,
783            agent_name,
784            agent_runner,
785            &[],
786            true, // fix: check cooldowns (was previously missing, inconsistent with other paths)
787            Some("opencode"),
788            false,
789            "all agents exhausted, trying free model",
790            store,
791            repo,
792        )
793        .await
794        {
795            return Ok(result);
796        }
797    }
798
799    let status = response::handle_failover(
800        task_id,
801        agent_name,
802        retryable,
803        &error_msg,
804        credit_recorded,
805        store,
806        repo,
807    )
808    .await;
809    match status.as_str() {
810        "new" => tracing::warn!(task_id, "failover exhausted (retryable), task reset to new"),
811        "blocked" => tracing::warn!(task_id, "failover exhausted (hard failure), task blocked"),
812        _ => {}
813    }
814
815    // Store failure memory for retry learning
816    response::store_failure_memory(
817        task_id,
818        new_attempts,
819        agent_name,
820        model_name,
821        &error_msg,
822        store,
823        repo,
824    )
825    .await;
826
827    Ok(ErrorHandleResult::Continue {
828        status,
829        error: error_msg,
830    })
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836    use crate::engine::runner::agents::{AgentError, AgentRunner, ParsedResponse, PermissionRules};
837    use crate::parser::AgentResponse;
838    use serial_test::serial;
839
840    struct MockRunner {
841        free: Vec<String>,
842    }
843
844    struct MockRunnerWithModels {
845        models: Vec<String>,
846    }
847
848    impl AgentRunner for MockRunnerWithModels {
849        fn stream_events(
850            &self,
851            _event: &serde_json::Value,
852        ) -> Vec<crate::engine::runner::agents::StreamEvent> {
853            vec![]
854        }
855
856        fn name(&self) -> &str {
857            "opencode"
858        }
859
860        fn build_command(
861            &self,
862            _model: Option<&str>,
863            _timeout_cmd: &str,
864            _sys_file: &str,
865            _msg_file: &str,
866            _permissions: &PermissionRules,
867        ) -> String {
868            String::new()
869        }
870
871        fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
872            Ok(ParsedResponse {
873                response: AgentResponse {
874                    status: "done".to_string(),
875                    summary: String::new(),
876                    accomplished: vec![],
877                    remaining: vec![],
878                    files: vec![],
879                    error: None,
880                    input_tokens: None,
881                    output_tokens: None,
882                    learnings: vec![],
883                    delegations: vec![],
884                },
885                input_tokens: None,
886                output_tokens: None,
887                duration_ms: None,
888            })
889        }
890
891        fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
892            AgentError::Unknown {
893                exit_code: 0,
894                message: String::new(),
895            }
896        }
897
898        fn available_models(&self) -> Vec<String> {
899            self.models.clone()
900        }
901
902        fn router_command(
903            &self,
904            _prompt: &str,
905            _model: Option<&str>,
906        ) -> anyhow::Result<tokio::process::Command> {
907            anyhow::bail!("not implemented")
908        }
909    }
910
911    impl AgentRunner for MockRunner {
912        fn stream_events(
913            &self,
914            _event: &serde_json::Value,
915        ) -> Vec<crate::engine::runner::agents::StreamEvent> {
916            vec![]
917        }
918
919        fn name(&self) -> &str {
920            "opencode"
921        }
922
923        fn build_command(
924            &self,
925            _model: Option<&str>,
926            _timeout_cmd: &str,
927            _sys_file: &str,
928            _msg_file: &str,
929            _permissions: &PermissionRules,
930        ) -> String {
931            String::new()
932        }
933
934        fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
935            Ok(ParsedResponse {
936                response: AgentResponse {
937                    status: "done".to_string(),
938                    summary: String::new(),
939                    accomplished: vec![],
940                    remaining: vec![],
941                    files: vec![],
942                    error: None,
943                    input_tokens: None,
944                    output_tokens: None,
945                    learnings: vec![],
946                    delegations: vec![],
947                },
948                input_tokens: None,
949                output_tokens: None,
950                duration_ms: None,
951            })
952        }
953
954        fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
955            AgentError::Unknown {
956                exit_code: 0,
957                message: String::new(),
958            }
959        }
960
961        fn free_models(&self) -> Vec<String> {
962            self.free.clone()
963        }
964
965        fn router_command(
966            &self,
967            _prompt: &str,
968            _model: Option<&str>,
969        ) -> anyhow::Result<tokio::process::Command> {
970            anyhow::bail!("not implemented")
971        }
972    }
973
974    /// Verify that a generic (non-credit-exhaustion) rate limit error sets the agent
975    /// cooldown immediately in `handle_error()`, before `handle_failover()` is called.
976    ///
977    /// This prevents concurrent dispatches from starting new runs against the same
978    /// rate-limited agent while the first task is still in its error-handling path.
979    #[serial(cooldown_state)]
980    #[tokio::test]
981    async fn rate_limit_sets_agent_cooldown_immediately() {
982        crate::engine::cooldown::reset_global_state().await;
983        let runner = MockRunner { free: vec![] };
984        let agent = "test-agent-1371-early-cooldown";
985
986        // Confirm no cooldown before the error.
987        assert!(
988            !crate::engine::cooldown::is_agent_in_cooldown(agent),
989            "agent should not be in cooldown before handle_error"
990        );
991
992        let err = AgentError::RateLimit {
993            message: "429 Too Many Requests".to_string(),
994        };
995
996        // Run handle_error — no store so handle_failover will find no agents and return Continue.
997        let _result = handle_error(
998            "test-1371-a",
999            &err,
1000            agent,
1001            &runner,
1002            Some("sonnet"),
1003            Some("medium"),
1004            1,
1005            &None,
1006            "owner/repo",
1007        )
1008        .await
1009        .unwrap();
1010
1011        // Cooldown must be set immediately after handle_error returns, not deferred.
1012        assert!(
1013            crate::engine::cooldown::is_agent_in_cooldown(agent),
1014            "agent should be in cooldown immediately after handle_error for RateLimit"
1015        );
1016    }
1017
1018    /// Verify that a rate limit error sets BOTH the agent-level and model-level cooldowns.
1019    ///
1020    /// This is critical: the router's model selection (model_for_complexity) checks
1021    /// is_model_in_cooldown(agent, model) which uses the agent:model key format.
1022    /// An agent-level cooldown alone does NOT prevent the same model from being
1023    /// re-selected, causing the rate-limited model to fail repeatedly (issue #2153).
1024    #[serial(cooldown_state)]
1025    #[tokio::test]
1026    async fn rate_limit_sets_both_agent_and_model_cooldowns() {
1027        crate::engine::cooldown::reset_global_state().await;
1028        let runner = MockRunner { free: vec![] };
1029        let agent = "test-agent-2153-model-cooldown";
1030        let model = "opencode/github-copilot/qwen3.6-plus-free";
1031
1032        // Confirm no cooldowns before the error.
1033        assert!(
1034            !crate::engine::cooldown::is_agent_in_cooldown(agent),
1035            "agent should not be in cooldown before handle_error"
1036        );
1037        assert!(
1038            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1039            "model should not be in cooldown before handle_error"
1040        );
1041
1042        let err = AgentError::RateLimit {
1043            message: "Upstream error from Alibaba: Request rate increased too quickly.".to_string(),
1044        };
1045
1046        let _result = handle_error(
1047            "test-2153-a",
1048            &err,
1049            agent,
1050            &runner,
1051            Some(model),
1052            Some("simple"),
1053            1,
1054            &None,
1055            "owner/repo",
1056        )
1057        .await
1058        .unwrap();
1059
1060        // Agent-level cooldown must be set immediately.
1061        assert!(
1062            crate::engine::cooldown::is_agent_in_cooldown(agent),
1063            "agent should be in agent-level cooldown after RateLimit"
1064        );
1065        // Model-level cooldown must ALSO be set immediately — this is the fix for issue #2153.
1066        // Without this, model_for_complexity would still pick the same model.
1067        assert!(
1068            crate::engine::cooldown::is_model_in_cooldown(agent, model),
1069            "model should be in model-level cooldown after RateLimit (fixes issue #2153)"
1070        );
1071    }
1072
1073    /// Verify that rate limit for a specific model does NOT cool other models of the same agent.
1074    #[serial(cooldown_state)]
1075    #[tokio::test]
1076    async fn rate_limit_cooldown_is_model_specific() {
1077        crate::engine::cooldown::reset_global_state().await;
1078        let runner = MockRunner { free: vec![] };
1079        let agent = "test-agent-2153-model-specific";
1080        let cooled_model = "opencode/github-copilot/qwen3.6-plus-free";
1081        let other_model = "opencode/minimax-m2.5-free";
1082
1083        let err = AgentError::RateLimit {
1084            message: "rate limit exceeded".to_string(),
1085        };
1086
1087        let _result = handle_error(
1088            "test-2153-b",
1089            &err,
1090            agent,
1091            &runner,
1092            Some(cooled_model),
1093            Some("simple"),
1094            1,
1095            &None,
1096            "owner/repo",
1097        )
1098        .await
1099        .unwrap();
1100
1101        // The specific model that hit the rate limit should be cooled.
1102        assert!(
1103            crate::engine::cooldown::is_model_in_cooldown(agent, cooled_model),
1104            "rate-limited model should be in cooldown"
1105        );
1106        // Other models of the same agent should NOT be cooled.
1107        assert!(
1108            !crate::engine::cooldown::is_model_in_cooldown(agent, other_model),
1109            "other models should not be cooled by a single model's rate limit"
1110        );
1111    }
1112
1113    /**
1114     * Regression test for issue #3652: a credit-exhaustion error records its
1115     * own credit_failure_count cooldown, but handle_failover() used to also
1116     * bump the generic failure_count:<agent> counter. After a few credit
1117     * errors the next ordinary failure would start at the 24h/48h extended
1118     * backoff tier instead of the 5 minute base. The generic counter must be
1119     * untouched here while credit_failure_count is incremented. The agent
1120     * name is deliberately not a configured agent, so handle_failover() finds
1121     * it as a fallback and takes the recording path this test exercises
1122     **/
1123    #[serial(cooldown_state)]
1124    #[tokio::test]
1125    async fn credit_exhaustion_does_not_bump_generic_failure_count() {
1126        crate::engine::cooldown::reset_global_state().await;
1127        let store = std::sync::Arc::new(
1128            crate::store::TaskStore::open_memory()
1129                .await
1130                .expect("in-memory store"),
1131        );
1132        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
1133
1134        let runner = MockRunner { free: vec![] };
1135        let agent = "test-agent-3652-credit-no-bump";
1136
1137        let err = AgentError::RateLimit {
1138            message: "out of credits, please recharge your balance".to_string(),
1139        };
1140
1141        let _result = handle_error(
1142            "test-3652-a",
1143            &err,
1144            agent,
1145            &runner,
1146            None,
1147            Some("simple"),
1148            1,
1149            &Some(store.clone()),
1150            "owner/repo",
1151        )
1152        .await
1153        .unwrap();
1154
1155        let credit_count = store
1156            .kv_get(&format!("credit_failure_count:{agent}"))
1157            .await
1158            .unwrap();
1159        assert!(
1160            credit_count.is_some(),
1161            "credit_failure_count should be incremented by the credit error"
1162        );
1163        let generic_count = store
1164            .kv_get(&format!("failure_count:{agent}"))
1165            .await
1166            .unwrap();
1167        assert!(
1168            generic_count.is_none(),
1169            "failure_count must not be bumped by credit errors, got {generic_count:?}"
1170        );
1171    }
1172
1173    /// A timeout is a long task, not an unavailable model. Two timeouts in a
1174    /// row must leave the model routable and write no rate_limits row
1175    #[serial(cooldown_state)]
1176    #[tokio::test]
1177    async fn two_timeouts_leave_model_routable() {
1178        crate::engine::cooldown::reset_global_state().await;
1179        let store = std::sync::Arc::new(
1180            crate::store::TaskStore::open_memory()
1181                .await
1182                .expect("in-memory store"),
1183        );
1184        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
1185
1186        let runner = MockRunner { free: vec![] };
1187        let agent = "opencode";
1188        let model = "opencode/nemotron-3-ultra-free";
1189
1190        let err = AgentError::Timeout {
1191            elapsed: std::time::Duration::from_secs(1800),
1192        };
1193
1194        for i in 1..=2 {
1195            let _result = handle_error(
1196                &format!("test-3876-timeout-{i}"),
1197                &err,
1198                agent,
1199                &runner,
1200                Some(model),
1201                Some("simple"),
1202                i,
1203                &Some(store.clone()),
1204                "owner/repo",
1205            )
1206            .await
1207            .unwrap();
1208        }
1209
1210        assert!(
1211            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1212            "timeouts must not cool the model"
1213        );
1214        assert!(
1215            !crate::engine::cooldown::is_agent_in_cooldown(agent),
1216            "timeouts must not cool the agent"
1217        );
1218        let counts = store.recent_rate_limit_counts(6).await.unwrap();
1219        assert_eq!(
1220            counts.get(agent).copied().unwrap_or(0),
1221            0,
1222            "timeouts must not write a rate_limits row"
1223        );
1224    }
1225
1226    /// A run that exits without a result envelope retries without cooling the
1227    /// model or writing a rate_limits row
1228    #[serial(cooldown_state)]
1229    #[tokio::test]
1230    async fn interrupted_run_does_not_write_rate_limit() {
1231        crate::engine::cooldown::reset_global_state().await;
1232        let store = std::sync::Arc::new(
1233            crate::store::TaskStore::open_memory()
1234                .await
1235                .expect("in-memory store"),
1236        );
1237        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
1238
1239        let runner = MockRunner { free: vec![] };
1240        let agent = "claude";
1241        let model = "sonnet";
1242        let err = AgentError::Interrupted {
1243            message: "exited without a result envelope".to_string(),
1244        };
1245
1246        let _result = handle_error(
1247            "test-3876-interrupted",
1248            &err,
1249            agent,
1250            &runner,
1251            Some(model),
1252            Some("medium"),
1253            1,
1254            &Some(store.clone()),
1255            "owner/repo",
1256        )
1257        .await
1258        .unwrap();
1259
1260        assert!(
1261            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1262            "interrupted runs must not cool the model"
1263        );
1264        let counts = store.recent_rate_limit_counts(6).await.unwrap();
1265        assert_eq!(
1266            counts.get(agent).copied().unwrap_or(0),
1267            0,
1268            "interrupted runs must not write a rate_limits row"
1269        );
1270    }
1271
1272    /// Regression test for issue #3580: the in-task failover path (this
1273    /// function) previously never called `parse_relative_usage_window()`, so a
1274    /// vendor-advertised window like "weekly (7-day) usage limit" fell through
1275    /// to the generic exponential backoff (5min base) instead of the ~7-day
1276    /// window the vendor advertised. Only the `needs_review` call site in
1277    /// `runner/mod.rs` applied the window fix from #3572/#3574 — this path did
1278    /// not. Verify the model-specific cooldown now matches the parsed window
1279    /// rather than the short exponential-backoff base.
1280    ///
1281    /// Note: `handle_failover()` still applies its own short agent-level
1282    /// cooldown when switching to a fallback agent — that bookkeeping is
1283    /// unrelated to and unaffected by this fix, so it isn't asserted here.
1284    #[serial(cooldown_state)]
1285    #[tokio::test]
1286    async fn rate_limit_with_relative_window_sets_window_length_model_cooldown() {
1287        crate::engine::cooldown::reset_global_state().await;
1288        let runner = MockRunner { free: vec![] };
1289        let agent = "test-agent-3580-kimi";
1290        let model = "opus";
1291        let key = format!("{agent}:{model}");
1292
1293        let err = AgentError::RateLimit {
1294            message: "You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window expires.".to_string(),
1295        };
1296
1297        let _result = handle_error(
1298            "test-3580-a",
1299            &err,
1300            agent,
1301            &runner,
1302            Some(model),
1303            Some("medium"),
1304            1,
1305            &None,
1306            "owner/repo",
1307        )
1308        .await
1309        .unwrap();
1310
1311        let now = chrono::Utc::now().timestamp();
1312        let until = crate::engine::cooldown::cooldown_until(&key)
1313            .expect("relative usage window should set model cooldown");
1314        let remaining = until.saturating_sub(now);
1315
1316        assert!(
1317            remaining >= 7 * 86400 - 30,
1318            "expected ~7-day model cooldown from the parsed window, got {remaining}s"
1319        );
1320        assert_eq!(
1321            crate::engine::cooldown::cooldown_reason(&key).as_deref(),
1322            Some("rate_limit_usage_window"),
1323            "relative usage-window cooldown must be labeled as a rate limit, not silence"
1324        );
1325    }
1326
1327    #[serial(cooldown_state)]
1328    #[tokio::test]
1329    async fn provider_returned_error_sets_extended_model_cooldown() {
1330        crate::engine::cooldown::reset_global_state().await;
1331        let runner = MockRunner { free: vec![] };
1332        let agent = "opencode";
1333        let model = "opencode/nemotron-3-super-free";
1334        let key = format!("{agent}:{model}");
1335
1336        let err = AgentError::RateLimit {
1337            message: "Provider returned error".to_string(),
1338        };
1339
1340        let _result = handle_error(
1341            "test-2478-provider-cooldown",
1342            &err,
1343            agent,
1344            &runner,
1345            Some(model),
1346            Some("simple"),
1347            1,
1348            &None,
1349            "owner/repo",
1350        )
1351        .await
1352        .unwrap();
1353
1354        let now = chrono::Utc::now().timestamp();
1355        let until = crate::engine::cooldown::cooldown_until(&key)
1356            .expect("provider error should set model cooldown");
1357        let remaining = until.saturating_sub(now);
1358
1359        // Allow a wider margin for scheduler/runtime jitter in concurrent test runs.
1360        assert!(
1361            remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1362            "expected ~4h model cooldown, got {remaining}s"
1363        );
1364    }
1365
1366    #[serial(cooldown_state)]
1367    #[tokio::test]
1368    async fn model_unavailable_sets_persistent_model_cooldown_for_codex_style() {
1369        crate::engine::cooldown::reset_global_state().await;
1370        let runner = MockRunner { free: vec![] };
1371        let agent = "codex";
1372        let model = "gpt-5.3-codex";
1373
1374        // Simulate Codex provider JSON error: message contains "model is not supported"
1375        let err = crate::engine::runner::agents::AgentError::ModelUnavailable {
1376            model: model.to_string(),
1377            message: "{\"detail\":\"The 'gpt-5.3-codex' model is not supported\"}".to_string(),
1378        };
1379
1380        let _result = handle_error(
1381            "test-codex-model-unsupported",
1382            &err,
1383            agent,
1384            &runner,
1385            Some(model),
1386            Some("simple"),
1387            1,
1388            &None,
1389            "owner/repo",
1390        )
1391        .await
1392        .unwrap();
1393
1394        // The persistent backoff base is ~4h; ensure cooldown exists and is at least that long minus a margin
1395        let key = format!("{}:{}", agent, model);
1396        let now = chrono::Utc::now().timestamp();
1397        let until =
1398            crate::engine::cooldown::cooldown_until(&key).expect("model cooldown should be set");
1399        let remaining = until.saturating_sub(now);
1400
1401        assert!(
1402            remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1403            "expected persistent model cooldown (~4h) for codex model-unavailable, got {remaining}s"
1404        );
1405    }
1406
1407    async fn vendor_reset_remaining(agent: &str, model: &str, message: &str) -> i64 {
1408        crate::engine::cooldown::reset_global_state().await;
1409        let runner = MockRunner { free: vec![] };
1410        let err = crate::engine::runner::agents::AgentError::RateLimit {
1411            message: message.to_string(),
1412        };
1413        let _ = handle_error(
1414            "test-3649",
1415            &err,
1416            agent,
1417            &runner,
1418            Some(model),
1419            Some("simple"),
1420            1,
1421            &None,
1422            "owner/repo",
1423        )
1424        .await
1425        .unwrap();
1426        let until = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
1427            .expect("model cooldown should be set");
1428        until - chrono::Utc::now().timestamp()
1429    }
1430
1431    #[serial(cooldown_state)]
1432    #[tokio::test]
1433    async fn vendor_reset_date_overrides_weekly_window() {
1434        use chrono::Datelike;
1435        let t = chrono::Local::now() + chrono::Duration::days(2);
1436        let msg = format!(
1437            "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
1438            t.format("%b"),
1439            t.day()
1440        );
1441        let remaining = vendor_reset_remaining("claude", "sonnet", &msg).await;
1442        assert!(
1443            remaining > 3600 && remaining < 3 * 86400,
1444            "expected ~2d cooldown, got {remaining}s"
1445        );
1446    }
1447
1448    #[serial(cooldown_state)]
1449    #[tokio::test]
1450    async fn vendor_reset_date_overrides_billing_cycle_backoff() {
1451        let t = chrono::Local::now() + chrono::Duration::days(3);
1452        let msg = format!(
1453            "You've hit your usage limit. Upgrade to Pro (https://x), or try again at {}.",
1454            t.format("%b %d, %Y %I:%M %p")
1455        );
1456        let remaining = vendor_reset_remaining("codex", "gpt-5.4", &msg).await;
1457        assert!(
1458            remaining > 2 * 86400 + 80000 - 3600 && remaining < 4 * 86400,
1459            "expected ~3d cooldown, got {remaining}s"
1460        );
1461    }
1462
1463    #[serial(cooldown_state)]
1464    #[tokio::test]
1465    async fn agent_failed_insufficient_balance_sets_credit_cooldown() {
1466        crate::engine::cooldown::reset_global_state().await;
1467        let runner = MockRunner { free: vec![] };
1468        let agent = "test-agent-3642-insufficient-balance";
1469
1470        // Confirm no cooldown before the error.
1471        assert!(
1472            !crate::engine::cooldown::is_agent_in_cooldown(agent),
1473            "agent should not be in cooldown before handle_error"
1474        );
1475
1476        let err = agents::AgentError::AgentFailed {
1477            message: "402 Payment Required: insufficient balance".to_string(),
1478        };
1479
1480        // Run handle_error — no store so handle_failover will find no agents and return Continue.
1481        let _result = handle_error(
1482            "test-3642-a",
1483            &err,
1484            agent,
1485            &runner,
1486            Some("sonnet"),
1487            Some("medium"),
1488            1,
1489            &None,
1490            "owner/repo",
1491        )
1492        .await
1493        .unwrap();
1494
1495        // The credit exhaustion path (from #3642) should set a cooldown of at least
1496        // CREDIT_BACKOFF_BASE_SECS (1h), not the generic 5-min backoff.
1497        // Without #3642, the AgentFailed arm would have gone to the else branch
1498        // and called record_agent_failure_with_message, setting a 5-min cooldown.
1499        assert!(
1500            crate::engine::cooldown::is_agent_in_cooldown(agent),
1501            "agent should be in cooldown after handle_error for AgentFailed with insufficient balance"
1502        );
1503
1504        let until = crate::engine::cooldown::cooldown_until(agent)
1505            .expect("agent cooldown should be active");
1506        let remaining = until - chrono::Utc::now().timestamp();
1507        assert!(
1508            remaining >= crate::engine::cooldown::CREDIT_BACKOFF_BASE_SECS - 30,
1509            "credit cooldown should be at least the 1h base, got {remaining}s"
1510        );
1511    }
1512
1513    #[serial(cooldown_state)]
1514    #[tokio::test]
1515    async fn silent_exit0_retries_free_model_for_simple_complexity() {
1516        crate::engine::cooldown::reset_global_state().await;
1517        let runner = MockRunner {
1518            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1519        };
1520        let err = AgentError::Unknown {
1521            exit_code: 0,
1522            message: String::new(),
1523        };
1524
1525        // complexity=simple → free model retry is appropriate
1526        let result = handle_error(
1527            "test-1112-a",
1528            &err,
1529            "opencode",
1530            &runner,
1531            Some("opencode/gpt-5.4-mini"),
1532            Some("simple"),
1533            1,
1534            &None,
1535            "owner/repo",
1536        )
1537        .await
1538        .unwrap();
1539
1540        assert!(
1541            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1542            "expected EarlyReturn{{status: new}} for simple complexity — free model should be tried first"
1543        );
1544    }
1545
1546    #[serial(cooldown_state)]
1547    #[tokio::test]
1548    async fn silent_exit0_retries_free_model_when_complexity_unknown() {
1549        crate::engine::cooldown::reset_global_state().await;
1550        let runner = MockRunner {
1551            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1552        };
1553        let err = AgentError::Unknown {
1554            exit_code: 0,
1555            message: String::new(),
1556        };
1557
1558        // complexity=None (unknown) → treated as simple → free model retry
1559        let result = handle_error(
1560            "test-1112-a2",
1561            &err,
1562            "opencode",
1563            &runner,
1564            Some("opencode/gpt-5.4-mini"),
1565            None,
1566            1,
1567            &None,
1568            "owner/repo",
1569        )
1570        .await
1571        .unwrap();
1572
1573        assert!(
1574            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1575            "expected EarlyReturn{{status: new}} for unknown complexity — free model should be tried first"
1576        );
1577    }
1578
1579    #[serial(cooldown_state)]
1580    #[tokio::test]
1581    async fn silent_exit0_retries_free_model_for_medium_complexity() {
1582        crate::engine::cooldown::reset_global_state().await;
1583        let runner = MockRunner {
1584            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1585        };
1586        let err = AgentError::Unknown {
1587            exit_code: 0,
1588            message: String::new(),
1589        };
1590
1591        // complexity=medium → still retry free model before cross-agent failover
1592        let result = handle_error(
1593            "test-1195-medium",
1594            &err,
1595            "opencode",
1596            &runner,
1597            Some("opencode/copilot-gpt-5.4-mini"),
1598            Some("medium"),
1599            1,
1600            &None,
1601            "owner/repo",
1602        )
1603        .await
1604        .unwrap();
1605
1606        assert!(
1607            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1608            "expected EarlyReturn{{status: new}} for medium complexity — free model should be tried before failover"
1609        );
1610    }
1611
1612    #[serial(cooldown_state)]
1613    #[tokio::test]
1614    async fn silent_exit0_retries_free_model_for_complex_complexity() {
1615        crate::engine::cooldown::reset_global_state().await;
1616        let runner = MockRunner {
1617            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1618        };
1619        let err = AgentError::Unknown {
1620            exit_code: 0,
1621            message: String::new(),
1622        };
1623
1624        // complexity=complex → still retry free model before cross-agent failover
1625        let result = handle_error(
1626            "test-1195-complex",
1627            &err,
1628            "opencode",
1629            &runner,
1630            Some("opencode/copilot-gpt-5.4-mini"),
1631            Some("complex"),
1632            1,
1633            &None,
1634            "owner/repo",
1635        )
1636        .await
1637        .unwrap();
1638
1639        assert!(
1640            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1641            "expected EarlyReturn{{status: new}} for complex complexity — free model should be tried before failover"
1642        );
1643    }
1644
1645    /// Context overflow is deterministic — the same context will overflow every agent.
1646    /// Verify that it escalates directly to needs_review without cycling through fallback agents.
1647    #[serial(cooldown_state)]
1648    #[tokio::test]
1649    async fn context_overflow_escalates_to_needs_review_immediately() {
1650        crate::engine::cooldown::reset_global_state().await;
1651        let runner = MockRunner { free: vec![] };
1652        let err = AgentError::ContextOverflow {
1653            message: "prompt is too long: 250000 tokens, max 200000".to_string(),
1654        };
1655
1656        let result = handle_error(
1657            "test-2129-a",
1658            &err,
1659            "claude",
1660            &runner,
1661            Some("sonnet"),
1662            Some("medium"),
1663            1,
1664            &None,
1665            "owner/repo",
1666        )
1667        .await
1668        .unwrap();
1669
1670        assert!(
1671            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "needs_review"),
1672            "context overflow must escalate directly to needs_review without cycling through fallback agents"
1673        );
1674    }
1675
1676    /// When store is None, store_set_result returns Ok(()) — reroute proceeds normally.
1677    /// This verifies the happy path is not broken by the error-propagation change.
1678    #[serial(cooldown_state)]
1679    #[tokio::test]
1680    async fn free_model_reroute_succeeds_without_store() {
1681        crate::engine::cooldown::reset_global_state().await;
1682        let runner = MockRunner {
1683            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1684        };
1685        let err = AgentError::Unknown {
1686            exit_code: 0,
1687            message: String::new(),
1688        };
1689
1690        // store=None → store_set_result returns Ok(()) → reroute proceeds
1691        let result = handle_error(
1692            "test-2293-a",
1693            &err,
1694            "opencode",
1695            &runner,
1696            Some("opencode/gpt-5.4-mini"),
1697            Some("simple"),
1698            1,
1699            &None,
1700            "owner/repo",
1701        )
1702        .await
1703        .unwrap();
1704
1705        assert!(
1706            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1707            "expected EarlyReturn{{status: new}} — store_set_result with None store should not prevent reroute"
1708        );
1709    }
1710
1711    #[serial(cooldown_state)]
1712    #[tokio::test]
1713    async fn silent_exit0_falls_through_when_no_free_models() {
1714        crate::engine::cooldown::reset_global_state().await;
1715        let runner = MockRunner { free: vec![] };
1716        let err = AgentError::Unknown {
1717            exit_code: 0,
1718            message: String::new(),
1719        };
1720
1721        let result = handle_error(
1722            "test-1112-b",
1723            &err,
1724            "opencode",
1725            &runner,
1726            Some("opencode/gpt-5.4-mini"),
1727            Some("simple"),
1728            1,
1729            &None,
1730            "owner/repo",
1731        )
1732        .await
1733        .unwrap();
1734
1735        assert!(
1736            matches!(result, ErrorHandleResult::Continue { .. }),
1737            "expected Continue (fallthrough to failover) when no free models available"
1738        );
1739    }
1740
1741    /// Verify that InvalidResponse applies model-level cooldown.
1742    ///
1743    /// This fixes issue #2750: InvalidResponse used to skip model-level cooldown,
1744    /// allowing models that produced unparseable responses to be immediately
1745    /// retried on the next task without any backoff.
1746    #[serial(cooldown_state)]
1747    #[tokio::test]
1748    async fn invalid_response_sets_model_cooldown() {
1749        crate::engine::cooldown::reset_global_state().await;
1750        let runner = MockRunner { free: vec![] };
1751        let agent = "opencode";
1752        let model = "opencode/nemotron-3-super-free";
1753
1754        // Confirm no model cooldown before the error.
1755        assert!(
1756            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1757            "model should not be in cooldown before handle_error"
1758        );
1759
1760        let err = AgentError::InvalidResponse {
1761            raw: "invalid json output".to_string(),
1762        };
1763
1764        let _result = handle_error(
1765            "test-2750-invalid-response",
1766            &err,
1767            agent,
1768            &runner,
1769            Some(model),
1770            Some("simple"),
1771            1,
1772            &None,
1773            "owner/repo",
1774        )
1775        .await
1776        .unwrap();
1777
1778        // Model-level cooldown must be set after InvalidResponse.
1779        assert!(
1780            crate::engine::cooldown::is_model_in_cooldown(agent, model),
1781            "model should be in cooldown after InvalidResponse (fixes issue #2750)"
1782        );
1783    }
1784
1785    /// BillingCycleExhausted with a known model must set ONLY the model-level cooldown.
1786    ///
1787    /// Regression test for issue #3382: "upgrade to pro" messages (added to
1788    /// billing_cycle_patterns in #3380) were triggering an agent-wide 24h cooldown
1789    /// when model_name was None, blocking all codex models even though only the
1790    /// specific model's plan quota was exhausted.
1791    #[serial(cooldown_state)]
1792    #[tokio::test]
1793    async fn billing_cycle_exhausted_with_known_model_sets_only_model_cooldown() {
1794        crate::engine::cooldown::reset_global_state().await;
1795        let runner = MockRunner { free: vec![] };
1796        let agent = "codex-3382-billing-cycle";
1797        let model = "gpt-5.5";
1798
1799        let err = AgentError::RateLimit {
1800            message: "rate limit: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro).".to_string(),
1801        };
1802
1803        let _result = handle_error(
1804            "test-3382-a",
1805            &err,
1806            agent,
1807            &runner,
1808            Some(model),
1809            Some("medium"),
1810            1,
1811            &None,
1812            "owner/repo",
1813        )
1814        .await
1815        .unwrap();
1816
1817        // Agent-level credit cooldown must NOT be set — the quota is model-scoped.
1818        assert!(
1819            !crate::engine::cooldown::is_agent_in_cooldown(agent),
1820            "agent must NOT be in agent-level cooldown when BillingCycleExhausted has a known model (issue #3382)"
1821        );
1822        // Model-level cooldown MUST be set.
1823        assert!(
1824            crate::engine::cooldown::is_model_in_cooldown(agent, model),
1825            "model must be in cooldown after BillingCycleExhausted (issue #3382)"
1826        );
1827        // Other models on the same agent must NOT be cooled.
1828        assert!(
1829            !crate::engine::cooldown::is_model_in_cooldown(agent, "gpt-5.4"),
1830            "other models on the same agent must not be cooled by a single model's billing exhaustion"
1831        );
1832    }
1833
1834    #[serial(cooldown_state)]
1835    #[tokio::test]
1836    async fn summarize_rate_limit_error_extracts_api_retry_info() {
1837        crate::engine::cooldown::reset_global_state().await;
1838        let raw_output = r#"some log lines
1839{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1840{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1841more logs"#;
1842
1843        let summarized = super::summarize_rate_limit_error(raw_output);
1844        assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1845    }
1846
1847    #[serial(cooldown_state)]
1848    #[tokio::test]
1849    async fn summarize_rate_limit_error_returns_original_when_no_api_retry() {
1850        crate::engine::cooldown::reset_global_state().await;
1851        let raw_output = "some regular error message without api_retry JSON";
1852
1853        let summarized = super::summarize_rate_limit_error(raw_output);
1854        assert_eq!(summarized, raw_output);
1855    }
1856
1857    #[serial(cooldown_state)]
1858    #[tokio::test]
1859    async fn summarize_rate_limit_error_handles_malformed_json_gracefully() {
1860        crate::engine::cooldown::reset_global_state().await;
1861        let raw_output = r#"some logs
1862{"type":"system","subtype":"api_retry","attempt":"not_a_number","retry_delay_ms":invalid,"error_status":429}
1863more logs"#;
1864
1865        let summarized = super::summarize_rate_limit_error(raw_output);
1866        // Should still return a formatted string even with invalid numbers
1867        assert!(summarized.starts_with("status="));
1868        assert!(summarized.contains("after"));
1869        assert!(summarized.contains("attempts"));
1870        assert!(summarized.contains("(last delay"));
1871    }
1872
1873    #[serial(cooldown_state)]
1874    #[tokio::test]
1875    async fn summarize_rate_limit_error_works_with_single_line() {
1876        crate::engine::cooldown::reset_global_state().await;
1877        let raw_output = r#"{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":5000,"error_status":429,"error":"rate_limit"}"#;
1878
1879        let summarized = super::summarize_rate_limit_error(raw_output);
1880        assert_eq!(summarized, "status=429 after 3 attempts (last delay 5s)");
1881    }
1882
1883    /// Verify extract_json_field correctly handles quoted string values.
1884    /// Previously it stripped from the wrong end (strip_suffix), returning None for
1885    /// any field that isn't the last one in the JSON object (fixes issue #2817).
1886    #[serial(cooldown_state)]
1887    #[tokio::test]
1888    async fn extract_json_field_handles_quoted_strings() {
1889        crate::engine::cooldown::reset_global_state().await;
1890        let json = r#"{"error_status":429,"attempt":7,"retry_delay_ms":35162.66,"error":"rate_limit","session_id":"331c099b-..."}"#;
1891
1892        assert_eq!(
1893            super::extract_json_field(json, "\"error\":"),
1894            Some("rate_limit".to_string()),
1895            "quoted string field mid-object should be extracted correctly"
1896        );
1897        assert_eq!(
1898            super::extract_json_field(json, "\"session_id\":"),
1899            Some("331c099b-...".to_string()),
1900            "quoted string field at end of object (no trailing comma) should be extracted"
1901        );
1902        // Numeric fields should still work (regression check)
1903        assert_eq!(
1904            super::extract_json_field(json, "\"error_status\":"),
1905            Some("429".to_string())
1906        );
1907        assert_eq!(
1908            super::extract_json_field(json, "\"attempt\":"),
1909            Some("7".to_string())
1910        );
1911        assert_eq!(
1912            super::extract_json_field(json, "\"retry_delay_ms\":"),
1913            Some("35162.66".to_string())
1914        );
1915    }
1916
1917    /// Truncated fragment starting mid-object with ,"subtype":"api_retry"...
1918    /// Should NOT return raw JSON blob (regression test for issue #2839).
1919    #[serial(cooldown_state)]
1920    #[tokio::test]
1921    async fn summarize_rate_limit_error_handles_truncated_fragment_with_subtype() {
1922        crate::engine::cooldown::reset_global_state().await;
1923        // Real-world truncated fragment from GLM runs (no "type":"system" prefix)
1924        let raw_output = r#"some log context
1925,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit"
1926more context"#;
1927
1928        let summarized = super::summarize_rate_limit_error(raw_output);
1929        assert!(
1930            !summarized.contains("subtype"),
1931            "summarized output must not contain raw 'subtype' JSON fragment"
1932        );
1933        assert!(
1934            !summarized.contains("{"),
1935            "summarized output must not contain JSON braces"
1936        );
1937        assert!(
1938            summarized.starts_with("status="),
1939            "summarized output should start with 'status=' — got: {summarized}"
1940        );
1941        // Note: "api_retry" is only included when fields are not extractable
1942        // (the fragment fallback path). When attempt/delay are extractable,
1943        // the summary is "status=X after N attempts (last delay Ys)" with no "api_retry".
1944    }
1945
1946    /// Fragment with attempt and delay fields extractable from mid-fragment.
1947    #[serial(cooldown_state)]
1948    #[tokio::test]
1949    async fn summarize_rate_limit_error_handles_fragment_with_extractable_fields() {
1950        crate::engine::cooldown::reset_global_state().await;
1951        let raw_output = r#"log output
1952,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit","session_id":"abc123","max_retries":10}
1953rest of output"#;
1954
1955        let summarized = super::summarize_rate_limit_error(raw_output);
1956        assert_eq!(summarized, "status=429 after 5 attempts (last delay 20s)");
1957    }
1958
1959    /// Complete JSON (no truncation) still works after removing "type":"system" requirement.
1960    #[serial(cooldown_state)]
1961    #[tokio::test]
1962    async fn summarize_rate_limit_error_complete_json_still_works() {
1963        crate::engine::cooldown::reset_global_state().await;
1964        let raw_output = r#"some log lines
1965{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1966{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1967more logs"#;
1968
1969        let summarized = super::summarize_rate_limit_error(raw_output);
1970        assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1971    }
1972
1973    /// When no api_retry JSON is found at all, should NOT return raw passthrough.
1974    #[serial(cooldown_state)]
1975    #[tokio::test]
1976    async fn summarize_rate_limit_error_no_api_retry_no_raw_passthrough() {
1977        crate::engine::cooldown::reset_global_state().await;
1978        let raw_output = "some regular error message without api_retry JSON that is quite long and should be truncated";
1979
1980        let summarized = super::summarize_rate_limit_error(raw_output);
1981        // Should be truncated to first line, not the full raw_output
1982        assert!(
1983            summarized.len() <= 140,
1984            "expected truncated first-line, got: {summarized}"
1985        );
1986    }
1987
1988    /// Short error without api_retry: should return the line as-is (no truncation needed).
1989    #[serial(cooldown_state)]
1990    #[tokio::test]
1991    async fn summarize_rate_limit_error_no_api_retry_short() {
1992        crate::engine::cooldown::reset_global_state().await;
1993        let raw_output = "rate limit exceeded";
1994
1995        let summarized = super::summarize_rate_limit_error(raw_output);
1996        assert_eq!(summarized, raw_output);
1997    }
1998
1999    /// "Model not found" is a permanent failure — verify it applies the persistent (4h base)
2000    /// cooldown rather than the standard transient (5min base) cooldown.
2001    ///
2002    /// Regression test for issue #2941: dead models were retried every 4h indefinitely
2003    /// because "not found" was treated the same as a transient unavailability.
2004    #[serial(cooldown_state)]
2005    #[tokio::test]
2006    async fn model_not_found_applies_persistent_cooldown() {
2007        crate::engine::cooldown::reset_global_state().await;
2008        let runner = MockRunner { free: vec![] };
2009        let agent = "opencode-2941-not-found";
2010        let model = "github-copilot/claude-opus-4.6";
2011        let key = format!("{agent}:{model}");
2012
2013        let err = AgentError::ModelUnavailable {
2014            model: model.to_string(),
2015            message: "Model not found: github-copilot/claude-opus-4.6".to_string(),
2016        };
2017
2018        let _result = handle_error(
2019            "test-2941-a",
2020            &err,
2021            agent,
2022            &runner,
2023            Some(model),
2024            Some("complex"),
2025            1,
2026            &None,
2027            "owner/repo",
2028        )
2029        .await
2030        .unwrap();
2031
2032        let now = chrono::Utc::now().timestamp();
2033        let until = crate::engine::cooldown::cooldown_until(&key)
2034            .expect("model not found should set model cooldown");
2035        let remaining = until.saturating_sub(now);
2036
2037        // Persistent cooldown starts at 4h; transient starts at 5min.
2038        // Verify we applied the persistent (4h base) path, not the short one.
2039        assert!(
2040            remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2041            "expected ~4h (persistent) cooldown for 'Model not found', got {remaining}s — \
2042             may have applied transient 5min cooldown instead"
2043        );
2044    }
2045
2046    /// "Model not supported" is a permanent account-type mismatch — verify it applies the
2047    /// persistent (4h base → 7d max) cooldown, same as "not found".
2048    ///
2049    /// Regression test for issue #3215: "not supported" was not matched by the
2050    /// `is_permanently_gone` check, so it fell through to the 5min→4h transient backoff
2051    /// and the model kept being retried every ~4h indefinitely.
2052    #[serial(cooldown_state)]
2053    #[tokio::test]
2054    async fn model_not_supported_applies_persistent_cooldown() {
2055        crate::engine::cooldown::reset_global_state().await;
2056        let runner = MockRunner { free: vec![] };
2057        let agent = "codex-3215-not-supported";
2058        let model = "gpt-5.2-codex";
2059        let key = format!("{agent}:{model}");
2060
2061        let err = AgentError::ModelUnavailable {
2062            model: model.to_string(),
2063            message: "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.".to_string(),
2064        };
2065
2066        let _result = handle_error(
2067            "test-3215-a",
2068            &err,
2069            agent,
2070            &runner,
2071            Some(model),
2072            Some("medium"),
2073            1,
2074            &None,
2075            "owner/repo",
2076        )
2077        .await
2078        .unwrap();
2079
2080        let now = chrono::Utc::now().timestamp();
2081        let until = crate::engine::cooldown::cooldown_until(&key)
2082            .expect("model not supported should set model cooldown");
2083        let remaining = until.saturating_sub(now);
2084
2085        assert!(
2086            remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2087            "expected ~4h (persistent) cooldown for 'not supported', got {remaining}s — \
2088             may have applied transient 5min cooldown instead (issue #3215)"
2089        );
2090    }
2091
2092    #[serial(cooldown_state)]
2093    #[tokio::test]
2094    async fn model_not_available_in_country_applies_persistent_cooldown() {
2095        crate::engine::cooldown::reset_global_state().await;
2096        let runner = MockRunner { free: vec![] };
2097        let agent = "opencode-3650-country";
2098        let model = "some-region-locked-model";
2099        let key = format!("{agent}:{model}");
2100
2101        // issue #3650: region/country blocks need the persistent backoff, not the 5min one
2102        for message in [
2103            "This model is not available in your country.",
2104            "This model is not available in your region.",
2105        ] {
2106            crate::engine::cooldown::reset_global_state().await;
2107            let err = AgentError::ModelUnavailable {
2108                model: model.to_string(),
2109                message: message.to_string(),
2110            };
2111
2112            let _result = handle_error(
2113                "test-3650",
2114                &err,
2115                agent,
2116                &runner,
2117                Some(model),
2118                Some("medium"),
2119                1,
2120                &None,
2121                "owner/repo",
2122            )
2123            .await
2124            .unwrap();
2125
2126            let now = chrono::Utc::now().timestamp();
2127            let until = crate::engine::cooldown::cooldown_until(&key)
2128                .expect("region block should set model cooldown");
2129            let remaining = until.saturating_sub(now);
2130
2131            assert!(
2132                remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2133                "expected ~4h (persistent) cooldown for '{message}', got {remaining}s — \
2134                 may have applied transient 5min cooldown instead (issue #3650)"
2135            );
2136        }
2137    }
2138
2139    /// ThinkingBlockConflict must NOT set any agent or model cooldown.
2140    ///
2141    /// This error is a transient Claude CLI 400 (thinking blocks modified in multi-turn
2142    /// tool-use) — not a model capacity problem. Penalising claude:opus with failure
2143    /// counts would prematurely push it into extended-backoff for unrelated reasons.
2144    #[serial(cooldown_state)]
2145    #[tokio::test]
2146    async fn thinking_block_conflict_does_not_set_cooldown() {
2147        crate::engine::cooldown::reset_global_state().await;
2148        let runner = MockRunner { free: vec![] };
2149        let agent = "claude-3204-thinking-block";
2150        let model = "opus";
2151
2152        assert!(
2153            !crate::engine::cooldown::is_agent_in_cooldown(agent),
2154            "agent must not be in cooldown before handle_error"
2155        );
2156        assert!(
2157            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
2158            "model must not be in cooldown before handle_error"
2159        );
2160
2161        let err = AgentError::ThinkingBlockConflict {
2162            message: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.".to_string(),
2163        };
2164
2165        let _result = handle_error(
2166            "test-3204-thinking",
2167            &err,
2168            agent,
2169            &runner,
2170            Some(model),
2171            Some("complex"),
2172            1,
2173            &None,
2174            "owner/repo",
2175        )
2176        .await
2177        .unwrap();
2178
2179        assert!(
2180            !crate::engine::cooldown::is_agent_in_cooldown(agent),
2181            "agent must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2182        );
2183        assert!(
2184            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
2185            "model must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2186        );
2187    }
2188
2189    /// Transient model unavailability (not "not found") should use the standard short cooldown.
2190    #[serial(cooldown_state)]
2191    #[tokio::test]
2192    async fn model_unavailable_transient_applies_short_cooldown() {
2193        crate::engine::cooldown::reset_global_state().await;
2194        let runner = MockRunner { free: vec![] };
2195        let agent = "opencode-2941-transient";
2196        let model = "github-copilot/gpt-4o";
2197        let key = format!("{agent}:{model}");
2198
2199        let err = AgentError::ModelUnavailable {
2200            model: model.to_string(),
2201            message: "No endpoints found for github-copilot/gpt-4o.".to_string(),
2202        };
2203
2204        let _result = handle_error(
2205            "test-2941-b",
2206            &err,
2207            agent,
2208            &runner,
2209            Some(model),
2210            Some("medium"),
2211            1,
2212            &None,
2213            "owner/repo",
2214        )
2215        .await
2216        .unwrap();
2217
2218        let now = chrono::Utc::now().timestamp();
2219        let until = crate::engine::cooldown::cooldown_until(&key)
2220            .expect("transient model unavailability should set model cooldown");
2221        let remaining = until.saturating_sub(now);
2222
2223        // Standard (transient) backoff base is 5min. Persistent base is 4h.
2224        // Confirm we did NOT apply the long 4h cooldown for a transient error.
2225        assert!(
2226            remaining < crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2227            "transient unavailability should use short cooldown (<4h), got {remaining}s"
2228        );
2229    }
2230
2231    #[serial(cooldown_state)]
2232    #[tokio::test]
2233    async fn model_unavailable_does_not_reuse_same_agent_when_agent_cooled() {
2234        crate::engine::cooldown::reset_global_state().await;
2235        let agent = "codex-3620";
2236        let failing_model = "gpt-5.4";
2237        let other_model = "gpt-5.5";
2238        let runner = MockRunnerWithModels {
2239            models: vec![failing_model.to_string(), other_model.to_string()],
2240        };
2241
2242        crate::engine::cooldown::set_agent_cooldown(agent, 3600).await;
2243
2244        let err = AgentError::ModelUnavailable {
2245            model: failing_model.to_string(),
2246            message: "model gpt-5.4 unavailable".to_string(),
2247        };
2248
2249        let result = handle_error(
2250            "test-3620",
2251            &err,
2252            agent,
2253            &runner,
2254            Some(failing_model),
2255            Some("medium"),
2256            1,
2257            &None,
2258            "owner/repo",
2259        )
2260        .await
2261        .unwrap();
2262
2263        if let ErrorHandleResult::EarlyReturn { status } = result {
2264            assert_ne!(
2265                status, "routed",
2266                "agent-wide cooldown was defeated by same-agent model substitution"
2267            );
2268        }
2269    }
2270}