Skip to main content

orch/engine/runner/
fallback.rs

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