use crate::store;
use crate::store::TaskStore;
use std::sync::Arc;
use super::{agents, response};
pub(crate) fn summarize_rate_limit_error(raw_output: &str) -> String {
const FIRST_LINE_CAP: usize = 120;
let last_api_retry_line = raw_output
.lines()
.rev()
.find(|line| line.contains("\"subtype\":\"api_retry\""));
if let Some(line) = last_api_retry_line {
return summarise_api_retry_line(line);
}
let api_retry_field_markers = ["\"error_status\":", "\"attempt\":", "\"retry_delay_ms\":"];
let field_line = raw_output
.lines()
.rev()
.find(|line| api_retry_field_markers.iter().any(|m| line.contains(m)));
if let Some(line) = field_line {
return summarise_api_retry_line(line);
}
let first_line = raw_output.lines().next().unwrap_or(raw_output);
if first_line.len() > FIRST_LINE_CAP {
let cutoff = agents::truncate_at_char_boundary(first_line, FIRST_LINE_CAP);
let remaining = raw_output.len().saturating_sub(cutoff);
format!("{}... (+{} more chars)", &first_line[..cutoff], remaining)
} else {
first_line.to_string()
}
}
fn summarise_api_retry_line(line: &str) -> String {
let error_status =
extract_json_field(line, "\"error_status\":").unwrap_or_else(|| "unknown".to_string());
let attempt = extract_json_field(line, "\"attempt\":").unwrap_or_else(|| "unknown".to_string());
let retry_delay_ms =
extract_json_field(line, "\"retry_delay_ms\":").unwrap_or_else(|| "0".to_string());
let has_field = error_status != "unknown"
|| attempt != "unknown"
|| (retry_delay_ms != "0" && retry_delay_ms != "unknown");
if has_field {
let retry_delay_s = retry_delay_ms.parse::<f64>().unwrap_or(0.0) / 1000.0;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the saturating cast floors the delay to whole seconds"
)]
let retry_delay_s = retry_delay_s as u64;
format!("status={error_status} after {attempt} attempts (last delay {retry_delay_s}s)")
} else {
let status_hint = extract_json_field(line, "\"status\":")
.or_else(|| extract_json_field(line, "\"error\":"))
.unwrap_or_else(|| "429".to_string());
format!("status={status_hint} (api_retry fragment)")
}
}
fn extract_json_field(json: &str, field: &str) -> Option<String> {
json.find(field).and_then(|start| {
let value_start = start + field.len();
let value_str = &json[value_start..];
if let Some(rest) = value_str.strip_prefix('"') {
let end = rest.find('"')?; Some(rest[..end].to_string())
}
else {
let end = value_str
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(value_str.len());
let num_str = &value_str[..end];
num_str
.strip_suffix(',')
.map(std::string::ToString::to_string)
.or_else(|| Some(num_str.to_string()))
}
})
}
#[derive(Debug)]
pub enum ErrorHandleResult {
EarlyReturn { status: String },
Continue { status: String, error: String },
}
#[expect(
clippy::too_many_arguments,
reason = "each caller picks a different mix of reroute options"
)]
#[expect(
clippy::ref_option,
reason = "the store helpers it calls take the store as &Option"
)]
async fn try_free_model_reroute(
task_id: &str,
agent_name: &str,
agent_runner: &dyn agents::AgentRunner,
exclude_models: &[&str],
check_cooldowns: bool,
set_agent: Option<&str>,
apply_backoff: bool,
reason_prefix: &str,
store: &Option<Arc<TaskStore>>,
repo: &str,
) -> Option<ErrorHandleResult> {
let free = agent_runner.free_models();
if free.is_empty() {
return None;
}
let tried_models: String = store::opt_store_get_task(store, repo, task_id)
.await
.map(|t| t.model_reroute_chain)
.unwrap_or_default();
let tried_set: std::collections::HashSet<&str> =
tried_models.split(',').filter(|s| !s.is_empty()).collect();
let next_free = free.iter().find(|m| {
!exclude_models.contains(&m.as_str())
&& !tried_set.contains(m.as_str())
&& (!check_cooldowns
|| (!crate::engine::cooldown::is_agent_in_cooldown(agent_name)
&& !response::is_model_in_cooldown(agent_name, m)))
})?;
tracing::info!(task_id, model = %next_free, "{}", reason_prefix);
let new_tried = if tried_models.is_empty() {
next_free.clone()
} else {
format!("{tried_models},{next_free}")
};
let msg = format!("{reason_prefix} {next_free}");
let mut fields: Vec<(&str, serde_json::Value)> = vec![
("model", serde_json::json!(next_free.clone())),
("model_reroute_chain", serde_json::json!(new_tried)),
("last_error", serde_json::json!(msg)),
];
if let Some(agent) = set_agent {
fields.push(("agent", serde_json::json!(agent)));
}
if let Err(e) = store::store_set_result(store, repo, task_id, &fields).await {
tracing::warn!(task_id, err = %e, "try_free_model_reroute: failed to persist model update — not rerouting");
return None; }
if apply_backoff {
response::wait_for_fallback_backoff(task_id, store, repo).await;
}
Some(ErrorHandleResult::EarlyReturn {
status: "new".to_string(),
})
}
async fn apply_vendor_reset_date(
agent_name: &str,
model_name: Option<&str>,
message: &str,
) -> bool {
use crate::engine::cooldown::CreditExhaustionReason;
let Some(model) = model_name else {
return false;
};
match crate::engine::cooldown::detect_credit_exhaustion(message) {
None | Some(CreditExhaustionReason::BillingCycleExhausted) => {}
Some(_) => return false,
}
match crate::engine::cooldown::parse_retry_at(message) {
Some(until) if until > chrono::Utc::now().timestamp() => {
crate::engine::cooldown::set_model_cooldown_until(agent_name, model, until).await;
true
}
_ => false,
}
}
#[expect(
clippy::too_many_arguments,
reason = "the error path needs the task, agent, model and store context together"
)]
pub async fn handle_error(
task_id: &str,
agent_err: &agents::AgentError,
agent_name: &str,
agent_runner: &dyn agents::AgentRunner,
model_name: Option<&str>,
complexity: Option<&str>,
new_attempts: u32,
store: &Option<Arc<TaskStore>>,
repo: &str,
) -> anyhow::Result<ErrorHandleResult> {
tracing::warn!(
task_id,
agent = agent_name,
error = %agent_err,
"agent error, attempting recovery"
);
let mut credit_recorded = false;
let (retryable, error_msg) = match agent_err {
agents::AgentError::RateLimit { message, .. } => {
let is_provider_returned_error = message
.to_ascii_lowercase()
.contains("provider returned error");
let mut billing_cycle_model_scoped = false;
if apply_vendor_reset_date(agent_name, model_name, message).await {
let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
credit_recorded = reason.is_some();
billing_cycle_model_scoped = reason
== Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
} else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
{
credit_recorded = true;
if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
{
if let Some(model) = model_name {
crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
.await;
billing_cycle_model_scoped = true;
} else {
crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
}
} else {
crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
}
} else if let (Some(model), Some(window_secs)) = (
model_name,
crate::engine::cooldown::parse_relative_usage_window(message),
) {
crate::engine::cooldown::set_model_cooldown(
agent_name,
model,
window_secs,
"rate_limit_usage_window",
)
.await;
tracing::info!(
task_id,
agent = agent_name,
model,
window_secs,
"relative usage window detected: applying model-specific cooldown"
);
} else {
crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
.await;
if let Some(model) = model_name {
if is_provider_returned_error {
crate::engine::cooldown::record_transient_model_failure(agent_name, model)
.await;
} else {
response::record_model_failure(agent_name, model).await;
}
}
}
let retryable = if billing_cycle_model_scoped {
response::RetryableError::ModelUnavailable
} else {
response::RetryableError::UsageLimit
};
(
retryable,
format!(
"{agent_name} rate limit: {}",
summarize_rate_limit_error(message)
),
)
}
agents::AgentError::Auth { message } => {
let mut billing_cycle_model_scoped = false;
if apply_vendor_reset_date(agent_name, model_name, message).await {
let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
credit_recorded = reason.is_some();
billing_cycle_model_scoped = reason
== Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
} else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
{
credit_recorded = true;
if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
{
if let Some(model) = model_name {
crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
.await;
billing_cycle_model_scoped = true;
} else {
crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
}
} else {
crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
}
} else {
crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
.await;
if let Some(model) = model_name {
response::record_model_failure(agent_name, model).await;
}
}
let retryable = if billing_cycle_model_scoped {
response::RetryableError::ModelUnavailable
} else {
response::RetryableError::AuthError
};
(retryable, format!("{agent_name} auth error: {message}"))
}
agents::AgentError::Timeout { elapsed } => {
(
response::RetryableError::Timeout,
format!("{agent_name} timed out after {}s", elapsed.as_secs()),
)
}
agents::AgentError::Interrupted { message } => (
response::RetryableError::Interrupted,
format!("{agent_name} interrupted: {message}"),
),
agents::AgentError::MissingTool { tool } => (
response::RetryableError::MissingTooling,
format!("missing tool: {tool}"),
),
agents::AgentError::ModelUnavailable { model, message } => {
crate::engine::cooldown::record_model_unavailable(agent_name, model, message).await;
let agent_cooled = crate::engine::cooldown::is_agent_in_cooldown(agent_name);
let models = agent_runner.available_models();
let current_model = model_name.unwrap_or("");
let next_model = if agent_cooled {
None
} else {
models.iter().find(|m| {
m.as_str() != current_model
&& m.as_str() != model
&& !response::is_model_in_cooldown(agent_name, m)
})
};
if let Some(next) = next_model {
tracing::info!(task_id, model = %next, "retrying with different model");
let msg = format!("model {model} unavailable, trying {next}");
if let Err(e) = store::store_set_result(
store,
repo,
task_id,
&[
("model", serde_json::json!(next.clone())),
("last_error", serde_json::json!(msg)),
],
)
.await
{
tracing::warn!(task_id, err = %e, "failed to write model failover to store");
}
response::wait_for_fallback_backoff(task_id, store, repo).await;
return Ok(ErrorHandleResult::EarlyReturn {
status: "routed".to_string(),
});
}
let is_simple = matches!(complexity, None | Some("simple"));
if is_simple
&& let Some(result) = try_free_model_reroute(
task_id,
agent_name,
agent_runner,
&[current_model, model.as_str()],
true,
None,
true,
&format!("model {model} unavailable, trying free model"),
store,
repo,
)
.await
{
return Ok(result);
}
(
response::RetryableError::ModelUnavailable,
format!("model {model} unavailable"),
)
}
agents::AgentError::ContextOverflow { message } => {
let msg = format!("{agent_name} context overflow: {message}");
if let Err(e) = store::store_set_result(
store,
repo,
task_id,
&[("last_error", serde_json::json!(msg))],
)
.await
{
tracing::warn!(task_id, err = %e, "failed to write context_overflow last_error to store");
}
return Ok(ErrorHandleResult::EarlyReturn {
status: "needs_review".to_string(),
});
}
agents::AgentError::WaitingForInput { message } => {
let msg = format!("waiting for input: {message}");
if let Err(e) = store::store_set_result(
store,
repo,
task_id,
&[("last_error", serde_json::json!(msg))],
)
.await
{
tracing::warn!(task_id, err = %e, "failed to write waiting_for_input last_error to store");
}
return Ok(ErrorHandleResult::EarlyReturn {
status: "needs_review".to_string(),
});
}
agents::AgentError::PermissionDenied { message } => (
response::RetryableError::Failed,
format!("permission denied: {message}"),
),
agents::AgentError::InvalidResponse { .. } => {
if let Some(model) = model_name {
response::record_model_failure(agent_name, model).await;
}
(
response::RetryableError::Failed,
format!("{agent_name} invalid response"),
)
}
agents::AgentError::AgentFailed { message } => {
if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
credit_recorded = true;
match model_name {
Some(model)
if reason
== crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted =>
{
crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
.await;
}
_ => crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await,
}
} else {
if let Some(model) = model_name {
response::record_model_failure(agent_name, model).await;
}
crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
.await;
}
(
response::RetryableError::Failed,
format!("{agent_name} failed: {message}"),
)
}
agents::AgentError::NetworkError { message } => {
const MAX_NETWORK_RETRIES: u64 = 8;
let msg = format!("{agent_name} network error: {message}");
let retry_count = match store::store_increment(store, repo, task_id, "network_retries")
.await
{
Ok(count) => {
tracing::info!(
task_id,
retry_count = count,
agent = agent_name,
"network retry scheduled"
);
count
}
Err(e) => {
tracing::warn!(
task_id,
error = %e,
"failed to increment network_retries — escalating to needs_review"
);
return Ok(ErrorHandleResult::Continue {
status: "needs_review".to_string(),
error: format!("{agent_name} network error (store unavailable): {message}"),
});
}
};
if let Err(e) = store::store_set_result(
store,
repo,
task_id,
&[("last_error", serde_json::json!(msg))],
)
.await
{
tracing::warn!(task_id, err = %e, "failed to write network_error last_error to store");
}
if retry_count >= MAX_NETWORK_RETRIES {
tracing::warn!(
task_id,
retry_count,
agent = agent_name,
"network retries exhausted, escalating to needs_review"
);
return Ok(ErrorHandleResult::Continue {
status: "needs_review".to_string(),
error: msg,
});
}
response::wait_for_fallback_backoff(task_id, store, repo).await;
return Ok(ErrorHandleResult::EarlyReturn {
status: "routed".to_string(),
});
}
agents::AgentError::StaleSession { session_id } => {
(
response::RetryableError::Failed,
format!("{agent_name} stale session: {session_id}"),
)
}
agents::AgentError::ThinkingBlockConflict { message } => {
(
response::RetryableError::ModelUnavailable,
format!("{agent_name} thinking block conflict: {message}"),
)
}
agents::AgentError::Unknown { exit_code, message } => {
if *exit_code == 0
&& (message.is_empty()
|| message == "empty-output-exit0"
|| message.starts_with("empty-output-exit0:"))
{
if let Some(m) = model_name {
crate::engine::cooldown::record_transient_model_failure(agent_name, m).await;
}
let current = model_name.unwrap_or("");
if let Some(result) = try_free_model_reroute(
task_id,
agent_name,
agent_runner,
&[current],
true,
Some("opencode"),
true,
"silent exit 0, retrying with free model",
store,
repo,
)
.await
{
return Ok(result);
}
}
let error_message = if *exit_code != 0 && message.contains("\"costUSD\":") {
format!(
"{agent_name} exited with non-zero status (cost telemetry in stdout, no error message)"
)
} else {
format!("{agent_name} exit {exit_code}: {message}")
};
(response::RetryableError::Failed, error_message)
}
};
if matches!(
retryable,
response::RetryableError::Timeout | response::RetryableError::Interrupted
) && let Err(e) = store::store_set_result(
store,
repo,
task_id,
&[
("agent", serde_json::json!("")),
("last_error", serde_json::json!(error_msg.clone())),
],
)
.await
{
tracing::warn!(task_id, err = %e, "failed to write timeout agent clear to store");
}
{
let error_type_str = match retryable {
response::RetryableError::UsageLimit => Some("rate"),
response::RetryableError::AuthError => Some("auth"),
response::RetryableError::Timeout | response::RetryableError::Interrupted => None,
_ => Some("error"),
};
if let Some(limit_type) = error_type_str
&& let Some(s) = store
&& let Err(e) = s
.record_rate_limit(agent_name, limit_type, Some(task_id))
.await
{
tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
}
}
let chain = response::get_reroute_chain(task_id, store, repo).await;
let available: Vec<String> = crate::engine::configured_agents()
.into_iter()
.filter(|a| crate::cmd_cache::command_exists(a))
.collect();
let all_agents_tried = {
let chain_set: std::collections::HashSet<&str> = if chain.is_empty() {
std::collections::HashSet::new()
} else {
chain.split(',').collect()
};
!available
.iter()
.any(|a| a.as_str() != agent_name && !chain_set.contains(a.as_str()))
};
let is_simple = matches!(complexity, None | Some("simple"));
if all_agents_tried && is_simple {
if let Some(result) = try_free_model_reroute(
task_id,
agent_name,
agent_runner,
&[],
true, Some("opencode"),
false,
"all agents exhausted, trying free model",
store,
repo,
)
.await
{
return Ok(result);
}
}
let status = response::handle_failover(
task_id,
agent_name,
retryable,
&error_msg,
credit_recorded,
store,
repo,
)
.await;
match status.as_str() {
"new" => tracing::warn!(task_id, "failover exhausted (retryable), task reset to new"),
"blocked" => tracing::warn!(task_id, "failover exhausted (hard failure), task blocked"),
_ => {}
}
response::store_failure_memory(
task_id,
new_attempts,
agent_name,
model_name,
&error_msg,
store,
repo,
)
.await;
Ok(ErrorHandleResult::Continue {
status,
error: error_msg,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::runner::agents::{AgentError, AgentRunner, ParsedResponse, PermissionRules};
use crate::parser::AgentResponse;
use serial_test::serial;
struct MockRunner {
free: Vec<String>,
}
struct MockRunnerWithModels {
models: Vec<String>,
}
impl AgentRunner for MockRunnerWithModels {
fn stream_events(
&self,
_event: &serde_json::Value,
) -> Vec<crate::engine::runner::agents::StreamEvent> {
vec![]
}
fn name(&self) -> &'static str {
"opencode"
}
fn build_command(
&self,
_model: Option<&str>,
_timeout_cmd: &str,
_sys_file: &str,
_msg_file: &str,
_permissions: &PermissionRules,
) -> String {
String::new()
}
fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
Ok(ParsedResponse {
response: AgentResponse {
status: "done".to_string(),
summary: String::new(),
accomplished: vec![],
remaining: vec![],
files: vec![],
error: None,
input_tokens: None,
output_tokens: None,
learnings: vec![],
delegations: vec![],
},
input_tokens: None,
output_tokens: None,
duration_ms: None,
})
}
fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
AgentError::Unknown {
exit_code: 0,
message: String::new(),
}
}
fn available_models(&self) -> Vec<String> {
self.models.clone()
}
fn router_command(
&self,
_prompt: &str,
_model: Option<&str>,
) -> anyhow::Result<tokio::process::Command> {
anyhow::bail!("not implemented")
}
}
impl AgentRunner for MockRunner {
fn stream_events(
&self,
_event: &serde_json::Value,
) -> Vec<crate::engine::runner::agents::StreamEvent> {
vec![]
}
fn name(&self) -> &'static str {
"opencode"
}
fn build_command(
&self,
_model: Option<&str>,
_timeout_cmd: &str,
_sys_file: &str,
_msg_file: &str,
_permissions: &PermissionRules,
) -> String {
String::new()
}
fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
Ok(ParsedResponse {
response: AgentResponse {
status: "done".to_string(),
summary: String::new(),
accomplished: vec![],
remaining: vec![],
files: vec![],
error: None,
input_tokens: None,
output_tokens: None,
learnings: vec![],
delegations: vec![],
},
input_tokens: None,
output_tokens: None,
duration_ms: None,
})
}
fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
AgentError::Unknown {
exit_code: 0,
message: String::new(),
}
}
fn free_models(&self) -> Vec<String> {
self.free.clone()
}
fn router_command(
&self,
_prompt: &str,
_model: Option<&str>,
) -> anyhow::Result<tokio::process::Command> {
anyhow::bail!("not implemented")
}
}
#[serial(cooldown_state)]
#[tokio::test]
async fn rate_limit_sets_agent_cooldown_immediately() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "test-agent-1371-early-cooldown";
assert!(
!crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent should not be in cooldown before handle_error"
);
let err = AgentError::RateLimit {
message: "429 Too Many Requests".to_string(),
};
let _result = handle_error(
"test-1371-a",
&err,
agent,
&runner,
Some("sonnet"),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent should be in cooldown immediately after handle_error for RateLimit"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn rate_limit_sets_both_agent_and_model_cooldowns() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "test-agent-2153-model-cooldown";
let model = "opencode/github-copilot/qwen3.6-plus-free";
assert!(
!crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent should not be in cooldown before handle_error"
);
assert!(
!crate::engine::cooldown::is_model_in_cooldown(agent, model),
"model should not be in cooldown before handle_error"
);
let err = AgentError::RateLimit {
message: "Upstream error from Alibaba: Request rate increased too quickly.".to_string(),
};
let _result = handle_error(
"test-2153-a",
&err,
agent,
&runner,
Some(model),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent should be in agent-level cooldown after RateLimit"
);
assert!(
crate::engine::cooldown::is_model_in_cooldown(agent, model),
"model should be in model-level cooldown after RateLimit (fixes issue #2153)"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn rate_limit_cooldown_is_model_specific() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "test-agent-2153-model-specific";
let cooled_model = "opencode/github-copilot/qwen3.6-plus-free";
let other_model = "opencode/minimax-m2.5-free";
let err = AgentError::RateLimit {
message: "rate limit exceeded".to_string(),
};
let _result = handle_error(
"test-2153-b",
&err,
agent,
&runner,
Some(cooled_model),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
crate::engine::cooldown::is_model_in_cooldown(agent, cooled_model),
"rate-limited model should be in cooldown"
);
assert!(
!crate::engine::cooldown::is_model_in_cooldown(agent, other_model),
"other models should not be cooled by a single model's rate limit"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn credit_exhaustion_does_not_bump_generic_failure_count() {
crate::engine::cooldown::reset_global_state().await;
let store = std::sync::Arc::new(
crate::store::TaskStore::open_memory()
.await
.expect("in-memory store"),
);
crate::engine::cooldown::init_cooldown_store(std::sync::Arc::clone(&store)).await;
let runner = MockRunner { free: vec![] };
let agent = "test-agent-3652-credit-no-bump";
let err = AgentError::RateLimit {
message: "out of credits, please recharge your balance".to_string(),
};
let _result = handle_error(
"test-3652-a",
&err,
agent,
&runner,
None,
Some("simple"),
1,
&Some(std::sync::Arc::clone(&store)),
"owner/repo",
)
.await
.unwrap();
let credit_count = store
.kv_get(&format!("credit_failure_count:{agent}"))
.await
.unwrap();
assert!(
credit_count.is_some(),
"credit_failure_count should be incremented by the credit error"
);
let generic_count = store
.kv_get(&format!("failure_count:{agent}"))
.await
.unwrap();
assert!(
generic_count.is_none(),
"failure_count must not be bumped by credit errors, got {generic_count:?}"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn two_timeouts_leave_model_routable() {
crate::engine::cooldown::reset_global_state().await;
let store = std::sync::Arc::new(
crate::store::TaskStore::open_memory()
.await
.expect("in-memory store"),
);
crate::engine::cooldown::init_cooldown_store(std::sync::Arc::clone(&store)).await;
let runner = MockRunner { free: vec![] };
let agent = "opencode";
let model = "opencode/nemotron-3-ultra-free";
let err = AgentError::Timeout {
elapsed: std::time::Duration::from_mins(30),
};
for i in 1..=2 {
let _result = handle_error(
&format!("test-3876-timeout-{i}"),
&err,
agent,
&runner,
Some(model),
Some("simple"),
i,
&Some(std::sync::Arc::clone(&store)),
"owner/repo",
)
.await
.unwrap();
}
assert!(
!crate::engine::cooldown::is_model_in_cooldown(agent, model),
"timeouts must not cool the model"
);
assert!(
!crate::engine::cooldown::is_agent_in_cooldown(agent),
"timeouts must not cool the agent"
);
let counts = store.recent_rate_limit_counts(6).await.unwrap();
assert_eq!(
counts.get(agent).copied().unwrap_or(0),
0,
"timeouts must not write a rate_limits row"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn interrupted_run_does_not_write_rate_limit() {
crate::engine::cooldown::reset_global_state().await;
let store = std::sync::Arc::new(
crate::store::TaskStore::open_memory()
.await
.expect("in-memory store"),
);
crate::engine::cooldown::init_cooldown_store(std::sync::Arc::clone(&store)).await;
let runner = MockRunner { free: vec![] };
let agent = "claude";
let model = "sonnet";
let err = AgentError::Interrupted {
message: "exited without a result envelope".to_string(),
};
let _result = handle_error(
"test-3876-interrupted",
&err,
agent,
&runner,
Some(model),
Some("medium"),
1,
&Some(std::sync::Arc::clone(&store)),
"owner/repo",
)
.await
.unwrap();
assert!(
!crate::engine::cooldown::is_model_in_cooldown(agent, model),
"interrupted runs must not cool the model"
);
let counts = store.recent_rate_limit_counts(6).await.unwrap();
assert_eq!(
counts.get(agent).copied().unwrap_or(0),
0,
"interrupted runs must not write a rate_limits row"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn rate_limit_with_relative_window_sets_window_length_model_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "test-agent-3580-kimi";
let model = "opus";
let key = format!("{agent}:{model}");
let err = AgentError::RateLimit {
message: "You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window expires.".to_string(),
};
let _result = handle_error(
"test-3580-a",
&err,
agent,
&runner,
Some(model),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
let now = chrono::Utc::now().timestamp();
let until = crate::engine::cooldown::cooldown_until(&key)
.expect("relative usage window should set model cooldown");
let remaining = until.saturating_sub(now);
assert!(
remaining >= 7 * 86400 - 30,
"expected ~7-day model cooldown from the parsed window, got {remaining}s"
);
assert_eq!(
crate::engine::cooldown::cooldown_reason(&key).as_deref(),
Some("rate_limit_usage_window"),
"relative usage-window cooldown must be labeled as a rate limit, not silence"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn provider_returned_error_sets_short_model_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "opencode";
let model = "opencode/nemotron-3-super-free";
let key = format!("{agent}:{model}");
let err = AgentError::RateLimit {
message: "Provider returned error".to_string(),
};
let _result = handle_error(
"test-2478-provider-cooldown",
&err,
agent,
&runner,
Some(model),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
let now = chrono::Utc::now().timestamp();
let until = crate::engine::cooldown::cooldown_until(&key)
.expect("provider error should set model cooldown");
let remaining = until.saturating_sub(now);
assert!(
remaining <= crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
"expected a 5 minute model cooldown, got {remaining}s"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_unavailable_sets_persistent_model_cooldown_for_codex_style() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "codex";
let model = "gpt-5.3-codex";
let err = crate::engine::runner::agents::AgentError::ModelUnavailable {
model: model.to_string(),
message: "{\"detail\":\"The 'gpt-5.3-codex' model is not supported\"}".to_string(),
};
let _result = handle_error(
"test-codex-model-unsupported",
&err,
agent,
&runner,
Some(model),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
let key = format!("{agent}:{model}");
let now = chrono::Utc::now().timestamp();
let until =
crate::engine::cooldown::cooldown_until(&key).expect("model cooldown should be set");
let remaining = until.saturating_sub(now);
assert!(
remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
"expected persistent model cooldown (~4h) for codex model-unavailable, got {remaining}s"
);
}
async fn vendor_reset_remaining(agent: &str, model: &str, message: &str) -> i64 {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let err = crate::engine::runner::agents::AgentError::RateLimit {
message: message.to_string(),
};
let _ = handle_error(
"test-3649",
&err,
agent,
&runner,
Some(model),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
let until = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
.expect("model cooldown should be set");
until - chrono::Utc::now().timestamp()
}
#[serial(cooldown_state)]
#[tokio::test]
async fn vendor_reset_date_overrides_weekly_window() {
use chrono::Datelike;
let t = chrono::Local::now() + chrono::Duration::days(2);
let msg = format!(
"You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
t.format("%b"),
t.day()
);
let remaining = vendor_reset_remaining("claude", "sonnet", &msg).await;
assert!(
remaining > 3600 && remaining < 3 * 86400,
"expected ~2d cooldown, got {remaining}s"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn vendor_reset_date_overrides_billing_cycle_backoff() {
let t = chrono::Local::now() + chrono::Duration::days(3);
let msg = format!(
"You've hit your usage limit. Upgrade to Pro (https://x), or try again at {}.",
t.format("%b %d, %Y %I:%M %p")
);
let remaining = vendor_reset_remaining("codex", "gpt-5.4", &msg).await;
assert!(
remaining > 2 * 86400 + 80000 - 3600 && remaining < 4 * 86400,
"expected ~3d cooldown, got {remaining}s"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn agent_failed_insufficient_balance_sets_credit_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "test-agent-3642-insufficient-balance";
assert!(
!crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent should not be in cooldown before handle_error"
);
let err = agents::AgentError::AgentFailed {
message: "402 Payment Required: insufficient balance".to_string(),
};
let _result = handle_error(
"test-3642-a",
&err,
agent,
&runner,
Some("sonnet"),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent should be in cooldown after handle_error for AgentFailed with insufficient balance"
);
let until = crate::engine::cooldown::cooldown_until(agent)
.expect("agent cooldown should be active");
let remaining = until - chrono::Utc::now().timestamp();
assert!(
remaining >= crate::engine::cooldown::CREDIT_BACKOFF_BASE_SECS - 30,
"credit cooldown should be at least the 1h base, got {remaining}s"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn silent_exit0_retries_free_model_for_simple_complexity() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner {
free: vec!["opencode/mimo-v2-omni-free".to_string()],
};
let err = AgentError::Unknown {
exit_code: 0,
message: String::new(),
};
let result = handle_error(
"test-1112-a",
&err,
"opencode",
&runner,
Some("opencode/gpt-5.4-mini"),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
"expected EarlyReturn{{status: new}} for simple complexity — free model should be tried first"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn silent_exit0_retries_free_model_when_complexity_unknown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner {
free: vec!["opencode/mimo-v2-omni-free".to_string()],
};
let err = AgentError::Unknown {
exit_code: 0,
message: String::new(),
};
let result = handle_error(
"test-1112-a2",
&err,
"opencode",
&runner,
Some("opencode/gpt-5.4-mini"),
None,
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
"expected EarlyReturn{{status: new}} for unknown complexity — free model should be tried first"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn silent_exit0_retries_free_model_for_medium_complexity() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner {
free: vec!["opencode/mimo-v2-omni-free".to_string()],
};
let err = AgentError::Unknown {
exit_code: 0,
message: String::new(),
};
let result = handle_error(
"test-1195-medium",
&err,
"opencode",
&runner,
Some("opencode/copilot-gpt-5.4-mini"),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
"expected EarlyReturn{{status: new}} for medium complexity — free model should be tried before failover"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn silent_exit0_retries_free_model_for_complex_complexity() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner {
free: vec!["opencode/mimo-v2-omni-free".to_string()],
};
let err = AgentError::Unknown {
exit_code: 0,
message: String::new(),
};
let result = handle_error(
"test-1195-complex",
&err,
"opencode",
&runner,
Some("opencode/copilot-gpt-5.4-mini"),
Some("complex"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
"expected EarlyReturn{{status: new}} for complex complexity — free model should be tried before failover"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn context_overflow_escalates_to_needs_review_immediately() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let err = AgentError::ContextOverflow {
message: "prompt is too long: 250000 tokens, max 200000".to_string(),
};
let result = handle_error(
"test-2129-a",
&err,
"claude",
&runner,
Some("sonnet"),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "needs_review"),
"context overflow must escalate directly to needs_review without cycling through fallback agents"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn free_model_reroute_succeeds_without_store() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner {
free: vec!["opencode/mimo-v2-omni-free".to_string()],
};
let err = AgentError::Unknown {
exit_code: 0,
message: String::new(),
};
let result = handle_error(
"test-2293-a",
&err,
"opencode",
&runner,
Some("opencode/gpt-5.4-mini"),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
"expected EarlyReturn{{status: new}} — store_set_result with None store should not prevent reroute"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn silent_exit0_falls_through_when_no_free_models() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let err = AgentError::Unknown {
exit_code: 0,
message: String::new(),
};
let result = handle_error(
"test-1112-b",
&err,
"opencode",
&runner,
Some("opencode/gpt-5.4-mini"),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
matches!(result, ErrorHandleResult::Continue { .. }),
"expected Continue (fallthrough to failover) when no free models available"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn invalid_response_sets_model_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "opencode";
let model = "opencode/nemotron-3-super-free";
assert!(
!crate::engine::cooldown::is_model_in_cooldown(agent, model),
"model should not be in cooldown before handle_error"
);
let err = AgentError::InvalidResponse {
raw: "invalid json output".to_string(),
};
let _result = handle_error(
"test-2750-invalid-response",
&err,
agent,
&runner,
Some(model),
Some("simple"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
crate::engine::cooldown::is_model_in_cooldown(agent, model),
"model should be in cooldown after InvalidResponse (fixes issue #2750)"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn billing_cycle_exhausted_with_known_model_sets_only_model_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "codex-3382-billing-cycle";
let model = "gpt-5.5";
let err = AgentError::RateLimit {
message: "rate limit: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro).".to_string(),
};
let _result = handle_error(
"test-3382-a",
&err,
agent,
&runner,
Some(model),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
!crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent must NOT be in agent-level cooldown when BillingCycleExhausted has a known model (issue #3382)"
);
assert!(
crate::engine::cooldown::is_model_in_cooldown(agent, model),
"model must be in cooldown after BillingCycleExhausted (issue #3382)"
);
assert!(
!crate::engine::cooldown::is_model_in_cooldown(agent, "gpt-5.4"),
"other models on the same agent must not be cooled by a single model's billing exhaustion"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_extracts_api_retry_info() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = r#"some log lines
{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
more logs"#;
let summarized = super::summarize_rate_limit_error(raw_output);
assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_returns_original_when_no_api_retry() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = "some regular error message without api_retry JSON";
let summarized = super::summarize_rate_limit_error(raw_output);
assert_eq!(summarized, raw_output);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_handles_malformed_json_gracefully() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = r#"some logs
{"type":"system","subtype":"api_retry","attempt":"not_a_number","retry_delay_ms":invalid,"error_status":429}
more logs"#;
let summarized = super::summarize_rate_limit_error(raw_output);
assert!(summarized.starts_with("status="));
assert!(summarized.contains("after"));
assert!(summarized.contains("attempts"));
assert!(summarized.contains("(last delay"));
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_works_with_single_line() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = r#"{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":5000,"error_status":429,"error":"rate_limit"}"#;
let summarized = super::summarize_rate_limit_error(raw_output);
assert_eq!(summarized, "status=429 after 3 attempts (last delay 5s)");
}
#[serial(cooldown_state)]
#[tokio::test]
async fn extract_json_field_handles_quoted_strings() {
crate::engine::cooldown::reset_global_state().await;
let json = r#"{"error_status":429,"attempt":7,"retry_delay_ms":35162.66,"error":"rate_limit","session_id":"331c099b-..."}"#;
assert_eq!(
super::extract_json_field(json, "\"error\":"),
Some("rate_limit".to_string()),
"quoted string field mid-object should be extracted correctly"
);
assert_eq!(
super::extract_json_field(json, "\"session_id\":"),
Some("331c099b-...".to_string()),
"quoted string field at end of object (no trailing comma) should be extracted"
);
assert_eq!(
super::extract_json_field(json, "\"error_status\":"),
Some("429".to_string())
);
assert_eq!(
super::extract_json_field(json, "\"attempt\":"),
Some("7".to_string())
);
assert_eq!(
super::extract_json_field(json, "\"retry_delay_ms\":"),
Some("35162.66".to_string())
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_handles_truncated_fragment_with_subtype() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = r#"some log context
,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit"
more context"#;
let summarized = super::summarize_rate_limit_error(raw_output);
assert!(
!summarized.contains("subtype"),
"summarized output must not contain raw 'subtype' JSON fragment"
);
assert!(
!summarized.contains('{'),
"summarized output must not contain JSON braces"
);
assert!(
summarized.starts_with("status="),
"summarized output should start with 'status=' — got: {summarized}"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_handles_fragment_with_extractable_fields() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = r#"log output
,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit","session_id":"abc123","max_retries":10}
rest of output"#;
let summarized = super::summarize_rate_limit_error(raw_output);
assert_eq!(summarized, "status=429 after 5 attempts (last delay 20s)");
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_complete_json_still_works() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = r#"some log lines
{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
more logs"#;
let summarized = super::summarize_rate_limit_error(raw_output);
assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_no_api_retry_no_raw_passthrough() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = "some regular error message without api_retry JSON that is quite long and should be truncated";
let summarized = super::summarize_rate_limit_error(raw_output);
assert!(
summarized.len() <= 140,
"expected truncated first-line, got: {summarized}"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn summarize_rate_limit_error_no_api_retry_short() {
crate::engine::cooldown::reset_global_state().await;
let raw_output = "rate limit exceeded";
let summarized = super::summarize_rate_limit_error(raw_output);
assert_eq!(summarized, raw_output);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_not_found_applies_persistent_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "opencode-2941-not-found";
let model = "github-copilot/claude-opus-4.6";
let key = format!("{agent}:{model}");
let err = AgentError::ModelUnavailable {
model: model.to_string(),
message: "Model not found: github-copilot/claude-opus-4.6".to_string(),
};
let _result = handle_error(
"test-2941-a",
&err,
agent,
&runner,
Some(model),
Some("complex"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
let now = chrono::Utc::now().timestamp();
let until = crate::engine::cooldown::cooldown_until(&key)
.expect("model not found should set model cooldown");
let remaining = until.saturating_sub(now);
assert!(
remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
"expected ~4h (persistent) cooldown for 'Model not found', got {remaining}s — \
may have applied transient 5min cooldown instead"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_not_supported_applies_persistent_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "codex-3215-not-supported";
let model = "gpt-5.2-codex";
let key = format!("{agent}:{model}");
let err = AgentError::ModelUnavailable {
model: model.to_string(),
message: "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.".to_string(),
};
let _result = handle_error(
"test-3215-a",
&err,
agent,
&runner,
Some(model),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
let now = chrono::Utc::now().timestamp();
let until = crate::engine::cooldown::cooldown_until(&key)
.expect("model not supported should set model cooldown");
let remaining = until.saturating_sub(now);
assert!(
remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
"expected ~4h (persistent) cooldown for 'not supported', got {remaining}s — \
may have applied transient 5min cooldown instead (issue #3215)"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_not_available_in_country_applies_persistent_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "opencode-3650-country";
let model = "some-region-locked-model";
let key = format!("{agent}:{model}");
for message in [
"This model is not available in your country.",
"This model is not available in your region.",
] {
crate::engine::cooldown::reset_global_state().await;
let err = AgentError::ModelUnavailable {
model: model.to_string(),
message: message.to_string(),
};
let _result = handle_error(
"test-3650",
&err,
agent,
&runner,
Some(model),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
let now = chrono::Utc::now().timestamp();
let until = crate::engine::cooldown::cooldown_until(&key)
.expect("region block should set model cooldown");
let remaining = until.saturating_sub(now);
assert!(
remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
"expected ~4h (persistent) cooldown for '{message}', got {remaining}s — \
may have applied transient 5min cooldown instead (issue #3650)"
);
}
}
#[serial(cooldown_state)]
#[tokio::test]
async fn thinking_block_conflict_does_not_set_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "claude-3204-thinking-block";
let model = "opus";
assert!(
!crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent must not be in cooldown before handle_error"
);
assert!(
!crate::engine::cooldown::is_model_in_cooldown(agent, model),
"model must not be in cooldown before handle_error"
);
let err = AgentError::ThinkingBlockConflict {
message: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.".to_string(),
};
let _result = handle_error(
"test-3204-thinking",
&err,
agent,
&runner,
Some(model),
Some("complex"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
assert!(
!crate::engine::cooldown::is_agent_in_cooldown(agent),
"agent must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
);
assert!(
!crate::engine::cooldown::is_model_in_cooldown(agent, model),
"model must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_unavailable_transient_applies_short_cooldown() {
crate::engine::cooldown::reset_global_state().await;
let runner = MockRunner { free: vec![] };
let agent = "opencode-2941-transient";
let model = "github-copilot/gpt-4o";
let key = format!("{agent}:{model}");
let err = AgentError::ModelUnavailable {
model: model.to_string(),
message: "No endpoints found for github-copilot/gpt-4o.".to_string(),
};
let _result = handle_error(
"test-2941-b",
&err,
agent,
&runner,
Some(model),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
let now = chrono::Utc::now().timestamp();
let until = crate::engine::cooldown::cooldown_until(&key)
.expect("transient model unavailability should set model cooldown");
let remaining = until.saturating_sub(now);
assert!(
remaining < crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
"transient unavailability should use short cooldown (<4h), got {remaining}s"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_unavailable_does_not_reuse_same_agent_when_agent_cooled() {
crate::engine::cooldown::reset_global_state().await;
let agent = "codex-3620";
let failing_model = "gpt-5.4";
let other_model = "gpt-5.5";
let runner = MockRunnerWithModels {
models: vec![failing_model.to_string(), other_model.to_string()],
};
crate::engine::cooldown::set_agent_cooldown(agent, 3600).await;
let err = AgentError::ModelUnavailable {
model: failing_model.to_string(),
message: "model gpt-5.4 unavailable".to_string(),
};
let result = handle_error(
"test-3620",
&err,
agent,
&runner,
Some(failing_model),
Some("medium"),
1,
&None,
"owner/repo",
)
.await
.unwrap();
if let ErrorHandleResult::EarlyReturn { status } = result {
assert_ne!(
status, "routed",
"agent-wide cooldown was defeated by same-agent model substitution"
);
}
}
}