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