Skip to main content

orch/engine/runner/
fallback.rs

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