1use crate::store;
10use crate::store::TaskStore;
11use std::collections::HashSet;
12use std::collections::hash_map::DefaultHasher;
13use std::fs;
14use std::fs::File;
15use std::hash::Hash;
16use std::hash::Hasher;
17use std::io::Read;
18use std::io::Seek;
19use std::io::SeekFrom;
20use std::path::{Path, PathBuf};
21use std::result;
22use std::sync::Arc;
23use std::thread;
24use std::time::Duration;
25use std::time::SystemTime;
26
27use tokio::fs as tokio_fs;
28use tokio::task;
29use tokio::time;
30use crate::cli::ndjson;
32use crate::cmd_cache;
33use crate::engine;
34pub use crate::engine::cooldown::{
35 clear_expired_cooldowns, is_agent_in_cooldown, is_model_in_cooldown,
36 record_agent_failure_with_message, record_model_failure, set_agent_cooldown,
37};
38use crate::engine::router::config;
39use crate::home;
40use crate::parser::AgentResponse;
41use crate::store::MemoryEntry;
42use crate::store::Task;
43
44const JITTER_FACTOR: f64 = 0.3;
45
46const MAX_OUTPUT_SIZE_BYTES: u64 = 10 * 1024 * 1024;
49
50fn read_file_with_limit<P: AsRef<Path>>(path: P, max_bytes: u64) -> anyhow::Result<String> {
56 let path = path.as_ref();
57 let metadata = fs::metadata(path)?;
58
59 if !metadata.is_file() {
60 anyhow::bail!("not a file: {}", path.display());
61 }
62
63 let file_size = metadata.len();
64
65 if file_size <= max_bytes {
67 return fs::read_to_string(path).map_err(Into::into);
68 }
69
70 let mut file = File::open(path)?;
72 let start_pos = file_size.saturating_sub(max_bytes);
73 file.seek(SeekFrom::Start(start_pos))?;
74
75 let mut buffer = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(0));
76 file.read_to_end(&mut buffer)?;
77
78 let content = String::from_utf8_lossy(&buffer);
80
81 tracing::warn!(
83 path = %path.display(),
84 file_size = file_size,
85 max_bytes = max_bytes,
86 "output file truncated: exceeded size limit"
87 );
88
89 Ok(content.into_owned())
90}
91
92pub(crate) fn calculate_backoff_delay(retry_count: usize) -> u64 {
97 let base_delay = config::retry_base_delay_ms();
98 let max_delay = config::retry_max_delay_ms();
99
100 let exponential =
102 base_delay * (2_u64.saturating_pow(u32::try_from(retry_count).unwrap_or(u32::MAX)));
103 let capped = exponential.min(max_delay);
104
105 #[expect(
110 clippy::cast_precision_loss,
111 clippy::cast_possible_truncation,
112 clippy::cast_sign_loss,
113 reason = "a jitter range in milliseconds, far below 2^52 and never negative"
114 )]
115 let jitter_range = (capped as f64 * JITTER_FACTOR) as u64;
116 let jitter = {
117 let mut h = DefaultHasher::new();
118 SystemTime::now().hash(&mut h);
119 thread::current().id().hash(&mut h);
120 h.finish() % (jitter_range * 2 + 1)
121 };
122
123 capped.saturating_sub(jitter_range) + jitter
124}
125
126fn fallback_retry_count(task: Option<&Task>) -> usize {
127 task.map_or(0, |t| {
128 let chain_retry_count = t
129 .limit_reroute_chain
130 .split(',')
131 .filter(|entry| !entry.is_empty())
132 .count();
133 let network_retry_count = usize::try_from(t.network_retries).unwrap_or(0);
134 chain_retry_count.max(network_retry_count)
135 })
136}
137
138pub async fn wait_for_fallback_backoff(
141 task_id: &str,
142 store: &Option<Arc<TaskStore>>,
143 repo: &str,
144) -> u64 {
145 let task = store::opt_store_get_task(store, repo, task_id).await;
146
147 let retry_count = fallback_retry_count(task.as_ref());
150
151 let delay = calculate_backoff_delay(retry_count);
152
153 if delay > 0 {
154 tracing::debug!(
155 task_id,
156 retry_count = retry_count,
157 delay_ms = delay,
158 "fallback backoff: waiting before retry"
159 );
160 time::sleep(Duration::from_millis(delay)).await;
161 }
162
163 delay
164}
165
166#[derive(Debug, Clone)]
171pub enum WeightSignal {
172 Success { agent: String },
174 RateLimited { agent: String },
176 Blocked,
178 Rerouted,
181 None,
183}
184
185pub async fn read_output_file(task_id: &str, primary_path: &Path, repo: &str) -> String {
190 if let Some(content) = task::spawn_blocking({
192 let p = primary_path.to_path_buf();
193 move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
194 })
195 .await
196 .ok()
197 .and_then(result::Result::ok)
198 && !content.is_empty()
199 {
200 return content;
201 }
202
203 if let Ok(task_dir) = home::task_dir_async(repo, task_id).await {
205 let attempts_dir = task_dir.join("attempts");
206 if tokio_fs::metadata(&attempts_dir)
208 .await
209 .is_ok_and(|m| m.is_dir())
210 {
211 let mut attempt_nums: Vec<u32> = task::spawn_blocking({
212 let a = attempts_dir.clone();
213 move || {
214 match fs::read_dir(&a) {
215 Ok(entries) => {
216 let mut nums = Vec::new();
217 for entry in entries {
218 match entry {
219 Ok(e) => {
220 if let Some(n) =
221 e.file_name().to_str().and_then(|n| n.parse().ok())
222 {
223 nums.push(n);
224 }
225 }
226 Err(e) => tracing::warn!(
227 path = %a.display(),
228 err = %e,
229 "error reading attempt dir entry in output fallback"
230 ),
231 }
232 }
233 nums
234 }
235 Err(e) => {
236 tracing::warn!(path = %a.display(), err = %e, "failed to read attempts dir in output fallback");
237 Vec::new()
238 }
239 }
240 }
241 })
242 .await
243 .unwrap_or_default();
244
245 attempt_nums.sort_unstable_by(|a, b| b.cmp(a)); for n in attempt_nums {
247 let p = attempts_dir.join(n.to_string()).join("output.json");
248 if let Some(content) = task::spawn_blocking({
249 let p = p.clone();
250 move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
251 })
252 .await
253 .ok()
254 .and_then(result::Result::ok)
255 && !content.is_empty()
256 {
257 tracing::info!(task_id, path = %p.display(), "read output from attempt dir");
258 return content;
259 }
260 }
261 }
262 }
263
264 let state_dir = home::state_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
266
267 let mut fallbacks = vec![
268 PathBuf::from(format!("/tmp/output-{task_id}.json")),
269 state_dir.join(format!("output-{task_id}.json")),
270 ];
271
272 if let Ok(legacy_path) = home::state_file(&format!("output-{task_id}.json"))
273 && !fallbacks.contains(&legacy_path)
274 {
275 fallbacks.push(legacy_path);
276 }
277
278 for path in &fallbacks {
279 if let Some(content) = task::spawn_blocking({
280 let p = path.clone();
281 move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
282 })
283 .await
284 .ok()
285 .and_then(result::Result::ok)
286 && !content.is_empty()
287 {
288 tracing::info!(task_id, path = %path.display(), "read output from legacy fallback");
289 return content;
290 }
291 }
292
293 String::new()
294}
295
296pub fn pick_fallback_agent(
298 current_agent: &str,
299 chain: &str,
300 available_agents: &[String],
301) -> Option<String> {
302 clear_expired_cooldowns();
304
305 let chain_set: HashSet<&str> = if chain.is_empty() {
306 HashSet::new()
307 } else {
308 chain.split(',').collect()
309 };
310
311 for agent in available_agents {
315 if agent == current_agent || chain_set.contains(agent.as_str()) {
316 continue;
317 }
318 if is_agent_in_cooldown(agent) {
319 continue;
320 }
321 return Some(agent.clone());
328 }
329
330 None
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub enum RetryableError {
336 Timeout,
338 Interrupted,
340 UsageLimit,
342 AuthError,
344 Failed,
346 MissingTooling,
348 ModelUnavailable,
352}
353
354impl RetryableError {
355 pub fn type_str(self) -> &'static str {
358 match self {
359 RetryableError::Timeout => "timeout",
360 RetryableError::Interrupted => "interrupted",
361 RetryableError::UsageLimit => "rate_limit",
362 RetryableError::AuthError => "auth_error",
363 RetryableError::ModelUnavailable => "model_unavailable",
364 RetryableError::Failed | RetryableError::MissingTooling => "failed",
365 }
366 }
367}
368
369pub async fn handle_failover(
378 task_id: &str,
379 agent_name: &str,
380 error_type: RetryableError,
381 error_message: &str,
382 credit_recorded: bool,
383 store: &Option<Arc<TaskStore>>,
384 repo: &str,
385) -> String {
386 let chain = get_reroute_chain(task_id, store, repo).await;
388 let chain = update_reroute_chain(task_id, agent_name, &chain, store, repo).await;
389
390 let available: Vec<String> = engine::configured_agents()
392 .into_iter()
393 .filter(|a| cmd_cache::command_exists(a))
394 .collect();
395
396 let chain_set: HashSet<&str> = if chain.is_empty() {
398 HashSet::new()
399 } else {
400 chain.split(',').collect()
401 };
402 let is_exhausted = available.iter().all(|a| chain_set.contains(a.as_str()));
403
404 if is_exhausted {
405 let is_retryable = matches!(
411 error_type,
412 RetryableError::UsageLimit
413 | RetryableError::AuthError
414 | RetryableError::ModelUnavailable
415 | RetryableError::Timeout
416 | RetryableError::Interrupted
417 );
418 let next_status = if is_retryable { "new" } else { "blocked" };
419
420 tracing::warn!(
421 task_id,
422 agent = agent_name,
423 error_type = ?error_type,
424 next_status,
425 "all agents exhausted"
426 );
427
428 let mut store_fields: Vec<(&str, serde_json::Value)> = Vec::new();
429
430 if is_retryable {
431 let msg = format!("{error_message} (all agents exhausted, will retry when available)");
434 store_fields.push(("last_error", serde_json::json!(msg)));
435 store_fields.push(("limit_reroute_chain", serde_json::json!("")));
436 } else {
437 let msg = format!("{error_message} (all agents exhausted)");
438 let block_reason = format!(
439 "all agents exhausted: {} — {}",
440 error_type.type_str(),
441 error_message
442 );
443 store_fields.push(("last_error", serde_json::json!(msg)));
444 store_fields.push(("block_reason", serde_json::json!(block_reason)));
445 }
446
447 if let Err(e) = store::store_set_result(store, repo, task_id, &store_fields).await {
448 tracing::warn!(task_id, err = %e, "failed to write agents_exhausted fields to store");
449 }
450
451 let details = serde_json::json!({
453 "failure_reason": error_type.type_str(),
454 "error_message": error_message,
455 "chain": chain,
456 "agents_exhausted": true,
457 "is_retryable": is_retryable,
458 });
459 store::store_log_activity(
460 store,
461 repo,
462 task_id,
463 "rerouted",
464 Some("in_progress"),
465 Some(next_status),
466 None::<&str>,
467 None::<&str>,
468 Some(&details),
469 )
470 .await;
471
472 return next_status.to_string();
473 }
474
475 if let Some(next) = pick_fallback_agent(agent_name, &chain, &available) {
477 tracing::info!(
478 task_id,
479 from = agent_name,
480 to = next,
481 error_type = ?error_type,
482 "failover: switching to fallback agent"
483 );
484
485 if !matches!(
487 error_type,
488 RetryableError::MissingTooling
489 | RetryableError::ModelUnavailable
490 | RetryableError::Timeout
491 | RetryableError::Interrupted
492 ) {
493 if !credit_recorded {
496 record_agent_failure_with_message(agent_name, error_message).await;
497 }
498
499 set_agent_cooldown(agent_name, 120).await;
504 }
505
506 let msg = format!("{error_message}, clearing agent/model for re-route");
507 if let Err(e) = store::store_set_result(
508 store,
509 repo,
510 task_id,
511 &[
512 ("agent", serde_json::json!("")),
513 ("model", serde_json::json!("")),
514 ("last_error", serde_json::json!(msg)),
515 ],
516 )
517 .await
518 {
519 tracing::warn!(task_id, err = %e, "failed to write agent/model clear to store");
520 }
521
522 wait_for_fallback_backoff(task_id, store, repo).await;
528
529 let details = serde_json::json!({
531 "failure_reason": error_type.type_str(),
532 "error_message": error_message,
533 "from_agent": agent_name,
534 "to_agent": next,
535 "chain": chain,
536 });
537 store::store_log_activity(
538 store,
539 repo,
540 task_id,
541 "rerouted",
542 Some("in_progress"),
543 Some("new"),
544 Some(&next),
545 None::<&str>,
546 Some(&details),
547 )
548 .await;
549
550 return "new".to_string();
551 }
552
553 tracing::warn!(task_id, agent = agent_name, "no fallback agents available");
555
556 if !matches!(
558 error_type,
559 RetryableError::MissingTooling
560 | RetryableError::ModelUnavailable
561 | RetryableError::Timeout
562 | RetryableError::Interrupted
563 ) && !credit_recorded
564 {
565 record_agent_failure_with_message(agent_name, error_message).await;
566 }
567
568 let msg = format!("{error_message}, no fallback agents");
569 if let Err(e) = store::store_set_result(
570 store,
571 repo,
572 task_id,
573 &[("last_error", serde_json::json!(msg))],
574 )
575 .await
576 {
577 tracing::warn!(task_id, err = %e, "failed to write no_fallback last_error to store");
578 }
579
580 let details = serde_json::json!({
582 "failure_reason": error_type.type_str(),
583 "error_message": error_message,
584 "chain": chain,
585 "no_fallback_available": true,
586 });
587 store::store_log_activity(
588 store,
589 repo,
590 task_id,
591 "rerouted",
592 Some("in_progress"),
593 Some("needs_review"),
594 None::<&str>,
595 None::<&str>,
596 Some(&details),
597 )
598 .await;
599
600 "needs_review".to_string()
601}
602
603pub async fn get_reroute_chain(
605 task_id: &str,
606 store: &Option<Arc<TaskStore>>,
607 repo: &str,
608) -> String {
609 store::opt_store_get_task(store, repo, task_id)
610 .await
611 .map(|t| t.limit_reroute_chain)
612 .unwrap_or_default()
613 .trim()
614 .to_string()
615}
616
617pub async fn update_reroute_chain(
619 task_id: &str,
620 current_agent: &str,
621 existing_chain: &str,
622 store: &Option<Arc<TaskStore>>,
623 repo: &str,
624) -> String {
625 let mut chain = existing_chain.to_string();
626 if chain.is_empty() {
627 chain = current_agent.to_string();
628 } else if !chain.split(',').any(|a| a == current_agent) {
629 chain = format!("{chain},{current_agent}");
630 }
631
632 if let Err(e) = store::store_set_result(
633 store,
634 repo,
635 task_id,
636 &[("limit_reroute_chain", serde_json::json!(chain))],
637 )
638 .await
639 {
640 tracing::warn!(task_id, err = %e, "failed to write limit_reroute_chain to store");
641 }
642 chain
643}
644
645#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
647pub struct ReviewResponse {
648 pub decision: String,
650 pub notes: String,
652 pub test_results: Option<String>,
654 pub issues: Vec<ReviewIssue>,
656}
657
658#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
660pub struct ReviewIssue {
661 pub file: String,
663 pub line: Option<u32>,
665 pub severity: String,
667 pub description: String,
669}
670
671pub fn parse_review_response(text: &str) -> anyhow::Result<ReviewResponse> {
677 let resp = parse_review_json(text)?;
678 if matches!(resp.decision.as_str(), "approve" | "request_changes") {
679 return Ok(resp);
680 }
681
682 let stripped = text.replace("approve|request_changes", "");
685 infer_review_response_from_text(&stripped)
686 .ok_or_else(|| anyhow::anyhow!("invalid review decision: {}", resp.decision))
687}
688
689fn parse_review_json(text: &str) -> anyhow::Result<ReviewResponse> {
690 if let Ok(resp) = serde_json::from_str::<ReviewResponse>(text) {
692 return Ok(resp);
693 }
694
695 if let Some(json_str) = extract_json_block(text)
697 && let Ok(resp) = serde_json::from_str::<ReviewResponse>(&json_str)
698 {
699 return Ok(resp);
700 }
701
702 if let Some(resp) = extract_review_response_object(text) {
703 return Ok(resp);
704 }
705
706 anyhow::bail!("failed to parse review response")
707}
708
709fn infer_review_response_from_text(text: &str) -> Option<ReviewResponse> {
710 let lower = text.to_ascii_lowercase();
711 let has_changes_requested =
712 lower.contains("changes requested") || lower.contains("request_changes");
713 if has_changes_requested {
714 return Some(ReviewResponse {
715 decision: "request_changes".to_string(),
716 notes: "Inferred decision from plain-text review output.".to_string(),
717 test_results: None,
718 issues: Vec::new(),
719 });
720 }
721
722 let positive_approval = lower
725 .split(|c: char| !c.is_ascii_alphabetic())
726 .any(|word| word == "lgtm")
727 || lower.contains("decision is `approve`")
728 || lower.contains("decision: `approve`")
729 || lower.contains("decision is approve")
730 || lower.contains("decision: approve");
731 if positive_approval {
732 return Some(ReviewResponse {
733 decision: "approve".to_string(),
734 notes: "Inferred decision from plain-text review output.".to_string(),
735 test_results: None,
736 issues: Vec::new(),
737 });
738 }
739
740 None
741}
742
743pub fn infer_review_response(text: &str) -> Option<ReviewResponse> {
747 infer_review_response_from_text(text)
748}
749
750fn extract_json_block(text: &str) -> Option<String> {
755 let mut search_from = 0;
756 while let Some(start) = text[search_from..].find("```json") {
757 let abs_start = search_from + start;
758 let after_tag = abs_start + "```json".len();
759 let Some(rest) = text[after_tag..].strip_prefix('\n') else {
760 search_from = after_tag;
761 continue;
762 };
763 let content_start = text.len() - rest.len();
764 let Some(end_offset) = rest.find("```") else {
765 search_from = content_start;
766 continue;
767 };
768 let end = content_start + end_offset;
769 let content = text[content_start..end].trim();
770 if content.starts_with('{') {
771 return Some(content.to_string());
772 }
773 search_from = end + 3;
774 }
775 None
776}
777
778fn extract_review_response_object(text: &str) -> Option<ReviewResponse> {
782 let mut start: Option<usize> = None;
783 let mut depth: i32 = 0;
784 let mut in_string = false;
785 let mut escape = false;
786
787 for (idx, ch) in text.char_indices() {
788 if start.is_some() {
789 if in_string {
790 if escape {
791 escape = false;
792 } else if ch == '\\' {
793 escape = true;
794 } else if ch == '"' {
795 in_string = false;
796 }
797 continue;
798 }
799
800 match ch {
801 '"' => in_string = true,
802 '{' => depth += 1,
803 '}' => {
804 depth -= 1;
805 if depth == 0 {
806 debug_assert!(
807 start.is_some(),
808 "start must be Some when depth returns to 0"
809 );
810 let start_idx = start.unwrap_or(0);
811 let candidate = &text[start_idx..=idx];
812 if let Ok(resp) = serde_json::from_str::<ReviewResponse>(candidate) {
813 return Some(resp);
814 }
815 start = None;
816 }
817 }
818 _ => {}
819 }
820 continue;
821 }
822
823 if ch == '{' {
824 start = Some(idx);
825 depth = 1;
826 in_string = false;
827 escape = false;
828 }
829 }
830
831 None
832}
833
834#[expect(
836 clippy::too_many_arguments,
837 reason = "a memory entry records the task, attempt, agent, model and outcome"
838)]
839pub async fn store_learnings_from_response(
840 task_id: &str,
841 attempt: u32,
842 agent: &str,
843 model: Option<&str>,
844 response: &AgentResponse,
845 error: Option<&str>,
846 store: &Option<Arc<TaskStore>>,
847 repo: &str,
848 raw_stdout: &str,
849) {
850 let files_modified = if response.files.is_empty() {
852 let extracted = ndjson::extract_files_changed(raw_stdout);
853 if !extracted.is_empty() {
854 tracing::debug!(
855 task_id,
856 count = extracted.len(),
857 "files_modified: fell back to NDJSON extraction (agent reported none)"
858 );
859 }
860 extracted
861 } else {
862 response.files.clone()
863 };
864
865 let entry = MemoryEntry {
867 attempt,
868 agent: agent.to_string(),
869 model: model.map(String::from),
870 learnings: response.learnings.clone(),
871 error: error.map(String::from),
872 files_modified,
873 approach: response.summary.clone(),
874 timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
875 };
876
877 if let Some(st) = store
878 && let Ok(Some(store_id)) = st.resolve_task_id(repo, task_id).await
879 {
880 match st.append_memory(store_id, &entry).await {
881 Err(e) => {
882 tracing::warn!(task_id, error = ?e, "failed to store memory");
883 }
884 _ => {
885 tracing::debug!(task_id, attempt, "stored memory for attempt");
886 }
887 }
888 }
889}
890
891pub async fn store_failure_memory(
893 task_id: &str,
894 attempt: u32,
895 agent: &str,
896 model: Option<&str>,
897 error: &str,
898 store: &Option<Arc<TaskStore>>,
899 repo: &str,
900) {
901 let entry = MemoryEntry {
902 attempt,
903 agent: agent.to_string(),
904 model: model.map(String::from),
905 learnings: vec![],
906 error: Some(error.to_string()),
907 files_modified: vec![],
908 approach: String::new(),
909 timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
910 };
911
912 if let Some(st) = store
913 && let Ok(Some(store_id)) = st.resolve_task_id(repo, task_id).await
914 {
915 match st.append_memory(store_id, &entry).await {
916 Err(e) => {
917 tracing::warn!(task_id, error = ?e, "failed to store failure memory");
918 }
919 _ => {
920 tracing::debug!(task_id, attempt, "stored failure memory");
921 }
922 }
923 }
924}
925
926#[cfg(test)]
927mod tests {
928 use std::env;
929 use std::process;
930
931 use super::*;
932 use crate::engine::cooldown;
933 use crate::engine::runner::agents::{AgentError, find_agent_result, patterns};
934 use crate::store::TaskStatus;
935 use serial_test::serial;
936
937 #[serial(cooldown_state)]
938 #[test]
939 fn calculate_backoff_delay_jitter_is_centered() {
940 let base = config::retry_base_delay_ms();
944 let max = config::retry_max_delay_ms();
945
946 let capped = base.min(max);
948 #[expect(
949 clippy::cast_precision_loss,
950 clippy::cast_possible_truncation,
951 clippy::cast_sign_loss,
952 reason = "mirrors the jitter range in calculate_backoff_delay"
953 )]
954 let jitter_range = (capped as f64 * JITTER_FACTOR) as u64;
955 let lo = capped.saturating_sub(jitter_range);
956 let hi = capped + jitter_range;
957
958 for _ in 0..200 {
959 let delay = calculate_backoff_delay(0);
960 assert!(
961 delay >= lo && delay <= hi,
962 "delay {delay} out of expected range [{lo}, {hi}]"
963 );
964 }
965 }
966
967 #[serial(cooldown_state)]
968 #[test]
969 fn fallback_retry_count_uses_larger_network_retry_streak() {
970 let task = Task {
971 id: 1,
972 external_id: None,
973 repo: "owner/repo".to_string(),
974 origin: "internal".to_string(),
975 title: "test".to_string(),
976 body: String::new(),
977 status: TaskStatus::New,
978 source: String::new(),
979 source_id: String::new(),
980 author: String::new(),
981 url: String::new(),
982 labels: vec![],
983 agent: None,
984 model: None,
985 complexity: "simple".to_string(),
986 estimate: 0,
987 route_reason: String::new(),
988 agent_profile: String::new(),
989 selected_skills: String::new(),
990 route_attempts: 0,
991 attempts: 0,
992 branch: String::new(),
993 worktree: String::new(),
994 worktree_cleaned: false,
995 owner_node: None,
996 summary: String::new(),
997 last_error: String::new(),
998 parent_id: None,
999 block_reason: None,
1000 pr_number: None,
1001 pr_review_context: String::new(),
1002 last_review_ts: String::new(),
1003 review_ts_map: "{}".to_string(),
1004 last_comment_review_ts: String::new(),
1005 merge_conflict_retries: 0,
1006 ci_merge_failures: 0,
1007 pr_create_failures: 0,
1008 push_failures: 0,
1009 network_retries: 3,
1010 review_agent_failures: 0,
1011 review_resets: 0,
1012 review_cycles: 0,
1013 review_invocations: 0,
1014 needs_review_refires: 0,
1015 input_tokens: 0,
1016 output_tokens: 0,
1017 input_cost_usd: 0.0,
1018 output_cost_usd: 0.0,
1019 total_cost_usd: 0.0,
1020 model_reroute_chain: String::new(),
1021 limit_reroute_chain: "claude".to_string(),
1022 memory: vec![],
1023 delegations: vec![],
1024 auto_unblock_count: 0,
1025 auto_unblock_last_at: String::new(),
1026 auto_unblock_last_reason: String::new(),
1027 ci_recovery_count: 0,
1028 no_code_reroutes: 0,
1029 no_code_last_agent: String::new(),
1030 created_at: String::new(),
1031 updated_at: String::new(),
1032 };
1033
1034 assert_eq!(fallback_retry_count(Some(&task)), 3);
1035 }
1036
1037 #[serial(cooldown_state)]
1038 #[test]
1039 fn retryable_type_str_returns_correct_labels() {
1040 assert_eq!(RetryableError::Timeout.type_str(), "timeout");
1041 assert_eq!(RetryableError::Interrupted.type_str(), "interrupted");
1042 assert_eq!(RetryableError::UsageLimit.type_str(), "rate_limit");
1043 assert_eq!(RetryableError::AuthError.type_str(), "auth_error");
1044 assert_eq!(RetryableError::Failed.type_str(), "failed");
1045 assert_eq!(RetryableError::MissingTooling.type_str(), "failed");
1046 }
1047
1048 #[serial(cooldown_state)]
1049 #[test]
1050 fn is_usage_limit_detects_rate_limit() {
1051 assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
1052 assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
1053 assert!(patterns::detect_rate_limit("quota exceeded for model").is_some());
1054 assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
1056 }
1057
1058 #[serial(cooldown_state)]
1059 #[test]
1060 fn is_usage_limit_rejects_normal_text() {
1061 assert!(patterns::detect_rate_limit("task completed successfully").is_none());
1062 assert!(patterns::detect_rate_limit("").is_none());
1063 }
1064
1065 #[serial(cooldown_state)]
1066 #[test]
1067 fn is_auth_error_detects_common_patterns() {
1068 assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
1069 assert!(patterns::detect_auth_error("invalid api key provided").is_some());
1070 assert!(patterns::detect_auth_error("Your billing plan has expired").is_some());
1071 assert!(patterns::detect_auth_error("Error: 403 Forbidden").is_some());
1072 }
1073
1074 #[serial(cooldown_state)]
1075 #[test]
1076 fn is_auth_error_rejects_normal_text() {
1077 assert!(patterns::detect_auth_error("task completed successfully").is_none());
1078 assert!(patterns::detect_auth_error("").is_none());
1079 }
1080
1081 #[serial(cooldown_state)]
1082 #[test]
1083 fn detect_missing_tooling_finds_known_tools() {
1084 let result = patterns::detect_missing_tool("bun: command not found");
1085 assert!(result.is_some());
1086 if let AgentError::MissingTool { tool } = result.unwrap() {
1087 assert_eq!(tool, "bun");
1088 } else {
1089 panic!("expected MissingTool error");
1090 }
1091
1092 let result = patterns::detect_missing_tool("env: anchor: no such file");
1093 assert!(result.is_some());
1094 if let AgentError::MissingTool { tool } = result.unwrap() {
1095 assert_eq!(tool, "anchor");
1096 } else {
1097 panic!("expected MissingTool error");
1098 }
1099
1100 let result = patterns::detect_missing_tool("spawn docker enoent");
1101 assert!(result.is_some());
1102 if let AgentError::MissingTool { tool } = result.unwrap() {
1103 assert_eq!(tool, "docker");
1104 } else {
1105 panic!("expected MissingTool error");
1106 }
1107 }
1108
1109 #[serial(cooldown_state)]
1110 #[test]
1111 fn detect_missing_tooling_returns_none_for_normal() {
1112 assert!(patterns::detect_missing_tool("everything works fine").is_none());
1113 assert!(patterns::detect_missing_tool("").is_none());
1114 }
1115
1116 #[serial(cooldown_state)]
1119 #[tokio::test]
1120 async fn record_and_check_agent_cooldown() {
1121 cooldown::reset_global_state().await;
1122 let agent = "test_cooldown_agent_1";
1124 assert!(!is_agent_in_cooldown(agent));
1125 record_agent_failure_with_message(agent, "").await;
1126 assert!(is_agent_in_cooldown(agent));
1127 }
1128
1129 #[serial(cooldown_state)]
1130 #[tokio::test]
1131 async fn record_and_check_model_cooldown() {
1132 cooldown::reset_global_state().await;
1133 let agent = "test_cooldown_agent_2";
1134 let model = "test_model_x";
1135 assert!(!is_model_in_cooldown(agent, model));
1136 record_model_failure(agent, model).await;
1137 assert!(is_model_in_cooldown(agent, model));
1138 assert!(!is_model_in_cooldown(agent, "other_model"));
1140 }
1141
1142 #[serial(cooldown_state)]
1143 #[tokio::test]
1144 async fn clear_expired_does_not_remove_fresh_entries() {
1145 cooldown::reset_global_state().await;
1146 let agent = "test_cooldown_agent_3";
1147 record_agent_failure_with_message(agent, "").await;
1148 clear_expired_cooldowns();
1149 assert!(is_agent_in_cooldown(agent));
1151 }
1152
1153 #[serial(cooldown_state)]
1155 #[test]
1156 fn pick_fallback_skips_current_agent() {
1157 let available = vec!["test_agent_a".to_string(), "test_agent_b".to_string()];
1158 let result = pick_fallback_agent("test_agent_a", "", &available);
1159 assert_eq!(result, Some("test_agent_b".to_string()));
1160 }
1161
1162 #[serial(cooldown_state)]
1163 #[test]
1164 fn pick_fallback_skips_chain_agents() {
1165 let available = vec![
1166 "test_agent_a".to_string(),
1167 "test_agent_b".to_string(),
1168 "test_agent_c".to_string(),
1169 ];
1170 let result = pick_fallback_agent("test_agent_a", "test_agent_a,test_agent_b", &available);
1171 assert_eq!(result, Some("test_agent_c".to_string()));
1172 }
1173
1174 #[serial(cooldown_state)]
1175 #[test]
1176 fn pick_fallback_returns_none_when_exhausted() {
1177 let available = vec!["test_agent_a".to_string(), "test_agent_b".to_string()];
1178 let result = pick_fallback_agent("test_agent_a", "test_agent_a,test_agent_b", &available);
1179 assert!(result.is_none());
1180 }
1181
1182 #[serial(cooldown_state)]
1183 #[test]
1184 fn weight_signal_variants() {
1185 let success = WeightSignal::Success {
1186 agent: "claude".to_string(),
1187 };
1188 let limited = WeightSignal::RateLimited {
1189 agent: "codex".to_string(),
1190 };
1191 let none = WeightSignal::None;
1192
1193 assert!(format!("{success:?}").contains("claude"));
1195 assert!(format!("{limited:?}").contains("codex"));
1196 assert!(format!("{none:?}").contains("None"));
1197 }
1198
1199 #[serial(cooldown_state)]
1200 #[test]
1201 fn parse_review_response_direct_json() {
1202 let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1203 let resp = parse_review_response(json).unwrap();
1204 assert_eq!(resp.decision, "approve");
1205 assert_eq!(resp.notes, "LGTM");
1206 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1207 }
1208
1209 #[serial(cooldown_state)]
1210 #[test]
1211 fn parse_review_response_from_markdown() {
1212 let md = r#"Here is my review:
1213
1214```json
1215{"decision":"request_changes","notes":"Fix the bug","issues":[{"file":"src/main.rs","line":10,"severity":"error","description":"null deref"}]}
1216```
1217
1218That's all."#;
1219 let resp = parse_review_response(md).unwrap();
1220 assert_eq!(resp.decision, "request_changes");
1221 assert_eq!(resp.issues.len(), 1);
1222 assert_eq!(resp.issues[0].file, "src/main.rs");
1223 }
1224
1225 #[serial(cooldown_state)]
1226 #[test]
1227 fn parse_review_response_embedded_json_object() {
1228 let text = "Review complete.\n\n{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}\nThanks!";
1229 let resp = parse_review_response(text).unwrap();
1230 assert_eq!(resp.decision, "approve");
1231 assert_eq!(resp.notes, "LGTM");
1232 }
1233
1234 #[serial(cooldown_state)]
1235 #[test]
1236 fn parse_review_response_template_echo_infers_from_notes() {
1237 let json = r#"{"decision":"approve|request_changes","notes":"LGTM","issues":[]}"#;
1238 assert_eq!(parse_review_response(json).unwrap().decision, "approve");
1239
1240 let no_signal = r#"{"decision":"approve|request_changes","notes":"hmm","issues":[]}"#;
1241 assert!(parse_review_response(no_signal).is_err());
1242 }
1243
1244 #[serial(cooldown_state)]
1245 #[test]
1246 fn parse_review_response_invalid() {
1247 let result = parse_review_response("not json at all");
1248 assert!(result.is_err());
1249 }
1250
1251 #[serial(cooldown_state)]
1252 #[test]
1253 fn extract_json_block_basic() {
1254 let md = "text\n```json\n{\"key\": \"value\"}\n```\nmore";
1255 let result = extract_json_block(md);
1256 assert!(result.is_some());
1257 assert!(result.unwrap().contains("\"key\""));
1258 }
1259
1260 #[serial(cooldown_state)]
1261 #[test]
1262 fn extract_json_block_skips_non_json_blocks() {
1263 let md = "```json\nnot-json-array\n```\n\n```json\n{\"real\": true}\n```";
1264 let result = extract_json_block(md);
1265 assert!(result.is_some());
1266 assert!(result.unwrap().contains("\"real\""));
1267 }
1268
1269 #[serial(cooldown_state)]
1270 #[test]
1271 fn extract_json_block_none_when_missing() {
1272 assert!(extract_json_block("no code blocks here").is_none());
1273 }
1274
1275 #[serial(cooldown_state)]
1276 #[test]
1277 fn extract_json_block_skips_malformed_intermediate_blocks() {
1278 let md = "prefix ```json{\"broken\": true}\n\n```json\n{\"real\": true}\n```";
1279 let result = extract_json_block(md);
1280 assert_eq!(result.as_deref(), Some("{\"real\": true}"));
1281 }
1282
1283 #[serial(cooldown_state)]
1284 #[test]
1285 fn extract_json_block_skips_multiple_malformed_intermediate_blocks() {
1286 let md = "prefix ```json{\"broken\": true}\nmid ```json[1, 2, 3]\n\n```json\n{\"real\": true}\n```";
1287 let result = extract_json_block(md);
1288 assert_eq!(result.as_deref(), Some("{\"real\": true}"));
1289 }
1290
1291 fn parse_review_plain(text: &str) -> anyhow::Result<ReviewResponse> {
1296 parse_review_response(text).or_else(|_| {
1297 infer_review_response(text)
1298 .ok_or_else(|| anyhow::anyhow!("failed to parse review response"))
1299 })
1300 }
1301
1302 #[serial(cooldown_state)]
1303 #[test]
1304 fn parse_review_from_output_direct_json() {
1305 let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1306 let resp = parse_review_response(json).unwrap();
1307 assert_eq!(resp.decision, "approve");
1308 }
1309
1310 #[serial(cooldown_state)]
1311 #[test]
1312 fn parse_review_from_output_markdown() {
1313 let md = "Review complete.\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix it\",\"issues\":[]}\n```\n";
1314 let resp = parse_review_response(md).unwrap();
1315 assert_eq!(resp.decision, "request_changes");
1316 }
1317
1318 #[serial(cooldown_state)]
1319 #[test]
1320 fn parse_review_from_output_plain_text_fallback() {
1321 let text = "The review is done. LGTM.";
1322 let resp = parse_review_plain(text).unwrap();
1323 assert_eq!(resp.decision, "approve");
1324 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1325 }
1326
1327 #[serial(cooldown_state)]
1329 #[test]
1330 fn parse_review_from_output_ndjson_part_format() {
1331 let ndjson = concat!(
1332 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1333 "\n",
1334 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Reviewing the PR...\n\n```json\n{\"decision\":\"approve\",\"notes\":\"All checks pass\",\"test_results\":\"pass\",\"issues\":[]}\n```\n"}}"#,
1335 "\n",
1336 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop"}}"#,
1337 );
1338 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1339 assert_eq!(resp.decision, "approve");
1340 assert_eq!(resp.notes, "All checks pass");
1341 }
1342
1343 #[serial(cooldown_state)]
1345 #[test]
1346 fn parse_review_from_output_ndjson_direct_text_format() {
1347 let ndjson = concat!(
1348 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1349 "\n",
1350 r#"{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"Here is my review:\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix the bug\",\"test_results\":\"fail\",\"issues\":[]}\n```\n"}"#,
1351 "\n",
1352 r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1353 );
1354 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1355 assert_eq!(resp.decision, "request_changes");
1356 assert_eq!(resp.notes, "Fix the bug");
1357 }
1358
1359 #[serial(cooldown_state)]
1361 #[test]
1362 fn parse_review_from_output_ndjson_direct_json_in_text_event() {
1363 let ndjson = concat!(
1364 r#"{"type":"step_start","timestamp":1000}"#,
1365 "\n",
1366 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1367 "\n",
1368 r#"{"type":"step_finish","timestamp":1002}"#,
1369 );
1370 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1371 assert_eq!(resp.decision, "approve");
1372 }
1373
1374 #[serial(cooldown_state)]
1375 #[test]
1376 fn parse_review_from_output_ndjson_prefers_final_json_text_event() {
1377 let ndjson = concat!(
1378 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on it..."}}"#,
1379 "\n",
1380 r#"{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1381 );
1382 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1383 assert_eq!(resp.decision, "approve");
1384 assert_eq!(resp.notes, "LGTM");
1385 }
1386
1387 #[serial(cooldown_state)]
1389 #[test]
1390 fn parse_review_from_output_ndjson_concatenated_text() {
1391 let ndjson = concat!(
1392 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Here is my\n"}}"#,
1393 "\n",
1394 r#"{"type":"text","timestamp":1002,"part":{"type":"text","text":"review:\n\n```json\n{\"decision\":\"approve\",\"notes\":\"OK\",\"test_results\":\"pass\",\"issues\":[]}\n```\n"}}"#,
1395 );
1396 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1397 assert_eq!(resp.decision, "approve");
1398 }
1399
1400 #[serial(cooldown_state)]
1405 #[test]
1406 fn parse_review_from_output_codex_agent_message() {
1407 let ndjson = concat!(
1408 r#"{"type":"thread.started","thread_id":"t1"}"#,
1409 "\n",
1410 r#"{"type":"turn.started"}"#,
1411 "\n",
1412 r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Planning git commands..."}}"#,
1413 "\n",
1414 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"git log --oneline -5","output":"abc123 fix: something"}}"#,
1415 "\n",
1416 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"All checks pass\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1417 "\n",
1418 r#"{"type":"turn.completed"}"#,
1419 );
1420 let resp = parse_via_agent_path("codex", ndjson).unwrap();
1421 assert_eq!(resp.decision, "approve");
1422 assert_eq!(resp.notes, "All checks pass");
1423 }
1424
1425 #[serial(cooldown_state)]
1427 #[test]
1428 fn parse_review_from_output_codex_agent_message_markdown() {
1429 let ndjson = concat!(
1430 r#"{"type":"thread.started","thread_id":"t1"}"#,
1431 "\n",
1432 r#"{"type":"turn.started"}"#,
1433 "\n",
1434 r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Reviewing..."}}"#,
1435 "\n",
1436 r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Review complete.\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix the tests\",\"issues\":[]}\n```\n"}}"#,
1437 "\n",
1438 r#"{"type":"turn.completed"}"#,
1439 );
1440 let resp = parse_via_agent_path("codex", ndjson).unwrap();
1441 assert_eq!(resp.decision, "request_changes");
1442 assert_eq!(resp.notes, "Fix the tests");
1443 }
1444
1445 #[serial(cooldown_state)]
1447 #[test]
1448 fn codex_skips_reasoning_items() {
1449 let ndjson = concat!(
1450 r#"{"type":"item.completed","item":{"type":"reasoning","text":"internal thoughts..."}}"#,
1451 "\n",
1452 r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}}"#,
1453 );
1454 let resp = parse_via_agent_path("codex", ndjson).unwrap();
1455 assert_eq!(resp.decision, "approve");
1456 assert_eq!(resp.notes, "LGTM");
1457 }
1458
1459 #[serial(cooldown_state)]
1461 #[test]
1462 fn infer_review_response_negation_not_approved() {
1463 assert!(
1464 parse_review_plain("The changes were not approved.").is_err(),
1465 "\"not approved\" should not infer approval"
1466 );
1467 }
1468
1469 #[serial(cooldown_state)]
1470 #[test]
1471 fn infer_review_response_negation_unapproved() {
1472 assert!(
1473 parse_review_plain("Unapproved changes found in the diff.").is_err(),
1474 "\"unapproved\" should not infer approval"
1475 );
1476 }
1477
1478 #[serial(cooldown_state)]
1479 #[test]
1480 fn infer_review_response_negation_not_approving() {
1481 assert!(
1482 parse_review_plain("I am not approving this PR.").is_err(),
1483 "\"not approving\" should not infer approval"
1484 );
1485 }
1486
1487 #[serial(cooldown_state)]
1488 #[test]
1489 fn infer_review_response_negation_cannot_be_approved() {
1490 assert!(
1491 parse_review_plain("This PR cannot be approved until the race in tick.rs is fixed.")
1492 .is_err(),
1493 "\"cannot be approved\" should not infer approval"
1494 );
1495 }
1496
1497 #[serial(cooldown_state)]
1498 #[test]
1499 fn infer_review_response_negation_should_not_be_approved() {
1500 assert!(
1501 parse_review_plain("The diff breaks the build and should not be approved.").is_err(),
1502 "\"should not be approved\" should not infer approval"
1503 );
1504 }
1505
1506 #[serial(cooldown_state)]
1507 #[test]
1508 fn infer_review_response_negation_not_yet_approved() {
1509 assert!(
1510 parse_review_plain("This change is not yet approved.").is_err(),
1511 "\"not yet approved\" should not infer approval"
1512 );
1513 }
1514
1515 #[serial(cooldown_state)]
1516 #[test]
1517 fn infer_review_response_negation_wont_approve() {
1518 assert!(
1519 parse_review_plain("I won't approve this until tests cover the retry path.").is_err(),
1520 "\"won't approve\" should not infer approval"
1521 );
1522 }
1523
1524 #[serial(cooldown_state)]
1525 #[test]
1526 fn infer_review_response_bare_approved() {
1527 assert!(
1528 parse_review_plain("The PR was approved.").is_err(),
1529 "bare \"approved\" should not infer approval"
1530 );
1531 }
1532
1533 #[serial(cooldown_state)]
1535 #[test]
1536 fn infer_review_response_lgtm() {
1537 let resp = parse_review_plain("LGTM, everything looks fine.").unwrap();
1538 assert_eq!(resp.decision, "approve");
1539 }
1540
1541 #[serial(cooldown_state)]
1543 #[test]
1544 fn infer_review_response_looks_good() {
1545 assert!(
1546 parse_review_plain("Looks good to me!").is_err(),
1547 "\"looks good\" should not infer approval"
1548 );
1549 }
1550
1551 #[serial(cooldown_state)]
1552 #[test]
1553 fn infer_review_response_all_checks_passed() {
1554 assert!(
1555 parse_review_plain(
1556 "The background task completed but I already got the CI results directly. All checks passed — no action needed.",
1557 )
1558 .is_err(),
1559 "\"all checks passed\" should not infer approval"
1560 );
1561 }
1562
1563 #[serial(cooldown_state)]
1564 #[test]
1565 fn infer_review_response_checks_passed() {
1566 assert!(
1567 parse_review_plain("All checks passed (fmt ✓, clippy ✓, tests ✓).").is_err(),
1568 "\"all checks passed\" should not infer approval"
1569 );
1570 }
1571
1572 #[serial(cooldown_state)]
1573 #[test]
1574 fn infer_review_response_all_tests_passed() {
1575 assert!(
1576 parse_review_plain("All tests passed and the implementation looks correct.").is_err(),
1577 "\"all tests passed\" should not infer approval"
1578 );
1579 }
1580
1581 #[serial(cooldown_state)]
1582 #[test]
1583 fn infer_review_response_no_issues_found() {
1584 assert!(
1585 parse_review_plain("Review complete. No issues found in the diff.").is_err(),
1586 "\"no issues found\" should not infer approval"
1587 );
1588 }
1589
1590 #[serial(cooldown_state)]
1592 #[test]
1593 fn infer_review_response_partial_tests_passed_no_approve() {
1594 let text = "The existing tests passed before this change but the new payment logic has a null-pointer risk on line 42.";
1595 assert!(parse_review_plain(text).is_err());
1596 }
1597
1598 #[serial(cooldown_state)]
1600 #[test]
1601 fn infer_review_response_partial_checks_passed_no_approve() {
1602 let text = "CI checks passed for the base branch but this PR adds a broken path.";
1603 assert!(parse_review_plain(text).is_err());
1604 }
1605
1606 #[serial(cooldown_state)]
1608 #[test]
1609 fn infer_review_response_decision_is_approve_backtick() {
1610 let text = "Already retrieved the output and completed the review above. The CI test check passed (3m11s) and all other checks are green. Decision is `approve`.";
1611 let resp = parse_review_plain(text).unwrap();
1612 assert_eq!(resp.decision, "approve");
1613 }
1614
1615 #[serial(cooldown_state)]
1617 #[test]
1618 fn infer_review_response_decision_colon_approve() {
1619 let text = "Reviewed the PR. No blocking issues found. Decision: approve";
1620 let resp = parse_review_plain(text).unwrap();
1621 assert_eq!(resp.decision, "approve");
1622 }
1623
1624 #[serial(cooldown_state)]
1627 #[test]
1628 fn infer_review_response_negation_do_not_approve_backtick() {
1629 let text = "There are unresolved issues — do not `approve` this PR until they are fixed.";
1630 assert!(
1631 parse_review_plain(text).is_err(),
1632 "negative context with backtick approve must not infer approval"
1633 );
1634 }
1635
1636 fn parse_via_agent_path(agent: &str, output: &str) -> anyhow::Result<ReviewResponse> {
1641 let text = find_agent_result(agent, output)
1642 .map(|r| r.result_text)
1643 .filter(|t| !t.is_empty())
1644 .unwrap_or_else(|| output.to_string());
1645 if let Ok(r) = parse_review_response(&text) {
1646 return Ok(r);
1647 }
1648 infer_review_response_from_text(&text)
1649 .ok_or_else(|| anyhow::anyhow!("failed to parse review response from {agent} output"))
1650 }
1651
1652 #[serial(cooldown_state)]
1653 #[test]
1654 fn agent_output_claude_ndjson_review() {
1655 let ndjson = concat!(
1656 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1657 "\n",
1658 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1659 "\n",
1660 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1661 );
1662 let resp = parse_via_agent_path("claude", ndjson).unwrap();
1663 assert_eq!(resp.decision, "approve");
1664 }
1665
1666 #[serial(cooldown_state)]
1667 #[test]
1668 fn agent_output_opencode_ndjson_review() {
1669 let ndjson = concat!(
1670 r#"{"type":"step_start","timestamp":1000}"#,
1671 "\n",
1672 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"request_changes\",\"notes\":\"Fix it\",\"issues\":[]}"}}"#,
1673 "\n",
1674 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
1675 );
1676 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1677 assert_eq!(resp.decision, "request_changes");
1678 }
1679
1680 #[serial(cooldown_state)]
1681 #[test]
1682 fn agent_output_codex_ndjson_review() {
1683 let ndjson = concat!(
1684 r#"{"type":"thread.started","thread_id":"t1"}"#,
1685 "\n",
1686 r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"All good\",\"issues\":[]}"}}"#,
1687 "\n",
1688 r#"{"type":"turn.completed"}"#,
1689 );
1690 let resp = parse_via_agent_path("codex", ndjson).unwrap();
1691 assert_eq!(resp.decision, "approve");
1692 }
1693
1694 #[serial(cooldown_state)]
1695 #[test]
1696 fn agent_output_plain_text_keyword_fallback() {
1697 let text = "All checks passed, LGTM.";
1698 let resp = parse_via_agent_path("claude", text).unwrap();
1699 assert_eq!(resp.decision, "approve");
1700 }
1701
1702 #[serial(cooldown_state)]
1703 #[test]
1704 fn agent_output_direct_json() {
1705 let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1706 let resp = parse_via_agent_path("opencode", json).unwrap();
1707 assert_eq!(resp.decision, "approve");
1708 }
1709
1710 #[serial(cooldown_state)]
1714 #[test]
1715 fn agent_output_text_with_json_fragment_no_false_positive() {
1716 let text = r#"I checked the file and found {"type":"text"} in the output. No issues."#;
1717 let result = parse_via_agent_path("claude", text);
1721 assert!(
1723 result.is_err() || result.as_ref().is_ok_and(|r| r.decision != "text"),
1724 "should not extract review from embedded JSON fragment"
1725 );
1726 }
1727
1728 #[serial(cooldown_state)]
1731 #[test]
1732 fn parse_review_response_ignores_prose_with_decision_word() {
1733 let text = r#"The decision was made. Here is a debug trace: {"trace":123}"#;
1734 assert!(
1735 parse_review_response(text).is_err(),
1736 "prose with 'decision' word and unrelated JSON should not parse as ReviewResponse"
1737 );
1738 }
1739
1740 #[serial(cooldown_state)]
1743 #[test]
1744 fn parse_review_response_ignores_embedded_json_without_decision_key() {
1745 let text = r#"Analysis complete. Config: {"timeout":30,"retries":3}. Done."#;
1746 assert!(
1747 parse_review_response(text).is_err(),
1748 "embedded JSON without 'decision' key should not parse as ReviewResponse"
1749 );
1750 }
1751
1752 #[serial(cooldown_state)]
1755 #[test]
1756 fn read_file_with_limit_reads_small_file_completely() {
1757 let temp_dir = env::temp_dir();
1758 let file_path = temp_dir.join(format!("orch_test_small_{}.txt", process::id()));
1759 let content = "Hello, World!";
1760 fs::write(&file_path, content).unwrap();
1761
1762 let result = read_file_with_limit(&file_path, 1024);
1763 assert!(result.is_ok());
1764 assert_eq!(result.unwrap(), content);
1765
1766 let _ = fs::remove_file(&file_path);
1767 }
1768
1769 #[serial(cooldown_state)]
1770 #[test]
1771 fn read_file_with_limit_truncates_large_file() {
1772 let temp_dir = env::temp_dir();
1773 let file_path = temp_dir.join(format!("orch_test_large_{}.txt", process::id()));
1774
1775 let large_content = "x".repeat(200);
1777 fs::write(&file_path, &large_content).unwrap();
1778
1779 let result = read_file_with_limit(&file_path, 50);
1781 assert!(result.is_ok());
1782
1783 let read_content = result.unwrap();
1784 assert_eq!(read_content.len(), 50);
1786 assert_eq!(read_content, "x".repeat(50));
1787
1788 let _ = fs::remove_file(&file_path);
1789 }
1790
1791 #[serial(cooldown_state)]
1792 #[test]
1793 fn read_file_with_limit_handles_empty_file() {
1794 let temp_dir = env::temp_dir();
1795 let file_path = temp_dir.join(format!("orch_test_empty_{}.txt", process::id()));
1796 fs::write(&file_path, "").unwrap();
1797
1798 let result = read_file_with_limit(&file_path, 1024);
1799 assert!(result.is_ok());
1800 assert_eq!(result.unwrap(), "");
1801
1802 let _ = fs::remove_file(&file_path);
1803 }
1804
1805 #[serial(cooldown_state)]
1806 #[test]
1807 fn read_file_with_limit_handles_nonexistent_file() {
1808 let temp_dir = env::temp_dir();
1809 let file_path = temp_dir.join("orch_test_nonexistent_xyz123.txt");
1810
1811 let result = read_file_with_limit(&file_path, 1024);
1812 assert!(result.is_err());
1813 }
1814
1815 #[serial(cooldown_state)]
1816 #[test]
1817 fn read_file_with_limit_reads_exact_limit() {
1818 let temp_dir = env::temp_dir();
1819 let file_path = temp_dir.join(format!("orch_test_exact_{}.txt", process::id()));
1820
1821 let content = "y".repeat(100);
1823 fs::write(&file_path, &content).unwrap();
1824
1825 let result = read_file_with_limit(&file_path, 100);
1827 assert!(result.is_ok());
1828 assert_eq!(result.unwrap().len(), 100);
1829
1830 let _ = fs::remove_file(&file_path);
1831 }
1832
1833 #[serial(cooldown_state)]
1834 #[test]
1835 fn read_file_with_limit_handles_unicode_truncation() {
1836 let temp_dir = env::temp_dir();
1837 let file_path = temp_dir.join(format!("orch_test_unicode_{}.txt", process::id()));
1838
1839 let emoji = "🎉".repeat(100); fs::write(&file_path, &emoji).unwrap();
1842
1843 let result = read_file_with_limit(&file_path, 50);
1845 assert!(result.is_ok());
1846
1847 let read_content = result.unwrap();
1848 assert_ne!(read_content, "");
1850
1851 let _ = fs::remove_file(&file_path);
1852 }
1853
1854 fn parse_review_via_agent(agent: &str, output: &str) -> anyhow::Result<ReviewResponse> {
1858 let text = find_agent_result(agent, output)
1859 .map(|r| r.result_text)
1860 .filter(|t| !t.is_empty())
1861 .unwrap_or_else(|| output.to_string());
1862 if let Ok(r) = parse_review_response(&text) {
1863 return Ok(r);
1864 }
1865 infer_review_response(&text)
1866 .ok_or_else(|| anyhow::anyhow!("failed to parse review from {agent} output"))
1867 }
1868
1869 #[serial(cooldown_state)]
1872 #[test]
1873 fn fixture_claude_review_approve() {
1874 let raw = include_str!("../../../tests/fixtures/review_claude_approve.jsonl");
1875 let resp = parse_review_via_agent("claude", raw).unwrap();
1876 assert_eq!(resp.decision, "approve");
1877 assert_eq!(resp.notes, "All checks pass");
1878 assert_eq!(resp.test_results.as_deref(), Some("pass"));
1879 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1880 }
1881
1882 #[serial(cooldown_state)]
1883 #[test]
1884 fn fixture_claude_review_request_changes() {
1885 let raw = include_str!("../../../tests/fixtures/review_claude_request_changes.jsonl");
1886 let resp = parse_review_via_agent("claude", raw).unwrap();
1887 assert_eq!(resp.decision, "request_changes");
1888 assert_eq!(resp.notes, "Fix the null deref on line 42");
1889 assert_eq!(resp.test_results.as_deref(), Some("fail"));
1890 assert_eq!(resp.issues.len(), 1);
1891 assert_eq!(resp.issues[0].file, "src/main.rs");
1892 assert_eq!(resp.issues[0].line, Some(42));
1893 assert_eq!(resp.issues[0].severity, "error");
1894 }
1895
1896 #[serial(cooldown_state)]
1897 #[test]
1898 fn fixture_claude_review_rate_limit_returns_error() {
1899 let raw = include_str!("../../../tests/fixtures/review_claude_rate_limit.jsonl");
1902 let result = find_agent_result("claude", raw);
1903 assert!(
1904 result.is_some(),
1905 "should find result even for error envelope"
1906 );
1907 let result = result.unwrap();
1908 assert!(result.is_error, "rate limit should be flagged as error");
1909 assert!(result.result_text.contains("rate limit"));
1910 }
1911
1912 #[serial(cooldown_state)]
1915 #[test]
1916 fn fixture_opencode_review_approve() {
1917 let raw = include_str!("../../../tests/fixtures/review_opencode_approve.jsonl");
1918 let resp = parse_review_via_agent("opencode", raw).unwrap();
1919 assert_eq!(resp.decision, "approve");
1920 assert_eq!(resp.notes, "All checks pass");
1921 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1922 }
1923
1924 #[serial(cooldown_state)]
1925 #[test]
1926 fn fixture_opencode_review_request_changes() {
1927 let raw = include_str!("../../../tests/fixtures/review_opencode_request_changes.jsonl");
1928 let resp = parse_review_via_agent("opencode", raw).unwrap();
1929 assert_eq!(resp.decision, "request_changes");
1930 assert_eq!(resp.notes, "Fix the memory leak");
1931 assert_eq!(resp.test_results.as_deref(), Some("fail"));
1932 assert_eq!(resp.issues.len(), 1);
1933 assert_eq!(resp.issues[0].file, "src/lib.rs");
1934 assert_eq!(resp.issues[0].line, Some(100));
1935 }
1936
1937 #[serial(cooldown_state)]
1938 #[test]
1939 fn fixture_opencode_review_plain_text_approve() {
1940 let raw = include_str!("../../../tests/fixtures/review_opencode_plain_text.jsonl");
1942 let resp = parse_review_via_agent("opencode", raw).unwrap();
1943 assert_eq!(resp.decision, "approve");
1944 }
1945
1946 #[serial(cooldown_state)]
1949 #[test]
1950 fn fixture_codex_review_approve() {
1951 let raw = include_str!("../../../tests/fixtures/review_codex_approve.jsonl");
1952 let resp = parse_review_via_agent("codex", raw).unwrap();
1953 assert_eq!(resp.decision, "approve");
1954 assert_eq!(resp.notes, "Clean implementation");
1955 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1956 }
1957
1958 #[serial(cooldown_state)]
1959 #[test]
1960 fn fixture_codex_review_request_changes() {
1961 let raw = include_str!("../../../tests/fixtures/review_codex_request_changes.jsonl");
1962 let resp = parse_review_via_agent("codex", raw).unwrap();
1963 assert_eq!(resp.decision, "request_changes");
1964 assert_eq!(resp.notes, "Race condition on line 15");
1965 assert_eq!(resp.issues.len(), 1);
1966 assert_eq!(resp.issues[0].file, "src/server.rs");
1967 assert_eq!(resp.issues[0].line, Some(15));
1968 }
1969
1970 #[serial(cooldown_state)]
1971 #[test]
1972 fn fixture_codex_review_plain_text_approve() {
1973 let raw = include_str!("../../../tests/fixtures/review_codex_plain_text.jsonl");
1975 let resp = parse_review_via_agent("codex", raw).unwrap();
1976 assert_eq!(resp.decision, "approve");
1977 }
1978
1979 #[serial(cooldown_state)]
1982 #[test]
1983 fn fixture_kimi_review_approve() {
1984 let raw = include_str!("../../../tests/fixtures/review_kimi_approve.jsonl");
1985 let resp = parse_review_via_agent("kimi", raw).unwrap();
1986 assert_eq!(resp.decision, "approve");
1987 assert_eq!(resp.notes, "LGTM");
1988 assert_eq!(resp.test_results.as_deref(), Some("pass"));
1989 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1990 }
1991
1992 #[serial(cooldown_state)]
1995 #[test]
1996 fn fixture_minimax_review_request_changes() {
1997 let raw = include_str!("../../../tests/fixtures/review_minimax_request_changes.jsonl");
1998 let resp = parse_review_via_agent("minimax", raw).unwrap();
1999 assert_eq!(resp.decision, "request_changes");
2000 assert_eq!(resp.notes, "Fix the bug");
2001 assert_eq!(resp.test_results.as_deref(), Some("fail"));
2002 assert_eq!(resp.issues.len(), 1);
2003 assert_eq!(resp.issues[0].file, "src/foo.rs");
2004 assert_eq!(resp.issues[0].line, Some(42));
2005 assert_eq!(resp.issues[0].severity, "error");
2006 }
2007
2008 #[serial(cooldown_state)]
2012 #[test]
2013 fn find_agent_result_extracts_tokens_claude() {
2014 let raw = include_str!("../../../tests/fixtures/review_claude_approve.jsonl");
2015 let result = find_agent_result("claude", raw).expect("should find result");
2016 assert_eq!(result.input_tokens, Some(200));
2017 assert_eq!(result.output_tokens, Some(30));
2018 assert!(result.cost_usd.is_none()); }
2020
2021 #[serial(cooldown_state)]
2022 #[test]
2023 fn find_agent_result_extracts_tokens_opencode() {
2024 let raw = include_str!("../../../tests/fixtures/review_opencode_approve.jsonl");
2025 let result = find_agent_result("opencode", raw).expect("should find result");
2026 assert_eq!(result.input_tokens, Some(2900));
2027 assert_eq!(result.output_tokens, Some(100));
2028 assert_eq!(result.cost_usd, Some(0.002));
2029 }
2030
2031 #[serial(cooldown_state)]
2033 #[test]
2034 fn find_agent_result_returns_none_for_empty() {
2035 assert!(find_agent_result("claude", "").is_none());
2036 assert!(find_agent_result("opencode", "").is_none());
2037 assert!(find_agent_result("codex", "").is_none());
2038 assert!(find_agent_result("kimi", "").is_none());
2039 assert!(find_agent_result("minimax", "").is_none());
2040 }
2041
2042 #[serial(cooldown_state)]
2043 #[test]
2044 fn find_agent_result_returns_none_for_plain_text() {
2045 assert!(find_agent_result("claude", "just some plain text").is_none());
2046 assert!(find_agent_result("codex", "just some plain text").is_none());
2047 }
2050
2051 #[serial(cooldown_state)]
2055 #[test]
2056 fn find_agent_result_propagates_claude_rate_limit() {
2057 let raw = include_str!("../../../tests/fixtures/review_claude_rate_limit.jsonl");
2058 let result = find_agent_result("claude", raw).expect("should find result");
2059 assert!(result.is_error, "should be flagged as error");
2060 assert!(result.result_text.contains("rate limit"));
2061 }
2062}