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_transient_model_failure(agent_name, model)
334 .await;
335 } else {
336 response::record_model_failure(agent_name, model).await;
337 }
338 }
339 }
340 let retryable = if billing_cycle_model_scoped {
344 response::RetryableError::ModelUnavailable
345 } else {
346 response::RetryableError::UsageLimit
347 };
348 (
349 retryable,
350 format!(
351 "{agent_name} rate limit: {}",
352 summarize_rate_limit_error(message)
353 ),
354 )
355 }
356 agents::AgentError::Auth { message } => {
357 let mut billing_cycle_model_scoped = false;
362
363 if apply_vendor_reset_date(agent_name, model_name, message).await {
364 let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
365 credit_recorded = reason.is_some();
366 billing_cycle_model_scoped = reason
367 == Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
368 } else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
369 {
370 credit_recorded = true;
371 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
374 {
375 if let Some(model) = model_name {
376 crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
377 .await;
378 billing_cycle_model_scoped = true;
379 } else {
380 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
381 }
382 } else {
383 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
384 }
385 } else {
386 crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
387 .await;
388 if let Some(model) = model_name {
389 response::record_model_failure(agent_name, model).await;
390 }
391 }
392 let retryable = if billing_cycle_model_scoped {
395 response::RetryableError::ModelUnavailable
396 } else {
397 response::RetryableError::AuthError
398 };
399 (retryable, format!("{agent_name} auth error: {message}"))
400 }
401 agents::AgentError::Timeout { elapsed } => {
402 (
405 response::RetryableError::Timeout,
406 format!("{agent_name} timed out after {}s", elapsed.as_secs()),
407 )
408 }
409 agents::AgentError::Interrupted { message } => (
410 response::RetryableError::Interrupted,
411 format!("{agent_name} interrupted: {message}"),
412 ),
413 agents::AgentError::MissingTool { tool } => (
414 response::RetryableError::MissingTooling,
415 format!("missing tool: {tool}"),
416 ),
417 agents::AgentError::ModelUnavailable { model, message } => {
418 let lower = message.to_lowercase();
421 let is_permanently_gone = lower.contains("not found")
422 || lower.contains("not supported")
423 || lower.contains("not available in your country")
424 || lower.contains("not available in your region");
425 if is_permanently_gone {
426 crate::engine::cooldown::record_persistent_model_failure(agent_name, model).await;
428 } else {
429 response::record_model_failure(agent_name, model).await;
431 }
432
433 let agent_cooled = crate::engine::cooldown::is_agent_in_cooldown(agent_name);
436 let models = agent_runner.available_models();
437 let current_model = model_name.unwrap_or("");
438 let next_model = if agent_cooled {
439 None
440 } else {
441 models.iter().find(|m| {
442 m.as_str() != current_model
443 && m.as_str() != model
444 && !response::is_model_in_cooldown(agent_name, m)
445 })
446 };
447 if let Some(next) = next_model {
448 tracing::info!(task_id, model = %next, "retrying with different model");
449 let msg = format!("model {model} unavailable, trying {next}");
450 if let Err(e) = store::store_set_result(
451 store,
452 repo,
453 task_id,
454 &[
455 ("model", serde_json::json!(next.to_string())),
456 ("last_error", serde_json::json!(msg)),
457 ],
458 )
459 .await
460 {
461 tracing::warn!(task_id, err = %e, "failed to write model failover to store");
462 }
463 response::wait_for_fallback_backoff(task_id, store, repo).await;
467 return Ok(ErrorHandleResult::EarlyReturn {
468 status: "routed".to_string(),
469 });
470 }
471
472 let is_simple = matches!(complexity, None | Some("simple"));
474 if is_simple {
475 if let Some(result) = try_free_model_reroute(
476 task_id,
477 agent_name,
478 agent_runner,
479 &[current_model, model.as_str()],
480 true,
481 None,
482 true,
483 &format!("model {model} unavailable, trying free model"),
484 store,
485 repo,
486 )
487 .await
488 {
489 return Ok(result);
490 }
491 }
492
493 (
494 response::RetryableError::ModelUnavailable,
495 format!("model {model} unavailable"),
496 )
497 }
498 agents::AgentError::ContextOverflow { message } => {
499 let msg = format!("{agent_name} context overflow: {message}");
505 if let Err(e) = store::store_set_result(
506 store,
507 repo,
508 task_id,
509 &[("last_error", serde_json::json!(msg))],
510 )
511 .await
512 {
513 tracing::warn!(task_id, err = %e, "failed to write context_overflow last_error to store");
514 }
515 return Ok(ErrorHandleResult::EarlyReturn {
516 status: "needs_review".to_string(),
517 });
518 }
519 agents::AgentError::WaitingForInput { message } => {
520 let msg = format!("waiting for input: {message}");
522 if let Err(e) = store::store_set_result(
523 store,
524 repo,
525 task_id,
526 &[("last_error", serde_json::json!(msg))],
527 )
528 .await
529 {
530 tracing::warn!(task_id, err = %e, "failed to write waiting_for_input last_error to store");
531 }
532 return Ok(ErrorHandleResult::EarlyReturn {
533 status: "needs_review".to_string(),
534 });
535 }
536 agents::AgentError::PermissionDenied { message } => (
537 response::RetryableError::Failed,
538 format!("permission denied: {message}"),
539 ),
540 agents::AgentError::InvalidResponse { .. } => {
541 if let Some(model) = model_name {
544 response::record_model_failure(agent_name, model).await;
545 }
546 (
547 response::RetryableError::Failed,
548 format!("{agent_name} invalid response"),
549 )
550 }
551 agents::AgentError::AgentFailed { message } => {
552 if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
559 credit_recorded = true;
560 match model_name {
561 Some(model)
562 if reason
563 == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted =>
564 {
565 crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
566 .await;
567 }
568 _ => crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await,
569 }
570 } else {
571 if let Some(model) = model_name {
572 response::record_model_failure(agent_name, model).await;
573 }
574 crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
575 .await;
576 }
577 (
578 response::RetryableError::Failed,
579 format!("{agent_name} failed: {message}"),
580 )
581 }
582 agents::AgentError::NetworkError { message } => {
583 const MAX_NETWORK_RETRIES: u64 = 8;
589 let msg = format!("{agent_name} network error: {message}");
590 let retry_count = match store::store_increment(store, repo, task_id, "network_retries")
591 .await
592 {
593 Ok(count) => {
594 tracing::info!(
595 task_id,
596 retry_count = count,
597 agent = agent_name,
598 "network retry scheduled"
599 );
600 count
601 }
602 Err(e) => {
603 tracing::warn!(
604 task_id,
605 error = %e,
606 "failed to increment network_retries — escalating to needs_review"
607 );
608 return Ok(ErrorHandleResult::Continue {
609 status: "needs_review".to_string(),
610 error: format!("{agent_name} network error (store unavailable): {message}"),
611 });
612 }
613 };
614 if let Err(e) = store::store_set_result(
615 store,
616 repo,
617 task_id,
618 &[("last_error", serde_json::json!(msg))],
619 )
620 .await
621 {
622 tracing::warn!(task_id, err = %e, "failed to write network_error last_error to store");
623 }
624 if retry_count >= MAX_NETWORK_RETRIES {
625 tracing::warn!(
626 task_id,
627 retry_count,
628 agent = agent_name,
629 "network retries exhausted, escalating to needs_review"
630 );
631 return Ok(ErrorHandleResult::Continue {
632 status: "needs_review".to_string(),
633 error: msg,
634 });
635 }
636 response::wait_for_fallback_backoff(task_id, store, repo).await;
638 return Ok(ErrorHandleResult::EarlyReturn {
639 status: "routed".to_string(),
640 });
641 }
642 agents::AgentError::StaleSession { session_id } => {
643 (
647 response::RetryableError::Failed,
648 format!("{agent_name} stale session: {session_id}"),
649 )
650 }
651 agents::AgentError::ThinkingBlockConflict { message } => {
652 (
658 response::RetryableError::ModelUnavailable,
659 format!("{agent_name} thinking block conflict: {message}"),
660 )
661 }
662 agents::AgentError::Unknown { exit_code, message } => {
663 if *exit_code == 0
669 && (message.is_empty()
670 || message == "empty-output-exit0"
671 || message.starts_with("empty-output-exit0:"))
672 {
673 if let Some(m) = model_name {
674 crate::engine::cooldown::record_transient_model_failure(agent_name, m).await;
676 }
677 let current = model_name.unwrap_or("");
682 if let Some(result) = try_free_model_reroute(
683 task_id,
684 agent_name,
685 agent_runner,
686 &[current],
687 true,
688 Some("opencode"),
689 true,
690 "silent exit 0, retrying with free model",
691 store,
692 repo,
693 )
694 .await
695 {
696 return Ok(result);
697 }
698 }
699
700 let error_message = if *exit_code != 0 && message.contains("\"costUSD\":") {
703 format!(
705 "{} exited with non-zero status (cost telemetry in stdout, no error message)",
706 agent_name
707 )
708 } else {
709 format!("{agent_name} exit {exit_code}: {message}")
710 };
711
712 (response::RetryableError::Failed, error_message)
713 }
714 };
715
716 if matches!(
718 retryable,
719 response::RetryableError::Timeout | response::RetryableError::Interrupted
720 ) {
721 if let Err(e) = store::store_set_result(
722 store,
723 repo,
724 task_id,
725 &[
726 ("agent", serde_json::json!("")),
727 ("last_error", serde_json::json!(error_msg.clone())),
728 ],
729 )
730 .await
731 {
732 tracing::warn!(task_id, err = %e, "failed to write timeout agent clear to store");
733 }
734 }
735
736 {
739 let error_type_str = match retryable {
740 response::RetryableError::UsageLimit => Some("rate"),
741 response::RetryableError::AuthError => Some("auth"),
742 response::RetryableError::Timeout | response::RetryableError::Interrupted => None,
743 _ => Some("error"),
744 };
745 if let Some(limit_type) = error_type_str {
746 if let Some(ref s) = store {
747 if let Err(e) = s
748 .record_rate_limit(agent_name, limit_type, Some(task_id))
749 .await
750 {
751 tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
752 }
753 }
754 }
755 }
756
757 let chain = response::get_reroute_chain(task_id, store, repo).await;
759 let available: Vec<String> = crate::engine::configured_agents()
760 .into_iter()
761 .filter(|a| crate::cmd_cache::command_exists(a))
762 .collect();
763
764 let all_agents_tried = {
765 let chain_set: std::collections::HashSet<&str> = if chain.is_empty() {
766 std::collections::HashSet::new()
767 } else {
768 chain.split(',').collect()
769 };
770 !available
771 .iter()
772 .any(|a| a.as_str() != agent_name && !chain_set.contains(a.as_str()))
773 };
774
775 let is_simple = matches!(complexity, None | Some("simple"));
776 if all_agents_tried && is_simple {
777 if let Some(result) = try_free_model_reroute(
779 task_id,
780 agent_name,
781 agent_runner,
782 &[],
783 true, Some("opencode"),
785 false,
786 "all agents exhausted, trying free model",
787 store,
788 repo,
789 )
790 .await
791 {
792 return Ok(result);
793 }
794 }
795
796 let status = response::handle_failover(
797 task_id,
798 agent_name,
799 retryable,
800 &error_msg,
801 credit_recorded,
802 store,
803 repo,
804 )
805 .await;
806 match status.as_str() {
807 "new" => tracing::warn!(task_id, "failover exhausted (retryable), task reset to new"),
808 "blocked" => tracing::warn!(task_id, "failover exhausted (hard failure), task blocked"),
809 _ => {}
810 }
811
812 response::store_failure_memory(
814 task_id,
815 new_attempts,
816 agent_name,
817 model_name,
818 &error_msg,
819 store,
820 repo,
821 )
822 .await;
823
824 Ok(ErrorHandleResult::Continue {
825 status,
826 error: error_msg,
827 })
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use crate::engine::runner::agents::{AgentError, AgentRunner, ParsedResponse, PermissionRules};
834 use crate::parser::AgentResponse;
835 use serial_test::serial;
836
837 struct MockRunner {
838 free: Vec<String>,
839 }
840
841 struct MockRunnerWithModels {
842 models: Vec<String>,
843 }
844
845 impl AgentRunner for MockRunnerWithModels {
846 fn stream_events(
847 &self,
848 _event: &serde_json::Value,
849 ) -> Vec<crate::engine::runner::agents::StreamEvent> {
850 vec![]
851 }
852
853 fn name(&self) -> &str {
854 "opencode"
855 }
856
857 fn build_command(
858 &self,
859 _model: Option<&str>,
860 _timeout_cmd: &str,
861 _sys_file: &str,
862 _msg_file: &str,
863 _permissions: &PermissionRules,
864 ) -> String {
865 String::new()
866 }
867
868 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
869 Ok(ParsedResponse {
870 response: AgentResponse {
871 status: "done".to_string(),
872 summary: String::new(),
873 accomplished: vec![],
874 remaining: vec![],
875 files: vec![],
876 error: None,
877 input_tokens: None,
878 output_tokens: None,
879 learnings: vec![],
880 delegations: vec![],
881 },
882 input_tokens: None,
883 output_tokens: None,
884 duration_ms: None,
885 })
886 }
887
888 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
889 AgentError::Unknown {
890 exit_code: 0,
891 message: String::new(),
892 }
893 }
894
895 fn available_models(&self) -> Vec<String> {
896 self.models.clone()
897 }
898
899 fn router_command(
900 &self,
901 _prompt: &str,
902 _model: Option<&str>,
903 ) -> anyhow::Result<tokio::process::Command> {
904 anyhow::bail!("not implemented")
905 }
906 }
907
908 impl AgentRunner for MockRunner {
909 fn stream_events(
910 &self,
911 _event: &serde_json::Value,
912 ) -> Vec<crate::engine::runner::agents::StreamEvent> {
913 vec![]
914 }
915
916 fn name(&self) -> &str {
917 "opencode"
918 }
919
920 fn build_command(
921 &self,
922 _model: Option<&str>,
923 _timeout_cmd: &str,
924 _sys_file: &str,
925 _msg_file: &str,
926 _permissions: &PermissionRules,
927 ) -> String {
928 String::new()
929 }
930
931 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
932 Ok(ParsedResponse {
933 response: AgentResponse {
934 status: "done".to_string(),
935 summary: String::new(),
936 accomplished: vec![],
937 remaining: vec![],
938 files: vec![],
939 error: None,
940 input_tokens: None,
941 output_tokens: None,
942 learnings: vec![],
943 delegations: vec![],
944 },
945 input_tokens: None,
946 output_tokens: None,
947 duration_ms: None,
948 })
949 }
950
951 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
952 AgentError::Unknown {
953 exit_code: 0,
954 message: String::new(),
955 }
956 }
957
958 fn free_models(&self) -> Vec<String> {
959 self.free.clone()
960 }
961
962 fn router_command(
963 &self,
964 _prompt: &str,
965 _model: Option<&str>,
966 ) -> anyhow::Result<tokio::process::Command> {
967 anyhow::bail!("not implemented")
968 }
969 }
970
971 #[serial(cooldown_state)]
977 #[tokio::test]
978 async fn rate_limit_sets_agent_cooldown_immediately() {
979 crate::engine::cooldown::reset_global_state().await;
980 let runner = MockRunner { free: vec![] };
981 let agent = "test-agent-1371-early-cooldown";
982
983 assert!(
985 !crate::engine::cooldown::is_agent_in_cooldown(agent),
986 "agent should not be in cooldown before handle_error"
987 );
988
989 let err = AgentError::RateLimit {
990 message: "429 Too Many Requests".to_string(),
991 };
992
993 let _result = handle_error(
995 "test-1371-a",
996 &err,
997 agent,
998 &runner,
999 Some("sonnet"),
1000 Some("medium"),
1001 1,
1002 &None,
1003 "owner/repo",
1004 )
1005 .await
1006 .unwrap();
1007
1008 assert!(
1010 crate::engine::cooldown::is_agent_in_cooldown(agent),
1011 "agent should be in cooldown immediately after handle_error for RateLimit"
1012 );
1013 }
1014
1015 #[serial(cooldown_state)]
1022 #[tokio::test]
1023 async fn rate_limit_sets_both_agent_and_model_cooldowns() {
1024 crate::engine::cooldown::reset_global_state().await;
1025 let runner = MockRunner { free: vec![] };
1026 let agent = "test-agent-2153-model-cooldown";
1027 let model = "opencode/github-copilot/qwen3.6-plus-free";
1028
1029 assert!(
1031 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1032 "agent should not be in cooldown before handle_error"
1033 );
1034 assert!(
1035 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1036 "model should not be in cooldown before handle_error"
1037 );
1038
1039 let err = AgentError::RateLimit {
1040 message: "Upstream error from Alibaba: Request rate increased too quickly.".to_string(),
1041 };
1042
1043 let _result = handle_error(
1044 "test-2153-a",
1045 &err,
1046 agent,
1047 &runner,
1048 Some(model),
1049 Some("simple"),
1050 1,
1051 &None,
1052 "owner/repo",
1053 )
1054 .await
1055 .unwrap();
1056
1057 assert!(
1059 crate::engine::cooldown::is_agent_in_cooldown(agent),
1060 "agent should be in agent-level cooldown after RateLimit"
1061 );
1062 assert!(
1065 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1066 "model should be in model-level cooldown after RateLimit (fixes issue #2153)"
1067 );
1068 }
1069
1070 #[serial(cooldown_state)]
1072 #[tokio::test]
1073 async fn rate_limit_cooldown_is_model_specific() {
1074 crate::engine::cooldown::reset_global_state().await;
1075 let runner = MockRunner { free: vec![] };
1076 let agent = "test-agent-2153-model-specific";
1077 let cooled_model = "opencode/github-copilot/qwen3.6-plus-free";
1078 let other_model = "opencode/minimax-m2.5-free";
1079
1080 let err = AgentError::RateLimit {
1081 message: "rate limit exceeded".to_string(),
1082 };
1083
1084 let _result = handle_error(
1085 "test-2153-b",
1086 &err,
1087 agent,
1088 &runner,
1089 Some(cooled_model),
1090 Some("simple"),
1091 1,
1092 &None,
1093 "owner/repo",
1094 )
1095 .await
1096 .unwrap();
1097
1098 assert!(
1100 crate::engine::cooldown::is_model_in_cooldown(agent, cooled_model),
1101 "rate-limited model should be in cooldown"
1102 );
1103 assert!(
1105 !crate::engine::cooldown::is_model_in_cooldown(agent, other_model),
1106 "other models should not be cooled by a single model's rate limit"
1107 );
1108 }
1109
1110 #[serial(cooldown_state)]
1121 #[tokio::test]
1122 async fn credit_exhaustion_does_not_bump_generic_failure_count() {
1123 crate::engine::cooldown::reset_global_state().await;
1124 let store = std::sync::Arc::new(
1125 crate::store::TaskStore::open_memory()
1126 .await
1127 .expect("in-memory store"),
1128 );
1129 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
1130
1131 let runner = MockRunner { free: vec![] };
1132 let agent = "test-agent-3652-credit-no-bump";
1133
1134 let err = AgentError::RateLimit {
1135 message: "out of credits, please recharge your balance".to_string(),
1136 };
1137
1138 let _result = handle_error(
1139 "test-3652-a",
1140 &err,
1141 agent,
1142 &runner,
1143 None,
1144 Some("simple"),
1145 1,
1146 &Some(store.clone()),
1147 "owner/repo",
1148 )
1149 .await
1150 .unwrap();
1151
1152 let credit_count = store
1153 .kv_get(&format!("credit_failure_count:{agent}"))
1154 .await
1155 .unwrap();
1156 assert!(
1157 credit_count.is_some(),
1158 "credit_failure_count should be incremented by the credit error"
1159 );
1160 let generic_count = store
1161 .kv_get(&format!("failure_count:{agent}"))
1162 .await
1163 .unwrap();
1164 assert!(
1165 generic_count.is_none(),
1166 "failure_count must not be bumped by credit errors, got {generic_count:?}"
1167 );
1168 }
1169
1170 #[serial(cooldown_state)]
1173 #[tokio::test]
1174 async fn two_timeouts_leave_model_routable() {
1175 crate::engine::cooldown::reset_global_state().await;
1176 let store = std::sync::Arc::new(
1177 crate::store::TaskStore::open_memory()
1178 .await
1179 .expect("in-memory store"),
1180 );
1181 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
1182
1183 let runner = MockRunner { free: vec![] };
1184 let agent = "opencode";
1185 let model = "opencode/nemotron-3-ultra-free";
1186
1187 let err = AgentError::Timeout {
1188 elapsed: std::time::Duration::from_secs(1800),
1189 };
1190
1191 for i in 1..=2 {
1192 let _result = handle_error(
1193 &format!("test-3876-timeout-{i}"),
1194 &err,
1195 agent,
1196 &runner,
1197 Some(model),
1198 Some("simple"),
1199 i,
1200 &Some(store.clone()),
1201 "owner/repo",
1202 )
1203 .await
1204 .unwrap();
1205 }
1206
1207 assert!(
1208 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1209 "timeouts must not cool the model"
1210 );
1211 assert!(
1212 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1213 "timeouts must not cool the agent"
1214 );
1215 let counts = store.recent_rate_limit_counts(6).await.unwrap();
1216 assert_eq!(
1217 counts.get(agent).copied().unwrap_or(0),
1218 0,
1219 "timeouts must not write a rate_limits row"
1220 );
1221 }
1222
1223 #[serial(cooldown_state)]
1226 #[tokio::test]
1227 async fn interrupted_run_does_not_write_rate_limit() {
1228 crate::engine::cooldown::reset_global_state().await;
1229 let store = std::sync::Arc::new(
1230 crate::store::TaskStore::open_memory()
1231 .await
1232 .expect("in-memory store"),
1233 );
1234 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
1235
1236 let runner = MockRunner { free: vec![] };
1237 let agent = "claude";
1238 let model = "sonnet";
1239 let err = AgentError::Interrupted {
1240 message: "exited without a result envelope".to_string(),
1241 };
1242
1243 let _result = handle_error(
1244 "test-3876-interrupted",
1245 &err,
1246 agent,
1247 &runner,
1248 Some(model),
1249 Some("medium"),
1250 1,
1251 &Some(store.clone()),
1252 "owner/repo",
1253 )
1254 .await
1255 .unwrap();
1256
1257 assert!(
1258 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1259 "interrupted runs must not cool the model"
1260 );
1261 let counts = store.recent_rate_limit_counts(6).await.unwrap();
1262 assert_eq!(
1263 counts.get(agent).copied().unwrap_or(0),
1264 0,
1265 "interrupted runs must not write a rate_limits row"
1266 );
1267 }
1268
1269 #[serial(cooldown_state)]
1282 #[tokio::test]
1283 async fn rate_limit_with_relative_window_sets_window_length_model_cooldown() {
1284 crate::engine::cooldown::reset_global_state().await;
1285 let runner = MockRunner { free: vec![] };
1286 let agent = "test-agent-3580-kimi";
1287 let model = "opus";
1288 let key = format!("{agent}:{model}");
1289
1290 let err = AgentError::RateLimit {
1291 message: "You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window expires.".to_string(),
1292 };
1293
1294 let _result = handle_error(
1295 "test-3580-a",
1296 &err,
1297 agent,
1298 &runner,
1299 Some(model),
1300 Some("medium"),
1301 1,
1302 &None,
1303 "owner/repo",
1304 )
1305 .await
1306 .unwrap();
1307
1308 let now = chrono::Utc::now().timestamp();
1309 let until = crate::engine::cooldown::cooldown_until(&key)
1310 .expect("relative usage window should set model cooldown");
1311 let remaining = until.saturating_sub(now);
1312
1313 assert!(
1314 remaining >= 7 * 86400 - 30,
1315 "expected ~7-day model cooldown from the parsed window, got {remaining}s"
1316 );
1317 assert_eq!(
1318 crate::engine::cooldown::cooldown_reason(&key).as_deref(),
1319 Some("rate_limit_usage_window"),
1320 "relative usage-window cooldown must be labeled as a rate limit, not silence"
1321 );
1322 }
1323
1324 #[serial(cooldown_state)]
1325 #[tokio::test]
1326 async fn provider_returned_error_sets_short_model_cooldown() {
1327 crate::engine::cooldown::reset_global_state().await;
1328 let runner = MockRunner { free: vec![] };
1329 let agent = "opencode";
1330 let model = "opencode/nemotron-3-super-free";
1331 let key = format!("{agent}:{model}");
1332
1333 let err = AgentError::RateLimit {
1334 message: "Provider returned error".to_string(),
1335 };
1336
1337 let _result = handle_error(
1338 "test-2478-provider-cooldown",
1339 &err,
1340 agent,
1341 &runner,
1342 Some(model),
1343 Some("simple"),
1344 1,
1345 &None,
1346 "owner/repo",
1347 )
1348 .await
1349 .unwrap();
1350
1351 let now = chrono::Utc::now().timestamp();
1352 let until = crate::engine::cooldown::cooldown_until(&key)
1353 .expect("provider error should set model cooldown");
1354 let remaining = until.saturating_sub(now);
1355
1356 assert!(
1357 remaining <= crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
1358 "expected a 5 minute model cooldown, got {remaining}s"
1359 );
1360 }
1361
1362 #[serial(cooldown_state)]
1363 #[tokio::test]
1364 async fn model_unavailable_sets_persistent_model_cooldown_for_codex_style() {
1365 crate::engine::cooldown::reset_global_state().await;
1366 let runner = MockRunner { free: vec![] };
1367 let agent = "codex";
1368 let model = "gpt-5.3-codex";
1369
1370 let err = crate::engine::runner::agents::AgentError::ModelUnavailable {
1372 model: model.to_string(),
1373 message: "{\"detail\":\"The 'gpt-5.3-codex' model is not supported\"}".to_string(),
1374 };
1375
1376 let _result = handle_error(
1377 "test-codex-model-unsupported",
1378 &err,
1379 agent,
1380 &runner,
1381 Some(model),
1382 Some("simple"),
1383 1,
1384 &None,
1385 "owner/repo",
1386 )
1387 .await
1388 .unwrap();
1389
1390 let key = format!("{}:{}", agent, model);
1392 let now = chrono::Utc::now().timestamp();
1393 let until =
1394 crate::engine::cooldown::cooldown_until(&key).expect("model cooldown should be set");
1395 let remaining = until.saturating_sub(now);
1396
1397 assert!(
1398 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1399 "expected persistent model cooldown (~4h) for codex model-unavailable, got {remaining}s"
1400 );
1401 }
1402
1403 async fn vendor_reset_remaining(agent: &str, model: &str, message: &str) -> i64 {
1404 crate::engine::cooldown::reset_global_state().await;
1405 let runner = MockRunner { free: vec![] };
1406 let err = crate::engine::runner::agents::AgentError::RateLimit {
1407 message: message.to_string(),
1408 };
1409 let _ = handle_error(
1410 "test-3649",
1411 &err,
1412 agent,
1413 &runner,
1414 Some(model),
1415 Some("simple"),
1416 1,
1417 &None,
1418 "owner/repo",
1419 )
1420 .await
1421 .unwrap();
1422 let until = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
1423 .expect("model cooldown should be set");
1424 until - chrono::Utc::now().timestamp()
1425 }
1426
1427 #[serial(cooldown_state)]
1428 #[tokio::test]
1429 async fn vendor_reset_date_overrides_weekly_window() {
1430 use chrono::Datelike;
1431 let t = chrono::Local::now() + chrono::Duration::days(2);
1432 let msg = format!(
1433 "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
1434 t.format("%b"),
1435 t.day()
1436 );
1437 let remaining = vendor_reset_remaining("claude", "sonnet", &msg).await;
1438 assert!(
1439 remaining > 3600 && remaining < 3 * 86400,
1440 "expected ~2d cooldown, got {remaining}s"
1441 );
1442 }
1443
1444 #[serial(cooldown_state)]
1445 #[tokio::test]
1446 async fn vendor_reset_date_overrides_billing_cycle_backoff() {
1447 let t = chrono::Local::now() + chrono::Duration::days(3);
1448 let msg = format!(
1449 "You've hit your usage limit. Upgrade to Pro (https://x), or try again at {}.",
1450 t.format("%b %d, %Y %I:%M %p")
1451 );
1452 let remaining = vendor_reset_remaining("codex", "gpt-5.4", &msg).await;
1453 assert!(
1454 remaining > 2 * 86400 + 80000 - 3600 && remaining < 4 * 86400,
1455 "expected ~3d cooldown, got {remaining}s"
1456 );
1457 }
1458
1459 #[serial(cooldown_state)]
1460 #[tokio::test]
1461 async fn agent_failed_insufficient_balance_sets_credit_cooldown() {
1462 crate::engine::cooldown::reset_global_state().await;
1463 let runner = MockRunner { free: vec![] };
1464 let agent = "test-agent-3642-insufficient-balance";
1465
1466 assert!(
1468 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1469 "agent should not be in cooldown before handle_error"
1470 );
1471
1472 let err = agents::AgentError::AgentFailed {
1473 message: "402 Payment Required: insufficient balance".to_string(),
1474 };
1475
1476 let _result = handle_error(
1478 "test-3642-a",
1479 &err,
1480 agent,
1481 &runner,
1482 Some("sonnet"),
1483 Some("medium"),
1484 1,
1485 &None,
1486 "owner/repo",
1487 )
1488 .await
1489 .unwrap();
1490
1491 assert!(
1496 crate::engine::cooldown::is_agent_in_cooldown(agent),
1497 "agent should be in cooldown after handle_error for AgentFailed with insufficient balance"
1498 );
1499
1500 let until = crate::engine::cooldown::cooldown_until(agent)
1501 .expect("agent cooldown should be active");
1502 let remaining = until - chrono::Utc::now().timestamp();
1503 assert!(
1504 remaining >= crate::engine::cooldown::CREDIT_BACKOFF_BASE_SECS - 30,
1505 "credit cooldown should be at least the 1h base, got {remaining}s"
1506 );
1507 }
1508
1509 #[serial(cooldown_state)]
1510 #[tokio::test]
1511 async fn silent_exit0_retries_free_model_for_simple_complexity() {
1512 crate::engine::cooldown::reset_global_state().await;
1513 let runner = MockRunner {
1514 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1515 };
1516 let err = AgentError::Unknown {
1517 exit_code: 0,
1518 message: String::new(),
1519 };
1520
1521 let result = handle_error(
1523 "test-1112-a",
1524 &err,
1525 "opencode",
1526 &runner,
1527 Some("opencode/gpt-5.4-mini"),
1528 Some("simple"),
1529 1,
1530 &None,
1531 "owner/repo",
1532 )
1533 .await
1534 .unwrap();
1535
1536 assert!(
1537 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1538 "expected EarlyReturn{{status: new}} for simple complexity — free model should be tried first"
1539 );
1540 }
1541
1542 #[serial(cooldown_state)]
1543 #[tokio::test]
1544 async fn silent_exit0_retries_free_model_when_complexity_unknown() {
1545 crate::engine::cooldown::reset_global_state().await;
1546 let runner = MockRunner {
1547 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1548 };
1549 let err = AgentError::Unknown {
1550 exit_code: 0,
1551 message: String::new(),
1552 };
1553
1554 let result = handle_error(
1556 "test-1112-a2",
1557 &err,
1558 "opencode",
1559 &runner,
1560 Some("opencode/gpt-5.4-mini"),
1561 None,
1562 1,
1563 &None,
1564 "owner/repo",
1565 )
1566 .await
1567 .unwrap();
1568
1569 assert!(
1570 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1571 "expected EarlyReturn{{status: new}} for unknown complexity — free model should be tried first"
1572 );
1573 }
1574
1575 #[serial(cooldown_state)]
1576 #[tokio::test]
1577 async fn silent_exit0_retries_free_model_for_medium_complexity() {
1578 crate::engine::cooldown::reset_global_state().await;
1579 let runner = MockRunner {
1580 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1581 };
1582 let err = AgentError::Unknown {
1583 exit_code: 0,
1584 message: String::new(),
1585 };
1586
1587 let result = handle_error(
1589 "test-1195-medium",
1590 &err,
1591 "opencode",
1592 &runner,
1593 Some("opencode/copilot-gpt-5.4-mini"),
1594 Some("medium"),
1595 1,
1596 &None,
1597 "owner/repo",
1598 )
1599 .await
1600 .unwrap();
1601
1602 assert!(
1603 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1604 "expected EarlyReturn{{status: new}} for medium complexity — free model should be tried before failover"
1605 );
1606 }
1607
1608 #[serial(cooldown_state)]
1609 #[tokio::test]
1610 async fn silent_exit0_retries_free_model_for_complex_complexity() {
1611 crate::engine::cooldown::reset_global_state().await;
1612 let runner = MockRunner {
1613 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1614 };
1615 let err = AgentError::Unknown {
1616 exit_code: 0,
1617 message: String::new(),
1618 };
1619
1620 let result = handle_error(
1622 "test-1195-complex",
1623 &err,
1624 "opencode",
1625 &runner,
1626 Some("opencode/copilot-gpt-5.4-mini"),
1627 Some("complex"),
1628 1,
1629 &None,
1630 "owner/repo",
1631 )
1632 .await
1633 .unwrap();
1634
1635 assert!(
1636 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1637 "expected EarlyReturn{{status: new}} for complex complexity — free model should be tried before failover"
1638 );
1639 }
1640
1641 #[serial(cooldown_state)]
1644 #[tokio::test]
1645 async fn context_overflow_escalates_to_needs_review_immediately() {
1646 crate::engine::cooldown::reset_global_state().await;
1647 let runner = MockRunner { free: vec![] };
1648 let err = AgentError::ContextOverflow {
1649 message: "prompt is too long: 250000 tokens, max 200000".to_string(),
1650 };
1651
1652 let result = handle_error(
1653 "test-2129-a",
1654 &err,
1655 "claude",
1656 &runner,
1657 Some("sonnet"),
1658 Some("medium"),
1659 1,
1660 &None,
1661 "owner/repo",
1662 )
1663 .await
1664 .unwrap();
1665
1666 assert!(
1667 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "needs_review"),
1668 "context overflow must escalate directly to needs_review without cycling through fallback agents"
1669 );
1670 }
1671
1672 #[serial(cooldown_state)]
1675 #[tokio::test]
1676 async fn free_model_reroute_succeeds_without_store() {
1677 crate::engine::cooldown::reset_global_state().await;
1678 let runner = MockRunner {
1679 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1680 };
1681 let err = AgentError::Unknown {
1682 exit_code: 0,
1683 message: String::new(),
1684 };
1685
1686 let result = handle_error(
1688 "test-2293-a",
1689 &err,
1690 "opencode",
1691 &runner,
1692 Some("opencode/gpt-5.4-mini"),
1693 Some("simple"),
1694 1,
1695 &None,
1696 "owner/repo",
1697 )
1698 .await
1699 .unwrap();
1700
1701 assert!(
1702 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1703 "expected EarlyReturn{{status: new}} — store_set_result with None store should not prevent reroute"
1704 );
1705 }
1706
1707 #[serial(cooldown_state)]
1708 #[tokio::test]
1709 async fn silent_exit0_falls_through_when_no_free_models() {
1710 crate::engine::cooldown::reset_global_state().await;
1711 let runner = MockRunner { free: vec![] };
1712 let err = AgentError::Unknown {
1713 exit_code: 0,
1714 message: String::new(),
1715 };
1716
1717 let result = handle_error(
1718 "test-1112-b",
1719 &err,
1720 "opencode",
1721 &runner,
1722 Some("opencode/gpt-5.4-mini"),
1723 Some("simple"),
1724 1,
1725 &None,
1726 "owner/repo",
1727 )
1728 .await
1729 .unwrap();
1730
1731 assert!(
1732 matches!(result, ErrorHandleResult::Continue { .. }),
1733 "expected Continue (fallthrough to failover) when no free models available"
1734 );
1735 }
1736
1737 #[serial(cooldown_state)]
1743 #[tokio::test]
1744 async fn invalid_response_sets_model_cooldown() {
1745 crate::engine::cooldown::reset_global_state().await;
1746 let runner = MockRunner { free: vec![] };
1747 let agent = "opencode";
1748 let model = "opencode/nemotron-3-super-free";
1749
1750 assert!(
1752 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1753 "model should not be in cooldown before handle_error"
1754 );
1755
1756 let err = AgentError::InvalidResponse {
1757 raw: "invalid json output".to_string(),
1758 };
1759
1760 let _result = handle_error(
1761 "test-2750-invalid-response",
1762 &err,
1763 agent,
1764 &runner,
1765 Some(model),
1766 Some("simple"),
1767 1,
1768 &None,
1769 "owner/repo",
1770 )
1771 .await
1772 .unwrap();
1773
1774 assert!(
1776 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1777 "model should be in cooldown after InvalidResponse (fixes issue #2750)"
1778 );
1779 }
1780
1781 #[serial(cooldown_state)]
1788 #[tokio::test]
1789 async fn billing_cycle_exhausted_with_known_model_sets_only_model_cooldown() {
1790 crate::engine::cooldown::reset_global_state().await;
1791 let runner = MockRunner { free: vec![] };
1792 let agent = "codex-3382-billing-cycle";
1793 let model = "gpt-5.5";
1794
1795 let err = AgentError::RateLimit {
1796 message: "rate limit: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro).".to_string(),
1797 };
1798
1799 let _result = handle_error(
1800 "test-3382-a",
1801 &err,
1802 agent,
1803 &runner,
1804 Some(model),
1805 Some("medium"),
1806 1,
1807 &None,
1808 "owner/repo",
1809 )
1810 .await
1811 .unwrap();
1812
1813 assert!(
1815 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1816 "agent must NOT be in agent-level cooldown when BillingCycleExhausted has a known model (issue #3382)"
1817 );
1818 assert!(
1820 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1821 "model must be in cooldown after BillingCycleExhausted (issue #3382)"
1822 );
1823 assert!(
1825 !crate::engine::cooldown::is_model_in_cooldown(agent, "gpt-5.4"),
1826 "other models on the same agent must not be cooled by a single model's billing exhaustion"
1827 );
1828 }
1829
1830 #[serial(cooldown_state)]
1831 #[tokio::test]
1832 async fn summarize_rate_limit_error_extracts_api_retry_info() {
1833 crate::engine::cooldown::reset_global_state().await;
1834 let raw_output = r#"some log lines
1835{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1836{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1837more logs"#;
1838
1839 let summarized = super::summarize_rate_limit_error(raw_output);
1840 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1841 }
1842
1843 #[serial(cooldown_state)]
1844 #[tokio::test]
1845 async fn summarize_rate_limit_error_returns_original_when_no_api_retry() {
1846 crate::engine::cooldown::reset_global_state().await;
1847 let raw_output = "some regular error message without api_retry JSON";
1848
1849 let summarized = super::summarize_rate_limit_error(raw_output);
1850 assert_eq!(summarized, raw_output);
1851 }
1852
1853 #[serial(cooldown_state)]
1854 #[tokio::test]
1855 async fn summarize_rate_limit_error_handles_malformed_json_gracefully() {
1856 crate::engine::cooldown::reset_global_state().await;
1857 let raw_output = r#"some logs
1858{"type":"system","subtype":"api_retry","attempt":"not_a_number","retry_delay_ms":invalid,"error_status":429}
1859more logs"#;
1860
1861 let summarized = super::summarize_rate_limit_error(raw_output);
1862 assert!(summarized.starts_with("status="));
1864 assert!(summarized.contains("after"));
1865 assert!(summarized.contains("attempts"));
1866 assert!(summarized.contains("(last delay"));
1867 }
1868
1869 #[serial(cooldown_state)]
1870 #[tokio::test]
1871 async fn summarize_rate_limit_error_works_with_single_line() {
1872 crate::engine::cooldown::reset_global_state().await;
1873 let raw_output = r#"{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":5000,"error_status":429,"error":"rate_limit"}"#;
1874
1875 let summarized = super::summarize_rate_limit_error(raw_output);
1876 assert_eq!(summarized, "status=429 after 3 attempts (last delay 5s)");
1877 }
1878
1879 #[serial(cooldown_state)]
1883 #[tokio::test]
1884 async fn extract_json_field_handles_quoted_strings() {
1885 crate::engine::cooldown::reset_global_state().await;
1886 let json = r#"{"error_status":429,"attempt":7,"retry_delay_ms":35162.66,"error":"rate_limit","session_id":"331c099b-..."}"#;
1887
1888 assert_eq!(
1889 super::extract_json_field(json, "\"error\":"),
1890 Some("rate_limit".to_string()),
1891 "quoted string field mid-object should be extracted correctly"
1892 );
1893 assert_eq!(
1894 super::extract_json_field(json, "\"session_id\":"),
1895 Some("331c099b-...".to_string()),
1896 "quoted string field at end of object (no trailing comma) should be extracted"
1897 );
1898 assert_eq!(
1900 super::extract_json_field(json, "\"error_status\":"),
1901 Some("429".to_string())
1902 );
1903 assert_eq!(
1904 super::extract_json_field(json, "\"attempt\":"),
1905 Some("7".to_string())
1906 );
1907 assert_eq!(
1908 super::extract_json_field(json, "\"retry_delay_ms\":"),
1909 Some("35162.66".to_string())
1910 );
1911 }
1912
1913 #[serial(cooldown_state)]
1916 #[tokio::test]
1917 async fn summarize_rate_limit_error_handles_truncated_fragment_with_subtype() {
1918 crate::engine::cooldown::reset_global_state().await;
1919 let raw_output = r#"some log context
1921,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit"
1922more context"#;
1923
1924 let summarized = super::summarize_rate_limit_error(raw_output);
1925 assert!(
1926 !summarized.contains("subtype"),
1927 "summarized output must not contain raw 'subtype' JSON fragment"
1928 );
1929 assert!(
1930 !summarized.contains("{"),
1931 "summarized output must not contain JSON braces"
1932 );
1933 assert!(
1934 summarized.starts_with("status="),
1935 "summarized output should start with 'status=' — got: {summarized}"
1936 );
1937 }
1941
1942 #[serial(cooldown_state)]
1944 #[tokio::test]
1945 async fn summarize_rate_limit_error_handles_fragment_with_extractable_fields() {
1946 crate::engine::cooldown::reset_global_state().await;
1947 let raw_output = r#"log output
1948,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit","session_id":"abc123","max_retries":10}
1949rest of output"#;
1950
1951 let summarized = super::summarize_rate_limit_error(raw_output);
1952 assert_eq!(summarized, "status=429 after 5 attempts (last delay 20s)");
1953 }
1954
1955 #[serial(cooldown_state)]
1957 #[tokio::test]
1958 async fn summarize_rate_limit_error_complete_json_still_works() {
1959 crate::engine::cooldown::reset_global_state().await;
1960 let raw_output = r#"some log lines
1961{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1962{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1963more logs"#;
1964
1965 let summarized = super::summarize_rate_limit_error(raw_output);
1966 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1967 }
1968
1969 #[serial(cooldown_state)]
1971 #[tokio::test]
1972 async fn summarize_rate_limit_error_no_api_retry_no_raw_passthrough() {
1973 crate::engine::cooldown::reset_global_state().await;
1974 let raw_output = "some regular error message without api_retry JSON that is quite long and should be truncated";
1975
1976 let summarized = super::summarize_rate_limit_error(raw_output);
1977 assert!(
1979 summarized.len() <= 140,
1980 "expected truncated first-line, got: {summarized}"
1981 );
1982 }
1983
1984 #[serial(cooldown_state)]
1986 #[tokio::test]
1987 async fn summarize_rate_limit_error_no_api_retry_short() {
1988 crate::engine::cooldown::reset_global_state().await;
1989 let raw_output = "rate limit exceeded";
1990
1991 let summarized = super::summarize_rate_limit_error(raw_output);
1992 assert_eq!(summarized, raw_output);
1993 }
1994
1995 #[serial(cooldown_state)]
2001 #[tokio::test]
2002 async fn model_not_found_applies_persistent_cooldown() {
2003 crate::engine::cooldown::reset_global_state().await;
2004 let runner = MockRunner { free: vec![] };
2005 let agent = "opencode-2941-not-found";
2006 let model = "github-copilot/claude-opus-4.6";
2007 let key = format!("{agent}:{model}");
2008
2009 let err = AgentError::ModelUnavailable {
2010 model: model.to_string(),
2011 message: "Model not found: github-copilot/claude-opus-4.6".to_string(),
2012 };
2013
2014 let _result = handle_error(
2015 "test-2941-a",
2016 &err,
2017 agent,
2018 &runner,
2019 Some(model),
2020 Some("complex"),
2021 1,
2022 &None,
2023 "owner/repo",
2024 )
2025 .await
2026 .unwrap();
2027
2028 let now = chrono::Utc::now().timestamp();
2029 let until = crate::engine::cooldown::cooldown_until(&key)
2030 .expect("model not found should set model cooldown");
2031 let remaining = until.saturating_sub(now);
2032
2033 assert!(
2036 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2037 "expected ~4h (persistent) cooldown for 'Model not found', got {remaining}s — \
2038 may have applied transient 5min cooldown instead"
2039 );
2040 }
2041
2042 #[serial(cooldown_state)]
2049 #[tokio::test]
2050 async fn model_not_supported_applies_persistent_cooldown() {
2051 crate::engine::cooldown::reset_global_state().await;
2052 let runner = MockRunner { free: vec![] };
2053 let agent = "codex-3215-not-supported";
2054 let model = "gpt-5.2-codex";
2055 let key = format!("{agent}:{model}");
2056
2057 let err = AgentError::ModelUnavailable {
2058 model: model.to_string(),
2059 message: "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.".to_string(),
2060 };
2061
2062 let _result = handle_error(
2063 "test-3215-a",
2064 &err,
2065 agent,
2066 &runner,
2067 Some(model),
2068 Some("medium"),
2069 1,
2070 &None,
2071 "owner/repo",
2072 )
2073 .await
2074 .unwrap();
2075
2076 let now = chrono::Utc::now().timestamp();
2077 let until = crate::engine::cooldown::cooldown_until(&key)
2078 .expect("model not supported should set model cooldown");
2079 let remaining = until.saturating_sub(now);
2080
2081 assert!(
2082 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2083 "expected ~4h (persistent) cooldown for 'not supported', got {remaining}s — \
2084 may have applied transient 5min cooldown instead (issue #3215)"
2085 );
2086 }
2087
2088 #[serial(cooldown_state)]
2089 #[tokio::test]
2090 async fn model_not_available_in_country_applies_persistent_cooldown() {
2091 crate::engine::cooldown::reset_global_state().await;
2092 let runner = MockRunner { free: vec![] };
2093 let agent = "opencode-3650-country";
2094 let model = "some-region-locked-model";
2095 let key = format!("{agent}:{model}");
2096
2097 for message in [
2099 "This model is not available in your country.",
2100 "This model is not available in your region.",
2101 ] {
2102 crate::engine::cooldown::reset_global_state().await;
2103 let err = AgentError::ModelUnavailable {
2104 model: model.to_string(),
2105 message: message.to_string(),
2106 };
2107
2108 let _result = handle_error(
2109 "test-3650",
2110 &err,
2111 agent,
2112 &runner,
2113 Some(model),
2114 Some("medium"),
2115 1,
2116 &None,
2117 "owner/repo",
2118 )
2119 .await
2120 .unwrap();
2121
2122 let now = chrono::Utc::now().timestamp();
2123 let until = crate::engine::cooldown::cooldown_until(&key)
2124 .expect("region block should set model cooldown");
2125 let remaining = until.saturating_sub(now);
2126
2127 assert!(
2128 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2129 "expected ~4h (persistent) cooldown for '{message}', got {remaining}s — \
2130 may have applied transient 5min cooldown instead (issue #3650)"
2131 );
2132 }
2133 }
2134
2135 #[serial(cooldown_state)]
2141 #[tokio::test]
2142 async fn thinking_block_conflict_does_not_set_cooldown() {
2143 crate::engine::cooldown::reset_global_state().await;
2144 let runner = MockRunner { free: vec![] };
2145 let agent = "claude-3204-thinking-block";
2146 let model = "opus";
2147
2148 assert!(
2149 !crate::engine::cooldown::is_agent_in_cooldown(agent),
2150 "agent must not be in cooldown before handle_error"
2151 );
2152 assert!(
2153 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
2154 "model must not be in cooldown before handle_error"
2155 );
2156
2157 let err = AgentError::ThinkingBlockConflict {
2158 message: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.".to_string(),
2159 };
2160
2161 let _result = handle_error(
2162 "test-3204-thinking",
2163 &err,
2164 agent,
2165 &runner,
2166 Some(model),
2167 Some("complex"),
2168 1,
2169 &None,
2170 "owner/repo",
2171 )
2172 .await
2173 .unwrap();
2174
2175 assert!(
2176 !crate::engine::cooldown::is_agent_in_cooldown(agent),
2177 "agent must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2178 );
2179 assert!(
2180 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
2181 "model must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2182 );
2183 }
2184
2185 #[serial(cooldown_state)]
2187 #[tokio::test]
2188 async fn model_unavailable_transient_applies_short_cooldown() {
2189 crate::engine::cooldown::reset_global_state().await;
2190 let runner = MockRunner { free: vec![] };
2191 let agent = "opencode-2941-transient";
2192 let model = "github-copilot/gpt-4o";
2193 let key = format!("{agent}:{model}");
2194
2195 let err = AgentError::ModelUnavailable {
2196 model: model.to_string(),
2197 message: "No endpoints found for github-copilot/gpt-4o.".to_string(),
2198 };
2199
2200 let _result = handle_error(
2201 "test-2941-b",
2202 &err,
2203 agent,
2204 &runner,
2205 Some(model),
2206 Some("medium"),
2207 1,
2208 &None,
2209 "owner/repo",
2210 )
2211 .await
2212 .unwrap();
2213
2214 let now = chrono::Utc::now().timestamp();
2215 let until = crate::engine::cooldown::cooldown_until(&key)
2216 .expect("transient model unavailability should set model cooldown");
2217 let remaining = until.saturating_sub(now);
2218
2219 assert!(
2222 remaining < crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2223 "transient unavailability should use short cooldown (<4h), got {remaining}s"
2224 );
2225 }
2226
2227 #[serial(cooldown_state)]
2228 #[tokio::test]
2229 async fn model_unavailable_does_not_reuse_same_agent_when_agent_cooled() {
2230 crate::engine::cooldown::reset_global_state().await;
2231 let agent = "codex-3620";
2232 let failing_model = "gpt-5.4";
2233 let other_model = "gpt-5.5";
2234 let runner = MockRunnerWithModels {
2235 models: vec![failing_model.to_string(), other_model.to_string()],
2236 };
2237
2238 crate::engine::cooldown::set_agent_cooldown(agent, 3600).await;
2239
2240 let err = AgentError::ModelUnavailable {
2241 model: failing_model.to_string(),
2242 message: "model gpt-5.4 unavailable".to_string(),
2243 };
2244
2245 let result = handle_error(
2246 "test-3620",
2247 &err,
2248 agent,
2249 &runner,
2250 Some(failing_model),
2251 Some("medium"),
2252 1,
2253 &None,
2254 "owner/repo",
2255 )
2256 .await
2257 .unwrap();
2258
2259 if let ErrorHandleResult::EarlyReturn { status } = result {
2260 assert_ne!(
2261 status, "routed",
2262 "agent-wide cooldown was defeated by same-agent model substitution"
2263 );
2264 }
2265 }
2266}