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