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