1use crate::store;
8use crate::store::TaskStore;
9use std::collections::HashSet;
10use std::sync::Arc;
11
12use super::{agents, response};
13use crate::cmd_cache;
14use crate::engine;
15use crate::engine::cooldown;
16use crate::engine::cooldown::CreditExhaustionReason;
17
18pub(crate) fn summarize_rate_limit_error(raw_output: &str) -> String {
29 const FIRST_LINE_CAP: usize = 120;
31 let last_api_retry_line = raw_output
35 .lines()
36 .rev()
37 .find(|line| line.contains("\"subtype\":\"api_retry\""));
38
39 if let Some(line) = last_api_retry_line {
40 return summarise_api_retry_line(line);
41 }
42
43 let api_retry_field_markers = ["\"error_status\":", "\"attempt\":", "\"retry_delay_ms\":"];
49 let field_line = raw_output
50 .lines()
51 .rev()
52 .find(|line| api_retry_field_markers.iter().any(|m| line.contains(m)));
53 if let Some(line) = field_line {
54 return summarise_api_retry_line(line);
55 }
56
57 let first_line = raw_output.lines().next().unwrap_or(raw_output);
61 if first_line.len() > FIRST_LINE_CAP {
62 let cutoff = agents::truncate_at_char_boundary(first_line, FIRST_LINE_CAP);
63 let remaining = raw_output.len().saturating_sub(cutoff);
64 format!("{}... (+{} more chars)", &first_line[..cutoff], remaining)
65 } else {
66 first_line.to_string()
67 }
68}
69
70fn summarise_api_retry_line(line: &str) -> String {
75 let error_status =
76 extract_json_field(line, "\"error_status\":").unwrap_or_else(|| "unknown".to_string());
77 let attempt = extract_json_field(line, "\"attempt\":").unwrap_or_else(|| "unknown".to_string());
78 let retry_delay_ms =
79 extract_json_field(line, "\"retry_delay_ms\":").unwrap_or_else(|| "0".to_string());
80
81 let has_field = error_status != "unknown"
82 || attempt != "unknown"
83 || (retry_delay_ms != "0" && retry_delay_ms != "unknown");
84
85 if has_field {
86 let retry_delay_s = retry_delay_ms.parse::<f64>().unwrap_or(0.0) / 1000.0;
87 #[expect(
88 clippy::cast_possible_truncation,
89 clippy::cast_sign_loss,
90 reason = "the saturating cast floors the delay to whole seconds"
91 )]
92 let retry_delay_s = retry_delay_s as u64;
93 format!("status={error_status} after {attempt} attempts (last delay {retry_delay_s}s)")
94 } else {
95 let status_hint = extract_json_field(line, "\"status\":")
96 .or_else(|| extract_json_field(line, "\"error\":"))
97 .unwrap_or_else(|| "429".to_string());
98 format!("status={status_hint} (api_retry fragment)")
99 }
100}
101
102fn extract_json_field(json: &str, field: &str) -> Option<String> {
104 json.find(field).and_then(|start| {
105 let value_start = start + field.len();
106 let value_str = &json[value_start..];
107
108 if let Some(rest) = value_str.strip_prefix('"') {
110 let end = rest.find('"')?; Some(rest[..end].to_string())
112 }
113 else {
115 let end = value_str
116 .find(|c: char| !c.is_ascii_digit() && c != '.')
117 .unwrap_or(value_str.len());
118 let num_str = &value_str[..end];
119 num_str
120 .strip_suffix(',')
121 .map(ToString::to_string)
122 .or_else(|| Some(num_str.to_string()))
123 }
124 })
125}
126
127#[derive(Debug)]
129pub enum ErrorHandleResult {
130 EarlyReturn { status: String },
132 Continue { status: String, error: String },
138}
139
140#[expect(
151 clippy::too_many_arguments,
152 reason = "each caller picks a different mix of reroute options"
153)]
154#[expect(
155 clippy::ref_option,
156 reason = "the store helpers it calls take the store as &Option"
157)]
158async fn try_free_model_reroute(
159 task_id: &str,
160 agent_name: &str,
161 agent_runner: &dyn agents::AgentRunner,
162 exclude_models: &[&str],
163 check_cooldowns: bool,
164 set_agent: Option<&str>,
165 apply_backoff: bool,
166 reason_prefix: &str,
167 store: &Option<Arc<TaskStore>>,
168 repo: &str,
169) -> Option<ErrorHandleResult> {
170 let free = agent_runner.free_models();
171 if free.is_empty() {
172 return None;
173 }
174 let tried_models: String = store::opt_store_get_task(store, repo, task_id)
175 .await
176 .map(|t| t.model_reroute_chain)
177 .unwrap_or_default();
178 let tried_set: HashSet<&str> = tried_models.split(',').filter(|s| !s.is_empty()).collect();
179 let next_free = free.iter().find(|m| {
180 !exclude_models.contains(&m.as_str())
181 && !tried_set.contains(m.as_str())
182 && (!check_cooldowns
183 || (!cooldown::is_agent_in_cooldown(agent_name)
184 && !response::is_model_in_cooldown(agent_name, m)))
185 })?;
186 tracing::info!(task_id, model = %next_free, "{}", reason_prefix);
187 let new_tried = if tried_models.is_empty() {
188 next_free.clone()
189 } else {
190 format!("{tried_models},{next_free}")
191 };
192 let msg = format!("{reason_prefix} {next_free}");
193 let mut fields: Vec<(&str, serde_json::Value)> = vec![
194 ("model", serde_json::json!(next_free.clone())),
195 ("model_reroute_chain", serde_json::json!(new_tried)),
196 ("last_error", serde_json::json!(msg)),
197 ];
198 if let Some(agent) = set_agent {
199 fields.push(("agent", serde_json::json!(agent)));
200 }
201 if let Err(e) = store::store_set_result(store, repo, task_id, &fields).await {
202 tracing::warn!(task_id, err = %e, "try_free_model_reroute: failed to persist model update — not rerouting");
203 return None; }
205 if apply_backoff {
206 response::wait_for_fallback_backoff(task_id, store, repo).await;
207 }
208 Some(ErrorHandleResult::EarlyReturn {
209 status: "new".to_string(),
210 })
211}
212
213async fn apply_vendor_reset_date(
218 agent_name: &str,
219 model_name: Option<&str>,
220 message: &str,
221) -> bool {
222 let Some(model) = model_name else {
223 return false;
224 };
225 match cooldown::detect_credit_exhaustion(message) {
226 None | Some(CreditExhaustionReason::BillingCycleExhausted) => {}
227 Some(_) => return false,
228 }
229 match cooldown::parse_retry_at(message) {
230 Some(until) if until > chrono::Utc::now().timestamp() => {
231 cooldown::set_model_cooldown_until(agent_name, model, until).await;
232 true
233 }
234 _ => false,
235 }
236}
237
238#[expect(
244 clippy::too_many_arguments,
245 reason = "the error path needs the task, agent, model and store context together"
246)]
247pub async fn handle_error(
248 task_id: &str,
249 agent_err: &agents::AgentError,
250 agent_name: &str,
251 agent_runner: &dyn agents::AgentRunner,
252 model_name: Option<&str>,
253 complexity: Option<&str>,
254 new_attempts: u32,
255 store: &Option<Arc<TaskStore>>,
256 repo: &str,
257) -> anyhow::Result<ErrorHandleResult> {
258 tracing::warn!(
259 task_id,
260 agent = agent_name,
261 error = %agent_err,
262 "agent error, attempting recovery"
263 );
264
265 let mut credit_recorded = false;
268 let (retryable, error_msg) = match agent_err {
269 agents::AgentError::RateLimit { message, .. } => {
270 let is_provider_returned_error = message
271 .to_ascii_lowercase()
272 .contains("provider returned error");
273 let mut billing_cycle_model_scoped = false;
293
294 if apply_vendor_reset_date(agent_name, model_name, message).await {
295 let reason = cooldown::detect_credit_exhaustion(message);
296 credit_recorded = reason.is_some();
297 billing_cycle_model_scoped =
298 reason == Some(CreditExhaustionReason::BillingCycleExhausted);
299 } else if let Some(reason) = cooldown::detect_credit_exhaustion(message) {
300 credit_recorded = true;
301 if reason == CreditExhaustionReason::BillingCycleExhausted {
307 if let Some(model) = model_name {
308 cooldown::record_persistent_model_failure(agent_name, model).await;
309 billing_cycle_model_scoped = true;
310 } else {
311 cooldown::record_credit_exhaustion(agent_name, reason).await;
312 }
313 } else {
314 cooldown::record_credit_exhaustion(agent_name, reason).await;
315 }
316 } else if let (Some(model), Some(window_secs)) =
317 (model_name, cooldown::parse_relative_usage_window(message))
318 {
319 cooldown::set_model_cooldown(
327 agent_name,
328 model,
329 window_secs,
330 "rate_limit_usage_window",
331 )
332 .await;
333 tracing::info!(
334 task_id,
335 agent = agent_name,
336 model,
337 window_secs,
338 "relative usage window detected: applying model-specific cooldown"
339 );
340 } else {
341 cooldown::record_agent_failure_with_message(agent_name, message).await;
342 if let Some(model) = model_name {
344 if is_provider_returned_error {
345 cooldown::record_transient_model_failure(agent_name, model).await;
346 } else {
347 response::record_model_failure(agent_name, model).await;
348 }
349 }
350 }
351 let retryable = if billing_cycle_model_scoped {
355 response::RetryableError::ModelUnavailable
356 } else {
357 response::RetryableError::UsageLimit
358 };
359 (
360 retryable,
361 format!(
362 "{agent_name} rate limit: {}",
363 summarize_rate_limit_error(message)
364 ),
365 )
366 }
367 agents::AgentError::Auth { message } => {
368 let mut billing_cycle_model_scoped = false;
373
374 if apply_vendor_reset_date(agent_name, model_name, message).await {
375 let reason = cooldown::detect_credit_exhaustion(message);
376 credit_recorded = reason.is_some();
377 billing_cycle_model_scoped =
378 reason == Some(CreditExhaustionReason::BillingCycleExhausted);
379 } else if let Some(reason) = cooldown::detect_credit_exhaustion(message) {
380 credit_recorded = true;
381 if reason == CreditExhaustionReason::BillingCycleExhausted {
384 if let Some(model) = model_name {
385 cooldown::record_persistent_model_failure(agent_name, model).await;
386 billing_cycle_model_scoped = true;
387 } else {
388 cooldown::record_credit_exhaustion(agent_name, reason).await;
389 }
390 } else {
391 cooldown::record_credit_exhaustion(agent_name, reason).await;
392 }
393 } else {
394 cooldown::record_agent_failure_with_message(agent_name, message).await;
395 if let Some(model) = model_name {
396 response::record_model_failure(agent_name, model).await;
397 }
398 }
399 let retryable = if billing_cycle_model_scoped {
402 response::RetryableError::ModelUnavailable
403 } else {
404 response::RetryableError::AuthError
405 };
406 (retryable, format!("{agent_name} auth error: {message}"))
407 }
408 agents::AgentError::Timeout { elapsed } => {
409 (
412 response::RetryableError::Timeout,
413 format!("{agent_name} timed out after {}s", elapsed.as_secs()),
414 )
415 }
416 agents::AgentError::Interrupted { message } => (
417 response::RetryableError::Interrupted,
418 format!("{agent_name} interrupted: {message}"),
419 ),
420 agents::AgentError::MissingTool { tool } => (
421 response::RetryableError::MissingTooling,
422 format!("missing tool: {tool}"),
423 ),
424 agents::AgentError::ModelUnavailable { model, message } => {
425 cooldown::record_model_unavailable(agent_name, model, message).await;
426
427 let agent_cooled = cooldown::is_agent_in_cooldown(agent_name);
430 let models = agent_runner.available_models();
431 let current_model = model_name.unwrap_or("");
432 let next_model = if agent_cooled {
433 None
434 } else {
435 models.iter().find(|m| {
436 m.as_str() != current_model
437 && m.as_str() != model
438 && !response::is_model_in_cooldown(agent_name, m)
439 })
440 };
441 if let Some(next) = next_model {
442 tracing::info!(task_id, model = %next, "retrying with different model");
443 let msg = format!("model {model} unavailable, trying {next}");
444 if let Err(e) = store::store_set_result(
445 store,
446 repo,
447 task_id,
448 &[
449 ("model", serde_json::json!(next.clone())),
450 ("last_error", serde_json::json!(msg)),
451 ],
452 )
453 .await
454 {
455 tracing::warn!(task_id, err = %e, "failed to write model failover to store");
456 }
457 response::wait_for_fallback_backoff(task_id, store, repo).await;
461 return Ok(ErrorHandleResult::EarlyReturn {
462 status: "routed".to_string(),
463 });
464 }
465
466 let is_simple = matches!(complexity, None | Some("simple"));
468 if is_simple
469 && let Some(result) = try_free_model_reroute(
470 task_id,
471 agent_name,
472 agent_runner,
473 &[current_model, model.as_str()],
474 true,
475 None,
476 true,
477 &format!("model {model} unavailable, trying free model"),
478 store,
479 repo,
480 )
481 .await
482 {
483 return Ok(result);
484 }
485
486 (
487 response::RetryableError::ModelUnavailable,
488 format!("model {model} unavailable"),
489 )
490 }
491 agents::AgentError::ContextOverflow { message } => {
492 let msg = format!("{agent_name} context overflow: {message}");
498 if let Err(e) = store::store_set_result(
499 store,
500 repo,
501 task_id,
502 &[("last_error", serde_json::json!(msg))],
503 )
504 .await
505 {
506 tracing::warn!(task_id, err = %e, "failed to write context_overflow last_error to store");
507 }
508 return Ok(ErrorHandleResult::EarlyReturn {
509 status: "needs_review".to_string(),
510 });
511 }
512 agents::AgentError::WaitingForInput { message } => {
513 let msg = format!("waiting for input: {message}");
515 if let Err(e) = store::store_set_result(
516 store,
517 repo,
518 task_id,
519 &[("last_error", serde_json::json!(msg))],
520 )
521 .await
522 {
523 tracing::warn!(task_id, err = %e, "failed to write waiting_for_input last_error to store");
524 }
525 return Ok(ErrorHandleResult::EarlyReturn {
526 status: "needs_review".to_string(),
527 });
528 }
529 agents::AgentError::PermissionDenied { message } => (
530 response::RetryableError::Failed,
531 format!("permission denied: {message}"),
532 ),
533 agents::AgentError::InvalidResponse { .. } => {
534 if let Some(model) = model_name {
537 response::record_model_failure(agent_name, model).await;
538 }
539 (
540 response::RetryableError::Failed,
541 format!("{agent_name} invalid response"),
542 )
543 }
544 agents::AgentError::AgentFailed { message } => {
545 if let Some(reason) = cooldown::detect_credit_exhaustion(message) {
552 credit_recorded = true;
553 match model_name {
554 Some(model) if reason == CreditExhaustionReason::BillingCycleExhausted => {
555 cooldown::record_persistent_model_failure(agent_name, model).await;
556 }
557 _ => cooldown::record_credit_exhaustion(agent_name, reason).await,
558 }
559 } else {
560 if let Some(model) = model_name {
561 response::record_model_failure(agent_name, model).await;
562 }
563 cooldown::record_agent_failure_with_message(agent_name, message).await;
564 }
565 (
566 response::RetryableError::Failed,
567 format!("{agent_name} failed: {message}"),
568 )
569 }
570 agents::AgentError::NetworkError { message } => {
571 const MAX_NETWORK_RETRIES: u64 = 8;
577 let msg = format!("{agent_name} network error: {message}");
578 let retry_count = match store::store_increment(store, repo, task_id, "network_retries")
579 .await
580 {
581 Ok(count) => {
582 tracing::info!(
583 task_id,
584 retry_count = count,
585 agent = agent_name,
586 "network retry scheduled"
587 );
588 count
589 }
590 Err(e) => {
591 tracing::warn!(
592 task_id,
593 error = %e,
594 "failed to increment network_retries — escalating to needs_review"
595 );
596 return Ok(ErrorHandleResult::Continue {
597 status: "needs_review".to_string(),
598 error: format!("{agent_name} network error (store unavailable): {message}"),
599 });
600 }
601 };
602 if let Err(e) = store::store_set_result(
603 store,
604 repo,
605 task_id,
606 &[("last_error", serde_json::json!(msg))],
607 )
608 .await
609 {
610 tracing::warn!(task_id, err = %e, "failed to write network_error last_error to store");
611 }
612 if retry_count >= MAX_NETWORK_RETRIES {
613 tracing::warn!(
614 task_id,
615 retry_count,
616 agent = agent_name,
617 "network retries exhausted, escalating to needs_review"
618 );
619 return Ok(ErrorHandleResult::Continue {
620 status: "needs_review".to_string(),
621 error: msg,
622 });
623 }
624 response::wait_for_fallback_backoff(task_id, store, repo).await;
626 return Ok(ErrorHandleResult::EarlyReturn {
627 status: "routed".to_string(),
628 });
629 }
630 agents::AgentError::StaleSession { session_id } => {
631 (
635 response::RetryableError::Failed,
636 format!("{agent_name} stale session: {session_id}"),
637 )
638 }
639 agents::AgentError::ThinkingBlockConflict { message } => {
640 (
646 response::RetryableError::ModelUnavailable,
647 format!("{agent_name} thinking block conflict: {message}"),
648 )
649 }
650 agents::AgentError::Unknown { exit_code, message } => {
651 if *exit_code == 0
657 && (message.is_empty()
658 || message == "empty-output-exit0"
659 || message.starts_with("empty-output-exit0:"))
660 {
661 if let Some(m) = model_name {
662 cooldown::record_transient_model_failure(agent_name, m).await;
664 }
665 let current = model_name.unwrap_or("");
670 if let Some(result) = try_free_model_reroute(
671 task_id,
672 agent_name,
673 agent_runner,
674 &[current],
675 true,
676 Some("opencode"),
677 true,
678 "silent exit 0, retrying with free model",
679 store,
680 repo,
681 )
682 .await
683 {
684 return Ok(result);
685 }
686 }
687
688 let error_message = if *exit_code != 0 && message.contains("\"costUSD\":") {
691 format!(
693 "{agent_name} exited with non-zero status (cost telemetry in stdout, no error message)"
694 )
695 } else {
696 format!("{agent_name} exit {exit_code}: {message}")
697 };
698
699 (response::RetryableError::Failed, error_message)
700 }
701 };
702
703 if matches!(
705 retryable,
706 response::RetryableError::Timeout | response::RetryableError::Interrupted
707 ) && let Err(e) = store::store_set_result(
708 store,
709 repo,
710 task_id,
711 &[
712 ("agent", serde_json::json!("")),
713 ("last_error", serde_json::json!(error_msg.clone())),
714 ],
715 )
716 .await
717 {
718 tracing::warn!(task_id, err = %e, "failed to write timeout agent clear to store");
719 }
720
721 {
724 let error_type_str = match retryable {
725 response::RetryableError::UsageLimit => Some("rate"),
726 response::RetryableError::AuthError => Some("auth"),
727 response::RetryableError::Timeout | response::RetryableError::Interrupted => None,
728 _ => Some("error"),
729 };
730 if let Some(limit_type) = error_type_str
731 && let Some(s) = store
732 && let Err(e) = s
733 .record_rate_limit(agent_name, limit_type, Some(task_id))
734 .await
735 {
736 tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
737 }
738 }
739
740 let chain = response::get_reroute_chain(task_id, store, repo).await;
742 let available: Vec<String> = engine::configured_agents()
743 .into_iter()
744 .filter(|a| cmd_cache::command_exists(a))
745 .collect();
746
747 let all_agents_tried = {
748 let chain_set: HashSet<&str> = if chain.is_empty() {
749 HashSet::new()
750 } else {
751 chain.split(',').collect()
752 };
753 !available
754 .iter()
755 .any(|a| a.as_str() != agent_name && !chain_set.contains(a.as_str()))
756 };
757
758 let is_simple = matches!(complexity, None | Some("simple"));
759 if all_agents_tried && is_simple {
760 if let Some(result) = try_free_model_reroute(
762 task_id,
763 agent_name,
764 agent_runner,
765 &[],
766 true, Some("opencode"),
768 false,
769 "all agents exhausted, trying free model",
770 store,
771 repo,
772 )
773 .await
774 {
775 return Ok(result);
776 }
777 }
778
779 let status = response::handle_failover(
780 task_id,
781 agent_name,
782 retryable,
783 &error_msg,
784 credit_recorded,
785 store,
786 repo,
787 )
788 .await;
789 match status.as_str() {
790 "new" => tracing::warn!(task_id, "failover exhausted (retryable), task reset to new"),
791 "blocked" => tracing::warn!(task_id, "failover exhausted (hard failure), task blocked"),
792 _ => {}
793 }
794
795 response::store_failure_memory(
797 task_id,
798 new_attempts,
799 agent_name,
800 model_name,
801 &error_msg,
802 store,
803 repo,
804 )
805 .await;
806
807 Ok(ErrorHandleResult::Continue {
808 status,
809 error: error_msg,
810 })
811}
812
813#[cfg(test)]
814mod tests {
815 use std::time::Duration;
816
817 use super::*;
818 use crate::engine::runner::agents::StreamEvent;
819 use crate::engine::runner::agents::{AgentError, AgentRunner, ParsedResponse, PermissionRules};
820 use crate::parser::AgentResponse;
821 use chrono::Datelike;
822 use serial_test::serial;
823 use tokio::process::Command;
824
825 struct MockRunner {
826 free: Vec<String>,
827 }
828
829 struct MockRunnerWithModels {
830 models: Vec<String>,
831 }
832
833 impl AgentRunner for MockRunnerWithModels {
834 fn stream_events(&self, _event: &serde_json::Value) -> Vec<StreamEvent> {
835 vec![]
836 }
837
838 fn name(&self) -> &'static str {
839 "opencode"
840 }
841
842 fn build_command(
843 &self,
844 _model: Option<&str>,
845 _timeout_cmd: &str,
846 _sys_file: &str,
847 _msg_file: &str,
848 _permissions: &PermissionRules,
849 ) -> String {
850 String::new()
851 }
852
853 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
854 Ok(ParsedResponse {
855 response: AgentResponse {
856 status: "done".to_string(),
857 summary: String::new(),
858 accomplished: vec![],
859 remaining: vec![],
860 files: vec![],
861 error: None,
862 input_tokens: None,
863 output_tokens: None,
864 learnings: vec![],
865 delegations: vec![],
866 },
867 input_tokens: None,
868 output_tokens: None,
869 cost_usd: None,
870 duration_ms: None,
871 })
872 }
873
874 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
875 AgentError::Unknown {
876 exit_code: 0,
877 message: String::new(),
878 }
879 }
880
881 fn available_models(&self) -> Vec<String> {
882 self.models.clone()
883 }
884
885 fn router_command(&self, _prompt: &str, _model: Option<&str>) -> anyhow::Result<Command> {
886 anyhow::bail!("not implemented")
887 }
888 }
889
890 impl AgentRunner for MockRunner {
891 fn stream_events(&self, _event: &serde_json::Value) -> Vec<StreamEvent> {
892 vec![]
893 }
894
895 fn name(&self) -> &'static str {
896 "opencode"
897 }
898
899 fn build_command(
900 &self,
901 _model: Option<&str>,
902 _timeout_cmd: &str,
903 _sys_file: &str,
904 _msg_file: &str,
905 _permissions: &PermissionRules,
906 ) -> String {
907 String::new()
908 }
909
910 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
911 Ok(ParsedResponse {
912 response: AgentResponse {
913 status: "done".to_string(),
914 summary: String::new(),
915 accomplished: vec![],
916 remaining: vec![],
917 files: vec![],
918 error: None,
919 input_tokens: None,
920 output_tokens: None,
921 learnings: vec![],
922 delegations: vec![],
923 },
924 input_tokens: None,
925 output_tokens: None,
926 cost_usd: None,
927 duration_ms: None,
928 })
929 }
930
931 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
932 AgentError::Unknown {
933 exit_code: 0,
934 message: String::new(),
935 }
936 }
937
938 fn free_models(&self) -> Vec<String> {
939 self.free.clone()
940 }
941
942 fn router_command(&self, _prompt: &str, _model: Option<&str>) -> anyhow::Result<Command> {
943 anyhow::bail!("not implemented")
944 }
945 }
946
947 #[serial(cooldown_state)]
953 #[tokio::test]
954 async fn rate_limit_sets_agent_cooldown_immediately() {
955 cooldown::reset_global_state().await;
956 let runner = MockRunner { free: vec![] };
957 let agent = "test-agent-1371-early-cooldown";
958
959 assert!(
961 !cooldown::is_agent_in_cooldown(agent),
962 "agent should not be in cooldown before handle_error"
963 );
964
965 let err = AgentError::RateLimit {
966 message: "429 Too Many Requests".to_string(),
967 };
968
969 let _result = handle_error(
971 "test-1371-a",
972 &err,
973 agent,
974 &runner,
975 Some("sonnet"),
976 Some("medium"),
977 1,
978 &None,
979 "owner/repo",
980 )
981 .await
982 .unwrap();
983
984 assert!(
986 cooldown::is_agent_in_cooldown(agent),
987 "agent should be in cooldown immediately after handle_error for RateLimit"
988 );
989 }
990
991 #[serial(cooldown_state)]
998 #[tokio::test]
999 async fn rate_limit_sets_both_agent_and_model_cooldowns() {
1000 cooldown::reset_global_state().await;
1001 let runner = MockRunner { free: vec![] };
1002 let agent = "test-agent-2153-model-cooldown";
1003 let model = "opencode/github-copilot/qwen3.6-plus-free";
1004
1005 assert!(
1007 !cooldown::is_agent_in_cooldown(agent),
1008 "agent should not be in cooldown before handle_error"
1009 );
1010 assert!(
1011 !cooldown::is_model_in_cooldown(agent, model),
1012 "model should not be in cooldown before handle_error"
1013 );
1014
1015 let err = AgentError::RateLimit {
1016 message: "Upstream error from Alibaba: Request rate increased too quickly.".to_string(),
1017 };
1018
1019 let _result = handle_error(
1020 "test-2153-a",
1021 &err,
1022 agent,
1023 &runner,
1024 Some(model),
1025 Some("simple"),
1026 1,
1027 &None,
1028 "owner/repo",
1029 )
1030 .await
1031 .unwrap();
1032
1033 assert!(
1035 cooldown::is_agent_in_cooldown(agent),
1036 "agent should be in agent-level cooldown after RateLimit"
1037 );
1038 assert!(
1041 cooldown::is_model_in_cooldown(agent, model),
1042 "model should be in model-level cooldown after RateLimit (fixes issue #2153)"
1043 );
1044 }
1045
1046 #[serial(cooldown_state)]
1048 #[tokio::test]
1049 async fn rate_limit_cooldown_is_model_specific() {
1050 cooldown::reset_global_state().await;
1051 let runner = MockRunner { free: vec![] };
1052 let agent = "test-agent-2153-model-specific";
1053 let cooled_model = "opencode/github-copilot/qwen3.6-plus-free";
1054 let other_model = "opencode/minimax-m2.5-free";
1055
1056 let err = AgentError::RateLimit {
1057 message: "rate limit exceeded".to_string(),
1058 };
1059
1060 let _result = handle_error(
1061 "test-2153-b",
1062 &err,
1063 agent,
1064 &runner,
1065 Some(cooled_model),
1066 Some("simple"),
1067 1,
1068 &None,
1069 "owner/repo",
1070 )
1071 .await
1072 .unwrap();
1073
1074 assert!(
1076 cooldown::is_model_in_cooldown(agent, cooled_model),
1077 "rate-limited model should be in cooldown"
1078 );
1079 assert!(
1081 !cooldown::is_model_in_cooldown(agent, other_model),
1082 "other models should not be cooled by a single model's rate limit"
1083 );
1084 }
1085
1086 #[serial(cooldown_state)]
1097 #[tokio::test]
1098 async fn credit_exhaustion_does_not_bump_generic_failure_count() {
1099 cooldown::reset_global_state().await;
1100 let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1101 cooldown::init_cooldown_store(Arc::clone(&store)).await;
1102
1103 let runner = MockRunner { free: vec![] };
1104 let agent = "test-agent-3652-credit-no-bump";
1105
1106 let err = AgentError::RateLimit {
1107 message: "out of credits, please recharge your balance".to_string(),
1108 };
1109
1110 let _result = handle_error(
1111 "test-3652-a",
1112 &err,
1113 agent,
1114 &runner,
1115 None,
1116 Some("simple"),
1117 1,
1118 &Some(Arc::clone(&store)),
1119 "owner/repo",
1120 )
1121 .await
1122 .unwrap();
1123
1124 let credit_count = store
1125 .kv_get(&format!("credit_failure_count:{agent}"))
1126 .await
1127 .unwrap();
1128 assert!(
1129 credit_count.is_some(),
1130 "credit_failure_count should be incremented by the credit error"
1131 );
1132 let generic_count = store
1133 .kv_get(&format!("failure_count:{agent}"))
1134 .await
1135 .unwrap();
1136 assert!(
1137 generic_count.is_none(),
1138 "failure_count must not be bumped by credit errors, got {generic_count:?}"
1139 );
1140 }
1141
1142 #[serial(cooldown_state)]
1145 #[tokio::test]
1146 async fn two_timeouts_leave_model_routable() {
1147 cooldown::reset_global_state().await;
1148 let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1149 cooldown::init_cooldown_store(Arc::clone(&store)).await;
1150
1151 let runner = MockRunner { free: vec![] };
1152 let agent = "opencode";
1153 let model = "opencode/nemotron-3-ultra-free";
1154
1155 let err = AgentError::Timeout {
1156 elapsed: Duration::from_mins(30),
1157 };
1158
1159 for i in 1..=2 {
1160 let _result = handle_error(
1161 &format!("test-3876-timeout-{i}"),
1162 &err,
1163 agent,
1164 &runner,
1165 Some(model),
1166 Some("simple"),
1167 i,
1168 &Some(Arc::clone(&store)),
1169 "owner/repo",
1170 )
1171 .await
1172 .unwrap();
1173 }
1174
1175 assert!(
1176 !cooldown::is_model_in_cooldown(agent, model),
1177 "timeouts must not cool the model"
1178 );
1179 assert!(
1180 !cooldown::is_agent_in_cooldown(agent),
1181 "timeouts must not cool the agent"
1182 );
1183 let counts = store.recent_rate_limit_counts(6).await.unwrap();
1184 assert_eq!(
1185 counts.get(agent).copied().unwrap_or(0),
1186 0,
1187 "timeouts must not write a rate_limits row"
1188 );
1189 }
1190
1191 #[serial(cooldown_state)]
1194 #[tokio::test]
1195 async fn interrupted_run_does_not_write_rate_limit() {
1196 cooldown::reset_global_state().await;
1197 let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1198 cooldown::init_cooldown_store(Arc::clone(&store)).await;
1199
1200 let runner = MockRunner { free: vec![] };
1201 let agent = "claude";
1202 let model = "sonnet";
1203 let err = AgentError::Interrupted {
1204 message: "exited without a result envelope".to_string(),
1205 };
1206
1207 let _result = handle_error(
1208 "test-3876-interrupted",
1209 &err,
1210 agent,
1211 &runner,
1212 Some(model),
1213 Some("medium"),
1214 1,
1215 &Some(Arc::clone(&store)),
1216 "owner/repo",
1217 )
1218 .await
1219 .unwrap();
1220
1221 assert!(
1222 !cooldown::is_model_in_cooldown(agent, model),
1223 "interrupted runs must not cool the model"
1224 );
1225 let counts = store.recent_rate_limit_counts(6).await.unwrap();
1226 assert_eq!(
1227 counts.get(agent).copied().unwrap_or(0),
1228 0,
1229 "interrupted runs must not write a rate_limits row"
1230 );
1231 }
1232
1233 #[serial(cooldown_state)]
1246 #[tokio::test]
1247 async fn rate_limit_with_relative_window_sets_window_length_model_cooldown() {
1248 cooldown::reset_global_state().await;
1249 let runner = MockRunner { free: vec![] };
1250 let agent = "test-agent-3580-kimi";
1251 let model = "opus";
1252 let key = format!("{agent}:{model}");
1253
1254 let err = AgentError::RateLimit {
1255 message: "You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window expires.".to_string(),
1256 };
1257
1258 let _result = handle_error(
1259 "test-3580-a",
1260 &err,
1261 agent,
1262 &runner,
1263 Some(model),
1264 Some("medium"),
1265 1,
1266 &None,
1267 "owner/repo",
1268 )
1269 .await
1270 .unwrap();
1271
1272 let now = chrono::Utc::now().timestamp();
1273 let until = cooldown::cooldown_until(&key)
1274 .expect("relative usage window should set model cooldown");
1275 let remaining = until.saturating_sub(now);
1276
1277 assert!(
1278 remaining >= 7 * 86400 - 30,
1279 "expected ~7-day model cooldown from the parsed window, got {remaining}s"
1280 );
1281 assert_eq!(
1282 cooldown::cooldown_reason(&key).as_deref(),
1283 Some("rate_limit_usage_window"),
1284 "relative usage-window cooldown must be labeled as a rate limit, not silence"
1285 );
1286 }
1287
1288 #[serial(cooldown_state)]
1289 #[tokio::test]
1290 async fn provider_returned_error_sets_short_model_cooldown() {
1291 cooldown::reset_global_state().await;
1292 let runner = MockRunner { free: vec![] };
1293 let agent = "opencode";
1294 let model = "opencode/nemotron-3-super-free";
1295 let key = format!("{agent}:{model}");
1296
1297 let err = AgentError::RateLimit {
1298 message: "Provider returned error".to_string(),
1299 };
1300
1301 let _result = handle_error(
1302 "test-2478-provider-cooldown",
1303 &err,
1304 agent,
1305 &runner,
1306 Some(model),
1307 Some("simple"),
1308 1,
1309 &None,
1310 "owner/repo",
1311 )
1312 .await
1313 .unwrap();
1314
1315 let now = chrono::Utc::now().timestamp();
1316 let until =
1317 cooldown::cooldown_until(&key).expect("provider error should set model cooldown");
1318 let remaining = until.saturating_sub(now);
1319
1320 assert!(
1321 remaining <= cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
1322 "expected a 5 minute model cooldown, got {remaining}s"
1323 );
1324 }
1325
1326 #[serial(cooldown_state)]
1327 #[tokio::test]
1328 async fn model_unavailable_sets_persistent_model_cooldown_for_codex_style() {
1329 cooldown::reset_global_state().await;
1330 let runner = MockRunner { free: vec![] };
1331 let agent = "codex";
1332 let model = "gpt-5.3-codex";
1333
1334 let err = AgentError::ModelUnavailable {
1336 model: model.to_string(),
1337 message: "{\"detail\":\"The 'gpt-5.3-codex' model is not supported\"}".to_string(),
1338 };
1339
1340 let _result = handle_error(
1341 "test-codex-model-unsupported",
1342 &err,
1343 agent,
1344 &runner,
1345 Some(model),
1346 Some("simple"),
1347 1,
1348 &None,
1349 "owner/repo",
1350 )
1351 .await
1352 .unwrap();
1353
1354 let key = format!("{agent}:{model}");
1356 let now = chrono::Utc::now().timestamp();
1357 let until = cooldown::cooldown_until(&key).expect("model cooldown should be set");
1358 let remaining = until.saturating_sub(now);
1359
1360 assert!(
1361 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1362 "expected persistent model cooldown (~4h) for codex model-unavailable, got {remaining}s"
1363 );
1364 }
1365
1366 async fn vendor_reset_remaining(agent: &str, model: &str, message: &str) -> i64 {
1367 cooldown::reset_global_state().await;
1368 let runner = MockRunner { free: vec![] };
1369 let err = AgentError::RateLimit {
1370 message: message.to_string(),
1371 };
1372 let _ = handle_error(
1373 "test-3649",
1374 &err,
1375 agent,
1376 &runner,
1377 Some(model),
1378 Some("simple"),
1379 1,
1380 &None,
1381 "owner/repo",
1382 )
1383 .await
1384 .unwrap();
1385 let until = cooldown::cooldown_until(&format!("{agent}:{model}"))
1386 .expect("model cooldown should be set");
1387 until - chrono::Utc::now().timestamp()
1388 }
1389
1390 #[serial(cooldown_state)]
1391 #[tokio::test]
1392 async fn vendor_reset_date_overrides_weekly_window() {
1393 let t = chrono::Local::now() + chrono::Duration::days(2);
1394 let msg = format!(
1395 "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
1396 t.format("%b"),
1397 t.day()
1398 );
1399 let remaining = vendor_reset_remaining("claude", "sonnet", &msg).await;
1400 assert!(
1401 remaining > 3600 && remaining < 3 * 86400,
1402 "expected ~2d cooldown, got {remaining}s"
1403 );
1404 }
1405
1406 #[serial(cooldown_state)]
1407 #[tokio::test]
1408 async fn vendor_reset_date_overrides_billing_cycle_backoff() {
1409 let t = chrono::Local::now() + chrono::Duration::days(3);
1410 let msg = format!(
1411 "You've hit your usage limit. Upgrade to Pro (https://x), or try again at {}.",
1412 t.format("%b %d, %Y %I:%M %p")
1413 );
1414 let remaining = vendor_reset_remaining("codex", "gpt-5.4", &msg).await;
1415 assert!(
1416 remaining > 2 * 86400 + 80000 - 3600 && remaining < 4 * 86400,
1417 "expected ~3d cooldown, got {remaining}s"
1418 );
1419 }
1420
1421 #[serial(cooldown_state)]
1422 #[tokio::test]
1423 async fn agent_failed_insufficient_balance_sets_credit_cooldown() {
1424 cooldown::reset_global_state().await;
1425 let runner = MockRunner { free: vec![] };
1426 let agent = "test-agent-3642-insufficient-balance";
1427
1428 assert!(
1430 !cooldown::is_agent_in_cooldown(agent),
1431 "agent should not be in cooldown before handle_error"
1432 );
1433
1434 let err = agents::AgentError::AgentFailed {
1435 message: "402 Payment Required: insufficient balance".to_string(),
1436 };
1437
1438 let _result = handle_error(
1440 "test-3642-a",
1441 &err,
1442 agent,
1443 &runner,
1444 Some("sonnet"),
1445 Some("medium"),
1446 1,
1447 &None,
1448 "owner/repo",
1449 )
1450 .await
1451 .unwrap();
1452
1453 assert!(
1458 cooldown::is_agent_in_cooldown(agent),
1459 "agent should be in cooldown after handle_error for AgentFailed with insufficient balance"
1460 );
1461
1462 let until = cooldown::cooldown_until(agent).expect("agent cooldown should be active");
1463 let remaining = until - chrono::Utc::now().timestamp();
1464 assert!(
1465 remaining >= cooldown::CREDIT_BACKOFF_BASE_SECS - 30,
1466 "credit cooldown should be at least the 1h base, got {remaining}s"
1467 );
1468 }
1469
1470 #[serial(cooldown_state)]
1471 #[tokio::test]
1472 async fn silent_exit0_retries_free_model_for_simple_complexity() {
1473 cooldown::reset_global_state().await;
1474 let runner = MockRunner {
1475 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1476 };
1477 let err = AgentError::Unknown {
1478 exit_code: 0,
1479 message: String::new(),
1480 };
1481
1482 let result = handle_error(
1484 "test-1112-a",
1485 &err,
1486 "opencode",
1487 &runner,
1488 Some("opencode/gpt-5.4-mini"),
1489 Some("simple"),
1490 1,
1491 &None,
1492 "owner/repo",
1493 )
1494 .await
1495 .unwrap();
1496
1497 assert!(
1498 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1499 "expected EarlyReturn{{status: new}} for simple complexity — free model should be tried first"
1500 );
1501 }
1502
1503 #[serial(cooldown_state)]
1504 #[tokio::test]
1505 async fn silent_exit0_retries_free_model_when_complexity_unknown() {
1506 cooldown::reset_global_state().await;
1507 let runner = MockRunner {
1508 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1509 };
1510 let err = AgentError::Unknown {
1511 exit_code: 0,
1512 message: String::new(),
1513 };
1514
1515 let result = handle_error(
1517 "test-1112-a2",
1518 &err,
1519 "opencode",
1520 &runner,
1521 Some("opencode/gpt-5.4-mini"),
1522 None,
1523 1,
1524 &None,
1525 "owner/repo",
1526 )
1527 .await
1528 .unwrap();
1529
1530 assert!(
1531 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1532 "expected EarlyReturn{{status: new}} for unknown complexity — free model should be tried first"
1533 );
1534 }
1535
1536 #[serial(cooldown_state)]
1537 #[tokio::test]
1538 async fn silent_exit0_retries_free_model_for_medium_complexity() {
1539 cooldown::reset_global_state().await;
1540 let runner = MockRunner {
1541 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1542 };
1543 let err = AgentError::Unknown {
1544 exit_code: 0,
1545 message: String::new(),
1546 };
1547
1548 let result = handle_error(
1550 "test-1195-medium",
1551 &err,
1552 "opencode",
1553 &runner,
1554 Some("opencode/copilot-gpt-5.4-mini"),
1555 Some("medium"),
1556 1,
1557 &None,
1558 "owner/repo",
1559 )
1560 .await
1561 .unwrap();
1562
1563 assert!(
1564 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1565 "expected EarlyReturn{{status: new}} for medium complexity — free model should be tried before failover"
1566 );
1567 }
1568
1569 #[serial(cooldown_state)]
1570 #[tokio::test]
1571 async fn silent_exit0_retries_free_model_for_complex_complexity() {
1572 cooldown::reset_global_state().await;
1573 let runner = MockRunner {
1574 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1575 };
1576 let err = AgentError::Unknown {
1577 exit_code: 0,
1578 message: String::new(),
1579 };
1580
1581 let result = handle_error(
1583 "test-1195-complex",
1584 &err,
1585 "opencode",
1586 &runner,
1587 Some("opencode/copilot-gpt-5.4-mini"),
1588 Some("complex"),
1589 1,
1590 &None,
1591 "owner/repo",
1592 )
1593 .await
1594 .unwrap();
1595
1596 assert!(
1597 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1598 "expected EarlyReturn{{status: new}} for complex complexity — free model should be tried before failover"
1599 );
1600 }
1601
1602 #[serial(cooldown_state)]
1605 #[tokio::test]
1606 async fn context_overflow_escalates_to_needs_review_immediately() {
1607 cooldown::reset_global_state().await;
1608 let runner = MockRunner { free: vec![] };
1609 let err = AgentError::ContextOverflow {
1610 message: "prompt is too long: 250000 tokens, max 200000".to_string(),
1611 };
1612
1613 let result = handle_error(
1614 "test-2129-a",
1615 &err,
1616 "claude",
1617 &runner,
1618 Some("sonnet"),
1619 Some("medium"),
1620 1,
1621 &None,
1622 "owner/repo",
1623 )
1624 .await
1625 .unwrap();
1626
1627 assert!(
1628 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "needs_review"),
1629 "context overflow must escalate directly to needs_review without cycling through fallback agents"
1630 );
1631 }
1632
1633 #[serial(cooldown_state)]
1636 #[tokio::test]
1637 async fn free_model_reroute_succeeds_without_store() {
1638 cooldown::reset_global_state().await;
1639 let runner = MockRunner {
1640 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1641 };
1642 let err = AgentError::Unknown {
1643 exit_code: 0,
1644 message: String::new(),
1645 };
1646
1647 let result = handle_error(
1649 "test-2293-a",
1650 &err,
1651 "opencode",
1652 &runner,
1653 Some("opencode/gpt-5.4-mini"),
1654 Some("simple"),
1655 1,
1656 &None,
1657 "owner/repo",
1658 )
1659 .await
1660 .unwrap();
1661
1662 assert!(
1663 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1664 "expected EarlyReturn{{status: new}} — store_set_result with None store should not prevent reroute"
1665 );
1666 }
1667
1668 #[serial(cooldown_state)]
1669 #[tokio::test]
1670 async fn silent_exit0_falls_through_when_no_free_models() {
1671 cooldown::reset_global_state().await;
1672 let runner = MockRunner { free: vec![] };
1673 let err = AgentError::Unknown {
1674 exit_code: 0,
1675 message: String::new(),
1676 };
1677
1678 let result = handle_error(
1679 "test-1112-b",
1680 &err,
1681 "opencode",
1682 &runner,
1683 Some("opencode/gpt-5.4-mini"),
1684 Some("simple"),
1685 1,
1686 &None,
1687 "owner/repo",
1688 )
1689 .await
1690 .unwrap();
1691
1692 assert!(
1693 matches!(result, ErrorHandleResult::Continue { .. }),
1694 "expected Continue (fallthrough to failover) when no free models available"
1695 );
1696 }
1697
1698 #[serial(cooldown_state)]
1704 #[tokio::test]
1705 async fn invalid_response_sets_model_cooldown() {
1706 cooldown::reset_global_state().await;
1707 let runner = MockRunner { free: vec![] };
1708 let agent = "opencode";
1709 let model = "opencode/nemotron-3-super-free";
1710
1711 assert!(
1713 !cooldown::is_model_in_cooldown(agent, model),
1714 "model should not be in cooldown before handle_error"
1715 );
1716
1717 let err = AgentError::InvalidResponse {
1718 raw: "invalid json output".to_string(),
1719 };
1720
1721 let _result = handle_error(
1722 "test-2750-invalid-response",
1723 &err,
1724 agent,
1725 &runner,
1726 Some(model),
1727 Some("simple"),
1728 1,
1729 &None,
1730 "owner/repo",
1731 )
1732 .await
1733 .unwrap();
1734
1735 assert!(
1737 cooldown::is_model_in_cooldown(agent, model),
1738 "model should be in cooldown after InvalidResponse (fixes issue #2750)"
1739 );
1740 }
1741
1742 #[serial(cooldown_state)]
1749 #[tokio::test]
1750 async fn billing_cycle_exhausted_with_known_model_sets_only_model_cooldown() {
1751 cooldown::reset_global_state().await;
1752 let runner = MockRunner { free: vec![] };
1753 let agent = "codex-3382-billing-cycle";
1754 let model = "gpt-5.5";
1755
1756 let err = AgentError::RateLimit {
1757 message: "rate limit: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro).".to_string(),
1758 };
1759
1760 let _result = handle_error(
1761 "test-3382-a",
1762 &err,
1763 agent,
1764 &runner,
1765 Some(model),
1766 Some("medium"),
1767 1,
1768 &None,
1769 "owner/repo",
1770 )
1771 .await
1772 .unwrap();
1773
1774 assert!(
1776 !cooldown::is_agent_in_cooldown(agent),
1777 "agent must NOT be in agent-level cooldown when BillingCycleExhausted has a known model (issue #3382)"
1778 );
1779 assert!(
1781 cooldown::is_model_in_cooldown(agent, model),
1782 "model must be in cooldown after BillingCycleExhausted (issue #3382)"
1783 );
1784 assert!(
1786 !cooldown::is_model_in_cooldown(agent, "gpt-5.4"),
1787 "other models on the same agent must not be cooled by a single model's billing exhaustion"
1788 );
1789 }
1790
1791 #[serial(cooldown_state)]
1792 #[tokio::test]
1793 async fn summarize_rate_limit_error_extracts_api_retry_info() {
1794 cooldown::reset_global_state().await;
1795 let raw_output = r#"some log lines
1796{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1797{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1798more logs"#;
1799
1800 let summarized = super::summarize_rate_limit_error(raw_output);
1801 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1802 }
1803
1804 #[serial(cooldown_state)]
1805 #[tokio::test]
1806 async fn summarize_rate_limit_error_returns_original_when_no_api_retry() {
1807 cooldown::reset_global_state().await;
1808 let raw_output = "some regular error message without api_retry JSON";
1809
1810 let summarized = super::summarize_rate_limit_error(raw_output);
1811 assert_eq!(summarized, raw_output);
1812 }
1813
1814 #[serial(cooldown_state)]
1815 #[tokio::test]
1816 async fn summarize_rate_limit_error_handles_malformed_json_gracefully() {
1817 cooldown::reset_global_state().await;
1818 let raw_output = r#"some logs
1819{"type":"system","subtype":"api_retry","attempt":"not_a_number","retry_delay_ms":invalid,"error_status":429}
1820more logs"#;
1821
1822 let summarized = super::summarize_rate_limit_error(raw_output);
1823 assert!(summarized.starts_with("status="));
1825 assert!(summarized.contains("after"));
1826 assert!(summarized.contains("attempts"));
1827 assert!(summarized.contains("(last delay"));
1828 }
1829
1830 #[serial(cooldown_state)]
1831 #[tokio::test]
1832 async fn summarize_rate_limit_error_works_with_single_line() {
1833 cooldown::reset_global_state().await;
1834 let raw_output = r#"{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":5000,"error_status":429,"error":"rate_limit"}"#;
1835
1836 let summarized = super::summarize_rate_limit_error(raw_output);
1837 assert_eq!(summarized, "status=429 after 3 attempts (last delay 5s)");
1838 }
1839
1840 #[serial(cooldown_state)]
1844 #[tokio::test]
1845 async fn extract_json_field_handles_quoted_strings() {
1846 cooldown::reset_global_state().await;
1847 let json = r#"{"error_status":429,"attempt":7,"retry_delay_ms":35162.66,"error":"rate_limit","session_id":"331c099b-..."}"#;
1848
1849 assert_eq!(
1850 super::extract_json_field(json, "\"error\":"),
1851 Some("rate_limit".to_string()),
1852 "quoted string field mid-object should be extracted correctly"
1853 );
1854 assert_eq!(
1855 super::extract_json_field(json, "\"session_id\":"),
1856 Some("331c099b-...".to_string()),
1857 "quoted string field at end of object (no trailing comma) should be extracted"
1858 );
1859 assert_eq!(
1861 super::extract_json_field(json, "\"error_status\":"),
1862 Some("429".to_string())
1863 );
1864 assert_eq!(
1865 super::extract_json_field(json, "\"attempt\":"),
1866 Some("7".to_string())
1867 );
1868 assert_eq!(
1869 super::extract_json_field(json, "\"retry_delay_ms\":"),
1870 Some("35162.66".to_string())
1871 );
1872 }
1873
1874 #[serial(cooldown_state)]
1877 #[tokio::test]
1878 async fn summarize_rate_limit_error_handles_truncated_fragment_with_subtype() {
1879 cooldown::reset_global_state().await;
1880 let raw_output = r#"some log context
1882,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit"
1883more context"#;
1884
1885 let summarized = super::summarize_rate_limit_error(raw_output);
1886 assert!(
1887 !summarized.contains("subtype"),
1888 "summarized output must not contain raw 'subtype' JSON fragment"
1889 );
1890 assert!(
1891 !summarized.contains('{'),
1892 "summarized output must not contain JSON braces"
1893 );
1894 assert!(
1895 summarized.starts_with("status="),
1896 "summarized output should start with 'status=' — got: {summarized}"
1897 );
1898 }
1902
1903 #[serial(cooldown_state)]
1905 #[tokio::test]
1906 async fn summarize_rate_limit_error_handles_fragment_with_extractable_fields() {
1907 cooldown::reset_global_state().await;
1908 let raw_output = r#"log output
1909,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit","session_id":"abc123","max_retries":10}
1910rest of output"#;
1911
1912 let summarized = super::summarize_rate_limit_error(raw_output);
1913 assert_eq!(summarized, "status=429 after 5 attempts (last delay 20s)");
1914 }
1915
1916 #[serial(cooldown_state)]
1918 #[tokio::test]
1919 async fn summarize_rate_limit_error_complete_json_still_works() {
1920 cooldown::reset_global_state().await;
1921 let raw_output = r#"some log lines
1922{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1923{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1924more logs"#;
1925
1926 let summarized = super::summarize_rate_limit_error(raw_output);
1927 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1928 }
1929
1930 #[serial(cooldown_state)]
1932 #[tokio::test]
1933 async fn summarize_rate_limit_error_no_api_retry_no_raw_passthrough() {
1934 cooldown::reset_global_state().await;
1935 let raw_output = "some regular error message without api_retry JSON that is quite long and should be truncated";
1936
1937 let summarized = super::summarize_rate_limit_error(raw_output);
1938 assert!(
1940 summarized.len() <= 140,
1941 "expected truncated first-line, got: {summarized}"
1942 );
1943 }
1944
1945 #[serial(cooldown_state)]
1947 #[tokio::test]
1948 async fn summarize_rate_limit_error_no_api_retry_short() {
1949 cooldown::reset_global_state().await;
1950 let raw_output = "rate limit exceeded";
1951
1952 let summarized = super::summarize_rate_limit_error(raw_output);
1953 assert_eq!(summarized, raw_output);
1954 }
1955
1956 #[serial(cooldown_state)]
1962 #[tokio::test]
1963 async fn model_not_found_applies_persistent_cooldown() {
1964 cooldown::reset_global_state().await;
1965 let runner = MockRunner { free: vec![] };
1966 let agent = "opencode-2941-not-found";
1967 let model = "github-copilot/claude-opus-4.6";
1968 let key = format!("{agent}:{model}");
1969
1970 let err = AgentError::ModelUnavailable {
1971 model: model.to_string(),
1972 message: "Model not found: github-copilot/claude-opus-4.6".to_string(),
1973 };
1974
1975 let _result = handle_error(
1976 "test-2941-a",
1977 &err,
1978 agent,
1979 &runner,
1980 Some(model),
1981 Some("complex"),
1982 1,
1983 &None,
1984 "owner/repo",
1985 )
1986 .await
1987 .unwrap();
1988
1989 let now = chrono::Utc::now().timestamp();
1990 let until =
1991 cooldown::cooldown_until(&key).expect("model not found should set model cooldown");
1992 let remaining = until.saturating_sub(now);
1993
1994 assert!(
1997 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1998 "expected ~4h (persistent) cooldown for 'Model not found', got {remaining}s — \
1999 may have applied transient 5min cooldown instead"
2000 );
2001 }
2002
2003 #[serial(cooldown_state)]
2010 #[tokio::test]
2011 async fn model_not_supported_applies_persistent_cooldown() {
2012 cooldown::reset_global_state().await;
2013 let runner = MockRunner { free: vec![] };
2014 let agent = "codex-3215-not-supported";
2015 let model = "gpt-5.2-codex";
2016 let key = format!("{agent}:{model}");
2017
2018 let err = AgentError::ModelUnavailable {
2019 model: model.to_string(),
2020 message: "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.".to_string(),
2021 };
2022
2023 let _result = handle_error(
2024 "test-3215-a",
2025 &err,
2026 agent,
2027 &runner,
2028 Some(model),
2029 Some("medium"),
2030 1,
2031 &None,
2032 "owner/repo",
2033 )
2034 .await
2035 .unwrap();
2036
2037 let now = chrono::Utc::now().timestamp();
2038 let until =
2039 cooldown::cooldown_until(&key).expect("model not supported should set model cooldown");
2040 let remaining = until.saturating_sub(now);
2041
2042 assert!(
2043 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2044 "expected ~4h (persistent) cooldown for 'not supported', got {remaining}s — \
2045 may have applied transient 5min cooldown instead (issue #3215)"
2046 );
2047 }
2048
2049 #[serial(cooldown_state)]
2050 #[tokio::test]
2051 async fn model_not_available_in_country_applies_persistent_cooldown() {
2052 cooldown::reset_global_state().await;
2053 let runner = MockRunner { free: vec![] };
2054 let agent = "opencode-3650-country";
2055 let model = "some-region-locked-model";
2056 let key = format!("{agent}:{model}");
2057
2058 for message in [
2060 "This model is not available in your country.",
2061 "This model is not available in your region.",
2062 ] {
2063 cooldown::reset_global_state().await;
2064 let err = AgentError::ModelUnavailable {
2065 model: model.to_string(),
2066 message: message.to_string(),
2067 };
2068
2069 let _result = handle_error(
2070 "test-3650",
2071 &err,
2072 agent,
2073 &runner,
2074 Some(model),
2075 Some("medium"),
2076 1,
2077 &None,
2078 "owner/repo",
2079 )
2080 .await
2081 .unwrap();
2082
2083 let now = chrono::Utc::now().timestamp();
2084 let until =
2085 cooldown::cooldown_until(&key).expect("region block should set model cooldown");
2086 let remaining = until.saturating_sub(now);
2087
2088 assert!(
2089 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2090 "expected ~4h (persistent) cooldown for '{message}', got {remaining}s — \
2091 may have applied transient 5min cooldown instead (issue #3650)"
2092 );
2093 }
2094 }
2095
2096 #[serial(cooldown_state)]
2102 #[tokio::test]
2103 async fn thinking_block_conflict_does_not_set_cooldown() {
2104 cooldown::reset_global_state().await;
2105 let runner = MockRunner { free: vec![] };
2106 let agent = "claude-3204-thinking-block";
2107 let model = "opus";
2108
2109 assert!(
2110 !cooldown::is_agent_in_cooldown(agent),
2111 "agent must not be in cooldown before handle_error"
2112 );
2113 assert!(
2114 !cooldown::is_model_in_cooldown(agent, model),
2115 "model must not be in cooldown before handle_error"
2116 );
2117
2118 let err = AgentError::ThinkingBlockConflict {
2119 message: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.".to_string(),
2120 };
2121
2122 let _result = handle_error(
2123 "test-3204-thinking",
2124 &err,
2125 agent,
2126 &runner,
2127 Some(model),
2128 Some("complex"),
2129 1,
2130 &None,
2131 "owner/repo",
2132 )
2133 .await
2134 .unwrap();
2135
2136 assert!(
2137 !cooldown::is_agent_in_cooldown(agent),
2138 "agent must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2139 );
2140 assert!(
2141 !cooldown::is_model_in_cooldown(agent, model),
2142 "model must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2143 );
2144 }
2145
2146 #[serial(cooldown_state)]
2148 #[tokio::test]
2149 async fn model_unavailable_transient_applies_short_cooldown() {
2150 cooldown::reset_global_state().await;
2151 let runner = MockRunner { free: vec![] };
2152 let agent = "opencode-2941-transient";
2153 let model = "github-copilot/gpt-4o";
2154 let key = format!("{agent}:{model}");
2155
2156 let err = AgentError::ModelUnavailable {
2157 model: model.to_string(),
2158 message: "No endpoints found for github-copilot/gpt-4o.".to_string(),
2159 };
2160
2161 let _result = handle_error(
2162 "test-2941-b",
2163 &err,
2164 agent,
2165 &runner,
2166 Some(model),
2167 Some("medium"),
2168 1,
2169 &None,
2170 "owner/repo",
2171 )
2172 .await
2173 .unwrap();
2174
2175 let now = chrono::Utc::now().timestamp();
2176 let until = cooldown::cooldown_until(&key)
2177 .expect("transient model unavailability should set model cooldown");
2178 let remaining = until.saturating_sub(now);
2179
2180 assert!(
2183 remaining < cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2184 "transient unavailability should use short cooldown (<4h), got {remaining}s"
2185 );
2186 }
2187
2188 #[serial(cooldown_state)]
2189 #[tokio::test]
2190 async fn model_unavailable_does_not_reuse_same_agent_when_agent_cooled() {
2191 cooldown::reset_global_state().await;
2192 let agent = "codex-3620";
2193 let failing_model = "gpt-5.4";
2194 let other_model = "gpt-5.5";
2195 let runner = MockRunnerWithModels {
2196 models: vec![failing_model.to_string(), other_model.to_string()],
2197 };
2198
2199 cooldown::set_agent_cooldown(agent, 3600).await;
2200
2201 let err = AgentError::ModelUnavailable {
2202 model: failing_model.to_string(),
2203 message: "model gpt-5.4 unavailable".to_string(),
2204 };
2205
2206 let result = handle_error(
2207 "test-3620",
2208 &err,
2209 agent,
2210 &runner,
2211 Some(failing_model),
2212 Some("medium"),
2213 1,
2214 &None,
2215 "owner/repo",
2216 )
2217 .await
2218 .unwrap();
2219
2220 if let ErrorHandleResult::EarlyReturn { status } = result {
2221 assert_ne!(
2222 status, "routed",
2223 "agent-wide cooldown was defeated by same-agent model substitution"
2224 );
2225 }
2226 }
2227}