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 const FIRST_LINE_CAP: usize = 120;
26 let last_api_retry_line = raw_output
30 .lines()
31 .rev()
32 .find(|line| line.contains("\"subtype\":\"api_retry\""));
33
34 if let Some(line) = last_api_retry_line {
35 return summarise_api_retry_line(line);
36 }
37
38 let api_retry_field_markers = ["\"error_status\":", "\"attempt\":", "\"retry_delay_ms\":"];
44 let field_line = raw_output
45 .lines()
46 .rev()
47 .find(|line| api_retry_field_markers.iter().any(|m| line.contains(m)));
48 if let Some(line) = field_line {
49 return summarise_api_retry_line(line);
50 }
51
52 let first_line = raw_output.lines().next().unwrap_or(raw_output);
56 if first_line.len() > FIRST_LINE_CAP {
57 let cutoff = agents::truncate_at_char_boundary(first_line, FIRST_LINE_CAP);
58 let remaining = raw_output.len().saturating_sub(cutoff);
59 format!("{}... (+{} more chars)", &first_line[..cutoff], remaining)
60 } else {
61 first_line.to_string()
62 }
63}
64
65fn summarise_api_retry_line(line: &str) -> String {
70 let error_status =
71 extract_json_field(line, "\"error_status\":").unwrap_or_else(|| "unknown".to_string());
72 let attempt = extract_json_field(line, "\"attempt\":").unwrap_or_else(|| "unknown".to_string());
73 let retry_delay_ms =
74 extract_json_field(line, "\"retry_delay_ms\":").unwrap_or_else(|| "0".to_string());
75
76 let has_field = error_status != "unknown"
77 || attempt != "unknown"
78 || (retry_delay_ms != "0" && retry_delay_ms != "unknown");
79
80 if has_field {
81 let retry_delay_s = retry_delay_ms.parse::<f64>().unwrap_or(0.0) / 1000.0;
82 #[expect(
83 clippy::cast_possible_truncation,
84 clippy::cast_sign_loss,
85 reason = "the saturating cast floors the delay to whole seconds"
86 )]
87 let retry_delay_s = retry_delay_s as u64;
88 format!("status={error_status} after {attempt} attempts (last delay {retry_delay_s}s)")
89 } else {
90 let status_hint = extract_json_field(line, "\"status\":")
91 .or_else(|| extract_json_field(line, "\"error\":"))
92 .unwrap_or_else(|| "429".to_string());
93 format!("status={status_hint} (api_retry fragment)")
94 }
95}
96
97fn extract_json_field(json: &str, field: &str) -> Option<String> {
99 json.find(field).and_then(|start| {
100 let value_start = start + field.len();
101 let value_str = &json[value_start..];
102
103 if let Some(rest) = value_str.strip_prefix('"') {
105 let end = rest.find('"')?; Some(rest[..end].to_string())
107 }
108 else {
110 let end = value_str
111 .find(|c: char| !c.is_ascii_digit() && c != '.')
112 .unwrap_or(value_str.len());
113 let num_str = &value_str[..end];
114 num_str
115 .strip_suffix(',')
116 .map(std::string::ToString::to_string)
117 .or_else(|| Some(num_str.to_string()))
118 }
119 })
120}
121
122#[derive(Debug)]
124pub enum ErrorHandleResult {
125 EarlyReturn { status: String },
127 Continue { status: String, error: String },
133}
134
135#[expect(
146 clippy::too_many_arguments,
147 reason = "each caller picks a different mix of reroute options"
148)]
149#[expect(
150 clippy::ref_option,
151 reason = "the store helpers it calls take the store as &Option"
152)]
153async fn try_free_model_reroute(
154 task_id: &str,
155 agent_name: &str,
156 agent_runner: &dyn agents::AgentRunner,
157 exclude_models: &[&str],
158 check_cooldowns: bool,
159 set_agent: Option<&str>,
160 apply_backoff: bool,
161 reason_prefix: &str,
162 store: &Option<Arc<TaskStore>>,
163 repo: &str,
164) -> Option<ErrorHandleResult> {
165 let free = agent_runner.free_models();
166 if free.is_empty() {
167 return None;
168 }
169 let tried_models: String = store::opt_store_get_task(store, repo, task_id)
170 .await
171 .map(|t| t.model_reroute_chain)
172 .unwrap_or_default();
173 let tried_set: std::collections::HashSet<&str> =
174 tried_models.split(',').filter(|s| !s.is_empty()).collect();
175 let next_free = free.iter().find(|m| {
176 !exclude_models.contains(&m.as_str())
177 && !tried_set.contains(m.as_str())
178 && (!check_cooldowns
179 || (!crate::engine::cooldown::is_agent_in_cooldown(agent_name)
180 && !response::is_model_in_cooldown(agent_name, m)))
181 })?;
182 tracing::info!(task_id, model = %next_free, "{}", reason_prefix);
183 let new_tried = if tried_models.is_empty() {
184 next_free.clone()
185 } else {
186 format!("{tried_models},{next_free}")
187 };
188 let msg = format!("{reason_prefix} {next_free}");
189 let mut fields: Vec<(&str, serde_json::Value)> = vec![
190 ("model", serde_json::json!(next_free.clone())),
191 ("model_reroute_chain", serde_json::json!(new_tried)),
192 ("last_error", serde_json::json!(msg)),
193 ];
194 if let Some(agent) = set_agent {
195 fields.push(("agent", serde_json::json!(agent)));
196 }
197 if let Err(e) = store::store_set_result(store, repo, task_id, &fields).await {
198 tracing::warn!(task_id, err = %e, "try_free_model_reroute: failed to persist model update — not rerouting");
199 return None; }
201 if apply_backoff {
202 response::wait_for_fallback_backoff(task_id, store, repo).await;
203 }
204 Some(ErrorHandleResult::EarlyReturn {
205 status: "new".to_string(),
206 })
207}
208
209async fn apply_vendor_reset_date(
214 agent_name: &str,
215 model_name: Option<&str>,
216 message: &str,
217) -> bool {
218 use crate::engine::cooldown::CreditExhaustionReason;
219 let Some(model) = model_name else {
220 return false;
221 };
222 match crate::engine::cooldown::detect_credit_exhaustion(message) {
223 None | Some(CreditExhaustionReason::BillingCycleExhausted) => {}
224 Some(_) => return false,
225 }
226 match crate::engine::cooldown::parse_retry_at(message) {
227 Some(until) if until > chrono::Utc::now().timestamp() => {
228 crate::engine::cooldown::set_model_cooldown_until(agent_name, model, until).await;
229 true
230 }
231 _ => false,
232 }
233}
234
235#[expect(
241 clippy::too_many_arguments,
242 reason = "the error path needs the task, agent, model and store context together"
243)]
244pub async fn handle_error(
245 task_id: &str,
246 agent_err: &agents::AgentError,
247 agent_name: &str,
248 agent_runner: &dyn agents::AgentRunner,
249 model_name: Option<&str>,
250 complexity: Option<&str>,
251 new_attempts: u32,
252 store: &Option<Arc<TaskStore>>,
253 repo: &str,
254) -> anyhow::Result<ErrorHandleResult> {
255 tracing::warn!(
256 task_id,
257 agent = agent_name,
258 error = %agent_err,
259 "agent error, attempting recovery"
260 );
261
262 let mut credit_recorded = false;
265 let (retryable, error_msg) = match agent_err {
266 agents::AgentError::RateLimit { message, .. } => {
267 let is_provider_returned_error = message
268 .to_ascii_lowercase()
269 .contains("provider returned error");
270 let mut billing_cycle_model_scoped = false;
290
291 if apply_vendor_reset_date(agent_name, model_name, message).await {
292 let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
293 credit_recorded = reason.is_some();
294 billing_cycle_model_scoped = reason
295 == Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
296 } else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
297 {
298 credit_recorded = true;
299 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
305 {
306 if let Some(model) = model_name {
307 crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
308 .await;
309 billing_cycle_model_scoped = true;
310 } else {
311 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
312 }
313 } else {
314 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
315 }
316 } else if let (Some(model), Some(window_secs)) = (
317 model_name,
318 crate::engine::cooldown::parse_relative_usage_window(message),
319 ) {
320 crate::engine::cooldown::set_model_cooldown(
328 agent_name,
329 model,
330 window_secs,
331 "rate_limit_usage_window",
332 )
333 .await;
334 tracing::info!(
335 task_id,
336 agent = agent_name,
337 model,
338 window_secs,
339 "relative usage window detected: applying model-specific cooldown"
340 );
341 } else {
342 crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
343 .await;
344 if let Some(model) = model_name {
346 if is_provider_returned_error {
347 crate::engine::cooldown::record_transient_model_failure(agent_name, model)
348 .await;
349 } else {
350 response::record_model_failure(agent_name, model).await;
351 }
352 }
353 }
354 let retryable = if billing_cycle_model_scoped {
358 response::RetryableError::ModelUnavailable
359 } else {
360 response::RetryableError::UsageLimit
361 };
362 (
363 retryable,
364 format!(
365 "{agent_name} rate limit: {}",
366 summarize_rate_limit_error(message)
367 ),
368 )
369 }
370 agents::AgentError::Auth { message } => {
371 let mut billing_cycle_model_scoped = false;
376
377 if apply_vendor_reset_date(agent_name, model_name, message).await {
378 let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
379 credit_recorded = reason.is_some();
380 billing_cycle_model_scoped = reason
381 == Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
382 } else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
383 {
384 credit_recorded = true;
385 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
388 {
389 if let Some(model) = model_name {
390 crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
391 .await;
392 billing_cycle_model_scoped = true;
393 } else {
394 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
395 }
396 } else {
397 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
398 }
399 } else {
400 crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
401 .await;
402 if let Some(model) = model_name {
403 response::record_model_failure(agent_name, model).await;
404 }
405 }
406 let retryable = if billing_cycle_model_scoped {
409 response::RetryableError::ModelUnavailable
410 } else {
411 response::RetryableError::AuthError
412 };
413 (retryable, format!("{agent_name} auth error: {message}"))
414 }
415 agents::AgentError::Timeout { elapsed } => {
416 (
419 response::RetryableError::Timeout,
420 format!("{agent_name} timed out after {}s", elapsed.as_secs()),
421 )
422 }
423 agents::AgentError::Interrupted { message } => (
424 response::RetryableError::Interrupted,
425 format!("{agent_name} interrupted: {message}"),
426 ),
427 agents::AgentError::MissingTool { tool } => (
428 response::RetryableError::MissingTooling,
429 format!("missing tool: {tool}"),
430 ),
431 agents::AgentError::ModelUnavailable { model, message } => {
432 crate::engine::cooldown::record_model_unavailable(agent_name, model, message).await;
433
434 let agent_cooled = crate::engine::cooldown::is_agent_in_cooldown(agent_name);
437 let models = agent_runner.available_models();
438 let current_model = model_name.unwrap_or("");
439 let next_model = if agent_cooled {
440 None
441 } else {
442 models.iter().find(|m| {
443 m.as_str() != current_model
444 && m.as_str() != model
445 && !response::is_model_in_cooldown(agent_name, m)
446 })
447 };
448 if let Some(next) = next_model {
449 tracing::info!(task_id, model = %next, "retrying with different model");
450 let msg = format!("model {model} unavailable, trying {next}");
451 if let Err(e) = store::store_set_result(
452 store,
453 repo,
454 task_id,
455 &[
456 ("model", serde_json::json!(next.clone())),
457 ("last_error", serde_json::json!(msg)),
458 ],
459 )
460 .await
461 {
462 tracing::warn!(task_id, err = %e, "failed to write model failover to store");
463 }
464 response::wait_for_fallback_backoff(task_id, store, repo).await;
468 return Ok(ErrorHandleResult::EarlyReturn {
469 status: "routed".to_string(),
470 });
471 }
472
473 let is_simple = matches!(complexity, None | Some("simple"));
475 if is_simple
476 && let Some(result) = try_free_model_reroute(
477 task_id,
478 agent_name,
479 agent_runner,
480 &[current_model, model.as_str()],
481 true,
482 None,
483 true,
484 &format!("model {model} unavailable, trying free model"),
485 store,
486 repo,
487 )
488 .await
489 {
490 return Ok(result);
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 "{agent_name} exited with non-zero status (cost telemetry in stdout, no error message)"
706 )
707 } else {
708 format!("{agent_name} exit {exit_code}: {message}")
709 };
710
711 (response::RetryableError::Failed, error_message)
712 }
713 };
714
715 if matches!(
717 retryable,
718 response::RetryableError::Timeout | response::RetryableError::Interrupted
719 ) && let Err(e) = store::store_set_result(
720 store,
721 repo,
722 task_id,
723 &[
724 ("agent", serde_json::json!("")),
725 ("last_error", serde_json::json!(error_msg.clone())),
726 ],
727 )
728 .await
729 {
730 tracing::warn!(task_id, err = %e, "failed to write timeout agent clear to store");
731 }
732
733 {
736 let error_type_str = match retryable {
737 response::RetryableError::UsageLimit => Some("rate"),
738 response::RetryableError::AuthError => Some("auth"),
739 response::RetryableError::Timeout | response::RetryableError::Interrupted => None,
740 _ => Some("error"),
741 };
742 if let Some(limit_type) = error_type_str
743 && let Some(s) = store
744 && let Err(e) = s
745 .record_rate_limit(agent_name, limit_type, Some(task_id))
746 .await
747 {
748 tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
749 }
750 }
751
752 let chain = response::get_reroute_chain(task_id, store, repo).await;
754 let available: Vec<String> = crate::engine::configured_agents()
755 .into_iter()
756 .filter(|a| crate::cmd_cache::command_exists(a))
757 .collect();
758
759 let all_agents_tried = {
760 let chain_set: std::collections::HashSet<&str> = if chain.is_empty() {
761 std::collections::HashSet::new()
762 } else {
763 chain.split(',').collect()
764 };
765 !available
766 .iter()
767 .any(|a| a.as_str() != agent_name && !chain_set.contains(a.as_str()))
768 };
769
770 let is_simple = matches!(complexity, None | Some("simple"));
771 if all_agents_tried && is_simple {
772 if let Some(result) = try_free_model_reroute(
774 task_id,
775 agent_name,
776 agent_runner,
777 &[],
778 true, Some("opencode"),
780 false,
781 "all agents exhausted, trying free model",
782 store,
783 repo,
784 )
785 .await
786 {
787 return Ok(result);
788 }
789 }
790
791 let status = response::handle_failover(
792 task_id,
793 agent_name,
794 retryable,
795 &error_msg,
796 credit_recorded,
797 store,
798 repo,
799 )
800 .await;
801 match status.as_str() {
802 "new" => tracing::warn!(task_id, "failover exhausted (retryable), task reset to new"),
803 "blocked" => tracing::warn!(task_id, "failover exhausted (hard failure), task blocked"),
804 _ => {}
805 }
806
807 response::store_failure_memory(
809 task_id,
810 new_attempts,
811 agent_name,
812 model_name,
813 &error_msg,
814 store,
815 repo,
816 )
817 .await;
818
819 Ok(ErrorHandleResult::Continue {
820 status,
821 error: error_msg,
822 })
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828 use crate::engine::runner::agents::{AgentError, AgentRunner, ParsedResponse, PermissionRules};
829 use crate::parser::AgentResponse;
830 use serial_test::serial;
831
832 struct MockRunner {
833 free: Vec<String>,
834 }
835
836 struct MockRunnerWithModels {
837 models: Vec<String>,
838 }
839
840 impl AgentRunner for MockRunnerWithModels {
841 fn stream_events(
842 &self,
843 _event: &serde_json::Value,
844 ) -> Vec<crate::engine::runner::agents::StreamEvent> {
845 vec![]
846 }
847
848 fn name(&self) -> &'static str {
849 "opencode"
850 }
851
852 fn build_command(
853 &self,
854 _model: Option<&str>,
855 _timeout_cmd: &str,
856 _sys_file: &str,
857 _msg_file: &str,
858 _permissions: &PermissionRules,
859 ) -> String {
860 String::new()
861 }
862
863 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
864 Ok(ParsedResponse {
865 response: AgentResponse {
866 status: "done".to_string(),
867 summary: String::new(),
868 accomplished: vec![],
869 remaining: vec![],
870 files: vec![],
871 error: None,
872 input_tokens: None,
873 output_tokens: None,
874 learnings: vec![],
875 delegations: vec![],
876 },
877 input_tokens: None,
878 output_tokens: None,
879 duration_ms: None,
880 })
881 }
882
883 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
884 AgentError::Unknown {
885 exit_code: 0,
886 message: String::new(),
887 }
888 }
889
890 fn available_models(&self) -> Vec<String> {
891 self.models.clone()
892 }
893
894 fn router_command(
895 &self,
896 _prompt: &str,
897 _model: Option<&str>,
898 ) -> anyhow::Result<tokio::process::Command> {
899 anyhow::bail!("not implemented")
900 }
901 }
902
903 impl AgentRunner for MockRunner {
904 fn stream_events(
905 &self,
906 _event: &serde_json::Value,
907 ) -> Vec<crate::engine::runner::agents::StreamEvent> {
908 vec![]
909 }
910
911 fn name(&self) -> &'static str {
912 "opencode"
913 }
914
915 fn build_command(
916 &self,
917 _model: Option<&str>,
918 _timeout_cmd: &str,
919 _sys_file: &str,
920 _msg_file: &str,
921 _permissions: &PermissionRules,
922 ) -> String {
923 String::new()
924 }
925
926 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
927 Ok(ParsedResponse {
928 response: AgentResponse {
929 status: "done".to_string(),
930 summary: String::new(),
931 accomplished: vec![],
932 remaining: vec![],
933 files: vec![],
934 error: None,
935 input_tokens: None,
936 output_tokens: None,
937 learnings: vec![],
938 delegations: vec![],
939 },
940 input_tokens: None,
941 output_tokens: None,
942 duration_ms: None,
943 })
944 }
945
946 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
947 AgentError::Unknown {
948 exit_code: 0,
949 message: String::new(),
950 }
951 }
952
953 fn free_models(&self) -> Vec<String> {
954 self.free.clone()
955 }
956
957 fn router_command(
958 &self,
959 _prompt: &str,
960 _model: Option<&str>,
961 ) -> anyhow::Result<tokio::process::Command> {
962 anyhow::bail!("not implemented")
963 }
964 }
965
966 #[serial(cooldown_state)]
972 #[tokio::test]
973 async fn rate_limit_sets_agent_cooldown_immediately() {
974 crate::engine::cooldown::reset_global_state().await;
975 let runner = MockRunner { free: vec![] };
976 let agent = "test-agent-1371-early-cooldown";
977
978 assert!(
980 !crate::engine::cooldown::is_agent_in_cooldown(agent),
981 "agent should not be in cooldown before handle_error"
982 );
983
984 let err = AgentError::RateLimit {
985 message: "429 Too Many Requests".to_string(),
986 };
987
988 let _result = handle_error(
990 "test-1371-a",
991 &err,
992 agent,
993 &runner,
994 Some("sonnet"),
995 Some("medium"),
996 1,
997 &None,
998 "owner/repo",
999 )
1000 .await
1001 .unwrap();
1002
1003 assert!(
1005 crate::engine::cooldown::is_agent_in_cooldown(agent),
1006 "agent should be in cooldown immediately after handle_error for RateLimit"
1007 );
1008 }
1009
1010 #[serial(cooldown_state)]
1017 #[tokio::test]
1018 async fn rate_limit_sets_both_agent_and_model_cooldowns() {
1019 crate::engine::cooldown::reset_global_state().await;
1020 let runner = MockRunner { free: vec![] };
1021 let agent = "test-agent-2153-model-cooldown";
1022 let model = "opencode/github-copilot/qwen3.6-plus-free";
1023
1024 assert!(
1026 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1027 "agent should not be in cooldown before handle_error"
1028 );
1029 assert!(
1030 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1031 "model should not be in cooldown before handle_error"
1032 );
1033
1034 let err = AgentError::RateLimit {
1035 message: "Upstream error from Alibaba: Request rate increased too quickly.".to_string(),
1036 };
1037
1038 let _result = handle_error(
1039 "test-2153-a",
1040 &err,
1041 agent,
1042 &runner,
1043 Some(model),
1044 Some("simple"),
1045 1,
1046 &None,
1047 "owner/repo",
1048 )
1049 .await
1050 .unwrap();
1051
1052 assert!(
1054 crate::engine::cooldown::is_agent_in_cooldown(agent),
1055 "agent should be in agent-level cooldown after RateLimit"
1056 );
1057 assert!(
1060 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1061 "model should be in model-level cooldown after RateLimit (fixes issue #2153)"
1062 );
1063 }
1064
1065 #[serial(cooldown_state)]
1067 #[tokio::test]
1068 async fn rate_limit_cooldown_is_model_specific() {
1069 crate::engine::cooldown::reset_global_state().await;
1070 let runner = MockRunner { free: vec![] };
1071 let agent = "test-agent-2153-model-specific";
1072 let cooled_model = "opencode/github-copilot/qwen3.6-plus-free";
1073 let other_model = "opencode/minimax-m2.5-free";
1074
1075 let err = AgentError::RateLimit {
1076 message: "rate limit exceeded".to_string(),
1077 };
1078
1079 let _result = handle_error(
1080 "test-2153-b",
1081 &err,
1082 agent,
1083 &runner,
1084 Some(cooled_model),
1085 Some("simple"),
1086 1,
1087 &None,
1088 "owner/repo",
1089 )
1090 .await
1091 .unwrap();
1092
1093 assert!(
1095 crate::engine::cooldown::is_model_in_cooldown(agent, cooled_model),
1096 "rate-limited model should be in cooldown"
1097 );
1098 assert!(
1100 !crate::engine::cooldown::is_model_in_cooldown(agent, other_model),
1101 "other models should not be cooled by a single model's rate limit"
1102 );
1103 }
1104
1105 #[serial(cooldown_state)]
1116 #[tokio::test]
1117 async fn credit_exhaustion_does_not_bump_generic_failure_count() {
1118 crate::engine::cooldown::reset_global_state().await;
1119 let store = std::sync::Arc::new(
1120 crate::store::TaskStore::open_memory()
1121 .await
1122 .expect("in-memory store"),
1123 );
1124 crate::engine::cooldown::init_cooldown_store(std::sync::Arc::clone(&store)).await;
1125
1126 let runner = MockRunner { free: vec![] };
1127 let agent = "test-agent-3652-credit-no-bump";
1128
1129 let err = AgentError::RateLimit {
1130 message: "out of credits, please recharge your balance".to_string(),
1131 };
1132
1133 let _result = handle_error(
1134 "test-3652-a",
1135 &err,
1136 agent,
1137 &runner,
1138 None,
1139 Some("simple"),
1140 1,
1141 &Some(std::sync::Arc::clone(&store)),
1142 "owner/repo",
1143 )
1144 .await
1145 .unwrap();
1146
1147 let credit_count = store
1148 .kv_get(&format!("credit_failure_count:{agent}"))
1149 .await
1150 .unwrap();
1151 assert!(
1152 credit_count.is_some(),
1153 "credit_failure_count should be incremented by the credit error"
1154 );
1155 let generic_count = store
1156 .kv_get(&format!("failure_count:{agent}"))
1157 .await
1158 .unwrap();
1159 assert!(
1160 generic_count.is_none(),
1161 "failure_count must not be bumped by credit errors, got {generic_count:?}"
1162 );
1163 }
1164
1165 #[serial(cooldown_state)]
1168 #[tokio::test]
1169 async fn two_timeouts_leave_model_routable() {
1170 crate::engine::cooldown::reset_global_state().await;
1171 let store = std::sync::Arc::new(
1172 crate::store::TaskStore::open_memory()
1173 .await
1174 .expect("in-memory store"),
1175 );
1176 crate::engine::cooldown::init_cooldown_store(std::sync::Arc::clone(&store)).await;
1177
1178 let runner = MockRunner { free: vec![] };
1179 let agent = "opencode";
1180 let model = "opencode/nemotron-3-ultra-free";
1181
1182 let err = AgentError::Timeout {
1183 elapsed: std::time::Duration::from_mins(30),
1184 };
1185
1186 for i in 1..=2 {
1187 let _result = handle_error(
1188 &format!("test-3876-timeout-{i}"),
1189 &err,
1190 agent,
1191 &runner,
1192 Some(model),
1193 Some("simple"),
1194 i,
1195 &Some(std::sync::Arc::clone(&store)),
1196 "owner/repo",
1197 )
1198 .await
1199 .unwrap();
1200 }
1201
1202 assert!(
1203 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1204 "timeouts must not cool the model"
1205 );
1206 assert!(
1207 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1208 "timeouts must not cool the agent"
1209 );
1210 let counts = store.recent_rate_limit_counts(6).await.unwrap();
1211 assert_eq!(
1212 counts.get(agent).copied().unwrap_or(0),
1213 0,
1214 "timeouts must not write a rate_limits row"
1215 );
1216 }
1217
1218 #[serial(cooldown_state)]
1221 #[tokio::test]
1222 async fn interrupted_run_does_not_write_rate_limit() {
1223 crate::engine::cooldown::reset_global_state().await;
1224 let store = std::sync::Arc::new(
1225 crate::store::TaskStore::open_memory()
1226 .await
1227 .expect("in-memory store"),
1228 );
1229 crate::engine::cooldown::init_cooldown_store(std::sync::Arc::clone(&store)).await;
1230
1231 let runner = MockRunner { free: vec![] };
1232 let agent = "claude";
1233 let model = "sonnet";
1234 let err = AgentError::Interrupted {
1235 message: "exited without a result envelope".to_string(),
1236 };
1237
1238 let _result = handle_error(
1239 "test-3876-interrupted",
1240 &err,
1241 agent,
1242 &runner,
1243 Some(model),
1244 Some("medium"),
1245 1,
1246 &Some(std::sync::Arc::clone(&store)),
1247 "owner/repo",
1248 )
1249 .await
1250 .unwrap();
1251
1252 assert!(
1253 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1254 "interrupted runs must not cool the model"
1255 );
1256 let counts = store.recent_rate_limit_counts(6).await.unwrap();
1257 assert_eq!(
1258 counts.get(agent).copied().unwrap_or(0),
1259 0,
1260 "interrupted runs must not write a rate_limits row"
1261 );
1262 }
1263
1264 #[serial(cooldown_state)]
1277 #[tokio::test]
1278 async fn rate_limit_with_relative_window_sets_window_length_model_cooldown() {
1279 crate::engine::cooldown::reset_global_state().await;
1280 let runner = MockRunner { free: vec![] };
1281 let agent = "test-agent-3580-kimi";
1282 let model = "opus";
1283 let key = format!("{agent}:{model}");
1284
1285 let err = AgentError::RateLimit {
1286 message: "You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window expires.".to_string(),
1287 };
1288
1289 let _result = handle_error(
1290 "test-3580-a",
1291 &err,
1292 agent,
1293 &runner,
1294 Some(model),
1295 Some("medium"),
1296 1,
1297 &None,
1298 "owner/repo",
1299 )
1300 .await
1301 .unwrap();
1302
1303 let now = chrono::Utc::now().timestamp();
1304 let until = crate::engine::cooldown::cooldown_until(&key)
1305 .expect("relative usage window should set model cooldown");
1306 let remaining = until.saturating_sub(now);
1307
1308 assert!(
1309 remaining >= 7 * 86400 - 30,
1310 "expected ~7-day model cooldown from the parsed window, got {remaining}s"
1311 );
1312 assert_eq!(
1313 crate::engine::cooldown::cooldown_reason(&key).as_deref(),
1314 Some("rate_limit_usage_window"),
1315 "relative usage-window cooldown must be labeled as a rate limit, not silence"
1316 );
1317 }
1318
1319 #[serial(cooldown_state)]
1320 #[tokio::test]
1321 async fn provider_returned_error_sets_short_model_cooldown() {
1322 crate::engine::cooldown::reset_global_state().await;
1323 let runner = MockRunner { free: vec![] };
1324 let agent = "opencode";
1325 let model = "opencode/nemotron-3-super-free";
1326 let key = format!("{agent}:{model}");
1327
1328 let err = AgentError::RateLimit {
1329 message: "Provider returned error".to_string(),
1330 };
1331
1332 let _result = handle_error(
1333 "test-2478-provider-cooldown",
1334 &err,
1335 agent,
1336 &runner,
1337 Some(model),
1338 Some("simple"),
1339 1,
1340 &None,
1341 "owner/repo",
1342 )
1343 .await
1344 .unwrap();
1345
1346 let now = chrono::Utc::now().timestamp();
1347 let until = crate::engine::cooldown::cooldown_until(&key)
1348 .expect("provider error should set model cooldown");
1349 let remaining = until.saturating_sub(now);
1350
1351 assert!(
1352 remaining <= crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
1353 "expected a 5 minute model cooldown, got {remaining}s"
1354 );
1355 }
1356
1357 #[serial(cooldown_state)]
1358 #[tokio::test]
1359 async fn model_unavailable_sets_persistent_model_cooldown_for_codex_style() {
1360 crate::engine::cooldown::reset_global_state().await;
1361 let runner = MockRunner { free: vec![] };
1362 let agent = "codex";
1363 let model = "gpt-5.3-codex";
1364
1365 let err = crate::engine::runner::agents::AgentError::ModelUnavailable {
1367 model: model.to_string(),
1368 message: "{\"detail\":\"The 'gpt-5.3-codex' model is not supported\"}".to_string(),
1369 };
1370
1371 let _result = handle_error(
1372 "test-codex-model-unsupported",
1373 &err,
1374 agent,
1375 &runner,
1376 Some(model),
1377 Some("simple"),
1378 1,
1379 &None,
1380 "owner/repo",
1381 )
1382 .await
1383 .unwrap();
1384
1385 let key = format!("{agent}:{model}");
1387 let now = chrono::Utc::now().timestamp();
1388 let until =
1389 crate::engine::cooldown::cooldown_until(&key).expect("model cooldown should be set");
1390 let remaining = until.saturating_sub(now);
1391
1392 assert!(
1393 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1394 "expected persistent model cooldown (~4h) for codex model-unavailable, got {remaining}s"
1395 );
1396 }
1397
1398 async fn vendor_reset_remaining(agent: &str, model: &str, message: &str) -> i64 {
1399 crate::engine::cooldown::reset_global_state().await;
1400 let runner = MockRunner { free: vec![] };
1401 let err = crate::engine::runner::agents::AgentError::RateLimit {
1402 message: message.to_string(),
1403 };
1404 let _ = handle_error(
1405 "test-3649",
1406 &err,
1407 agent,
1408 &runner,
1409 Some(model),
1410 Some("simple"),
1411 1,
1412 &None,
1413 "owner/repo",
1414 )
1415 .await
1416 .unwrap();
1417 let until = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
1418 .expect("model cooldown should be set");
1419 until - chrono::Utc::now().timestamp()
1420 }
1421
1422 #[serial(cooldown_state)]
1423 #[tokio::test]
1424 async fn vendor_reset_date_overrides_weekly_window() {
1425 use chrono::Datelike;
1426 let t = chrono::Local::now() + chrono::Duration::days(2);
1427 let msg = format!(
1428 "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
1429 t.format("%b"),
1430 t.day()
1431 );
1432 let remaining = vendor_reset_remaining("claude", "sonnet", &msg).await;
1433 assert!(
1434 remaining > 3600 && remaining < 3 * 86400,
1435 "expected ~2d cooldown, got {remaining}s"
1436 );
1437 }
1438
1439 #[serial(cooldown_state)]
1440 #[tokio::test]
1441 async fn vendor_reset_date_overrides_billing_cycle_backoff() {
1442 let t = chrono::Local::now() + chrono::Duration::days(3);
1443 let msg = format!(
1444 "You've hit your usage limit. Upgrade to Pro (https://x), or try again at {}.",
1445 t.format("%b %d, %Y %I:%M %p")
1446 );
1447 let remaining = vendor_reset_remaining("codex", "gpt-5.4", &msg).await;
1448 assert!(
1449 remaining > 2 * 86400 + 80000 - 3600 && remaining < 4 * 86400,
1450 "expected ~3d cooldown, got {remaining}s"
1451 );
1452 }
1453
1454 #[serial(cooldown_state)]
1455 #[tokio::test]
1456 async fn agent_failed_insufficient_balance_sets_credit_cooldown() {
1457 crate::engine::cooldown::reset_global_state().await;
1458 let runner = MockRunner { free: vec![] };
1459 let agent = "test-agent-3642-insufficient-balance";
1460
1461 assert!(
1463 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1464 "agent should not be in cooldown before handle_error"
1465 );
1466
1467 let err = agents::AgentError::AgentFailed {
1468 message: "402 Payment Required: insufficient balance".to_string(),
1469 };
1470
1471 let _result = handle_error(
1473 "test-3642-a",
1474 &err,
1475 agent,
1476 &runner,
1477 Some("sonnet"),
1478 Some("medium"),
1479 1,
1480 &None,
1481 "owner/repo",
1482 )
1483 .await
1484 .unwrap();
1485
1486 assert!(
1491 crate::engine::cooldown::is_agent_in_cooldown(agent),
1492 "agent should be in cooldown after handle_error for AgentFailed with insufficient balance"
1493 );
1494
1495 let until = crate::engine::cooldown::cooldown_until(agent)
1496 .expect("agent cooldown should be active");
1497 let remaining = until - chrono::Utc::now().timestamp();
1498 assert!(
1499 remaining >= crate::engine::cooldown::CREDIT_BACKOFF_BASE_SECS - 30,
1500 "credit cooldown should be at least the 1h base, got {remaining}s"
1501 );
1502 }
1503
1504 #[serial(cooldown_state)]
1505 #[tokio::test]
1506 async fn silent_exit0_retries_free_model_for_simple_complexity() {
1507 crate::engine::cooldown::reset_global_state().await;
1508 let runner = MockRunner {
1509 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1510 };
1511 let err = AgentError::Unknown {
1512 exit_code: 0,
1513 message: String::new(),
1514 };
1515
1516 let result = handle_error(
1518 "test-1112-a",
1519 &err,
1520 "opencode",
1521 &runner,
1522 Some("opencode/gpt-5.4-mini"),
1523 Some("simple"),
1524 1,
1525 &None,
1526 "owner/repo",
1527 )
1528 .await
1529 .unwrap();
1530
1531 assert!(
1532 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1533 "expected EarlyReturn{{status: new}} for simple complexity — free model should be tried first"
1534 );
1535 }
1536
1537 #[serial(cooldown_state)]
1538 #[tokio::test]
1539 async fn silent_exit0_retries_free_model_when_complexity_unknown() {
1540 crate::engine::cooldown::reset_global_state().await;
1541 let runner = MockRunner {
1542 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1543 };
1544 let err = AgentError::Unknown {
1545 exit_code: 0,
1546 message: String::new(),
1547 };
1548
1549 let result = handle_error(
1551 "test-1112-a2",
1552 &err,
1553 "opencode",
1554 &runner,
1555 Some("opencode/gpt-5.4-mini"),
1556 None,
1557 1,
1558 &None,
1559 "owner/repo",
1560 )
1561 .await
1562 .unwrap();
1563
1564 assert!(
1565 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1566 "expected EarlyReturn{{status: new}} for unknown complexity — free model should be tried first"
1567 );
1568 }
1569
1570 #[serial(cooldown_state)]
1571 #[tokio::test]
1572 async fn silent_exit0_retries_free_model_for_medium_complexity() {
1573 crate::engine::cooldown::reset_global_state().await;
1574 let runner = MockRunner {
1575 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1576 };
1577 let err = AgentError::Unknown {
1578 exit_code: 0,
1579 message: String::new(),
1580 };
1581
1582 let result = handle_error(
1584 "test-1195-medium",
1585 &err,
1586 "opencode",
1587 &runner,
1588 Some("opencode/copilot-gpt-5.4-mini"),
1589 Some("medium"),
1590 1,
1591 &None,
1592 "owner/repo",
1593 )
1594 .await
1595 .unwrap();
1596
1597 assert!(
1598 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1599 "expected EarlyReturn{{status: new}} for medium complexity — free model should be tried before failover"
1600 );
1601 }
1602
1603 #[serial(cooldown_state)]
1604 #[tokio::test]
1605 async fn silent_exit0_retries_free_model_for_complex_complexity() {
1606 crate::engine::cooldown::reset_global_state().await;
1607 let runner = MockRunner {
1608 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1609 };
1610 let err = AgentError::Unknown {
1611 exit_code: 0,
1612 message: String::new(),
1613 };
1614
1615 let result = handle_error(
1617 "test-1195-complex",
1618 &err,
1619 "opencode",
1620 &runner,
1621 Some("opencode/copilot-gpt-5.4-mini"),
1622 Some("complex"),
1623 1,
1624 &None,
1625 "owner/repo",
1626 )
1627 .await
1628 .unwrap();
1629
1630 assert!(
1631 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1632 "expected EarlyReturn{{status: new}} for complex complexity — free model should be tried before failover"
1633 );
1634 }
1635
1636 #[serial(cooldown_state)]
1639 #[tokio::test]
1640 async fn context_overflow_escalates_to_needs_review_immediately() {
1641 crate::engine::cooldown::reset_global_state().await;
1642 let runner = MockRunner { free: vec![] };
1643 let err = AgentError::ContextOverflow {
1644 message: "prompt is too long: 250000 tokens, max 200000".to_string(),
1645 };
1646
1647 let result = handle_error(
1648 "test-2129-a",
1649 &err,
1650 "claude",
1651 &runner,
1652 Some("sonnet"),
1653 Some("medium"),
1654 1,
1655 &None,
1656 "owner/repo",
1657 )
1658 .await
1659 .unwrap();
1660
1661 assert!(
1662 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "needs_review"),
1663 "context overflow must escalate directly to needs_review without cycling through fallback agents"
1664 );
1665 }
1666
1667 #[serial(cooldown_state)]
1670 #[tokio::test]
1671 async fn free_model_reroute_succeeds_without_store() {
1672 crate::engine::cooldown::reset_global_state().await;
1673 let runner = MockRunner {
1674 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1675 };
1676 let err = AgentError::Unknown {
1677 exit_code: 0,
1678 message: String::new(),
1679 };
1680
1681 let result = handle_error(
1683 "test-2293-a",
1684 &err,
1685 "opencode",
1686 &runner,
1687 Some("opencode/gpt-5.4-mini"),
1688 Some("simple"),
1689 1,
1690 &None,
1691 "owner/repo",
1692 )
1693 .await
1694 .unwrap();
1695
1696 assert!(
1697 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1698 "expected EarlyReturn{{status: new}} — store_set_result with None store should not prevent reroute"
1699 );
1700 }
1701
1702 #[serial(cooldown_state)]
1703 #[tokio::test]
1704 async fn silent_exit0_falls_through_when_no_free_models() {
1705 crate::engine::cooldown::reset_global_state().await;
1706 let runner = MockRunner { free: vec![] };
1707 let err = AgentError::Unknown {
1708 exit_code: 0,
1709 message: String::new(),
1710 };
1711
1712 let result = handle_error(
1713 "test-1112-b",
1714 &err,
1715 "opencode",
1716 &runner,
1717 Some("opencode/gpt-5.4-mini"),
1718 Some("simple"),
1719 1,
1720 &None,
1721 "owner/repo",
1722 )
1723 .await
1724 .unwrap();
1725
1726 assert!(
1727 matches!(result, ErrorHandleResult::Continue { .. }),
1728 "expected Continue (fallthrough to failover) when no free models available"
1729 );
1730 }
1731
1732 #[serial(cooldown_state)]
1738 #[tokio::test]
1739 async fn invalid_response_sets_model_cooldown() {
1740 crate::engine::cooldown::reset_global_state().await;
1741 let runner = MockRunner { free: vec![] };
1742 let agent = "opencode";
1743 let model = "opencode/nemotron-3-super-free";
1744
1745 assert!(
1747 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1748 "model should not be in cooldown before handle_error"
1749 );
1750
1751 let err = AgentError::InvalidResponse {
1752 raw: "invalid json output".to_string(),
1753 };
1754
1755 let _result = handle_error(
1756 "test-2750-invalid-response",
1757 &err,
1758 agent,
1759 &runner,
1760 Some(model),
1761 Some("simple"),
1762 1,
1763 &None,
1764 "owner/repo",
1765 )
1766 .await
1767 .unwrap();
1768
1769 assert!(
1771 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1772 "model should be in cooldown after InvalidResponse (fixes issue #2750)"
1773 );
1774 }
1775
1776 #[serial(cooldown_state)]
1783 #[tokio::test]
1784 async fn billing_cycle_exhausted_with_known_model_sets_only_model_cooldown() {
1785 crate::engine::cooldown::reset_global_state().await;
1786 let runner = MockRunner { free: vec![] };
1787 let agent = "codex-3382-billing-cycle";
1788 let model = "gpt-5.5";
1789
1790 let err = AgentError::RateLimit {
1791 message: "rate limit: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro).".to_string(),
1792 };
1793
1794 let _result = handle_error(
1795 "test-3382-a",
1796 &err,
1797 agent,
1798 &runner,
1799 Some(model),
1800 Some("medium"),
1801 1,
1802 &None,
1803 "owner/repo",
1804 )
1805 .await
1806 .unwrap();
1807
1808 assert!(
1810 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1811 "agent must NOT be in agent-level cooldown when BillingCycleExhausted has a known model (issue #3382)"
1812 );
1813 assert!(
1815 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1816 "model must be in cooldown after BillingCycleExhausted (issue #3382)"
1817 );
1818 assert!(
1820 !crate::engine::cooldown::is_model_in_cooldown(agent, "gpt-5.4"),
1821 "other models on the same agent must not be cooled by a single model's billing exhaustion"
1822 );
1823 }
1824
1825 #[serial(cooldown_state)]
1826 #[tokio::test]
1827 async fn summarize_rate_limit_error_extracts_api_retry_info() {
1828 crate::engine::cooldown::reset_global_state().await;
1829 let raw_output = r#"some log lines
1830{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1831{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1832more logs"#;
1833
1834 let summarized = super::summarize_rate_limit_error(raw_output);
1835 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1836 }
1837
1838 #[serial(cooldown_state)]
1839 #[tokio::test]
1840 async fn summarize_rate_limit_error_returns_original_when_no_api_retry() {
1841 crate::engine::cooldown::reset_global_state().await;
1842 let raw_output = "some regular error message without api_retry JSON";
1843
1844 let summarized = super::summarize_rate_limit_error(raw_output);
1845 assert_eq!(summarized, raw_output);
1846 }
1847
1848 #[serial(cooldown_state)]
1849 #[tokio::test]
1850 async fn summarize_rate_limit_error_handles_malformed_json_gracefully() {
1851 crate::engine::cooldown::reset_global_state().await;
1852 let raw_output = r#"some logs
1853{"type":"system","subtype":"api_retry","attempt":"not_a_number","retry_delay_ms":invalid,"error_status":429}
1854more logs"#;
1855
1856 let summarized = super::summarize_rate_limit_error(raw_output);
1857 assert!(summarized.starts_with("status="));
1859 assert!(summarized.contains("after"));
1860 assert!(summarized.contains("attempts"));
1861 assert!(summarized.contains("(last delay"));
1862 }
1863
1864 #[serial(cooldown_state)]
1865 #[tokio::test]
1866 async fn summarize_rate_limit_error_works_with_single_line() {
1867 crate::engine::cooldown::reset_global_state().await;
1868 let raw_output = r#"{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":5000,"error_status":429,"error":"rate_limit"}"#;
1869
1870 let summarized = super::summarize_rate_limit_error(raw_output);
1871 assert_eq!(summarized, "status=429 after 3 attempts (last delay 5s)");
1872 }
1873
1874 #[serial(cooldown_state)]
1878 #[tokio::test]
1879 async fn extract_json_field_handles_quoted_strings() {
1880 crate::engine::cooldown::reset_global_state().await;
1881 let json = r#"{"error_status":429,"attempt":7,"retry_delay_ms":35162.66,"error":"rate_limit","session_id":"331c099b-..."}"#;
1882
1883 assert_eq!(
1884 super::extract_json_field(json, "\"error\":"),
1885 Some("rate_limit".to_string()),
1886 "quoted string field mid-object should be extracted correctly"
1887 );
1888 assert_eq!(
1889 super::extract_json_field(json, "\"session_id\":"),
1890 Some("331c099b-...".to_string()),
1891 "quoted string field at end of object (no trailing comma) should be extracted"
1892 );
1893 assert_eq!(
1895 super::extract_json_field(json, "\"error_status\":"),
1896 Some("429".to_string())
1897 );
1898 assert_eq!(
1899 super::extract_json_field(json, "\"attempt\":"),
1900 Some("7".to_string())
1901 );
1902 assert_eq!(
1903 super::extract_json_field(json, "\"retry_delay_ms\":"),
1904 Some("35162.66".to_string())
1905 );
1906 }
1907
1908 #[serial(cooldown_state)]
1911 #[tokio::test]
1912 async fn summarize_rate_limit_error_handles_truncated_fragment_with_subtype() {
1913 crate::engine::cooldown::reset_global_state().await;
1914 let raw_output = r#"some log context
1916,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit"
1917more context"#;
1918
1919 let summarized = super::summarize_rate_limit_error(raw_output);
1920 assert!(
1921 !summarized.contains("subtype"),
1922 "summarized output must not contain raw 'subtype' JSON fragment"
1923 );
1924 assert!(
1925 !summarized.contains('{'),
1926 "summarized output must not contain JSON braces"
1927 );
1928 assert!(
1929 summarized.starts_with("status="),
1930 "summarized output should start with 'status=' — got: {summarized}"
1931 );
1932 }
1936
1937 #[serial(cooldown_state)]
1939 #[tokio::test]
1940 async fn summarize_rate_limit_error_handles_fragment_with_extractable_fields() {
1941 crate::engine::cooldown::reset_global_state().await;
1942 let raw_output = r#"log output
1943,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit","session_id":"abc123","max_retries":10}
1944rest of output"#;
1945
1946 let summarized = super::summarize_rate_limit_error(raw_output);
1947 assert_eq!(summarized, "status=429 after 5 attempts (last delay 20s)");
1948 }
1949
1950 #[serial(cooldown_state)]
1952 #[tokio::test]
1953 async fn summarize_rate_limit_error_complete_json_still_works() {
1954 crate::engine::cooldown::reset_global_state().await;
1955 let raw_output = r#"some log lines
1956{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1957{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1958more logs"#;
1959
1960 let summarized = super::summarize_rate_limit_error(raw_output);
1961 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1962 }
1963
1964 #[serial(cooldown_state)]
1966 #[tokio::test]
1967 async fn summarize_rate_limit_error_no_api_retry_no_raw_passthrough() {
1968 crate::engine::cooldown::reset_global_state().await;
1969 let raw_output = "some regular error message without api_retry JSON that is quite long and should be truncated";
1970
1971 let summarized = super::summarize_rate_limit_error(raw_output);
1972 assert!(
1974 summarized.len() <= 140,
1975 "expected truncated first-line, got: {summarized}"
1976 );
1977 }
1978
1979 #[serial(cooldown_state)]
1981 #[tokio::test]
1982 async fn summarize_rate_limit_error_no_api_retry_short() {
1983 crate::engine::cooldown::reset_global_state().await;
1984 let raw_output = "rate limit exceeded";
1985
1986 let summarized = super::summarize_rate_limit_error(raw_output);
1987 assert_eq!(summarized, raw_output);
1988 }
1989
1990 #[serial(cooldown_state)]
1996 #[tokio::test]
1997 async fn model_not_found_applies_persistent_cooldown() {
1998 crate::engine::cooldown::reset_global_state().await;
1999 let runner = MockRunner { free: vec![] };
2000 let agent = "opencode-2941-not-found";
2001 let model = "github-copilot/claude-opus-4.6";
2002 let key = format!("{agent}:{model}");
2003
2004 let err = AgentError::ModelUnavailable {
2005 model: model.to_string(),
2006 message: "Model not found: github-copilot/claude-opus-4.6".to_string(),
2007 };
2008
2009 let _result = handle_error(
2010 "test-2941-a",
2011 &err,
2012 agent,
2013 &runner,
2014 Some(model),
2015 Some("complex"),
2016 1,
2017 &None,
2018 "owner/repo",
2019 )
2020 .await
2021 .unwrap();
2022
2023 let now = chrono::Utc::now().timestamp();
2024 let until = crate::engine::cooldown::cooldown_until(&key)
2025 .expect("model not found should set model cooldown");
2026 let remaining = until.saturating_sub(now);
2027
2028 assert!(
2031 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2032 "expected ~4h (persistent) cooldown for 'Model not found', got {remaining}s — \
2033 may have applied transient 5min cooldown instead"
2034 );
2035 }
2036
2037 #[serial(cooldown_state)]
2044 #[tokio::test]
2045 async fn model_not_supported_applies_persistent_cooldown() {
2046 crate::engine::cooldown::reset_global_state().await;
2047 let runner = MockRunner { free: vec![] };
2048 let agent = "codex-3215-not-supported";
2049 let model = "gpt-5.2-codex";
2050 let key = format!("{agent}:{model}");
2051
2052 let err = AgentError::ModelUnavailable {
2053 model: model.to_string(),
2054 message: "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.".to_string(),
2055 };
2056
2057 let _result = handle_error(
2058 "test-3215-a",
2059 &err,
2060 agent,
2061 &runner,
2062 Some(model),
2063 Some("medium"),
2064 1,
2065 &None,
2066 "owner/repo",
2067 )
2068 .await
2069 .unwrap();
2070
2071 let now = chrono::Utc::now().timestamp();
2072 let until = crate::engine::cooldown::cooldown_until(&key)
2073 .expect("model not supported should set model cooldown");
2074 let remaining = until.saturating_sub(now);
2075
2076 assert!(
2077 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2078 "expected ~4h (persistent) cooldown for 'not supported', got {remaining}s — \
2079 may have applied transient 5min cooldown instead (issue #3215)"
2080 );
2081 }
2082
2083 #[serial(cooldown_state)]
2084 #[tokio::test]
2085 async fn model_not_available_in_country_applies_persistent_cooldown() {
2086 crate::engine::cooldown::reset_global_state().await;
2087 let runner = MockRunner { free: vec![] };
2088 let agent = "opencode-3650-country";
2089 let model = "some-region-locked-model";
2090 let key = format!("{agent}:{model}");
2091
2092 for message in [
2094 "This model is not available in your country.",
2095 "This model is not available in your region.",
2096 ] {
2097 crate::engine::cooldown::reset_global_state().await;
2098 let err = AgentError::ModelUnavailable {
2099 model: model.to_string(),
2100 message: message.to_string(),
2101 };
2102
2103 let _result = handle_error(
2104 "test-3650",
2105 &err,
2106 agent,
2107 &runner,
2108 Some(model),
2109 Some("medium"),
2110 1,
2111 &None,
2112 "owner/repo",
2113 )
2114 .await
2115 .unwrap();
2116
2117 let now = chrono::Utc::now().timestamp();
2118 let until = crate::engine::cooldown::cooldown_until(&key)
2119 .expect("region block should set model cooldown");
2120 let remaining = until.saturating_sub(now);
2121
2122 assert!(
2123 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2124 "expected ~4h (persistent) cooldown for '{message}', got {remaining}s — \
2125 may have applied transient 5min cooldown instead (issue #3650)"
2126 );
2127 }
2128 }
2129
2130 #[serial(cooldown_state)]
2136 #[tokio::test]
2137 async fn thinking_block_conflict_does_not_set_cooldown() {
2138 crate::engine::cooldown::reset_global_state().await;
2139 let runner = MockRunner { free: vec![] };
2140 let agent = "claude-3204-thinking-block";
2141 let model = "opus";
2142
2143 assert!(
2144 !crate::engine::cooldown::is_agent_in_cooldown(agent),
2145 "agent must not be in cooldown before handle_error"
2146 );
2147 assert!(
2148 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
2149 "model must not be in cooldown before handle_error"
2150 );
2151
2152 let err = AgentError::ThinkingBlockConflict {
2153 message: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.".to_string(),
2154 };
2155
2156 let _result = handle_error(
2157 "test-3204-thinking",
2158 &err,
2159 agent,
2160 &runner,
2161 Some(model),
2162 Some("complex"),
2163 1,
2164 &None,
2165 "owner/repo",
2166 )
2167 .await
2168 .unwrap();
2169
2170 assert!(
2171 !crate::engine::cooldown::is_agent_in_cooldown(agent),
2172 "agent must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2173 );
2174 assert!(
2175 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
2176 "model must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2177 );
2178 }
2179
2180 #[serial(cooldown_state)]
2182 #[tokio::test]
2183 async fn model_unavailable_transient_applies_short_cooldown() {
2184 crate::engine::cooldown::reset_global_state().await;
2185 let runner = MockRunner { free: vec![] };
2186 let agent = "opencode-2941-transient";
2187 let model = "github-copilot/gpt-4o";
2188 let key = format!("{agent}:{model}");
2189
2190 let err = AgentError::ModelUnavailable {
2191 model: model.to_string(),
2192 message: "No endpoints found for github-copilot/gpt-4o.".to_string(),
2193 };
2194
2195 let _result = handle_error(
2196 "test-2941-b",
2197 &err,
2198 agent,
2199 &runner,
2200 Some(model),
2201 Some("medium"),
2202 1,
2203 &None,
2204 "owner/repo",
2205 )
2206 .await
2207 .unwrap();
2208
2209 let now = chrono::Utc::now().timestamp();
2210 let until = crate::engine::cooldown::cooldown_until(&key)
2211 .expect("transient model unavailability should set model cooldown");
2212 let remaining = until.saturating_sub(now);
2213
2214 assert!(
2217 remaining < crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2218 "transient unavailability should use short cooldown (<4h), got {remaining}s"
2219 );
2220 }
2221
2222 #[serial(cooldown_state)]
2223 #[tokio::test]
2224 async fn model_unavailable_does_not_reuse_same_agent_when_agent_cooled() {
2225 crate::engine::cooldown::reset_global_state().await;
2226 let agent = "codex-3620";
2227 let failing_model = "gpt-5.4";
2228 let other_model = "gpt-5.5";
2229 let runner = MockRunnerWithModels {
2230 models: vec![failing_model.to_string(), other_model.to_string()],
2231 };
2232
2233 crate::engine::cooldown::set_agent_cooldown(agent, 3600).await;
2234
2235 let err = AgentError::ModelUnavailable {
2236 model: failing_model.to_string(),
2237 message: "model gpt-5.4 unavailable".to_string(),
2238 };
2239
2240 let result = handle_error(
2241 "test-3620",
2242 &err,
2243 agent,
2244 &runner,
2245 Some(failing_model),
2246 Some("medium"),
2247 1,
2248 &None,
2249 "owner/repo",
2250 )
2251 .await
2252 .unwrap();
2253
2254 if let ErrorHandleResult::EarlyReturn { status } = result {
2255 assert_ne!(
2256 status, "routed",
2257 "agent-wide cooldown was defeated by same-agent model substitution"
2258 );
2259 }
2260 }
2261}