1use crate::config::GitFlowConfig;
10use crate::stage::Stage;
11use crate::state::State;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16pub struct AgentResult {
17 pub status: AgentStatus,
18 pub exit_code: Option<i32>,
19 pub reason: Option<String>,
20 pub commits: Option<u32>,
21 pub summary: Option<String>,
22 #[serde(default, deserialize_with = "deserialize_verdict_lenient")]
34 pub verdict: Option<Verdict>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum AgentStatus {
41 Success,
43 Failed,
45 RateLimited,
47 Unknown,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Verdict {
55 Pass,
57 Gaps,
60}
61
62fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
80where
81 D: serde::Deserializer<'de>,
82{
83 let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
84 Ok(raw.and_then(|v| {
85 v.as_str().and_then(|s| match s {
86 "pass" => Some(Verdict::Pass),
87 "gaps" => Some(Verdict::Gaps),
88 _ => None,
89 })
90 }))
91}
92
93#[derive(Debug, thiserror::Error)]
95pub enum ResultError {
96 #[error("I/O error reading agent output: {0}")]
97 Io(#[from] std::io::Error),
98 #[error("phase directory not found")]
99 NoPhaseDir,
100}
101
102pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
113 if let Some(inner) = extract_json_result_text(stdout)
114 && let Some(result) = parse_marker_lines(&inner)
115 {
116 return Some(result);
117 }
118 parse_marker_lines(stdout)
119}
120
121pub fn detect_rate_limit(stdout: &str) -> Option<String> {
128 detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
129}
130
131fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
132 let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
133 let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
134 || json_has_i64(&value, "api_error_status", 429)
135 || json_has_i64(&value, "status", 429)
136 || json_has_i64(&value, "status_code", 429);
137 if !rate_limited {
138 return None;
139 }
140 json_find_key(&value, "retry_after")
141 .and_then(json_scalar_to_string)
142 .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
143 .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
144 .or_else(|| Some("usage limit".to_string()))
145}
146
147fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
148 let stdout: String = stdout
155 .lines()
156 .filter(|line| {
157 serde_json::from_str::<serde_json::Value>(line)
158 .map(|v| !v.is_object())
159 .unwrap_or(true)
160 })
161 .collect::<Vec<_>>()
162 .join("\n");
163 let stdout = stdout.as_str();
164 let lower = stdout.to_ascii_lowercase();
165 if let Some(idx) = lower.find("try again at ") {
166 let start = idx + "try again at ".len();
167 let retry = stdout[start..]
168 .lines()
169 .next()
170 .unwrap_or_default()
171 .trim()
172 .trim_end_matches(['.', ',', ';'])
173 .trim();
174 if !retry.is_empty() {
175 return Some(retry.to_string());
176 }
177 }
178
179 if lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("429") {
180 stdout
181 .lines()
182 .find(|line| {
183 let line = line.to_ascii_lowercase();
184 line.contains("usage limit") || line.contains("rate limit") || line.contains("429")
185 })
186 .map(str::trim)
187 .filter(|line| !line.is_empty())
188 .map(str::to_string)
189 .or_else(|| Some("usage limit".to_string()))
190 } else {
191 None
192 }
193}
194
195fn extract_json_result_text(stdout: &str) -> Option<String> {
198 let trimmed = stdout.trim();
199 if !trimmed.starts_with('{') {
200 return None;
201 }
202 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
203 value.get("result")?.as_str().map(str::to_string)
204}
205
206fn json_scan<'a, T>(
219 value: &'a serde_json::Value,
220 visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
221) -> Option<T> {
222 let mut stack = vec![value];
223 while let Some(current) = stack.pop() {
224 match current {
225 serde_json::Value::Object(map) => {
226 if let Some(found) = visit(map) {
227 return Some(found);
228 }
229 for child in map.values().rev() {
231 stack.push(child);
232 }
233 }
234 serde_json::Value::Array(values) => {
235 for child in values.iter().rev() {
236 stack.push(child);
237 }
238 }
239 _ => {}
240 }
241 }
242 None
243}
244
245fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
246 json_scan(value, |map| {
247 (map.get(key)?.as_str()? == expected).then_some(())
248 })
249 .is_some()
250}
251
252fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
253 json_scan(value, |map| {
254 (map.get(key)?.as_i64()? == expected).then_some(())
255 })
256 .is_some()
257}
258
259fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
260 json_scan(value, |map| map.get(key))
261}
262
263fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
264 match value {
265 serde_json::Value::String(s) => Some(s.clone()),
266 serde_json::Value::Number(n) => Some(n.to_string()),
267 _ => None,
268 }
269}
270
271fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
289 let trimmed = stdout.trim();
290 if !trimmed.starts_with('{') {
291 return None;
292 }
293 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
294 let is_error = value.get("is_error")?.as_bool()?;
295 if !is_error {
296 return None;
297 }
298
299 let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
300 let base_reason = value
301 .get("result")
302 .and_then(serde_json::Value::as_str)
303 .map(str::to_string)
304 .or_else(|| {
305 value
306 .get("subtype")
307 .and_then(serde_json::Value::as_str)
308 .map(str::to_string)
309 })
310 .unwrap_or_else(|| "agent reported is_error".to_string());
311 let reason = match num_turns {
312 Some(n) => format!("{base_reason} (num_turns: {n})"),
313 None => base_reason,
314 };
315
316 Some(AgentResult {
317 status: AgentStatus::Failed,
318 exit_code: None,
319 reason: Some(reason),
320 commits: None,
321 summary: None,
322 verdict: None,
323 })
324}
325
326fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
330 events.iter().any(|v| {
331 v.get("type")
332 .and_then(serde_json::Value::as_str)
333 .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
334 })
335}
336
337fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
358 let events: Vec<serde_json::Value> = stdout
359 .lines()
360 .filter(|line| !line.trim().is_empty())
361 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
362 .collect();
363
364 if !is_codex_event_stream(&events) {
365 return None;
366 }
367
368 let marker = events.iter().rev().find_map(|v| {
375 if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
376 return None;
377 }
378 let item = v.get("item")?;
379 if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
380 return None;
381 }
382 let text = item.get("text").and_then(serde_json::Value::as_str)?;
383 parse_marker_lines(text)
384 });
385 if marker.is_some() {
386 return marker;
387 }
388
389 let terminal = events.iter().rev().find(|v| {
390 matches!(
391 v.get("type").and_then(serde_json::Value::as_str),
392 Some("turn.completed") | Some("turn.failed")
393 )
394 })?;
395
396 if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
397 return None;
400 }
401
402 let reason = terminal
403 .get("error")
404 .and_then(|e| e.get("message"))
405 .and_then(serde_json::Value::as_str)
406 .map(str::to_string)
407 .unwrap_or_else(|| "codex turn failed".to_string());
408
409 Some(AgentResult {
410 status: AgentStatus::Failed,
411 exit_code: None,
412 reason: Some(reason),
413 commits: None,
414 summary: None,
415 verdict: None,
416 })
417}
418
419fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
425 let tail: String = stdout
428 .chars()
429 .rev()
430 .take(4000)
431 .collect::<Vec<_>>()
432 .into_iter()
433 .rev()
434 .collect();
435
436 for line in tail.lines().rev() {
437 let Some(json_str) = line
438 .strip_prefix("DEVFLOW_RESULT: ")
439 .or_else(|| line.strip_prefix("devflow_result: "))
440 .or_else(|| line.strip_prefix("DEVFLOW_RESULT:"))
441 .or_else(|| line.strip_prefix("devflow_result:"))
442 else {
443 continue;
444 };
445
446 let json_str = json_str.trim();
447 if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
448 return Some(result);
449 }
450 }
451 None
452}
453
454pub fn evaluate_layer1(project_root: &Path, phase: u32) -> Option<AgentResult> {
466 let stdout_path = devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase));
467 let bytes = std::fs::read(&stdout_path).ok()?;
473 let stdout = String::from_utf8_lossy(&bytes);
474 detect_claude_rate_limit(&stdout)
475 .map(rate_limited_result)
476 .or_else(|| detect_claude_envelope_failure(&stdout))
477 .or_else(|| parse_devflow_result(&stdout))
478 .or_else(|| parse_codex_event_result(&stdout))
479 .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
480}
481
482fn rate_limited_result(retry: String) -> AgentResult {
484 AgentResult {
485 status: AgentStatus::RateLimited,
486 exit_code: None,
487 reason: Some(format!("rate limited until {retry}")),
488 commits: None,
489 summary: None,
490 verdict: None,
491 }
492}
493
494pub fn evaluate_layer2(
521 project_root: &Path,
522 phase: u32,
523 git_flow: &GitFlowConfig,
524 stage: Stage,
525) -> Result<Option<AgentResult>, ResultError> {
526 let exit_path = devflow_dir(project_root).join(format!("phase-{:02}-exit", phase));
527 let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
528 Ok(s) => s.trim().parse().unwrap_or(-1),
529 Err(_) => return Ok(None), };
531
532 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
533
534 let branch_exists = std::process::Command::new("git")
536 .args(["rev-parse", "--verify", &branch])
537 .current_dir(project_root)
538 .output()
539 .map(|o| o.status.success())
540 .unwrap_or(false);
541
542 let commits: u32 = if branch_exists {
543 let range = format!("{}..{branch}", git_flow.develop);
544 std::process::Command::new("git")
545 .args(["rev-list", "--count", &range])
546 .current_dir(project_root)
547 .output()
548 .ok()
549 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
550 .unwrap_or(0)
551 } else {
552 0
553 };
554
555 let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
556 let no_work_done = commit_gated && commits == 0;
557
558 Ok(Some(AgentResult {
559 status: if exit_code != 0 || no_work_done {
560 AgentStatus::Failed
561 } else {
562 AgentStatus::Success
563 },
564 exit_code: Some(exit_code),
565 reason: if exit_code != 0 {
566 Some(format!(
567 "agent exited with code {} ({} commits on {})",
568 exit_code, commits, branch
569 ))
570 } else if no_work_done {
571 Some(format!(
572 "no commits found on {} (agent exit code was {})",
573 branch, exit_code
574 ))
575 } else {
576 Some(format!(
577 "{} commits on {} (agent exit code was {})",
578 commits, branch, exit_code
579 ))
580 },
581 commits: Some(commits),
582 summary: None,
583 verdict: None,
584 }))
585}
586
587pub fn evaluate_layer3(
592 project_root: &Path,
593 phase: u32,
594 git_flow: &GitFlowConfig,
595) -> Result<AgentResult, ResultError> {
596 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
597 let commits = std::process::Command::new("git")
598 .args([
599 "rev-list",
600 "--count",
601 &format!("{}..{branch}", git_flow.develop),
602 ])
603 .current_dir(project_root)
604 .output()
605 .ok()
606 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
607 .unwrap_or(0);
608
609 Ok(AgentResult {
610 status: AgentStatus::Unknown,
611 exit_code: None,
612 reason: if commits > 0 {
613 Some(format!(
614 "unverified — agent process is gone but {} commits exist on {}",
615 commits, branch
616 ))
617 } else {
618 Some("no work detected — agent process is gone with no commits".into())
619 },
620 commits: Some(commits),
621 summary: None,
622 verdict: None,
623 })
624}
625
626pub fn evaluate_agent_result(
628 project_root: &Path,
629 state: &State,
630 git_flow: &GitFlowConfig,
631) -> Result<AgentResult, ResultError> {
632 if let Some(result) = evaluate_layer1(project_root, state.phase) {
634 return Ok(result);
635 }
636
637 if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
639 return Ok(result);
640 }
641
642 evaluate_layer3(project_root, state.phase, git_flow)
644}
645
646fn devflow_dir(project_root: &Path) -> PathBuf {
648 project_root.join(".devflow")
649}
650
651pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
653 devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
654}
655
656pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
659 devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
660}
661
662pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
664 devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
665}
666
667pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
669 devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
670}
671
672pub fn cleanup_phase_files(project_root: &Path, phase: u32) {
674 let _ = std::fs::remove_file(stdout_path(project_root, phase));
675 let _ = std::fs::remove_file(exit_code_path(project_root, phase));
676 let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use crate::config::GitFlowConfig;
683 use crate::mode::Mode;
684 use crate::stage::Stage;
685 use crate::state::{AgentKind, State};
686 use std::process::Command;
687
688 fn state_in(root: &Path, phase: u32) -> State {
689 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
690 state.stage = Stage::Code;
691 state
692 }
693
694 fn git(root: &Path, args: &[&str]) {
695 let output = Command::new("git")
696 .args(args)
697 .current_dir(root)
698 .output()
699 .unwrap();
700 assert!(
701 output.status.success(),
702 "git {:?} failed\nstdout: {}\nstderr: {}",
703 args,
704 String::from_utf8_lossy(&output.stdout),
705 String::from_utf8_lossy(&output.stderr)
706 );
707 }
708
709 fn init_repo_with_feature_commit(root: &Path, phase: u32) {
710 git(root, &["init"]);
711 git(root, &["config", "user.email", "devflow@example.com"]);
712 git(root, &["config", "user.name", "DevFlow Tests"]);
713 git(root, &["config", "commit.gpgsign", "false"]);
714 git(root, &["config", "tag.gpgsign", "false"]);
715 git(root, &["checkout", "-b", "develop"]);
716 std::fs::write(root.join("README.md"), "base\n").unwrap();
717 git(root, &["add", "README.md"]);
718 git(root, &["commit", "-m", "base"]);
719
720 let branch = format!("feature/phase-{phase:02}");
721 git(root, &["checkout", "-b", &branch]);
722 std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
723 git(root, &["add", "phase.txt"]);
724 git(root, &["commit", "-m", "feature work"]);
725 }
726
727 fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
730 git(root, &["init"]);
731 git(root, &["config", "user.email", "devflow@example.com"]);
732 git(root, &["config", "user.name", "DevFlow Tests"]);
733 git(root, &["config", "commit.gpgsign", "false"]);
734 git(root, &["config", "tag.gpgsign", "false"]);
735 git(root, &["checkout", "-b", "develop"]);
736 std::fs::write(root.join("README.md"), "base\n").unwrap();
737 git(root, &["add", "README.md"]);
738 git(root, &["commit", "-m", "base"]);
739
740 let branch = format!("feature/phase-{phase:02}");
741 git(root, &["checkout", "-b", &branch]);
742 }
743
744 #[test]
745 fn parse_success_marker() {
746 let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
747 let result = parse_devflow_result(stdout).unwrap();
748 assert_eq!(result.status, AgentStatus::Success);
749 }
750
751 #[test]
752 fn parse_failed_marker_with_reason() {
753 let stdout =
754 "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
755 let result = parse_devflow_result(stdout).unwrap();
756 assert_eq!(result.status, AgentStatus::Failed);
757 assert_eq!(result.reason.unwrap(), "clippy errors");
758 }
759
760 #[test]
761 fn parse_missing_marker_returns_none() {
762 let stdout = "just some output\nno marker here\n";
763 assert!(parse_devflow_result(stdout).is_none());
764 }
765
766 #[test]
767 fn parse_malformed_json_returns_none() {
768 let stdout = "DEVFLOW_RESULT: {not valid json}\n";
769 assert!(parse_devflow_result(stdout).is_none());
770 }
771
772 #[test]
773 fn parse_lowercase_marker() {
774 let stdout = "devflow_result: {\"status\":\"success\"}\n";
775 let result = parse_devflow_result(stdout).unwrap();
776 assert_eq!(result.status, AgentStatus::Success);
777 }
778
779 #[test]
780 fn parse_marker_without_space_after_colon() {
781 let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
782 let result = parse_devflow_result(stdout).unwrap();
783 assert_eq!(result.status, AgentStatus::Success);
784 }
785
786 #[test]
787 fn parse_lowercase_no_space_marker() {
788 let stdout = "devflow_result:{\"status\":\"success\"}\n";
791 let result = parse_devflow_result(stdout).unwrap();
792 assert_eq!(result.status, AgentStatus::Success);
793 }
794
795 #[test]
796 fn parse_finds_last_marker_in_tail() {
797 let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
799 let result = parse_devflow_result(stdout).unwrap();
800 assert_eq!(result.status, AgentStatus::Success);
801 }
802
803 #[test]
804 fn parse_marker_lines_returns_last_marker_in_long_output() {
805 let stdout = format!(
806 "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
807 DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
808 "prefix".repeat(900),
809 "tail output".repeat(100)
810 );
811
812 let result = parse_marker_lines(&stdout).unwrap();
813
814 assert_eq!(result.status, AgentStatus::Success);
815 }
816
817 #[test]
818 fn parse_marker_only_in_last_4000_chars() {
819 let prefix = "a".repeat(5000);
821 let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
822 assert!(parse_devflow_result(&stdout).is_none());
823 }
824
825 #[test]
826 fn parse_marker_with_commits_and_summary() {
827 let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
828 let result = parse_devflow_result(stdout).unwrap();
829 assert_eq!(result.status, AgentStatus::Success);
830 assert_eq!(result.commits, Some(3));
831 assert_eq!(result.summary.unwrap(), "added tests");
832 }
833
834 #[test]
835 fn parse_marker_inside_json_result_envelope() {
836 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
839 let result = parse_devflow_result(stdout).unwrap();
840 assert_eq!(result.status, AgentStatus::Success);
841 assert_eq!(result.commits, Some(2));
842 }
843
844 #[test]
845 fn parse_failed_marker_inside_json_envelope() {
846 let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
847 let result = parse_devflow_result(stdout).unwrap();
848 assert_eq!(result.status, AgentStatus::Failed);
849 assert_eq!(result.reason.unwrap(), "tests failed");
850 }
851
852 #[test]
853 fn parse_json_envelope_without_marker_returns_none() {
854 let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
855 assert!(parse_devflow_result(stdout).is_none());
856 }
857
858 #[test]
859 fn detect_claude_json_rate_limit_by_subtype() {
860 let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
861 assert_eq!(
862 detect_rate_limit(stdout).as_deref(),
863 Some("2026-06-18T15:45:30Z")
864 );
865 }
866
867 #[test]
868 fn detect_claude_json_rate_limit_by_429() {
869 let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
870 assert_eq!(
871 detect_rate_limit(stdout).as_deref(),
872 Some("Too many requests. Try later.")
873 );
874 }
875
876 #[test]
877 fn detect_codex_try_again_rate_limit() {
878 let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
879 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
880 }
881
882 #[test]
891 fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
892 const DEPTH: usize = 100;
895 let mut stdout = String::new();
896 for _ in 0..DEPTH {
897 stdout.push_str(r#"{"nested":"#);
898 }
899 stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
900 for _ in 0..DEPTH {
901 stdout.push('}');
902 }
903
904 assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
907 }
908
909 #[test]
910 fn detect_rate_limit_ignores_normal_stdout() {
911 let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
912 assert!(detect_rate_limit(stdout).is_none());
913 }
914
915 #[test]
916 fn claude_envelope_is_error_detected() {
917 let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
918 let result = detect_claude_envelope_failure(stdout).unwrap();
919 assert_eq!(result.status, AgentStatus::Failed);
920 }
921
922 #[test]
923 fn claude_is_error_overrides_success_marker() {
924 let dir = tempfile::tempdir().unwrap();
925 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
926 std::fs::write(
927 stdout_path(dir.path(), 9),
928 r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
929 )
930 .unwrap();
931
932 let result = evaluate_layer1(dir.path(), 9).unwrap();
933
934 assert_eq!(result.status, AgentStatus::Failed);
935 }
936
937 #[test]
938 fn claude_envelope_is_error_false_defers() {
939 let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
940 assert!(detect_claude_envelope_failure(stdout).is_none());
941 }
942
943 #[test]
944 fn claude_envelope_marker_still_wins() {
945 let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
946 assert!(detect_claude_envelope_failure(stdout).is_none());
947 let result = parse_devflow_result(stdout).unwrap();
948 assert_eq!(result.status, AgentStatus::Success);
949 assert_eq!(result.commits, Some(2));
950 }
951
952 #[test]
953 fn codex_event_stream_parses_turn_failed() {
954 let stdout = concat!(
955 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
956 "{\"type\":\"turn.started\"}\n",
957 "{\"type\":\"item.started\",\"item\":{}}\n",
958 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
959 );
960 let result = parse_codex_event_result(stdout).unwrap();
961 assert_eq!(result.status, AgentStatus::Failed);
962 assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
963 }
964
965 #[test]
966 fn codex_turn_completed_no_marker_defers() {
967 let stdout = concat!(
968 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
969 "{\"type\":\"turn.started\"}\n",
970 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
971 );
972 assert!(parse_codex_event_result(stdout).is_none());
973 }
974
975 #[test]
981 fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
982 let stdout = concat!(
983 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
984 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
985 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
986 );
987 let result = parse_codex_event_result(stdout).unwrap();
988 assert_eq!(result.status, AgentStatus::Failed);
989 assert_eq!(
990 result.reason.as_deref(),
991 Some("interactive input unavailable")
992 );
993 }
994
995 #[test]
996 fn codex_agent_message_marker_success_short_circuits() {
997 let stdout = concat!(
998 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
999 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1000 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1001 );
1002 let result = parse_codex_event_result(stdout).unwrap();
1003 assert_eq!(result.status, AgentStatus::Success);
1004 }
1005
1006 #[test]
1012 fn detect_rate_limit_ignores_json_event_lines() {
1013 let stdout = concat!(
1014 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1015 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1016 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1017 );
1018 assert_eq!(detect_rate_limit(stdout), None);
1019 }
1020
1021 #[test]
1022 fn detect_rate_limit_still_reads_codex_plain_text() {
1023 let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1024 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1025 }
1026
1027 #[test]
1028 fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1029 let stdout = concat!(
1030 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1031 "not json at all\n",
1032 "{\"type\":\"item.started\",\"item\":{}}\n",
1033 "{\"type\":\"item.updated\",\"item\":{}}\n",
1034 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1035 );
1036 let result = parse_codex_event_result(stdout).unwrap();
1037 assert_eq!(result.status, AgentStatus::Failed);
1038 assert_eq!(result.reason.as_deref(), Some("boom"));
1039 }
1040
1041 #[test]
1042 fn claude_envelope_not_consumed_by_codex_parser() {
1043 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1044 assert!(parse_codex_event_result(stdout).is_none());
1045 }
1046
1047 #[test]
1048 fn evaluate_layer1_reports_rate_limited_without_marker() {
1049 let dir = tempfile::tempdir().unwrap();
1050 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1051 std::fs::write(
1052 stdout_path(dir.path(), 7),
1053 r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1054 )
1055 .unwrap();
1056
1057 let result = evaluate_layer1(dir.path(), 7).unwrap();
1058
1059 assert_eq!(result.status, AgentStatus::RateLimited);
1060 assert_eq!(
1061 result.reason.as_deref(),
1062 Some("rate limited until 2026-06-18T15:45:30Z")
1063 );
1064 }
1065
1066 #[test]
1071 fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1072 let dir = tempfile::tempdir().unwrap();
1073 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1074 std::fs::write(
1075 stdout_path(dir.path(), 7),
1076 r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1077 )
1078 .unwrap();
1079
1080 let result = evaluate_layer1(dir.path(), 7).unwrap();
1081
1082 assert_eq!(result.status, AgentStatus::RateLimited);
1083 assert_eq!(
1084 result.reason.as_deref(),
1085 Some("rate limited until 2026-06-18T15:45:30Z")
1086 );
1087 }
1088
1089 #[test]
1095 fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1096 let dir = tempfile::tempdir().unwrap();
1097 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1098 let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1099 bytes.extend_from_slice(
1100 b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1101 );
1102 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1103
1104 let result = evaluate_layer1(dir.path(), 5).unwrap();
1105
1106 assert_eq!(result.status, AgentStatus::Failed);
1107 assert_eq!(result.reason.as_deref(), Some("review: bad"));
1108 }
1109
1110 #[test]
1111 fn cleanup_removes_phase_files() {
1112 let dir = tempfile::tempdir().unwrap();
1113 let root = dir.path();
1114 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1115 std::fs::write(root.join(".devflow/phase-01-stdout"), "test").unwrap();
1116 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1117
1118 cleanup_phase_files(root, 1);
1119 assert!(!root.join(".devflow/phase-01-stdout").exists());
1120 assert!(!root.join(".devflow/phase-01-exit").exists());
1121 }
1122
1123 #[test]
1124 fn cleanup_handles_missing_files() {
1125 let dir = tempfile::tempdir().unwrap();
1126 let root = dir.path();
1127 cleanup_phase_files(root, 1);
1129 }
1130
1131 #[test]
1132 fn evaluate_agent_result_reads_files_end_to_end() {
1133 let dir = tempfile::tempdir().unwrap();
1134 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1135 std::fs::write(
1136 stdout_path(dir.path(), 6),
1137 "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
1138 )
1139 .unwrap();
1140 std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
1141 let state = state_in(dir.path(), 6);
1142
1143 let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1144
1145 assert_eq!(result.status, AgentStatus::Success);
1146 assert_eq!(result.commits, Some(2));
1147 assert_eq!(result.summary.as_deref(), Some("ok"));
1148 }
1149
1150 #[test]
1151 fn evaluate_layer1_finds_devflow_result_in_file() {
1152 let dir = tempfile::tempdir().unwrap();
1153 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1154 std::fs::write(
1155 stdout_path(dir.path(), 3),
1156 "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
1157 )
1158 .unwrap();
1159
1160 let result = evaluate_layer1(dir.path(), 3).unwrap();
1161
1162 assert_eq!(result.status, AgentStatus::Failed);
1163 assert_eq!(result.reason.as_deref(), Some("bad output"));
1164 }
1165
1166 #[test]
1167 fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
1168 let dir = tempfile::tempdir().unwrap();
1169 init_repo_with_feature_commit(dir.path(), 4);
1170 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1171 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
1172 let state = state_in(dir.path(), 4);
1173
1174 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1175 .unwrap()
1176 .unwrap();
1177
1178 assert_eq!(result.status, AgentStatus::Success);
1179 assert_eq!(result.exit_code, Some(0));
1180 assert_eq!(result.commits, Some(1));
1181 assert!(result.reason.unwrap().contains("1 commits"));
1182 }
1183
1184 #[test]
1185 fn evaluate_layer2_exit_zero_no_commits_is_failed() {
1186 let dir = tempfile::tempdir().unwrap();
1189 init_repo_with_feature_no_commit(dir.path(), 4);
1190 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1191 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
1192 let state = state_in(dir.path(), 4);
1193
1194 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1195 .unwrap()
1196 .unwrap();
1197
1198 assert_eq!(result.status, AgentStatus::Failed);
1199 assert_eq!(result.exit_code, Some(0));
1200 assert_eq!(result.commits, Some(0));
1201 assert!(result.reason.unwrap().contains("no commits"));
1202 }
1203
1204 #[test]
1205 fn evaluate_layer2_nonzero_exit_is_failed() {
1206 let dir = tempfile::tempdir().unwrap();
1208 init_repo_with_feature_commit(dir.path(), 4);
1209 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1210 std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
1211 let state = state_in(dir.path(), 4);
1212
1213 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1214 .unwrap()
1215 .unwrap();
1216
1217 assert_eq!(result.status, AgentStatus::Failed);
1218 assert_eq!(result.exit_code, Some(1));
1219 assert!(result.reason.unwrap().contains("exited with code 1"));
1220 }
1221
1222 #[test]
1223 fn layer2_nonzero_exit_is_failed_all_stages() {
1224 let dir = tempfile::tempdir().unwrap();
1228 init_repo_with_feature_no_commit(dir.path(), 10);
1229 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1230 std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
1231
1232 for stage in [
1233 Stage::Define,
1234 Stage::Plan,
1235 Stage::Code,
1236 Stage::Validate,
1237 Stage::Ship,
1238 ] {
1239 let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
1240 .unwrap()
1241 .unwrap();
1242 assert_eq!(
1243 result.status,
1244 AgentStatus::Failed,
1245 "stage {stage:?} should be Failed on nonzero exit"
1246 );
1247 }
1248 }
1249
1250 #[test]
1251 fn layer2_skips_commit_gate_for_define_and_validate() {
1252 let dir = tempfile::tempdir().unwrap();
1253 init_repo_with_feature_no_commit(dir.path(), 11);
1254 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1255 std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
1256
1257 for stage in [Stage::Define, Stage::Validate] {
1258 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
1259 .unwrap()
1260 .unwrap();
1261 assert_ne!(
1262 result.status,
1263 AgentStatus::Failed,
1264 "stage {stage:?} should not be Failed for zero commits"
1265 );
1266 }
1267
1268 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
1271 .unwrap()
1272 .unwrap();
1273 assert_eq!(result.status, AgentStatus::Failed);
1274 }
1275
1276 #[test]
1277 fn evaluate_layer3_falls_back_to_commit_count() {
1278 let dir = tempfile::tempdir().unwrap();
1279 init_repo_with_feature_commit(dir.path(), 5);
1280
1281 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
1282
1283 assert_eq!(result.status, AgentStatus::Unknown);
1284 assert_eq!(result.exit_code, None);
1285 assert_eq!(result.commits, Some(1));
1286 assert!(result.reason.unwrap().contains("1 commits"));
1287 }
1288
1289 #[test]
1290 fn parse_devflow_result_reads_verdict() {
1291 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
1292 let result = parse_devflow_result(stdout).unwrap();
1293 assert_eq!(result.status, AgentStatus::Success);
1294 assert_eq!(result.verdict, Some(Verdict::Gaps));
1295 }
1296
1297 #[test]
1298 fn parse_devflow_result_reads_verdict_pass() {
1299 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
1300 let result = parse_devflow_result(stdout).unwrap();
1301 assert_eq!(result.status, AgentStatus::Success);
1302 assert_eq!(result.verdict, Some(Verdict::Pass));
1303 }
1304
1305 #[test]
1306 fn parse_devflow_result_verdict_absent_is_none() {
1307 let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
1308 let result = parse_devflow_result(stdout).unwrap();
1309 assert_eq!(result.status, AgentStatus::Success);
1310 assert_eq!(result.verdict, None);
1311 }
1312
1313 #[test]
1314 fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
1315 let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
1318 let result = parse_devflow_result(unknown).unwrap();
1319 assert_eq!(result.status, AgentStatus::Success);
1320 assert_eq!(result.verdict, None);
1321
1322 let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
1324 let result = parse_devflow_result(miscased).unwrap();
1325 assert_eq!(result.status, AgentStatus::Success);
1326 assert_eq!(result.verdict, None);
1327 }
1328
1329 #[test]
1336 fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
1337 let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
1338 let result = parse_devflow_result(bool_verdict).unwrap();
1339 assert_eq!(result.status, AgentStatus::Success);
1340 assert_eq!(result.verdict, None);
1341
1342 let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
1343 let result = parse_devflow_result(numeric_verdict).unwrap();
1344 assert_eq!(result.status, AgentStatus::Success);
1345 assert_eq!(result.verdict, None);
1346
1347 let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
1348 let result = parse_devflow_result(object_verdict).unwrap();
1349 assert_eq!(result.status, AgentStatus::Success);
1350 assert_eq!(result.verdict, None);
1351 }
1352}