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 duration_ms: None,
870 })
871 }
872
873 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
874 AgentError::Unknown {
875 exit_code: 0,
876 message: String::new(),
877 }
878 }
879
880 fn available_models(&self) -> Vec<String> {
881 self.models.clone()
882 }
883
884 fn router_command(&self, _prompt: &str, _model: Option<&str>) -> anyhow::Result<Command> {
885 anyhow::bail!("not implemented")
886 }
887 }
888
889 impl AgentRunner for MockRunner {
890 fn stream_events(&self, _event: &serde_json::Value) -> Vec<StreamEvent> {
891 vec![]
892 }
893
894 fn name(&self) -> &'static str {
895 "opencode"
896 }
897
898 fn build_command(
899 &self,
900 _model: Option<&str>,
901 _timeout_cmd: &str,
902 _sys_file: &str,
903 _msg_file: &str,
904 _permissions: &PermissionRules,
905 ) -> String {
906 String::new()
907 }
908
909 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
910 Ok(ParsedResponse {
911 response: AgentResponse {
912 status: "done".to_string(),
913 summary: String::new(),
914 accomplished: vec![],
915 remaining: vec![],
916 files: vec![],
917 error: None,
918 input_tokens: None,
919 output_tokens: None,
920 learnings: vec![],
921 delegations: vec![],
922 },
923 input_tokens: None,
924 output_tokens: None,
925 duration_ms: None,
926 })
927 }
928
929 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
930 AgentError::Unknown {
931 exit_code: 0,
932 message: String::new(),
933 }
934 }
935
936 fn free_models(&self) -> Vec<String> {
937 self.free.clone()
938 }
939
940 fn router_command(&self, _prompt: &str, _model: Option<&str>) -> anyhow::Result<Command> {
941 anyhow::bail!("not implemented")
942 }
943 }
944
945 #[serial(cooldown_state)]
951 #[tokio::test]
952 async fn rate_limit_sets_agent_cooldown_immediately() {
953 cooldown::reset_global_state().await;
954 let runner = MockRunner { free: vec![] };
955 let agent = "test-agent-1371-early-cooldown";
956
957 assert!(
959 !cooldown::is_agent_in_cooldown(agent),
960 "agent should not be in cooldown before handle_error"
961 );
962
963 let err = AgentError::RateLimit {
964 message: "429 Too Many Requests".to_string(),
965 };
966
967 let _result = handle_error(
969 "test-1371-a",
970 &err,
971 agent,
972 &runner,
973 Some("sonnet"),
974 Some("medium"),
975 1,
976 &None,
977 "owner/repo",
978 )
979 .await
980 .unwrap();
981
982 assert!(
984 cooldown::is_agent_in_cooldown(agent),
985 "agent should be in cooldown immediately after handle_error for RateLimit"
986 );
987 }
988
989 #[serial(cooldown_state)]
996 #[tokio::test]
997 async fn rate_limit_sets_both_agent_and_model_cooldowns() {
998 cooldown::reset_global_state().await;
999 let runner = MockRunner { free: vec![] };
1000 let agent = "test-agent-2153-model-cooldown";
1001 let model = "opencode/github-copilot/qwen3.6-plus-free";
1002
1003 assert!(
1005 !cooldown::is_agent_in_cooldown(agent),
1006 "agent should not be in cooldown before handle_error"
1007 );
1008 assert!(
1009 !cooldown::is_model_in_cooldown(agent, model),
1010 "model should not be in cooldown before handle_error"
1011 );
1012
1013 let err = AgentError::RateLimit {
1014 message: "Upstream error from Alibaba: Request rate increased too quickly.".to_string(),
1015 };
1016
1017 let _result = handle_error(
1018 "test-2153-a",
1019 &err,
1020 agent,
1021 &runner,
1022 Some(model),
1023 Some("simple"),
1024 1,
1025 &None,
1026 "owner/repo",
1027 )
1028 .await
1029 .unwrap();
1030
1031 assert!(
1033 cooldown::is_agent_in_cooldown(agent),
1034 "agent should be in agent-level cooldown after RateLimit"
1035 );
1036 assert!(
1039 cooldown::is_model_in_cooldown(agent, model),
1040 "model should be in model-level cooldown after RateLimit (fixes issue #2153)"
1041 );
1042 }
1043
1044 #[serial(cooldown_state)]
1046 #[tokio::test]
1047 async fn rate_limit_cooldown_is_model_specific() {
1048 cooldown::reset_global_state().await;
1049 let runner = MockRunner { free: vec![] };
1050 let agent = "test-agent-2153-model-specific";
1051 let cooled_model = "opencode/github-copilot/qwen3.6-plus-free";
1052 let other_model = "opencode/minimax-m2.5-free";
1053
1054 let err = AgentError::RateLimit {
1055 message: "rate limit exceeded".to_string(),
1056 };
1057
1058 let _result = handle_error(
1059 "test-2153-b",
1060 &err,
1061 agent,
1062 &runner,
1063 Some(cooled_model),
1064 Some("simple"),
1065 1,
1066 &None,
1067 "owner/repo",
1068 )
1069 .await
1070 .unwrap();
1071
1072 assert!(
1074 cooldown::is_model_in_cooldown(agent, cooled_model),
1075 "rate-limited model should be in cooldown"
1076 );
1077 assert!(
1079 !cooldown::is_model_in_cooldown(agent, other_model),
1080 "other models should not be cooled by a single model's rate limit"
1081 );
1082 }
1083
1084 #[serial(cooldown_state)]
1095 #[tokio::test]
1096 async fn credit_exhaustion_does_not_bump_generic_failure_count() {
1097 cooldown::reset_global_state().await;
1098 let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1099 cooldown::init_cooldown_store(Arc::clone(&store)).await;
1100
1101 let runner = MockRunner { free: vec![] };
1102 let agent = "test-agent-3652-credit-no-bump";
1103
1104 let err = AgentError::RateLimit {
1105 message: "out of credits, please recharge your balance".to_string(),
1106 };
1107
1108 let _result = handle_error(
1109 "test-3652-a",
1110 &err,
1111 agent,
1112 &runner,
1113 None,
1114 Some("simple"),
1115 1,
1116 &Some(Arc::clone(&store)),
1117 "owner/repo",
1118 )
1119 .await
1120 .unwrap();
1121
1122 let credit_count = store
1123 .kv_get(&format!("credit_failure_count:{agent}"))
1124 .await
1125 .unwrap();
1126 assert!(
1127 credit_count.is_some(),
1128 "credit_failure_count should be incremented by the credit error"
1129 );
1130 let generic_count = store
1131 .kv_get(&format!("failure_count:{agent}"))
1132 .await
1133 .unwrap();
1134 assert!(
1135 generic_count.is_none(),
1136 "failure_count must not be bumped by credit errors, got {generic_count:?}"
1137 );
1138 }
1139
1140 #[serial(cooldown_state)]
1143 #[tokio::test]
1144 async fn two_timeouts_leave_model_routable() {
1145 cooldown::reset_global_state().await;
1146 let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1147 cooldown::init_cooldown_store(Arc::clone(&store)).await;
1148
1149 let runner = MockRunner { free: vec![] };
1150 let agent = "opencode";
1151 let model = "opencode/nemotron-3-ultra-free";
1152
1153 let err = AgentError::Timeout {
1154 elapsed: Duration::from_mins(30),
1155 };
1156
1157 for i in 1..=2 {
1158 let _result = handle_error(
1159 &format!("test-3876-timeout-{i}"),
1160 &err,
1161 agent,
1162 &runner,
1163 Some(model),
1164 Some("simple"),
1165 i,
1166 &Some(Arc::clone(&store)),
1167 "owner/repo",
1168 )
1169 .await
1170 .unwrap();
1171 }
1172
1173 assert!(
1174 !cooldown::is_model_in_cooldown(agent, model),
1175 "timeouts must not cool the model"
1176 );
1177 assert!(
1178 !cooldown::is_agent_in_cooldown(agent),
1179 "timeouts must not cool the agent"
1180 );
1181 let counts = store.recent_rate_limit_counts(6).await.unwrap();
1182 assert_eq!(
1183 counts.get(agent).copied().unwrap_or(0),
1184 0,
1185 "timeouts must not write a rate_limits row"
1186 );
1187 }
1188
1189 #[serial(cooldown_state)]
1192 #[tokio::test]
1193 async fn interrupted_run_does_not_write_rate_limit() {
1194 cooldown::reset_global_state().await;
1195 let store = Arc::new(TaskStore::open_memory().await.expect("in-memory store"));
1196 cooldown::init_cooldown_store(Arc::clone(&store)).await;
1197
1198 let runner = MockRunner { free: vec![] };
1199 let agent = "claude";
1200 let model = "sonnet";
1201 let err = AgentError::Interrupted {
1202 message: "exited without a result envelope".to_string(),
1203 };
1204
1205 let _result = handle_error(
1206 "test-3876-interrupted",
1207 &err,
1208 agent,
1209 &runner,
1210 Some(model),
1211 Some("medium"),
1212 1,
1213 &Some(Arc::clone(&store)),
1214 "owner/repo",
1215 )
1216 .await
1217 .unwrap();
1218
1219 assert!(
1220 !cooldown::is_model_in_cooldown(agent, model),
1221 "interrupted runs must not cool the model"
1222 );
1223 let counts = store.recent_rate_limit_counts(6).await.unwrap();
1224 assert_eq!(
1225 counts.get(agent).copied().unwrap_or(0),
1226 0,
1227 "interrupted runs must not write a rate_limits row"
1228 );
1229 }
1230
1231 #[serial(cooldown_state)]
1244 #[tokio::test]
1245 async fn rate_limit_with_relative_window_sets_window_length_model_cooldown() {
1246 cooldown::reset_global_state().await;
1247 let runner = MockRunner { free: vec![] };
1248 let agent = "test-agent-3580-kimi";
1249 let model = "opus";
1250 let key = format!("{agent}:{model}");
1251
1252 let err = AgentError::RateLimit {
1253 message: "You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window expires.".to_string(),
1254 };
1255
1256 let _result = handle_error(
1257 "test-3580-a",
1258 &err,
1259 agent,
1260 &runner,
1261 Some(model),
1262 Some("medium"),
1263 1,
1264 &None,
1265 "owner/repo",
1266 )
1267 .await
1268 .unwrap();
1269
1270 let now = chrono::Utc::now().timestamp();
1271 let until = cooldown::cooldown_until(&key)
1272 .expect("relative usage window should set model cooldown");
1273 let remaining = until.saturating_sub(now);
1274
1275 assert!(
1276 remaining >= 7 * 86400 - 30,
1277 "expected ~7-day model cooldown from the parsed window, got {remaining}s"
1278 );
1279 assert_eq!(
1280 cooldown::cooldown_reason(&key).as_deref(),
1281 Some("rate_limit_usage_window"),
1282 "relative usage-window cooldown must be labeled as a rate limit, not silence"
1283 );
1284 }
1285
1286 #[serial(cooldown_state)]
1287 #[tokio::test]
1288 async fn provider_returned_error_sets_short_model_cooldown() {
1289 cooldown::reset_global_state().await;
1290 let runner = MockRunner { free: vec![] };
1291 let agent = "opencode";
1292 let model = "opencode/nemotron-3-super-free";
1293 let key = format!("{agent}:{model}");
1294
1295 let err = AgentError::RateLimit {
1296 message: "Provider returned error".to_string(),
1297 };
1298
1299 let _result = handle_error(
1300 "test-2478-provider-cooldown",
1301 &err,
1302 agent,
1303 &runner,
1304 Some(model),
1305 Some("simple"),
1306 1,
1307 &None,
1308 "owner/repo",
1309 )
1310 .await
1311 .unwrap();
1312
1313 let now = chrono::Utc::now().timestamp();
1314 let until =
1315 cooldown::cooldown_until(&key).expect("provider error should set model cooldown");
1316 let remaining = until.saturating_sub(now);
1317
1318 assert!(
1319 remaining <= cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
1320 "expected a 5 minute model cooldown, got {remaining}s"
1321 );
1322 }
1323
1324 #[serial(cooldown_state)]
1325 #[tokio::test]
1326 async fn model_unavailable_sets_persistent_model_cooldown_for_codex_style() {
1327 cooldown::reset_global_state().await;
1328 let runner = MockRunner { free: vec![] };
1329 let agent = "codex";
1330 let model = "gpt-5.3-codex";
1331
1332 let err = AgentError::ModelUnavailable {
1334 model: model.to_string(),
1335 message: "{\"detail\":\"The 'gpt-5.3-codex' model is not supported\"}".to_string(),
1336 };
1337
1338 let _result = handle_error(
1339 "test-codex-model-unsupported",
1340 &err,
1341 agent,
1342 &runner,
1343 Some(model),
1344 Some("simple"),
1345 1,
1346 &None,
1347 "owner/repo",
1348 )
1349 .await
1350 .unwrap();
1351
1352 let key = format!("{agent}:{model}");
1354 let now = chrono::Utc::now().timestamp();
1355 let until = cooldown::cooldown_until(&key).expect("model cooldown should be set");
1356 let remaining = until.saturating_sub(now);
1357
1358 assert!(
1359 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1360 "expected persistent model cooldown (~4h) for codex model-unavailable, got {remaining}s"
1361 );
1362 }
1363
1364 async fn vendor_reset_remaining(agent: &str, model: &str, message: &str) -> i64 {
1365 cooldown::reset_global_state().await;
1366 let runner = MockRunner { free: vec![] };
1367 let err = AgentError::RateLimit {
1368 message: message.to_string(),
1369 };
1370 let _ = handle_error(
1371 "test-3649",
1372 &err,
1373 agent,
1374 &runner,
1375 Some(model),
1376 Some("simple"),
1377 1,
1378 &None,
1379 "owner/repo",
1380 )
1381 .await
1382 .unwrap();
1383 let until = cooldown::cooldown_until(&format!("{agent}:{model}"))
1384 .expect("model cooldown should be set");
1385 until - chrono::Utc::now().timestamp()
1386 }
1387
1388 #[serial(cooldown_state)]
1389 #[tokio::test]
1390 async fn vendor_reset_date_overrides_weekly_window() {
1391 let t = chrono::Local::now() + chrono::Duration::days(2);
1392 let msg = format!(
1393 "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
1394 t.format("%b"),
1395 t.day()
1396 );
1397 let remaining = vendor_reset_remaining("claude", "sonnet", &msg).await;
1398 assert!(
1399 remaining > 3600 && remaining < 3 * 86400,
1400 "expected ~2d cooldown, got {remaining}s"
1401 );
1402 }
1403
1404 #[serial(cooldown_state)]
1405 #[tokio::test]
1406 async fn vendor_reset_date_overrides_billing_cycle_backoff() {
1407 let t = chrono::Local::now() + chrono::Duration::days(3);
1408 let msg = format!(
1409 "You've hit your usage limit. Upgrade to Pro (https://x), or try again at {}.",
1410 t.format("%b %d, %Y %I:%M %p")
1411 );
1412 let remaining = vendor_reset_remaining("codex", "gpt-5.4", &msg).await;
1413 assert!(
1414 remaining > 2 * 86400 + 80000 - 3600 && remaining < 4 * 86400,
1415 "expected ~3d cooldown, got {remaining}s"
1416 );
1417 }
1418
1419 #[serial(cooldown_state)]
1420 #[tokio::test]
1421 async fn agent_failed_insufficient_balance_sets_credit_cooldown() {
1422 cooldown::reset_global_state().await;
1423 let runner = MockRunner { free: vec![] };
1424 let agent = "test-agent-3642-insufficient-balance";
1425
1426 assert!(
1428 !cooldown::is_agent_in_cooldown(agent),
1429 "agent should not be in cooldown before handle_error"
1430 );
1431
1432 let err = agents::AgentError::AgentFailed {
1433 message: "402 Payment Required: insufficient balance".to_string(),
1434 };
1435
1436 let _result = handle_error(
1438 "test-3642-a",
1439 &err,
1440 agent,
1441 &runner,
1442 Some("sonnet"),
1443 Some("medium"),
1444 1,
1445 &None,
1446 "owner/repo",
1447 )
1448 .await
1449 .unwrap();
1450
1451 assert!(
1456 cooldown::is_agent_in_cooldown(agent),
1457 "agent should be in cooldown after handle_error for AgentFailed with insufficient balance"
1458 );
1459
1460 let until = cooldown::cooldown_until(agent).expect("agent cooldown should be active");
1461 let remaining = until - chrono::Utc::now().timestamp();
1462 assert!(
1463 remaining >= cooldown::CREDIT_BACKOFF_BASE_SECS - 30,
1464 "credit cooldown should be at least the 1h base, got {remaining}s"
1465 );
1466 }
1467
1468 #[serial(cooldown_state)]
1469 #[tokio::test]
1470 async fn silent_exit0_retries_free_model_for_simple_complexity() {
1471 cooldown::reset_global_state().await;
1472 let runner = MockRunner {
1473 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1474 };
1475 let err = AgentError::Unknown {
1476 exit_code: 0,
1477 message: String::new(),
1478 };
1479
1480 let result = handle_error(
1482 "test-1112-a",
1483 &err,
1484 "opencode",
1485 &runner,
1486 Some("opencode/gpt-5.4-mini"),
1487 Some("simple"),
1488 1,
1489 &None,
1490 "owner/repo",
1491 )
1492 .await
1493 .unwrap();
1494
1495 assert!(
1496 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1497 "expected EarlyReturn{{status: new}} for simple complexity — free model should be tried first"
1498 );
1499 }
1500
1501 #[serial(cooldown_state)]
1502 #[tokio::test]
1503 async fn silent_exit0_retries_free_model_when_complexity_unknown() {
1504 cooldown::reset_global_state().await;
1505 let runner = MockRunner {
1506 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1507 };
1508 let err = AgentError::Unknown {
1509 exit_code: 0,
1510 message: String::new(),
1511 };
1512
1513 let result = handle_error(
1515 "test-1112-a2",
1516 &err,
1517 "opencode",
1518 &runner,
1519 Some("opencode/gpt-5.4-mini"),
1520 None,
1521 1,
1522 &None,
1523 "owner/repo",
1524 )
1525 .await
1526 .unwrap();
1527
1528 assert!(
1529 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1530 "expected EarlyReturn{{status: new}} for unknown complexity — free model should be tried first"
1531 );
1532 }
1533
1534 #[serial(cooldown_state)]
1535 #[tokio::test]
1536 async fn silent_exit0_retries_free_model_for_medium_complexity() {
1537 cooldown::reset_global_state().await;
1538 let runner = MockRunner {
1539 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1540 };
1541 let err = AgentError::Unknown {
1542 exit_code: 0,
1543 message: String::new(),
1544 };
1545
1546 let result = handle_error(
1548 "test-1195-medium",
1549 &err,
1550 "opencode",
1551 &runner,
1552 Some("opencode/copilot-gpt-5.4-mini"),
1553 Some("medium"),
1554 1,
1555 &None,
1556 "owner/repo",
1557 )
1558 .await
1559 .unwrap();
1560
1561 assert!(
1562 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1563 "expected EarlyReturn{{status: new}} for medium complexity — free model should be tried before failover"
1564 );
1565 }
1566
1567 #[serial(cooldown_state)]
1568 #[tokio::test]
1569 async fn silent_exit0_retries_free_model_for_complex_complexity() {
1570 cooldown::reset_global_state().await;
1571 let runner = MockRunner {
1572 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1573 };
1574 let err = AgentError::Unknown {
1575 exit_code: 0,
1576 message: String::new(),
1577 };
1578
1579 let result = handle_error(
1581 "test-1195-complex",
1582 &err,
1583 "opencode",
1584 &runner,
1585 Some("opencode/copilot-gpt-5.4-mini"),
1586 Some("complex"),
1587 1,
1588 &None,
1589 "owner/repo",
1590 )
1591 .await
1592 .unwrap();
1593
1594 assert!(
1595 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1596 "expected EarlyReturn{{status: new}} for complex complexity — free model should be tried before failover"
1597 );
1598 }
1599
1600 #[serial(cooldown_state)]
1603 #[tokio::test]
1604 async fn context_overflow_escalates_to_needs_review_immediately() {
1605 cooldown::reset_global_state().await;
1606 let runner = MockRunner { free: vec![] };
1607 let err = AgentError::ContextOverflow {
1608 message: "prompt is too long: 250000 tokens, max 200000".to_string(),
1609 };
1610
1611 let result = handle_error(
1612 "test-2129-a",
1613 &err,
1614 "claude",
1615 &runner,
1616 Some("sonnet"),
1617 Some("medium"),
1618 1,
1619 &None,
1620 "owner/repo",
1621 )
1622 .await
1623 .unwrap();
1624
1625 assert!(
1626 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "needs_review"),
1627 "context overflow must escalate directly to needs_review without cycling through fallback agents"
1628 );
1629 }
1630
1631 #[serial(cooldown_state)]
1634 #[tokio::test]
1635 async fn free_model_reroute_succeeds_without_store() {
1636 cooldown::reset_global_state().await;
1637 let runner = MockRunner {
1638 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1639 };
1640 let err = AgentError::Unknown {
1641 exit_code: 0,
1642 message: String::new(),
1643 };
1644
1645 let result = handle_error(
1647 "test-2293-a",
1648 &err,
1649 "opencode",
1650 &runner,
1651 Some("opencode/gpt-5.4-mini"),
1652 Some("simple"),
1653 1,
1654 &None,
1655 "owner/repo",
1656 )
1657 .await
1658 .unwrap();
1659
1660 assert!(
1661 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1662 "expected EarlyReturn{{status: new}} — store_set_result with None store should not prevent reroute"
1663 );
1664 }
1665
1666 #[serial(cooldown_state)]
1667 #[tokio::test]
1668 async fn silent_exit0_falls_through_when_no_free_models() {
1669 cooldown::reset_global_state().await;
1670 let runner = MockRunner { free: vec![] };
1671 let err = AgentError::Unknown {
1672 exit_code: 0,
1673 message: String::new(),
1674 };
1675
1676 let result = handle_error(
1677 "test-1112-b",
1678 &err,
1679 "opencode",
1680 &runner,
1681 Some("opencode/gpt-5.4-mini"),
1682 Some("simple"),
1683 1,
1684 &None,
1685 "owner/repo",
1686 )
1687 .await
1688 .unwrap();
1689
1690 assert!(
1691 matches!(result, ErrorHandleResult::Continue { .. }),
1692 "expected Continue (fallthrough to failover) when no free models available"
1693 );
1694 }
1695
1696 #[serial(cooldown_state)]
1702 #[tokio::test]
1703 async fn invalid_response_sets_model_cooldown() {
1704 cooldown::reset_global_state().await;
1705 let runner = MockRunner { free: vec![] };
1706 let agent = "opencode";
1707 let model = "opencode/nemotron-3-super-free";
1708
1709 assert!(
1711 !cooldown::is_model_in_cooldown(agent, model),
1712 "model should not be in cooldown before handle_error"
1713 );
1714
1715 let err = AgentError::InvalidResponse {
1716 raw: "invalid json output".to_string(),
1717 };
1718
1719 let _result = handle_error(
1720 "test-2750-invalid-response",
1721 &err,
1722 agent,
1723 &runner,
1724 Some(model),
1725 Some("simple"),
1726 1,
1727 &None,
1728 "owner/repo",
1729 )
1730 .await
1731 .unwrap();
1732
1733 assert!(
1735 cooldown::is_model_in_cooldown(agent, model),
1736 "model should be in cooldown after InvalidResponse (fixes issue #2750)"
1737 );
1738 }
1739
1740 #[serial(cooldown_state)]
1747 #[tokio::test]
1748 async fn billing_cycle_exhausted_with_known_model_sets_only_model_cooldown() {
1749 cooldown::reset_global_state().await;
1750 let runner = MockRunner { free: vec![] };
1751 let agent = "codex-3382-billing-cycle";
1752 let model = "gpt-5.5";
1753
1754 let err = AgentError::RateLimit {
1755 message: "rate limit: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro).".to_string(),
1756 };
1757
1758 let _result = handle_error(
1759 "test-3382-a",
1760 &err,
1761 agent,
1762 &runner,
1763 Some(model),
1764 Some("medium"),
1765 1,
1766 &None,
1767 "owner/repo",
1768 )
1769 .await
1770 .unwrap();
1771
1772 assert!(
1774 !cooldown::is_agent_in_cooldown(agent),
1775 "agent must NOT be in agent-level cooldown when BillingCycleExhausted has a known model (issue #3382)"
1776 );
1777 assert!(
1779 cooldown::is_model_in_cooldown(agent, model),
1780 "model must be in cooldown after BillingCycleExhausted (issue #3382)"
1781 );
1782 assert!(
1784 !cooldown::is_model_in_cooldown(agent, "gpt-5.4"),
1785 "other models on the same agent must not be cooled by a single model's billing exhaustion"
1786 );
1787 }
1788
1789 #[serial(cooldown_state)]
1790 #[tokio::test]
1791 async fn summarize_rate_limit_error_extracts_api_retry_info() {
1792 cooldown::reset_global_state().await;
1793 let raw_output = r#"some log lines
1794{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1795{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1796more logs"#;
1797
1798 let summarized = super::summarize_rate_limit_error(raw_output);
1799 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1800 }
1801
1802 #[serial(cooldown_state)]
1803 #[tokio::test]
1804 async fn summarize_rate_limit_error_returns_original_when_no_api_retry() {
1805 cooldown::reset_global_state().await;
1806 let raw_output = "some regular error message without api_retry JSON";
1807
1808 let summarized = super::summarize_rate_limit_error(raw_output);
1809 assert_eq!(summarized, raw_output);
1810 }
1811
1812 #[serial(cooldown_state)]
1813 #[tokio::test]
1814 async fn summarize_rate_limit_error_handles_malformed_json_gracefully() {
1815 cooldown::reset_global_state().await;
1816 let raw_output = r#"some logs
1817{"type":"system","subtype":"api_retry","attempt":"not_a_number","retry_delay_ms":invalid,"error_status":429}
1818more logs"#;
1819
1820 let summarized = super::summarize_rate_limit_error(raw_output);
1821 assert!(summarized.starts_with("status="));
1823 assert!(summarized.contains("after"));
1824 assert!(summarized.contains("attempts"));
1825 assert!(summarized.contains("(last delay"));
1826 }
1827
1828 #[serial(cooldown_state)]
1829 #[tokio::test]
1830 async fn summarize_rate_limit_error_works_with_single_line() {
1831 cooldown::reset_global_state().await;
1832 let raw_output = r#"{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":5000,"error_status":429,"error":"rate_limit"}"#;
1833
1834 let summarized = super::summarize_rate_limit_error(raw_output);
1835 assert_eq!(summarized, "status=429 after 3 attempts (last delay 5s)");
1836 }
1837
1838 #[serial(cooldown_state)]
1842 #[tokio::test]
1843 async fn extract_json_field_handles_quoted_strings() {
1844 cooldown::reset_global_state().await;
1845 let json = r#"{"error_status":429,"attempt":7,"retry_delay_ms":35162.66,"error":"rate_limit","session_id":"331c099b-..."}"#;
1846
1847 assert_eq!(
1848 super::extract_json_field(json, "\"error\":"),
1849 Some("rate_limit".to_string()),
1850 "quoted string field mid-object should be extracted correctly"
1851 );
1852 assert_eq!(
1853 super::extract_json_field(json, "\"session_id\":"),
1854 Some("331c099b-...".to_string()),
1855 "quoted string field at end of object (no trailing comma) should be extracted"
1856 );
1857 assert_eq!(
1859 super::extract_json_field(json, "\"error_status\":"),
1860 Some("429".to_string())
1861 );
1862 assert_eq!(
1863 super::extract_json_field(json, "\"attempt\":"),
1864 Some("7".to_string())
1865 );
1866 assert_eq!(
1867 super::extract_json_field(json, "\"retry_delay_ms\":"),
1868 Some("35162.66".to_string())
1869 );
1870 }
1871
1872 #[serial(cooldown_state)]
1875 #[tokio::test]
1876 async fn summarize_rate_limit_error_handles_truncated_fragment_with_subtype() {
1877 cooldown::reset_global_state().await;
1878 let raw_output = r#"some log context
1880,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit"
1881more context"#;
1882
1883 let summarized = super::summarize_rate_limit_error(raw_output);
1884 assert!(
1885 !summarized.contains("subtype"),
1886 "summarized output must not contain raw 'subtype' JSON fragment"
1887 );
1888 assert!(
1889 !summarized.contains('{'),
1890 "summarized output must not contain JSON braces"
1891 );
1892 assert!(
1893 summarized.starts_with("status="),
1894 "summarized output should start with 'status=' — got: {summarized}"
1895 );
1896 }
1900
1901 #[serial(cooldown_state)]
1903 #[tokio::test]
1904 async fn summarize_rate_limit_error_handles_fragment_with_extractable_fields() {
1905 cooldown::reset_global_state().await;
1906 let raw_output = r#"log output
1907,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit","session_id":"abc123","max_retries":10}
1908rest of output"#;
1909
1910 let summarized = super::summarize_rate_limit_error(raw_output);
1911 assert_eq!(summarized, "status=429 after 5 attempts (last delay 20s)");
1912 }
1913
1914 #[serial(cooldown_state)]
1916 #[tokio::test]
1917 async fn summarize_rate_limit_error_complete_json_still_works() {
1918 cooldown::reset_global_state().await;
1919 let raw_output = r#"some log lines
1920{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1921{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1922more logs"#;
1923
1924 let summarized = super::summarize_rate_limit_error(raw_output);
1925 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1926 }
1927
1928 #[serial(cooldown_state)]
1930 #[tokio::test]
1931 async fn summarize_rate_limit_error_no_api_retry_no_raw_passthrough() {
1932 cooldown::reset_global_state().await;
1933 let raw_output = "some regular error message without api_retry JSON that is quite long and should be truncated";
1934
1935 let summarized = super::summarize_rate_limit_error(raw_output);
1936 assert!(
1938 summarized.len() <= 140,
1939 "expected truncated first-line, got: {summarized}"
1940 );
1941 }
1942
1943 #[serial(cooldown_state)]
1945 #[tokio::test]
1946 async fn summarize_rate_limit_error_no_api_retry_short() {
1947 cooldown::reset_global_state().await;
1948 let raw_output = "rate limit exceeded";
1949
1950 let summarized = super::summarize_rate_limit_error(raw_output);
1951 assert_eq!(summarized, raw_output);
1952 }
1953
1954 #[serial(cooldown_state)]
1960 #[tokio::test]
1961 async fn model_not_found_applies_persistent_cooldown() {
1962 cooldown::reset_global_state().await;
1963 let runner = MockRunner { free: vec![] };
1964 let agent = "opencode-2941-not-found";
1965 let model = "github-copilot/claude-opus-4.6";
1966 let key = format!("{agent}:{model}");
1967
1968 let err = AgentError::ModelUnavailable {
1969 model: model.to_string(),
1970 message: "Model not found: github-copilot/claude-opus-4.6".to_string(),
1971 };
1972
1973 let _result = handle_error(
1974 "test-2941-a",
1975 &err,
1976 agent,
1977 &runner,
1978 Some(model),
1979 Some("complex"),
1980 1,
1981 &None,
1982 "owner/repo",
1983 )
1984 .await
1985 .unwrap();
1986
1987 let now = chrono::Utc::now().timestamp();
1988 let until =
1989 cooldown::cooldown_until(&key).expect("model not found should set model cooldown");
1990 let remaining = until.saturating_sub(now);
1991
1992 assert!(
1995 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1996 "expected ~4h (persistent) cooldown for 'Model not found', got {remaining}s — \
1997 may have applied transient 5min cooldown instead"
1998 );
1999 }
2000
2001 #[serial(cooldown_state)]
2008 #[tokio::test]
2009 async fn model_not_supported_applies_persistent_cooldown() {
2010 cooldown::reset_global_state().await;
2011 let runner = MockRunner { free: vec![] };
2012 let agent = "codex-3215-not-supported";
2013 let model = "gpt-5.2-codex";
2014 let key = format!("{agent}:{model}");
2015
2016 let err = AgentError::ModelUnavailable {
2017 model: model.to_string(),
2018 message: "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.".to_string(),
2019 };
2020
2021 let _result = handle_error(
2022 "test-3215-a",
2023 &err,
2024 agent,
2025 &runner,
2026 Some(model),
2027 Some("medium"),
2028 1,
2029 &None,
2030 "owner/repo",
2031 )
2032 .await
2033 .unwrap();
2034
2035 let now = chrono::Utc::now().timestamp();
2036 let until =
2037 cooldown::cooldown_until(&key).expect("model not supported should set model cooldown");
2038 let remaining = until.saturating_sub(now);
2039
2040 assert!(
2041 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2042 "expected ~4h (persistent) cooldown for 'not supported', got {remaining}s — \
2043 may have applied transient 5min cooldown instead (issue #3215)"
2044 );
2045 }
2046
2047 #[serial(cooldown_state)]
2048 #[tokio::test]
2049 async fn model_not_available_in_country_applies_persistent_cooldown() {
2050 cooldown::reset_global_state().await;
2051 let runner = MockRunner { free: vec![] };
2052 let agent = "opencode-3650-country";
2053 let model = "some-region-locked-model";
2054 let key = format!("{agent}:{model}");
2055
2056 for message in [
2058 "This model is not available in your country.",
2059 "This model is not available in your region.",
2060 ] {
2061 cooldown::reset_global_state().await;
2062 let err = AgentError::ModelUnavailable {
2063 model: model.to_string(),
2064 message: message.to_string(),
2065 };
2066
2067 let _result = handle_error(
2068 "test-3650",
2069 &err,
2070 agent,
2071 &runner,
2072 Some(model),
2073 Some("medium"),
2074 1,
2075 &None,
2076 "owner/repo",
2077 )
2078 .await
2079 .unwrap();
2080
2081 let now = chrono::Utc::now().timestamp();
2082 let until =
2083 cooldown::cooldown_until(&key).expect("region block should set model cooldown");
2084 let remaining = until.saturating_sub(now);
2085
2086 assert!(
2087 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2088 "expected ~4h (persistent) cooldown for '{message}', got {remaining}s — \
2089 may have applied transient 5min cooldown instead (issue #3650)"
2090 );
2091 }
2092 }
2093
2094 #[serial(cooldown_state)]
2100 #[tokio::test]
2101 async fn thinking_block_conflict_does_not_set_cooldown() {
2102 cooldown::reset_global_state().await;
2103 let runner = MockRunner { free: vec![] };
2104 let agent = "claude-3204-thinking-block";
2105 let model = "opus";
2106
2107 assert!(
2108 !cooldown::is_agent_in_cooldown(agent),
2109 "agent must not be in cooldown before handle_error"
2110 );
2111 assert!(
2112 !cooldown::is_model_in_cooldown(agent, model),
2113 "model must not be in cooldown before handle_error"
2114 );
2115
2116 let err = AgentError::ThinkingBlockConflict {
2117 message: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.".to_string(),
2118 };
2119
2120 let _result = handle_error(
2121 "test-3204-thinking",
2122 &err,
2123 agent,
2124 &runner,
2125 Some(model),
2126 Some("complex"),
2127 1,
2128 &None,
2129 "owner/repo",
2130 )
2131 .await
2132 .unwrap();
2133
2134 assert!(
2135 !cooldown::is_agent_in_cooldown(agent),
2136 "agent must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2137 );
2138 assert!(
2139 !cooldown::is_model_in_cooldown(agent, model),
2140 "model must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2141 );
2142 }
2143
2144 #[serial(cooldown_state)]
2146 #[tokio::test]
2147 async fn model_unavailable_transient_applies_short_cooldown() {
2148 cooldown::reset_global_state().await;
2149 let runner = MockRunner { free: vec![] };
2150 let agent = "opencode-2941-transient";
2151 let model = "github-copilot/gpt-4o";
2152 let key = format!("{agent}:{model}");
2153
2154 let err = AgentError::ModelUnavailable {
2155 model: model.to_string(),
2156 message: "No endpoints found for github-copilot/gpt-4o.".to_string(),
2157 };
2158
2159 let _result = handle_error(
2160 "test-2941-b",
2161 &err,
2162 agent,
2163 &runner,
2164 Some(model),
2165 Some("medium"),
2166 1,
2167 &None,
2168 "owner/repo",
2169 )
2170 .await
2171 .unwrap();
2172
2173 let now = chrono::Utc::now().timestamp();
2174 let until = cooldown::cooldown_until(&key)
2175 .expect("transient model unavailability should set model cooldown");
2176 let remaining = until.saturating_sub(now);
2177
2178 assert!(
2181 remaining < cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2182 "transient unavailability should use short cooldown (<4h), got {remaining}s"
2183 );
2184 }
2185
2186 #[serial(cooldown_state)]
2187 #[tokio::test]
2188 async fn model_unavailable_does_not_reuse_same_agent_when_agent_cooled() {
2189 cooldown::reset_global_state().await;
2190 let agent = "codex-3620";
2191 let failing_model = "gpt-5.4";
2192 let other_model = "gpt-5.5";
2193 let runner = MockRunnerWithModels {
2194 models: vec![failing_model.to_string(), other_model.to_string()],
2195 };
2196
2197 cooldown::set_agent_cooldown(agent, 3600).await;
2198
2199 let err = AgentError::ModelUnavailable {
2200 model: failing_model.to_string(),
2201 message: "model gpt-5.4 unavailable".to_string(),
2202 };
2203
2204 let result = handle_error(
2205 "test-3620",
2206 &err,
2207 agent,
2208 &runner,
2209 Some(failing_model),
2210 Some("medium"),
2211 1,
2212 &None,
2213 "owner/repo",
2214 )
2215 .await
2216 .unwrap();
2217
2218 if let ErrorHandleResult::EarlyReturn { status } = result {
2219 assert_ne!(
2220 status, "routed",
2221 "agent-wide cooldown was defeated by same-agent model substitution"
2222 );
2223 }
2224 }
2225}