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                duration_ms: None,
870            })
871        }
872
873        fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
874            AgentError::Unknown {
875                exit_code: 0,
876                message: String::new(),
877            }
878        }
879
880        fn available_models(&self) -> Vec<String> {
881            self.models.clone()
882        }
883
884        fn router_command(&self, _prompt: &str, _model: Option<&str>) -> anyhow::Result<Command> {
885            anyhow::bail!("not implemented")
886        }
887    }
888
889    impl AgentRunner for MockRunner {
890        fn stream_events(&self, _event: &serde_json::Value) -> Vec<StreamEvent> {
891            vec![]
892        }
893
894        fn name(&self) -> &'static str {
895            "opencode"
896        }
897
898        fn build_command(
899            &self,
900            _model: Option<&str>,
901            _timeout_cmd: &str,
902            _sys_file: &str,
903            _msg_file: &str,
904            _permissions: &PermissionRules,
905        ) -> String {
906            String::new()
907        }
908
909        fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
910            Ok(ParsedResponse {
911                response: AgentResponse {
912                    status: "done".to_string(),
913                    summary: String::new(),
914                    accomplished: vec![],
915                    remaining: vec![],
916                    files: vec![],
917                    error: None,
918                    input_tokens: None,
919                    output_tokens: None,
920                    learnings: vec![],
921                    delegations: vec![],
922                },
923                input_tokens: None,
924                output_tokens: None,
925                duration_ms: None,
926            })
927        }
928
929        fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
930            AgentError::Unknown {
931                exit_code: 0,
932                message: String::new(),
933            }
934        }
935
936        fn free_models(&self) -> Vec<String> {
937            self.free.clone()
938        }
939
940        fn router_command(&self, _prompt: &str, _model: Option<&str>) -> anyhow::Result<Command> {
941            anyhow::bail!("not implemented")
942        }
943    }
944
945    /// Verify that a generic (non-credit-exhaustion) rate limit error sets the agent
946    /// cooldown immediately in `handle_error()`, before `handle_failover()` is called.
947    ///
948    /// This prevents concurrent dispatches from starting new runs against the same
949    /// rate-limited agent while the first task is still in its error-handling path.
950    #[serial(cooldown_state)]
951    #[tokio::test]
952    async fn rate_limit_sets_agent_cooldown_immediately() {
953        cooldown::reset_global_state().await;
954        let runner = MockRunner { free: vec![] };
955        let agent = "test-agent-1371-early-cooldown";
956
957        // Confirm no cooldown before the error.
958        assert!(
959            !cooldown::is_agent_in_cooldown(agent),
960            "agent should not be in cooldown before handle_error"
961        );
962
963        let err = AgentError::RateLimit {
964            message: "429 Too Many Requests".to_string(),
965        };
966
967        // Run handle_error — no store so handle_failover will find no agents and return Continue.
968        let _result = handle_error(
969            "test-1371-a",
970            &err,
971            agent,
972            &runner,
973            Some("sonnet"),
974            Some("medium"),
975            1,
976            &None,
977            "owner/repo",
978        )
979        .await
980        .unwrap();
981
982        // Cooldown must be set immediately after handle_error returns, not deferred.
983        assert!(
984            cooldown::is_agent_in_cooldown(agent),
985            "agent should be in cooldown immediately after handle_error for RateLimit"
986        );
987    }
988
989    /// Verify that a rate limit error sets BOTH the agent-level and model-level cooldowns.
990    ///
991    /// This is critical: the router's model selection (model_for_complexity) checks
992    /// is_model_in_cooldown(agent, model) which uses the agent:model key format.
993    /// An agent-level cooldown alone does NOT prevent the same model from being
994    /// re-selected, causing the rate-limited model to fail repeatedly (issue #2153).
995    #[serial(cooldown_state)]
996    #[tokio::test]
997    async fn rate_limit_sets_both_agent_and_model_cooldowns() {
998        cooldown::reset_global_state().await;
999        let runner = MockRunner { free: vec![] };
1000        let agent = "test-agent-2153-model-cooldown";
1001        let model = "opencode/github-copilot/qwen3.6-plus-free";
1002
1003        // Confirm no cooldowns before the error.
1004        assert!(
1005            !cooldown::is_agent_in_cooldown(agent),
1006            "agent should not be in cooldown before handle_error"
1007        );
1008        assert!(
1009            !cooldown::is_model_in_cooldown(agent, model),
1010            "model should not be in cooldown before handle_error"
1011        );
1012
1013        let err = AgentError::RateLimit {
1014            message: "Upstream error from Alibaba: Request rate increased too quickly.".to_string(),
1015        };
1016
1017        let _result = handle_error(
1018            "test-2153-a",
1019            &err,
1020            agent,
1021            &runner,
1022            Some(model),
1023            Some("simple"),
1024            1,
1025            &None,
1026            "owner/repo",
1027        )
1028        .await
1029        .unwrap();
1030
1031        // Agent-level cooldown must be set immediately.
1032        assert!(
1033            cooldown::is_agent_in_cooldown(agent),
1034            "agent should be in agent-level cooldown after RateLimit"
1035        );
1036        // Model-level cooldown must ALSO be set immediately — this is the fix for issue #2153.
1037        // Without this, model_for_complexity would still pick the same model.
1038        assert!(
1039            cooldown::is_model_in_cooldown(agent, model),
1040            "model should be in model-level cooldown after RateLimit (fixes issue #2153)"
1041        );
1042    }
1043
1044    /// Verify that rate limit for a specific model does NOT cool other models of the same agent.
1045    #[serial(cooldown_state)]
1046    #[tokio::test]
1047    async fn rate_limit_cooldown_is_model_specific() {
1048        cooldown::reset_global_state().await;
1049        let runner = MockRunner { free: vec![] };
1050        let agent = "test-agent-2153-model-specific";
1051        let cooled_model = "opencode/github-copilot/qwen3.6-plus-free";
1052        let other_model = "opencode/minimax-m2.5-free";
1053
1054        let err = AgentError::RateLimit {
1055            message: "rate limit exceeded".to_string(),
1056        };
1057
1058        let _result = handle_error(
1059            "test-2153-b",
1060            &err,
1061            agent,
1062            &runner,
1063            Some(cooled_model),
1064            Some("simple"),
1065            1,
1066            &None,
1067            "owner/repo",
1068        )
1069        .await
1070        .unwrap();
1071
1072        // The specific model that hit the rate limit should be cooled.
1073        assert!(
1074            cooldown::is_model_in_cooldown(agent, cooled_model),
1075            "rate-limited model should be in cooldown"
1076        );
1077        // Other models of the same agent should NOT be cooled.
1078        assert!(
1079            !cooldown::is_model_in_cooldown(agent, other_model),
1080            "other models should not be cooled by a single model's rate limit"
1081        );
1082    }
1083
1084    /**
1085     * Regression test for issue #3652: a credit-exhaustion error records its
1086     * own credit_failure_count cooldown, but handle_failover() used to also
1087     * bump the generic failure_count:<agent> counter. After a few credit
1088     * errors the next ordinary failure would start at the 24h/48h extended
1089     * backoff tier instead of the 5 minute base. The generic counter must be
1090     * untouched here while credit_failure_count is incremented. The agent
1091     * name is deliberately not a configured agent, so handle_failover() finds
1092     * it as a fallback and takes the recording path this test exercises
1093     **/
1094    #[serial(cooldown_state)]
1095    #[tokio::test]
1096    async fn credit_exhaustion_does_not_bump_generic_failure_count() {
1097        cooldown::reset_global_state().await;
1098        let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1099        cooldown::init_cooldown_store(Arc::clone(&store)).await;
1100
1101        let runner = MockRunner { free: vec![] };
1102        let agent = "test-agent-3652-credit-no-bump";
1103
1104        let err = AgentError::RateLimit {
1105            message: "out of credits, please recharge your balance".to_string(),
1106        };
1107
1108        let _result = handle_error(
1109            "test-3652-a",
1110            &err,
1111            agent,
1112            &runner,
1113            None,
1114            Some("simple"),
1115            1,
1116            &Some(Arc::clone(&store)),
1117            "owner/repo",
1118        )
1119        .await
1120        .unwrap();
1121
1122        let credit_count = store
1123            .kv_get(&format!("credit_failure_count:{agent}"))
1124            .await
1125            .unwrap();
1126        assert!(
1127            credit_count.is_some(),
1128            "credit_failure_count should be incremented by the credit error"
1129        );
1130        let generic_count = store
1131            .kv_get(&format!("failure_count:{agent}"))
1132            .await
1133            .unwrap();
1134        assert!(
1135            generic_count.is_none(),
1136            "failure_count must not be bumped by credit errors, got {generic_count:?}"
1137        );
1138    }
1139
1140    /// A timeout is a long task, not an unavailable model. Two timeouts in a
1141    /// row must leave the model routable and write no rate_limits row
1142    #[serial(cooldown_state)]
1143    #[tokio::test]
1144    async fn two_timeouts_leave_model_routable() {
1145        cooldown::reset_global_state().await;
1146        let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1147        cooldown::init_cooldown_store(Arc::clone(&store)).await;
1148
1149        let runner = MockRunner { free: vec![] };
1150        let agent = "opencode";
1151        let model = "opencode/nemotron-3-ultra-free";
1152
1153        let err = AgentError::Timeout {
1154            elapsed: Duration::from_mins(30),
1155        };
1156
1157        for i in 1..=2 {
1158            let _result = handle_error(
1159                &format!("test-3876-timeout-{i}"),
1160                &err,
1161                agent,
1162                &runner,
1163                Some(model),
1164                Some("simple"),
1165                i,
1166                &Some(Arc::clone(&store)),
1167                "owner/repo",
1168            )
1169            .await
1170            .unwrap();
1171        }
1172
1173        assert!(
1174            !cooldown::is_model_in_cooldown(agent, model),
1175            "timeouts must not cool the model"
1176        );
1177        assert!(
1178            !cooldown::is_agent_in_cooldown(agent),
1179            "timeouts must not cool the agent"
1180        );
1181        let counts = store.recent_rate_limit_counts(6).await.unwrap();
1182        assert_eq!(
1183            counts.get(agent).copied().unwrap_or(0),
1184            0,
1185            "timeouts must not write a rate_limits row"
1186        );
1187    }
1188
1189    /// A run that exits without a result envelope retries without cooling the
1190    /// model or writing a rate_limits row
1191    #[serial(cooldown_state)]
1192    #[tokio::test]
1193    async fn interrupted_run_does_not_write_rate_limit() {
1194        cooldown::reset_global_state().await;
1195        let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1196        cooldown::init_cooldown_store(Arc::clone(&store)).await;
1197
1198        let runner = MockRunner { free: vec![] };
1199        let agent = "claude";
1200        let model = "sonnet";
1201        let err = AgentError::Interrupted {
1202            message: "exited without a result envelope".to_string(),
1203        };
1204
1205        let _result = handle_error(
1206            "test-3876-interrupted",
1207            &err,
1208            agent,
1209            &runner,
1210            Some(model),
1211            Some("medium"),
1212            1,
1213            &Some(Arc::clone(&store)),
1214            "owner/repo",
1215        )
1216        .await
1217        .unwrap();
1218
1219        assert!(
1220            !cooldown::is_model_in_cooldown(agent, model),
1221            "interrupted runs must not cool the model"
1222        );
1223        let counts = store.recent_rate_limit_counts(6).await.unwrap();
1224        assert_eq!(
1225            counts.get(agent).copied().unwrap_or(0),
1226            0,
1227            "interrupted runs must not write a rate_limits row"
1228        );
1229    }
1230
1231    /// Regression test for issue #3580: the in-task failover path (this
1232    /// function) previously never called `parse_relative_usage_window()`, so a
1233    /// vendor-advertised window like "weekly (7-day) usage limit" fell through
1234    /// to the generic exponential backoff (5min base) instead of the ~7-day
1235    /// window the vendor advertised. Only the `needs_review` call site in
1236    /// `runner/mod.rs` applied the window fix from #3572/#3574 — this path did
1237    /// not. Verify the model-specific cooldown now matches the parsed window
1238    /// rather than the short exponential-backoff base.
1239    ///
1240    /// Note: `handle_failover()` still applies its own short agent-level
1241    /// cooldown when switching to a fallback agent — that bookkeeping is
1242    /// unrelated to and unaffected by this fix, so it isn't asserted here.
1243    #[serial(cooldown_state)]
1244    #[tokio::test]
1245    async fn rate_limit_with_relative_window_sets_window_length_model_cooldown() {
1246        cooldown::reset_global_state().await;
1247        let runner = MockRunner { free: vec![] };
1248        let agent = "test-agent-3580-kimi";
1249        let model = "opus";
1250        let key = format!("{agent}:{model}");
1251
1252        let err = AgentError::RateLimit {
1253            message: "You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window expires.".to_string(),
1254        };
1255
1256        let _result = handle_error(
1257            "test-3580-a",
1258            &err,
1259            agent,
1260            &runner,
1261            Some(model),
1262            Some("medium"),
1263            1,
1264            &None,
1265            "owner/repo",
1266        )
1267        .await
1268        .unwrap();
1269
1270        let now = chrono::Utc::now().timestamp();
1271        let until = cooldown::cooldown_until(&key)
1272            .expect("relative usage window should set model cooldown");
1273        let remaining = until.saturating_sub(now);
1274
1275        assert!(
1276            remaining >= 7 * 86400 - 30,
1277            "expected ~7-day model cooldown from the parsed window, got {remaining}s"
1278        );
1279        assert_eq!(
1280            cooldown::cooldown_reason(&key).as_deref(),
1281            Some("rate_limit_usage_window"),
1282            "relative usage-window cooldown must be labeled as a rate limit, not silence"
1283        );
1284    }
1285
1286    #[serial(cooldown_state)]
1287    #[tokio::test]
1288    async fn provider_returned_error_sets_short_model_cooldown() {
1289        cooldown::reset_global_state().await;
1290        let runner = MockRunner { free: vec![] };
1291        let agent = "opencode";
1292        let model = "opencode/nemotron-3-super-free";
1293        let key = format!("{agent}:{model}");
1294
1295        let err = AgentError::RateLimit {
1296            message: "Provider returned error".to_string(),
1297        };
1298
1299        let _result = handle_error(
1300            "test-2478-provider-cooldown",
1301            &err,
1302            agent,
1303            &runner,
1304            Some(model),
1305            Some("simple"),
1306            1,
1307            &None,
1308            "owner/repo",
1309        )
1310        .await
1311        .unwrap();
1312
1313        let now = chrono::Utc::now().timestamp();
1314        let until =
1315            cooldown::cooldown_until(&key).expect("provider error should set model cooldown");
1316        let remaining = until.saturating_sub(now);
1317
1318        assert!(
1319            remaining <= cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
1320            "expected a 5 minute model cooldown, got {remaining}s"
1321        );
1322    }
1323
1324    #[serial(cooldown_state)]
1325    #[tokio::test]
1326    async fn model_unavailable_sets_persistent_model_cooldown_for_codex_style() {
1327        cooldown::reset_global_state().await;
1328        let runner = MockRunner { free: vec![] };
1329        let agent = "codex";
1330        let model = "gpt-5.3-codex";
1331
1332        // Simulate Codex provider JSON error: message contains "model is not supported"
1333        let err = AgentError::ModelUnavailable {
1334            model: model.to_string(),
1335            message: "{\"detail\":\"The 'gpt-5.3-codex' model is not supported\"}".to_string(),
1336        };
1337
1338        let _result = handle_error(
1339            "test-codex-model-unsupported",
1340            &err,
1341            agent,
1342            &runner,
1343            Some(model),
1344            Some("simple"),
1345            1,
1346            &None,
1347            "owner/repo",
1348        )
1349        .await
1350        .unwrap();
1351
1352        // The persistent backoff base is ~4h; ensure cooldown exists and is at least that long minus a margin
1353        let key = format!("{agent}:{model}");
1354        let now = chrono::Utc::now().timestamp();
1355        let until = cooldown::cooldown_until(&key).expect("model cooldown should be set");
1356        let remaining = until.saturating_sub(now);
1357
1358        assert!(
1359            remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1360            "expected persistent model cooldown (~4h) for codex model-unavailable, got {remaining}s"
1361        );
1362    }
1363
1364    async fn vendor_reset_remaining(agent: &str, model: &str, message: &str) -> i64 {
1365        cooldown::reset_global_state().await;
1366        let runner = MockRunner { free: vec![] };
1367        let err = AgentError::RateLimit {
1368            message: message.to_string(),
1369        };
1370        let _ = handle_error(
1371            "test-3649",
1372            &err,
1373            agent,
1374            &runner,
1375            Some(model),
1376            Some("simple"),
1377            1,
1378            &None,
1379            "owner/repo",
1380        )
1381        .await
1382        .unwrap();
1383        let until = cooldown::cooldown_until(&format!("{agent}:{model}"))
1384            .expect("model cooldown should be set");
1385        until - chrono::Utc::now().timestamp()
1386    }
1387
1388    #[serial(cooldown_state)]
1389    #[tokio::test]
1390    async fn vendor_reset_date_overrides_weekly_window() {
1391        let t = chrono::Local::now() + chrono::Duration::days(2);
1392        let msg = format!(
1393            "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
1394            t.format("%b"),
1395            t.day()
1396        );
1397        let remaining = vendor_reset_remaining("claude", "sonnet", &msg).await;
1398        assert!(
1399            remaining > 3600 && remaining < 3 * 86400,
1400            "expected ~2d cooldown, got {remaining}s"
1401        );
1402    }
1403
1404    #[serial(cooldown_state)]
1405    #[tokio::test]
1406    async fn vendor_reset_date_overrides_billing_cycle_backoff() {
1407        let t = chrono::Local::now() + chrono::Duration::days(3);
1408        let msg = format!(
1409            "You've hit your usage limit. Upgrade to Pro (https://x), or try again at {}.",
1410            t.format("%b %d, %Y %I:%M %p")
1411        );
1412        let remaining = vendor_reset_remaining("codex", "gpt-5.4", &msg).await;
1413        assert!(
1414            remaining > 2 * 86400 + 80000 - 3600 && remaining < 4 * 86400,
1415            "expected ~3d cooldown, got {remaining}s"
1416        );
1417    }
1418
1419    #[serial(cooldown_state)]
1420    #[tokio::test]
1421    async fn agent_failed_insufficient_balance_sets_credit_cooldown() {
1422        cooldown::reset_global_state().await;
1423        let runner = MockRunner { free: vec![] };
1424        let agent = "test-agent-3642-insufficient-balance";
1425
1426        // Confirm no cooldown before the error.
1427        assert!(
1428            !cooldown::is_agent_in_cooldown(agent),
1429            "agent should not be in cooldown before handle_error"
1430        );
1431
1432        let err = agents::AgentError::AgentFailed {
1433            message: "402 Payment Required: insufficient balance".to_string(),
1434        };
1435
1436        // Run handle_error — no store so handle_failover will find no agents and return Continue.
1437        let _result = handle_error(
1438            "test-3642-a",
1439            &err,
1440            agent,
1441            &runner,
1442            Some("sonnet"),
1443            Some("medium"),
1444            1,
1445            &None,
1446            "owner/repo",
1447        )
1448        .await
1449        .unwrap();
1450
1451        // The credit exhaustion path (from #3642) should set a cooldown of at least
1452        // CREDIT_BACKOFF_BASE_SECS (1h), not the generic 5-min backoff.
1453        // Without #3642, the AgentFailed arm would have gone to the else branch
1454        // and called record_agent_failure_with_message, setting a 5-min cooldown.
1455        assert!(
1456            cooldown::is_agent_in_cooldown(agent),
1457            "agent should be in cooldown after handle_error for AgentFailed with insufficient balance"
1458        );
1459
1460        let until = cooldown::cooldown_until(agent).expect("agent cooldown should be active");
1461        let remaining = until - chrono::Utc::now().timestamp();
1462        assert!(
1463            remaining >= cooldown::CREDIT_BACKOFF_BASE_SECS - 30,
1464            "credit cooldown should be at least the 1h base, got {remaining}s"
1465        );
1466    }
1467
1468    #[serial(cooldown_state)]
1469    #[tokio::test]
1470    async fn silent_exit0_retries_free_model_for_simple_complexity() {
1471        cooldown::reset_global_state().await;
1472        let runner = MockRunner {
1473            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1474        };
1475        let err = AgentError::Unknown {
1476            exit_code: 0,
1477            message: String::new(),
1478        };
1479
1480        // complexity=simple → free model retry is appropriate
1481        let result = handle_error(
1482            "test-1112-a",
1483            &err,
1484            "opencode",
1485            &runner,
1486            Some("opencode/gpt-5.4-mini"),
1487            Some("simple"),
1488            1,
1489            &None,
1490            "owner/repo",
1491        )
1492        .await
1493        .unwrap();
1494
1495        assert!(
1496            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1497            "expected EarlyReturn{{status: new}} for simple complexity — free model should be tried first"
1498        );
1499    }
1500
1501    #[serial(cooldown_state)]
1502    #[tokio::test]
1503    async fn silent_exit0_retries_free_model_when_complexity_unknown() {
1504        cooldown::reset_global_state().await;
1505        let runner = MockRunner {
1506            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1507        };
1508        let err = AgentError::Unknown {
1509            exit_code: 0,
1510            message: String::new(),
1511        };
1512
1513        // complexity=None (unknown) → treated as simple → free model retry
1514        let result = handle_error(
1515            "test-1112-a2",
1516            &err,
1517            "opencode",
1518            &runner,
1519            Some("opencode/gpt-5.4-mini"),
1520            None,
1521            1,
1522            &None,
1523            "owner/repo",
1524        )
1525        .await
1526        .unwrap();
1527
1528        assert!(
1529            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1530            "expected EarlyReturn{{status: new}} for unknown complexity — free model should be tried first"
1531        );
1532    }
1533
1534    #[serial(cooldown_state)]
1535    #[tokio::test]
1536    async fn silent_exit0_retries_free_model_for_medium_complexity() {
1537        cooldown::reset_global_state().await;
1538        let runner = MockRunner {
1539            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1540        };
1541        let err = AgentError::Unknown {
1542            exit_code: 0,
1543            message: String::new(),
1544        };
1545
1546        // complexity=medium → still retry free model before cross-agent failover
1547        let result = handle_error(
1548            "test-1195-medium",
1549            &err,
1550            "opencode",
1551            &runner,
1552            Some("opencode/copilot-gpt-5.4-mini"),
1553            Some("medium"),
1554            1,
1555            &None,
1556            "owner/repo",
1557        )
1558        .await
1559        .unwrap();
1560
1561        assert!(
1562            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1563            "expected EarlyReturn{{status: new}} for medium complexity — free model should be tried before failover"
1564        );
1565    }
1566
1567    #[serial(cooldown_state)]
1568    #[tokio::test]
1569    async fn silent_exit0_retries_free_model_for_complex_complexity() {
1570        cooldown::reset_global_state().await;
1571        let runner = MockRunner {
1572            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1573        };
1574        let err = AgentError::Unknown {
1575            exit_code: 0,
1576            message: String::new(),
1577        };
1578
1579        // complexity=complex → still retry free model before cross-agent failover
1580        let result = handle_error(
1581            "test-1195-complex",
1582            &err,
1583            "opencode",
1584            &runner,
1585            Some("opencode/copilot-gpt-5.4-mini"),
1586            Some("complex"),
1587            1,
1588            &None,
1589            "owner/repo",
1590        )
1591        .await
1592        .unwrap();
1593
1594        assert!(
1595            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1596            "expected EarlyReturn{{status: new}} for complex complexity — free model should be tried before failover"
1597        );
1598    }
1599
1600    /// Context overflow is deterministic — the same context will overflow every agent.
1601    /// Verify that it escalates directly to needs_review without cycling through fallback agents.
1602    #[serial(cooldown_state)]
1603    #[tokio::test]
1604    async fn context_overflow_escalates_to_needs_review_immediately() {
1605        cooldown::reset_global_state().await;
1606        let runner = MockRunner { free: vec![] };
1607        let err = AgentError::ContextOverflow {
1608            message: "prompt is too long: 250000 tokens, max 200000".to_string(),
1609        };
1610
1611        let result = handle_error(
1612            "test-2129-a",
1613            &err,
1614            "claude",
1615            &runner,
1616            Some("sonnet"),
1617            Some("medium"),
1618            1,
1619            &None,
1620            "owner/repo",
1621        )
1622        .await
1623        .unwrap();
1624
1625        assert!(
1626            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "needs_review"),
1627            "context overflow must escalate directly to needs_review without cycling through fallback agents"
1628        );
1629    }
1630
1631    /// When store is None, store_set_result returns Ok(()) — reroute proceeds normally.
1632    /// This verifies the happy path is not broken by the error-propagation change.
1633    #[serial(cooldown_state)]
1634    #[tokio::test]
1635    async fn free_model_reroute_succeeds_without_store() {
1636        cooldown::reset_global_state().await;
1637        let runner = MockRunner {
1638            free: vec!["opencode/mimo-v2-omni-free".to_string()],
1639        };
1640        let err = AgentError::Unknown {
1641            exit_code: 0,
1642            message: String::new(),
1643        };
1644
1645        // store=None → store_set_result returns Ok(()) → reroute proceeds
1646        let result = handle_error(
1647            "test-2293-a",
1648            &err,
1649            "opencode",
1650            &runner,
1651            Some("opencode/gpt-5.4-mini"),
1652            Some("simple"),
1653            1,
1654            &None,
1655            "owner/repo",
1656        )
1657        .await
1658        .unwrap();
1659
1660        assert!(
1661            matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1662            "expected EarlyReturn{{status: new}} — store_set_result with None store should not prevent reroute"
1663        );
1664    }
1665
1666    #[serial(cooldown_state)]
1667    #[tokio::test]
1668    async fn silent_exit0_falls_through_when_no_free_models() {
1669        cooldown::reset_global_state().await;
1670        let runner = MockRunner { free: vec![] };
1671        let err = AgentError::Unknown {
1672            exit_code: 0,
1673            message: String::new(),
1674        };
1675
1676        let result = handle_error(
1677            "test-1112-b",
1678            &err,
1679            "opencode",
1680            &runner,
1681            Some("opencode/gpt-5.4-mini"),
1682            Some("simple"),
1683            1,
1684            &None,
1685            "owner/repo",
1686        )
1687        .await
1688        .unwrap();
1689
1690        assert!(
1691            matches!(result, ErrorHandleResult::Continue { .. }),
1692            "expected Continue (fallthrough to failover) when no free models available"
1693        );
1694    }
1695
1696    /// Verify that InvalidResponse applies model-level cooldown.
1697    ///
1698    /// This fixes issue #2750: InvalidResponse used to skip model-level cooldown,
1699    /// allowing models that produced unparseable responses to be immediately
1700    /// retried on the next task without any backoff.
1701    #[serial(cooldown_state)]
1702    #[tokio::test]
1703    async fn invalid_response_sets_model_cooldown() {
1704        cooldown::reset_global_state().await;
1705        let runner = MockRunner { free: vec![] };
1706        let agent = "opencode";
1707        let model = "opencode/nemotron-3-super-free";
1708
1709        // Confirm no model cooldown before the error.
1710        assert!(
1711            !cooldown::is_model_in_cooldown(agent, model),
1712            "model should not be in cooldown before handle_error"
1713        );
1714
1715        let err = AgentError::InvalidResponse {
1716            raw: "invalid json output".to_string(),
1717        };
1718
1719        let _result = handle_error(
1720            "test-2750-invalid-response",
1721            &err,
1722            agent,
1723            &runner,
1724            Some(model),
1725            Some("simple"),
1726            1,
1727            &None,
1728            "owner/repo",
1729        )
1730        .await
1731        .unwrap();
1732
1733        // Model-level cooldown must be set after InvalidResponse.
1734        assert!(
1735            cooldown::is_model_in_cooldown(agent, model),
1736            "model should be in cooldown after InvalidResponse (fixes issue #2750)"
1737        );
1738    }
1739
1740    /// BillingCycleExhausted with a known model must set ONLY the model-level cooldown.
1741    ///
1742    /// Regression test for issue #3382: "upgrade to pro" messages (added to
1743    /// billing_cycle_patterns in #3380) were triggering an agent-wide 24h cooldown
1744    /// when model_name was None, blocking all codex models even though only the
1745    /// specific model's plan quota was exhausted.
1746    #[serial(cooldown_state)]
1747    #[tokio::test]
1748    async fn billing_cycle_exhausted_with_known_model_sets_only_model_cooldown() {
1749        cooldown::reset_global_state().await;
1750        let runner = MockRunner { free: vec![] };
1751        let agent = "codex-3382-billing-cycle";
1752        let model = "gpt-5.5";
1753
1754        let err = AgentError::RateLimit {
1755            message: "rate limit: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro).".to_string(),
1756        };
1757
1758        let _result = handle_error(
1759            "test-3382-a",
1760            &err,
1761            agent,
1762            &runner,
1763            Some(model),
1764            Some("medium"),
1765            1,
1766            &None,
1767            "owner/repo",
1768        )
1769        .await
1770        .unwrap();
1771
1772        // Agent-level credit cooldown must NOT be set — the quota is model-scoped.
1773        assert!(
1774            !cooldown::is_agent_in_cooldown(agent),
1775            "agent must NOT be in agent-level cooldown when BillingCycleExhausted has a known model (issue #3382)"
1776        );
1777        // Model-level cooldown MUST be set.
1778        assert!(
1779            cooldown::is_model_in_cooldown(agent, model),
1780            "model must be in cooldown after BillingCycleExhausted (issue #3382)"
1781        );
1782        // Other models on the same agent must NOT be cooled.
1783        assert!(
1784            !cooldown::is_model_in_cooldown(agent, "gpt-5.4"),
1785            "other models on the same agent must not be cooled by a single model's billing exhaustion"
1786        );
1787    }
1788
1789    #[serial(cooldown_state)]
1790    #[tokio::test]
1791    async fn summarize_rate_limit_error_extracts_api_retry_info() {
1792        cooldown::reset_global_state().await;
1793        let raw_output = r#"some log lines
1794{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1795{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1796more logs"#;
1797
1798        let summarized = super::summarize_rate_limit_error(raw_output);
1799        assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1800    }
1801
1802    #[serial(cooldown_state)]
1803    #[tokio::test]
1804    async fn summarize_rate_limit_error_returns_original_when_no_api_retry() {
1805        cooldown::reset_global_state().await;
1806        let raw_output = "some regular error message without api_retry JSON";
1807
1808        let summarized = super::summarize_rate_limit_error(raw_output);
1809        assert_eq!(summarized, raw_output);
1810    }
1811
1812    #[serial(cooldown_state)]
1813    #[tokio::test]
1814    async fn summarize_rate_limit_error_handles_malformed_json_gracefully() {
1815        cooldown::reset_global_state().await;
1816        let raw_output = r#"some logs
1817{"type":"system","subtype":"api_retry","attempt":"not_a_number","retry_delay_ms":invalid,"error_status":429}
1818more logs"#;
1819
1820        let summarized = super::summarize_rate_limit_error(raw_output);
1821        // Should still return a formatted string even with invalid numbers
1822        assert!(summarized.starts_with("status="));
1823        assert!(summarized.contains("after"));
1824        assert!(summarized.contains("attempts"));
1825        assert!(summarized.contains("(last delay"));
1826    }
1827
1828    #[serial(cooldown_state)]
1829    #[tokio::test]
1830    async fn summarize_rate_limit_error_works_with_single_line() {
1831        cooldown::reset_global_state().await;
1832        let raw_output = r#"{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":5000,"error_status":429,"error":"rate_limit"}"#;
1833
1834        let summarized = super::summarize_rate_limit_error(raw_output);
1835        assert_eq!(summarized, "status=429 after 3 attempts (last delay 5s)");
1836    }
1837
1838    /// Verify extract_json_field correctly handles quoted string values.
1839    /// Previously it stripped from the wrong end (strip_suffix), returning None for
1840    /// any field that isn't the last one in the JSON object (fixes issue #2817).
1841    #[serial(cooldown_state)]
1842    #[tokio::test]
1843    async fn extract_json_field_handles_quoted_strings() {
1844        cooldown::reset_global_state().await;
1845        let json = r#"{"error_status":429,"attempt":7,"retry_delay_ms":35162.66,"error":"rate_limit","session_id":"331c099b-..."}"#;
1846
1847        assert_eq!(
1848            super::extract_json_field(json, "\"error\":"),
1849            Some("rate_limit".to_string()),
1850            "quoted string field mid-object should be extracted correctly"
1851        );
1852        assert_eq!(
1853            super::extract_json_field(json, "\"session_id\":"),
1854            Some("331c099b-...".to_string()),
1855            "quoted string field at end of object (no trailing comma) should be extracted"
1856        );
1857        // Numeric fields should still work (regression check)
1858        assert_eq!(
1859            super::extract_json_field(json, "\"error_status\":"),
1860            Some("429".to_string())
1861        );
1862        assert_eq!(
1863            super::extract_json_field(json, "\"attempt\":"),
1864            Some("7".to_string())
1865        );
1866        assert_eq!(
1867            super::extract_json_field(json, "\"retry_delay_ms\":"),
1868            Some("35162.66".to_string())
1869        );
1870    }
1871
1872    /// Truncated fragment starting mid-object with ,"subtype":"api_retry"...
1873    /// Should NOT return raw JSON blob (regression test for issue #2839).
1874    #[serial(cooldown_state)]
1875    #[tokio::test]
1876    async fn summarize_rate_limit_error_handles_truncated_fragment_with_subtype() {
1877        cooldown::reset_global_state().await;
1878        // Real-world truncated fragment from GLM runs (no "type":"system" prefix)
1879        let raw_output = r#"some log context
1880,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit"
1881more context"#;
1882
1883        let summarized = super::summarize_rate_limit_error(raw_output);
1884        assert!(
1885            !summarized.contains("subtype"),
1886            "summarized output must not contain raw 'subtype' JSON fragment"
1887        );
1888        assert!(
1889            !summarized.contains('{'),
1890            "summarized output must not contain JSON braces"
1891        );
1892        assert!(
1893            summarized.starts_with("status="),
1894            "summarized output should start with 'status=' — got: {summarized}"
1895        );
1896        // Note: "api_retry" is only included when fields are not extractable
1897        // (the fragment fallback path). When attempt/delay are extractable,
1898        // the summary is "status=X after N attempts (last delay Ys)" with no "api_retry".
1899    }
1900
1901    /// Fragment with attempt and delay fields extractable from mid-fragment.
1902    #[serial(cooldown_state)]
1903    #[tokio::test]
1904    async fn summarize_rate_limit_error_handles_fragment_with_extractable_fields() {
1905        cooldown::reset_global_state().await;
1906        let raw_output = r#"log output
1907,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit","session_id":"abc123","max_retries":10}
1908rest of output"#;
1909
1910        let summarized = super::summarize_rate_limit_error(raw_output);
1911        assert_eq!(summarized, "status=429 after 5 attempts (last delay 20s)");
1912    }
1913
1914    /// Complete JSON (no truncation) still works after removing "type":"system" requirement.
1915    #[serial(cooldown_state)]
1916    #[tokio::test]
1917    async fn summarize_rate_limit_error_complete_json_still_works() {
1918        cooldown::reset_global_state().await;
1919        let raw_output = r#"some log lines
1920{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1921{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1922more logs"#;
1923
1924        let summarized = super::summarize_rate_limit_error(raw_output);
1925        assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1926    }
1927
1928    /// When no api_retry JSON is found at all, should NOT return raw passthrough.
1929    #[serial(cooldown_state)]
1930    #[tokio::test]
1931    async fn summarize_rate_limit_error_no_api_retry_no_raw_passthrough() {
1932        cooldown::reset_global_state().await;
1933        let raw_output = "some regular error message without api_retry JSON that is quite long and should be truncated";
1934
1935        let summarized = super::summarize_rate_limit_error(raw_output);
1936        // Should be truncated to first line, not the full raw_output
1937        assert!(
1938            summarized.len() <= 140,
1939            "expected truncated first-line, got: {summarized}"
1940        );
1941    }
1942
1943    /// Short error without api_retry: should return the line as-is (no truncation needed).
1944    #[serial(cooldown_state)]
1945    #[tokio::test]
1946    async fn summarize_rate_limit_error_no_api_retry_short() {
1947        cooldown::reset_global_state().await;
1948        let raw_output = "rate limit exceeded";
1949
1950        let summarized = super::summarize_rate_limit_error(raw_output);
1951        assert_eq!(summarized, raw_output);
1952    }
1953
1954    /// "Model not found" is a permanent failure — verify it applies the persistent (4h base)
1955    /// cooldown rather than the standard transient (5min base) cooldown.
1956    ///
1957    /// Regression test for issue #2941: dead models were retried every 4h indefinitely
1958    /// because "not found" was treated the same as a transient unavailability.
1959    #[serial(cooldown_state)]
1960    #[tokio::test]
1961    async fn model_not_found_applies_persistent_cooldown() {
1962        cooldown::reset_global_state().await;
1963        let runner = MockRunner { free: vec![] };
1964        let agent = "opencode-2941-not-found";
1965        let model = "github-copilot/claude-opus-4.6";
1966        let key = format!("{agent}:{model}");
1967
1968        let err = AgentError::ModelUnavailable {
1969            model: model.to_string(),
1970            message: "Model not found: github-copilot/claude-opus-4.6".to_string(),
1971        };
1972
1973        let _result = handle_error(
1974            "test-2941-a",
1975            &err,
1976            agent,
1977            &runner,
1978            Some(model),
1979            Some("complex"),
1980            1,
1981            &None,
1982            "owner/repo",
1983        )
1984        .await
1985        .unwrap();
1986
1987        let now = chrono::Utc::now().timestamp();
1988        let until =
1989            cooldown::cooldown_until(&key).expect("model not found should set model cooldown");
1990        let remaining = until.saturating_sub(now);
1991
1992        // Persistent cooldown starts at 4h; transient starts at 5min.
1993        // Verify we applied the persistent (4h base) path, not the short one.
1994        assert!(
1995            remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1996            "expected ~4h (persistent) cooldown for 'Model not found', got {remaining}s — \
1997             may have applied transient 5min cooldown instead"
1998        );
1999    }
2000
2001    /// "Model not supported" is a permanent account-type mismatch — verify it applies the
2002    /// persistent (4h base → 7d max) cooldown, same as "not found".
2003    ///
2004    /// Regression test for issue #3215: "not supported" was not matched by the
2005    /// `is_permanently_gone` check, so it fell through to the 5min→4h transient backoff
2006    /// and the model kept being retried every ~4h indefinitely.
2007    #[serial(cooldown_state)]
2008    #[tokio::test]
2009    async fn model_not_supported_applies_persistent_cooldown() {
2010        cooldown::reset_global_state().await;
2011        let runner = MockRunner { free: vec![] };
2012        let agent = "codex-3215-not-supported";
2013        let model = "gpt-5.2-codex";
2014        let key = format!("{agent}:{model}");
2015
2016        let err = AgentError::ModelUnavailable {
2017            model: model.to_string(),
2018            message: "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.".to_string(),
2019        };
2020
2021        let _result = handle_error(
2022            "test-3215-a",
2023            &err,
2024            agent,
2025            &runner,
2026            Some(model),
2027            Some("medium"),
2028            1,
2029            &None,
2030            "owner/repo",
2031        )
2032        .await
2033        .unwrap();
2034
2035        let now = chrono::Utc::now().timestamp();
2036        let until =
2037            cooldown::cooldown_until(&key).expect("model not supported should set model cooldown");
2038        let remaining = until.saturating_sub(now);
2039
2040        assert!(
2041            remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2042            "expected ~4h (persistent) cooldown for 'not supported', got {remaining}s — \
2043             may have applied transient 5min cooldown instead (issue #3215)"
2044        );
2045    }
2046
2047    #[serial(cooldown_state)]
2048    #[tokio::test]
2049    async fn model_not_available_in_country_applies_persistent_cooldown() {
2050        cooldown::reset_global_state().await;
2051        let runner = MockRunner { free: vec![] };
2052        let agent = "opencode-3650-country";
2053        let model = "some-region-locked-model";
2054        let key = format!("{agent}:{model}");
2055
2056        // issue #3650: region/country blocks need the persistent backoff, not the 5min one
2057        for message in [
2058            "This model is not available in your country.",
2059            "This model is not available in your region.",
2060        ] {
2061            cooldown::reset_global_state().await;
2062            let err = AgentError::ModelUnavailable {
2063                model: model.to_string(),
2064                message: message.to_string(),
2065            };
2066
2067            let _result = handle_error(
2068                "test-3650",
2069                &err,
2070                agent,
2071                &runner,
2072                Some(model),
2073                Some("medium"),
2074                1,
2075                &None,
2076                "owner/repo",
2077            )
2078            .await
2079            .unwrap();
2080
2081            let now = chrono::Utc::now().timestamp();
2082            let until =
2083                cooldown::cooldown_until(&key).expect("region block should set model cooldown");
2084            let remaining = until.saturating_sub(now);
2085
2086            assert!(
2087                remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2088                "expected ~4h (persistent) cooldown for '{message}', got {remaining}s — \
2089                 may have applied transient 5min cooldown instead (issue #3650)"
2090            );
2091        }
2092    }
2093
2094    /// ThinkingBlockConflict must NOT set any agent or model cooldown.
2095    ///
2096    /// This error is a transient Claude CLI 400 (thinking blocks modified in multi-turn
2097    /// tool-use) — not a model capacity problem. Penalising claude:opus with failure
2098    /// counts would prematurely push it into extended-backoff for unrelated reasons.
2099    #[serial(cooldown_state)]
2100    #[tokio::test]
2101    async fn thinking_block_conflict_does_not_set_cooldown() {
2102        cooldown::reset_global_state().await;
2103        let runner = MockRunner { free: vec![] };
2104        let agent = "claude-3204-thinking-block";
2105        let model = "opus";
2106
2107        assert!(
2108            !cooldown::is_agent_in_cooldown(agent),
2109            "agent must not be in cooldown before handle_error"
2110        );
2111        assert!(
2112            !cooldown::is_model_in_cooldown(agent, model),
2113            "model must not be in cooldown before handle_error"
2114        );
2115
2116        let err = AgentError::ThinkingBlockConflict {
2117            message: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.".to_string(),
2118        };
2119
2120        let _result = handle_error(
2121            "test-3204-thinking",
2122            &err,
2123            agent,
2124            &runner,
2125            Some(model),
2126            Some("complex"),
2127            1,
2128            &None,
2129            "owner/repo",
2130        )
2131        .await
2132        .unwrap();
2133
2134        assert!(
2135            !cooldown::is_agent_in_cooldown(agent),
2136            "agent must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2137        );
2138        assert!(
2139            !cooldown::is_model_in_cooldown(agent, model),
2140            "model must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2141        );
2142    }
2143
2144    /// Transient model unavailability (not "not found") should use the standard short cooldown.
2145    #[serial(cooldown_state)]
2146    #[tokio::test]
2147    async fn model_unavailable_transient_applies_short_cooldown() {
2148        cooldown::reset_global_state().await;
2149        let runner = MockRunner { free: vec![] };
2150        let agent = "opencode-2941-transient";
2151        let model = "github-copilot/gpt-4o";
2152        let key = format!("{agent}:{model}");
2153
2154        let err = AgentError::ModelUnavailable {
2155            model: model.to_string(),
2156            message: "No endpoints found for github-copilot/gpt-4o.".to_string(),
2157        };
2158
2159        let _result = handle_error(
2160            "test-2941-b",
2161            &err,
2162            agent,
2163            &runner,
2164            Some(model),
2165            Some("medium"),
2166            1,
2167            &None,
2168            "owner/repo",
2169        )
2170        .await
2171        .unwrap();
2172
2173        let now = chrono::Utc::now().timestamp();
2174        let until = cooldown::cooldown_until(&key)
2175            .expect("transient model unavailability should set model cooldown");
2176        let remaining = until.saturating_sub(now);
2177
2178        // Standard (transient) backoff base is 5min. Persistent base is 4h.
2179        // Confirm we did NOT apply the long 4h cooldown for a transient error.
2180        assert!(
2181            remaining < cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2182            "transient unavailability should use short cooldown (<4h), got {remaining}s"
2183        );
2184    }
2185
2186    #[serial(cooldown_state)]
2187    #[tokio::test]
2188    async fn model_unavailable_does_not_reuse_same_agent_when_agent_cooled() {
2189        cooldown::reset_global_state().await;
2190        let agent = "codex-3620";
2191        let failing_model = "gpt-5.4";
2192        let other_model = "gpt-5.5";
2193        let runner = MockRunnerWithModels {
2194            models: vec![failing_model.to_string(), other_model.to_string()],
2195        };
2196
2197        cooldown::set_agent_cooldown(agent, 3600).await;
2198
2199        let err = AgentError::ModelUnavailable {
2200            model: failing_model.to_string(),
2201            message: "model gpt-5.4 unavailable".to_string(),
2202        };
2203
2204        let result = handle_error(
2205            "test-3620",
2206            &err,
2207            agent,
2208            &runner,
2209            Some(failing_model),
2210            Some("medium"),
2211            1,
2212            &None,
2213            "owner/repo",
2214        )
2215        .await
2216        .unwrap();
2217
2218        if let ErrorHandleResult::EarlyReturn { status } = result {
2219            assert_ne!(
2220                status, "routed",
2221                "agent-wide cooldown was defeated by same-agent model substitution"
2222            );
2223        }
2224    }
2225}