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