Skip to main content

orch/engine/runner/
fallback.rs

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