1use crate::config::GitFlowConfig;
11use crate::git::git_command;
12use crate::stage::Stage;
13use crate::state::State;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct AgentResult {
19 pub status: AgentStatus,
20 pub exit_code: Option<i32>,
21 pub reason: Option<String>,
22 pub commits: Option<u32>,
23 pub summary: Option<String>,
24 #[serde(default, deserialize_with = "deserialize_verdict_lenient")]
36 pub verdict: Option<Verdict>,
37 #[serde(default)]
41 pub decided_by_layer: Option<u8>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum AgentStatus {
48 Success,
50 Failed,
52 RateLimited,
54 Unknown,
56 #[serde(rename = "resource_killed")]
59 ResourceKilled,
60 #[serde(rename = "agent_unavailable")]
63 AgentUnavailable,
64}
65
66impl AgentStatus {
67 pub fn as_wire_str(&self) -> &'static str {
75 match self {
76 AgentStatus::Success => "success",
77 AgentStatus::Failed => "failed",
78 AgentStatus::RateLimited => "ratelimited",
79 AgentStatus::Unknown => "unknown",
80 AgentStatus::ResourceKilled => "resource_killed",
81 AgentStatus::AgentUnavailable => "agent_unavailable",
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
88#[serde(rename_all = "lowercase")]
89pub enum Verdict {
90 Pass,
92 Gaps,
95}
96
97fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
115where
116 D: serde::Deserializer<'de>,
117{
118 let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
119 Ok(raw.and_then(|v| {
120 v.as_str().and_then(|s| match s {
121 "pass" => Some(Verdict::Pass),
122 "gaps" => Some(Verdict::Gaps),
123 _ => None,
124 })
125 }))
126}
127
128#[derive(Debug, thiserror::Error)]
130pub enum ResultError {
131 #[error("I/O error reading agent output: {0}")]
132 Io(#[from] std::io::Error),
133 #[error("phase directory not found")]
134 NoPhaseDir,
135}
136
137pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
148 if let Some(inner) = extract_json_result_text(stdout)
149 && let Some(result) = parse_marker_lines(&inner)
150 {
151 return Some(result);
152 }
153 parse_marker_lines(stdout)
154}
155
156pub fn detect_rate_limit(stdout: &str) -> Option<String> {
163 detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
164}
165
166fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
167 let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
168 let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
169 || json_has_i64(&value, "api_error_status", 429)
170 || json_has_i64(&value, "status", 429)
171 || json_has_i64(&value, "status_code", 429);
172 if !rate_limited {
173 return None;
174 }
175 json_find_key(&value, "retry_after")
176 .and_then(json_scalar_to_string)
177 .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
178 .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
179 .or_else(|| Some("usage limit".to_string()))
180}
181
182fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
183 let stdout: String = stdout
190 .lines()
191 .filter(|line| {
192 serde_json::from_str::<serde_json::Value>(line)
193 .map(|v| !v.is_object())
194 .unwrap_or(true)
195 })
196 .collect::<Vec<_>>()
197 .join("\n");
198 let stdout = stdout.as_str();
199 let lower = stdout.to_ascii_lowercase();
200 if let Some(idx) = lower.find("try again at ") {
201 let start = idx + "try again at ".len();
202 let retry = stdout[start..]
203 .lines()
204 .next()
205 .unwrap_or_default()
206 .trim()
207 .trim_end_matches(['.', ',', ';'])
208 .trim();
209 if !retry.is_empty() {
210 return Some(retry.to_string());
211 }
212 }
213
214 if lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("429") {
215 stdout
216 .lines()
217 .find(|line| {
218 let line = line.to_ascii_lowercase();
219 line.contains("usage limit") || line.contains("rate limit") || line.contains("429")
220 })
221 .map(str::trim)
222 .filter(|line| !line.is_empty())
223 .map(str::to_string)
224 .or_else(|| Some("usage limit".to_string()))
225 } else {
226 None
227 }
228}
229
230fn extract_json_result_text(stdout: &str) -> Option<String> {
233 let trimmed = stdout.trim();
234 if !trimmed.starts_with('{') {
235 return None;
236 }
237 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
238 value.get("result")?.as_str().map(str::to_string)
239}
240
241pub fn claude_session_id(stdout: &str) -> Option<String> {
267 let trimmed = stdout.trim();
268 if !trimmed.starts_with('{') {
269 return None;
270 }
271 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
272 value.get("session_id")?.as_str().map(str::to_string)
273}
274
275pub fn session_id_from_capture(project_root: &Path, phase: u32) -> Option<String> {
281 let bytes = std::fs::read(stdout_path(project_root, phase)).ok()?;
282 let stdout = String::from_utf8_lossy(&bytes);
283 claude_session_id(&stdout)
284}
285
286fn json_scan<'a, T>(
299 value: &'a serde_json::Value,
300 visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
301) -> Option<T> {
302 let mut stack = vec![value];
303 while let Some(current) = stack.pop() {
304 match current {
305 serde_json::Value::Object(map) => {
306 if let Some(found) = visit(map) {
307 return Some(found);
308 }
309 for child in map.values().rev() {
311 stack.push(child);
312 }
313 }
314 serde_json::Value::Array(values) => {
315 for child in values.iter().rev() {
316 stack.push(child);
317 }
318 }
319 _ => {}
320 }
321 }
322 None
323}
324
325fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
326 json_scan(value, |map| {
327 (map.get(key)?.as_str()? == expected).then_some(())
328 })
329 .is_some()
330}
331
332fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
333 json_scan(value, |map| {
334 (map.get(key)?.as_i64()? == expected).then_some(())
335 })
336 .is_some()
337}
338
339fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
340 json_scan(value, |map| map.get(key))
341}
342
343fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
344 match value {
345 serde_json::Value::String(s) => Some(s.clone()),
346 serde_json::Value::Number(n) => Some(n.to_string()),
347 _ => None,
348 }
349}
350
351fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
369 let trimmed = stdout.trim();
370 if !trimmed.starts_with('{') {
371 return None;
372 }
373 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
374 let is_error = value.get("is_error")?.as_bool()?;
375 if !is_error {
376 return None;
377 }
378
379 let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
380 let base_reason = value
381 .get("result")
382 .and_then(serde_json::Value::as_str)
383 .map(str::to_string)
384 .or_else(|| {
385 value
386 .get("subtype")
387 .and_then(serde_json::Value::as_str)
388 .map(str::to_string)
389 })
390 .unwrap_or_else(|| "agent reported is_error".to_string());
391 let reason = match num_turns {
392 Some(n) => format!("{base_reason} (num_turns: {n})"),
393 None => base_reason,
394 };
395
396 Some(AgentResult {
397 status: AgentStatus::Failed,
398 exit_code: None,
399 reason: Some(reason),
400 commits: None,
401 summary: None,
402 verdict: None,
403 decided_by_layer: Some(1),
404 })
405}
406
407const HUMAN_GATE_VALUE: &str = "blocking-human";
433
434pub fn blocking_human_checkpoint_reported(stdout: &str) -> bool {
460 if text_reports_human_gate(stdout) {
461 return true;
462 }
463 extract_json_result_text(stdout)
464 .as_deref()
465 .is_some_and(text_reports_human_gate)
466}
467
468fn text_reports_human_gate(text: &str) -> bool {
488 let lower = text.to_ascii_lowercase();
489 let mut search_from = 0;
490 while let Some(rel_idx) = lower[search_from..].find("gate") {
491 let idx = search_from + rel_idx;
492 let after_label = &lower[idx + "gate".len()..];
493 let after_label = after_label.trim_start_matches(['*', ' ', '`']);
494 if let Some(rest) = after_label.strip_prefix(':') {
495 let value_region = rest.trim_start_matches(['*', ' ', '`']);
496 let value_token: String = value_region
497 .chars()
498 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
499 .collect();
500 if value_token == HUMAN_GATE_VALUE {
501 return true;
502 }
503 }
504 search_from = idx + "gate".len();
505 }
506 false
507}
508
509pub fn checkpoint_reported_in_capture(project_root: &Path, phase: u32) -> bool {
513 let Ok(bytes) = std::fs::read(stdout_path(project_root, phase)) else {
514 return false;
515 };
516 let stdout = String::from_utf8_lossy(&bytes);
517 blocking_human_checkpoint_reported(&stdout)
518}
519
520fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
524 events.iter().any(|v| {
525 v.get("type")
526 .and_then(serde_json::Value::as_str)
527 .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
528 })
529}
530
531fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
552 let events: Vec<serde_json::Value> = stdout
553 .lines()
554 .filter(|line| !line.trim().is_empty())
555 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
556 .collect();
557
558 if !is_codex_event_stream(&events) {
559 return None;
560 }
561
562 let marker = events.iter().rev().find_map(|v| {
569 if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
570 return None;
571 }
572 let item = v.get("item")?;
573 if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
574 return None;
575 }
576 let text = item.get("text").and_then(serde_json::Value::as_str)?;
577 parse_marker_lines(text)
578 });
579 if marker.is_some() {
580 return marker;
581 }
582
583 let terminal = events.iter().rev().find(|v| {
584 matches!(
585 v.get("type").and_then(serde_json::Value::as_str),
586 Some("turn.completed") | Some("turn.failed")
587 )
588 })?;
589
590 if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
591 return None;
594 }
595
596 let reason = terminal
597 .get("error")
598 .and_then(|e| e.get("message"))
599 .and_then(serde_json::Value::as_str)
600 .map(str::to_string)
601 .unwrap_or_else(|| "codex turn failed".to_string());
602
603 Some(AgentResult {
604 status: AgentStatus::Failed,
605 exit_code: None,
606 reason: Some(reason),
607 commits: None,
608 summary: None,
609 verdict: None,
610 decided_by_layer: Some(1),
611 })
612}
613
614fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
620 let tail: String = stdout
623 .chars()
624 .rev()
625 .take(4000)
626 .collect::<Vec<_>>()
627 .into_iter()
628 .rev()
629 .collect();
630
631 for line in tail.lines().rev() {
632 let Some(json_str) = line
633 .strip_prefix("DEVFLOW_RESULT: ")
634 .or_else(|| line.strip_prefix("devflow_result: "))
635 .or_else(|| line.strip_prefix("DEVFLOW_RESULT:"))
636 .or_else(|| line.strip_prefix("devflow_result:"))
637 else {
638 continue;
639 };
640
641 let json_str = json_str.trim();
642 if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
643 return Some(result);
644 }
645 }
646 None
647}
648
649pub fn evaluate_layer1(project_root: &Path, phase: u32) -> Option<AgentResult> {
661 let stdout_path = devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase));
662 let bytes = std::fs::read(&stdout_path).ok()?;
668 let stdout = String::from_utf8_lossy(&bytes);
669 detect_claude_rate_limit(&stdout)
670 .map(rate_limited_result)
671 .or_else(|| detect_claude_envelope_failure(&stdout))
672 .or_else(|| parse_devflow_result(&stdout))
673 .or_else(|| parse_codex_event_result(&stdout))
674 .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
675}
676
677fn rate_limited_result(retry: String) -> AgentResult {
679 AgentResult {
680 status: AgentStatus::RateLimited,
681 exit_code: None,
682 reason: Some(format!("rate limited until {retry}")),
683 commits: None,
684 summary: None,
685 verdict: None,
686 decided_by_layer: Some(1),
687 }
688}
689
690pub fn evaluate_layer2(
719 project_root: &Path,
720 phase: u32,
721 git_flow: &GitFlowConfig,
722 stage: Stage,
723) -> Result<Option<AgentResult>, ResultError> {
724 let exit_path = devflow_dir(project_root).join(format!("phase-{:02}-exit", phase));
725 let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
726 Ok(s) => s.trim().parse().unwrap_or(-1),
727 Err(_) => return Ok(None), };
729
730 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
731
732 let branch_exists = git_command(project_root)
734 .args(["rev-parse", "--verify", &branch])
735 .output()
736 .map(|o| o.status.success())
737 .unwrap_or(false);
738
739 let commits: u32 = if branch_exists {
740 let range = format!("{}..{branch}", git_flow.develop);
741 git_command(project_root)
742 .args(["rev-list", "--count", &range])
743 .output()
744 .ok()
745 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
746 .unwrap_or(0)
747 } else {
748 0
749 };
750
751 let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
752 let no_work_done = commit_gated && commits == 0;
753
754 let status = if exit_code == 137 {
759 AgentStatus::ResourceKilled
760 } else if exit_code == 127 {
761 AgentStatus::AgentUnavailable
762 } else if exit_code != 0 || no_work_done {
763 AgentStatus::Failed
764 } else {
765 AgentStatus::Success
766 };
767
768 Ok(Some(AgentResult {
769 status,
770 exit_code: Some(exit_code),
771 reason: if exit_code == 137 {
772 Some(format!(
773 "agent process was killed (exit code 137, likely OOM) ({} commits on {})",
774 commits, branch
775 ))
776 } else if exit_code == 127 {
777 Some(format!(
778 "agent command was unavailable (exit code 127, command not found) ({} commits on {})",
779 commits, branch
780 ))
781 } else if exit_code != 0 {
782 Some(format!(
783 "agent exited with code {} ({} commits on {})",
784 exit_code, commits, branch
785 ))
786 } else if no_work_done {
787 Some(format!(
788 "no commits found on {} (agent exit code was {})",
789 branch, exit_code
790 ))
791 } else {
792 Some(format!(
793 "{} commits on {} (agent exit code was {})",
794 commits, branch, exit_code
795 ))
796 },
797 commits: Some(commits),
798 summary: None,
799 verdict: None,
800 decided_by_layer: Some(2),
801 }))
802}
803
804pub fn evaluate_layer3(
816 project_root: &Path,
817 phase: u32,
818 git_flow: &GitFlowConfig,
819) -> Result<AgentResult, ResultError> {
820 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
821 let commits = git_command(project_root)
822 .args([
823 "rev-list",
824 "--count",
825 &format!("{}..{branch}", git_flow.develop),
826 ])
827 .output()
828 .ok()
829 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
830 .unwrap_or(0);
831
832 let (status, reason) = if commits > 0 {
833 (
834 AgentStatus::Unknown,
835 format!(
836 "unverified — agent process is gone but {} commits exist on {}",
837 commits, branch
838 ),
839 )
840 } else {
841 (
842 AgentStatus::Failed,
843 "no work accounted for — agent process is gone with no commits and no declared \
844 external post-condition; human review needed"
845 .to_string(),
846 )
847 };
848
849 Ok(AgentResult {
850 status,
851 exit_code: None,
852 reason: Some(reason),
853 commits: Some(commits),
854 summary: None,
855 verdict: None,
856 decided_by_layer: Some(3),
857 })
858}
859
860fn evaluate_layer0(
877 project_root: &Path,
878 state: &State,
879 approved_commands: Option<&[String]>,
880) -> Option<AgentResult> {
881 if !crate::config::external_verify_enabled(project_root) {
882 return None;
883 }
884
885 let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
886 let commands = crate::verify::external_verify_commands(project_root, state.phase);
887 if commands.is_empty() {
888 return approved_commands.map(|_| AgentResult {
889 status: AgentStatus::Failed,
890 exit_code: None,
891 reason: Some(
892 "external verification approval mismatch; PLAN declaration was removed".into(),
893 ),
894 commits: None,
895 summary: None,
896 verdict: None,
897 decided_by_layer: Some(0),
898 });
899 }
900 let Some(approved_commands) = approved_commands else {
901 return Some(AgentResult {
902 status: AgentStatus::Failed,
903 exit_code: None,
904 reason: Some(format!(
905 "external verification is not approved; set {} to the reviewed JSON command array",
906 crate::verify::TRUST_EXTERNAL_VERIFY_ENV
907 )),
908 commits: None,
909 summary: None,
910 verdict: None,
911 decided_by_layer: Some(0),
912 });
913 };
914 if commands != approved_commands {
915 return Some(AgentResult {
916 status: AgentStatus::Failed,
917 exit_code: None,
918 reason: Some("external verification approval mismatch; PLAN commands changed".into()),
919 commits: None,
920 summary: None,
921 verdict: None,
922 decided_by_layer: Some(0),
923 });
924 }
925 match commands
926 .into_iter()
927 .find(|command| !crate::verify::run_external_verification(command, execution_root))
928 {
929 Some(command) => Some(AgentResult {
930 status: AgentStatus::Failed,
931 exit_code: None,
932 reason: Some(format!("external verification failed: {command}")),
933 commits: None,
934 summary: None,
935 verdict: None,
936 decided_by_layer: Some(0),
937 }),
938 None => Some(AgentResult {
941 status: AgentStatus::Success,
942 exit_code: None,
943 reason: Some(
944 "external verification passed — all declared, approved probes succeeded".into(),
945 ),
946 commits: None,
947 summary: None,
948 verdict: None,
949 decided_by_layer: Some(0),
950 }),
951 }
952}
953
954fn reconcile_layer0_verdict(
978 project_root: &Path,
979 state: &State,
980 result: AgentResult,
981) -> AgentResult {
982 if state.stage != Stage::Validate
983 || result.status != AgentStatus::Success
984 || result.decided_by_layer != Some(0)
985 {
986 return result;
987 }
988 let verdict = evaluate_layer1(project_root, state.phase).and_then(|layer1| layer1.verdict);
989 AgentResult { verdict, ..result }
990}
991
992pub fn evaluate_agent_result(
994 project_root: &Path,
995 state: &State,
996 git_flow: &GitFlowConfig,
997) -> Result<AgentResult, ResultError> {
998 let approval = crate::verify::external_verification_approval();
999 evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
1000}
1001
1002fn evaluate_agent_result_inner(
1003 project_root: &Path,
1004 state: &State,
1005 git_flow: &GitFlowConfig,
1006 approved_commands: Option<&[String]>,
1007) -> Result<AgentResult, ResultError> {
1008 if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
1010 return Ok(reconcile_layer0_verdict(project_root, state, result));
1011 }
1012
1013 if let Some(result) = evaluate_layer1(project_root, state.phase) {
1015 return Ok(result);
1016 }
1017
1018 if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
1020 return Ok(result);
1021 }
1022
1023 evaluate_layer3(project_root, state.phase, git_flow)
1025}
1026
1027fn devflow_dir(project_root: &Path) -> PathBuf {
1029 project_root.join(".devflow")
1030}
1031
1032pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
1034 devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
1035}
1036
1037pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
1040 devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
1041}
1042
1043pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
1045 devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
1046}
1047
1048pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
1050 devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
1051}
1052
1053pub fn history_dir(project_root: &Path, phase: u32) -> PathBuf {
1061 devflow_dir(project_root)
1062 .join("history")
1063 .join(format!("phase-{:02}", phase))
1064}
1065
1066static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1070
1071fn archive_stamp() -> String {
1076 let nanos = std::time::SystemTime::now()
1077 .duration_since(std::time::UNIX_EPOCH)
1078 .map(|d| d.as_nanos())
1079 .unwrap_or(0);
1080 let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1081 format!("{nanos}-{seq}")
1082}
1083
1084pub fn archive_phase_files(
1094 project_root: &Path,
1095 evidence_root: &Path,
1096 phase: u32,
1097 retain: usize,
1098) -> Result<Option<String>, std::io::Error> {
1099 archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
1100}
1101
1102fn archive_phase_files_with_stamp(
1103 project_root: &Path,
1104 evidence_root: &Path,
1105 phase: u32,
1106 retain: usize,
1107 stamp: &str,
1108) -> Result<Option<String>, std::io::Error> {
1109 let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
1110
1111 let stdout_src = stdout_path(project_root, phase);
1112 let exit_src = exit_code_path(project_root, phase);
1113 let stdout_exists = stdout_src.exists();
1114 let exit_exists = exit_src.exists();
1115 if !stdout_exists && !exit_exists {
1116 return Ok(None); }
1118
1119 let history_dir = history_dir(project_root, phase);
1120 crate::workflow::ensure_devflow_dir(&history_dir)?;
1121
1122 let staging_dir = history_dir.join(format!(".pending-{stamp}"));
1123 std::fs::create_dir(&staging_dir)?;
1124 let stdout_stage = staging_dir.join("stdout");
1125 let exit_stage = staging_dir.join("exit");
1126 let review_stage = staging_dir.join("REVIEW.md");
1127 let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
1128 let exit_dest = history_dir.join(format!("{stamp}-exit"));
1129 let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
1130 let review_src = phase_review_path(evidence_root, phase);
1131
1132 let mut stdout_staged = false;
1133 let mut exit_staged = false;
1134 let mut stdout_published = false;
1135 let mut exit_published = false;
1136 let mut review_published = false;
1137
1138 let archive_result = (|| -> Result<(), std::io::Error> {
1139 if stdout_exists {
1140 std::fs::rename(&stdout_src, &stdout_stage)?;
1141 stdout_staged = true;
1142 }
1143 if exit_exists {
1144 std::fs::rename(&exit_src, &exit_stage)?;
1145 exit_staged = true;
1146 }
1147 if let Some(review) = &review_src {
1148 std::fs::copy(review, &review_stage)?;
1149 }
1150
1151 if stdout_exists {
1152 std::fs::rename(&stdout_stage, &stdout_dest)?;
1153 stdout_staged = false;
1154 stdout_published = true;
1155 }
1156 if exit_exists {
1157 std::fs::rename(&exit_stage, &exit_dest)?;
1158 exit_staged = false;
1159 exit_published = true;
1160 }
1161 if review_src.is_some() {
1162 std::fs::rename(&review_stage, &review_dest)?;
1163 review_published = true;
1164 }
1165 Ok(())
1166 })();
1167
1168 if let Err(error) = archive_result {
1169 let mut rollback_error = None;
1170 let mut restore = |from: &Path, to: &Path| {
1171 if let Err(error) = std::fs::rename(from, to)
1172 && rollback_error.is_none()
1173 {
1174 rollback_error = Some(error);
1175 }
1176 };
1177 if stdout_published {
1178 restore(&stdout_dest, &stdout_src);
1179 } else if stdout_staged {
1180 restore(&stdout_stage, &stdout_src);
1181 }
1182 if exit_published {
1183 restore(&exit_dest, &exit_src);
1184 } else if exit_staged {
1185 restore(&exit_stage, &exit_src);
1186 }
1187 if review_published {
1188 let _ = std::fs::remove_file(&review_dest);
1189 }
1190 let _ = std::fs::remove_dir_all(&staging_dir);
1191
1192 if let Some(rollback_error) = rollback_error {
1193 return Err(std::io::Error::new(
1194 error.kind(),
1195 format!("{error}; archive rollback failed: {rollback_error}"),
1196 ));
1197 }
1198 return Err(error);
1199 }
1200
1201 let _ = std::fs::remove_dir(&staging_dir);
1202
1203 prune_history(&history_dir, retain);
1204 Ok(Some(stamp.to_string()))
1205}
1206
1207fn phase_review_path(project_root: &Path, phase: u32) -> Option<PathBuf> {
1208 let phases = std::fs::read_dir(project_root.join(".planning/phases")).ok()?;
1209 let prefix = format!("{phase:02}-");
1210 for entry in phases.flatten() {
1211 if entry
1212 .file_name()
1213 .to_str()
1214 .is_some_and(|name| name.starts_with(&prefix))
1215 {
1216 let review = entry.path().join(format!("{phase:02}-REVIEW.md"));
1217 if review.exists() {
1218 return Some(review);
1219 }
1220 }
1221 }
1222 None
1223}
1224
1225fn prune_history(history_dir: &Path, retain: usize) {
1233 let Ok(entries) = std::fs::read_dir(history_dir) else {
1234 return;
1235 };
1236
1237 let mut stamps: Vec<String> = entries
1238 .flatten()
1239 .filter_map(|entry| {
1240 let name = entry.file_name().to_str()?.to_string();
1241 name.rsplit_once('-')
1242 .map(|(stamp, _suffix)| stamp.to_string())
1243 })
1244 .collect();
1245 stamps.sort_by_key(|stamp| {
1246 let mut parts = stamp.split('-');
1247 let nanos = parts
1248 .next()
1249 .and_then(|part| part.parse::<u128>().ok())
1250 .unwrap_or(0);
1251 let sequence = parts
1252 .next()
1253 .and_then(|part| part.parse::<u64>().ok())
1254 .unwrap_or(0);
1255 (nanos, sequence)
1256 });
1257 stamps.dedup();
1258
1259 if stamps.len() <= retain {
1260 return;
1261 }
1262
1263 let to_remove = stamps.len() - retain;
1264 for stamp in &stamps[..to_remove] {
1265 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
1266 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
1267 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
1268 }
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273 use super::*;
1274 use crate::config::GitFlowConfig;
1275 use crate::mode::Mode;
1276 use crate::stage::Stage;
1277 use crate::state::{AgentKind, State};
1278
1279 fn state_in(root: &Path, phase: u32) -> State {
1280 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
1281 state.stage = Stage::Code;
1282 state
1283 }
1284
1285 fn git(root: &Path, args: &[&str]) {
1286 let output = crate::test_support::git_command(root)
1287 .args(args)
1288 .output()
1289 .unwrap();
1290 assert!(
1291 output.status.success(),
1292 "git {:?} failed\nstdout: {}\nstderr: {}",
1293 args,
1294 String::from_utf8_lossy(&output.stdout),
1295 String::from_utf8_lossy(&output.stderr)
1296 );
1297 }
1298
1299 fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1300 git(root, &["init"]);
1301 git(root, &["config", "user.email", "devflow@example.com"]);
1302 git(root, &["config", "user.name", "DevFlow Tests"]);
1303 git(root, &["config", "commit.gpgsign", "false"]);
1304 git(root, &["config", "tag.gpgsign", "false"]);
1305 git(root, &["config", "core.hooksPath", "/dev/null"]);
1306 git(root, &["checkout", "-b", "develop"]);
1307 std::fs::write(root.join("README.md"), "base\n").unwrap();
1308 git(root, &["add", "README.md"]);
1309 git(root, &["commit", "-m", "base"]);
1310
1311 let branch = format!("feature/phase-{phase:02}");
1312 git(root, &["checkout", "-b", &branch]);
1313 std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1314 git(root, &["add", "phase.txt"]);
1315 git(root, &["commit", "-m", "feature work"]);
1316 }
1317
1318 fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1321 git(root, &["init"]);
1322 git(root, &["config", "user.email", "devflow@example.com"]);
1323 git(root, &["config", "user.name", "DevFlow Tests"]);
1324 git(root, &["config", "commit.gpgsign", "false"]);
1325 git(root, &["config", "tag.gpgsign", "false"]);
1326 git(root, &["config", "core.hooksPath", "/dev/null"]);
1327 git(root, &["checkout", "-b", "develop"]);
1328 std::fs::write(root.join("README.md"), "base\n").unwrap();
1329 git(root, &["add", "README.md"]);
1330 git(root, &["commit", "-m", "base"]);
1331
1332 let branch = format!("feature/phase-{phase:02}");
1333 git(root, &["checkout", "-b", &branch]);
1334 }
1335
1336 #[test]
1337 fn parse_success_marker() {
1338 let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1339 let result = parse_devflow_result(stdout).unwrap();
1340 assert_eq!(result.status, AgentStatus::Success);
1341 }
1342
1343 #[test]
1344 fn parse_failed_marker_with_reason() {
1345 let stdout =
1346 "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1347 let result = parse_devflow_result(stdout).unwrap();
1348 assert_eq!(result.status, AgentStatus::Failed);
1349 assert_eq!(result.reason.unwrap(), "clippy errors");
1350 }
1351
1352 #[test]
1353 fn parse_missing_marker_returns_none() {
1354 let stdout = "just some output\nno marker here\n";
1355 assert!(parse_devflow_result(stdout).is_none());
1356 }
1357
1358 #[test]
1359 fn parse_malformed_json_returns_none() {
1360 let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1361 assert!(parse_devflow_result(stdout).is_none());
1362 }
1363
1364 #[test]
1365 fn parse_lowercase_marker() {
1366 let stdout = "devflow_result: {\"status\":\"success\"}\n";
1367 let result = parse_devflow_result(stdout).unwrap();
1368 assert_eq!(result.status, AgentStatus::Success);
1369 }
1370
1371 #[test]
1372 fn parse_marker_without_space_after_colon() {
1373 let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1374 let result = parse_devflow_result(stdout).unwrap();
1375 assert_eq!(result.status, AgentStatus::Success);
1376 }
1377
1378 #[test]
1379 fn parse_lowercase_no_space_marker() {
1380 let stdout = "devflow_result:{\"status\":\"success\"}\n";
1383 let result = parse_devflow_result(stdout).unwrap();
1384 assert_eq!(result.status, AgentStatus::Success);
1385 }
1386
1387 #[test]
1388 fn parse_finds_last_marker_in_tail() {
1389 let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1391 let result = parse_devflow_result(stdout).unwrap();
1392 assert_eq!(result.status, AgentStatus::Success);
1393 }
1394
1395 #[test]
1396 fn parse_marker_lines_returns_last_marker_in_long_output() {
1397 let stdout = format!(
1398 "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1399 DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1400 "prefix".repeat(900),
1401 "tail output".repeat(100)
1402 );
1403
1404 let result = parse_marker_lines(&stdout).unwrap();
1405
1406 assert_eq!(result.status, AgentStatus::Success);
1407 }
1408
1409 #[test]
1410 fn parse_marker_only_in_last_4000_chars() {
1411 let prefix = "a".repeat(5000);
1413 let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1414 assert!(parse_devflow_result(&stdout).is_none());
1415 }
1416
1417 #[test]
1418 fn parse_marker_with_commits_and_summary() {
1419 let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1420 let result = parse_devflow_result(stdout).unwrap();
1421 assert_eq!(result.status, AgentStatus::Success);
1422 assert_eq!(result.commits, Some(3));
1423 assert_eq!(result.summary.unwrap(), "added tests");
1424 }
1425
1426 #[test]
1427 fn parse_marker_inside_json_result_envelope() {
1428 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1431 let result = parse_devflow_result(stdout).unwrap();
1432 assert_eq!(result.status, AgentStatus::Success);
1433 assert_eq!(result.commits, Some(2));
1434 }
1435
1436 #[test]
1437 fn parse_failed_marker_inside_json_envelope() {
1438 let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1439 let result = parse_devflow_result(stdout).unwrap();
1440 assert_eq!(result.status, AgentStatus::Failed);
1441 assert_eq!(result.reason.unwrap(), "tests failed");
1442 }
1443
1444 #[test]
1445 fn parse_json_envelope_without_marker_returns_none() {
1446 let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1447 assert!(parse_devflow_result(stdout).is_none());
1448 }
1449
1450 #[test]
1451 fn detect_claude_json_rate_limit_by_subtype() {
1452 let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1453 assert_eq!(
1454 detect_rate_limit(stdout).as_deref(),
1455 Some("2026-06-18T15:45:30Z")
1456 );
1457 }
1458
1459 #[test]
1460 fn detect_claude_json_rate_limit_by_429() {
1461 let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1462 assert_eq!(
1463 detect_rate_limit(stdout).as_deref(),
1464 Some("Too many requests. Try later.")
1465 );
1466 }
1467
1468 #[test]
1469 fn detect_codex_try_again_rate_limit() {
1470 let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1471 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1472 }
1473
1474 #[test]
1483 fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1484 const DEPTH: usize = 100;
1487 let mut stdout = String::new();
1488 for _ in 0..DEPTH {
1489 stdout.push_str(r#"{"nested":"#);
1490 }
1491 stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1492 for _ in 0..DEPTH {
1493 stdout.push('}');
1494 }
1495
1496 assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1499 }
1500
1501 #[test]
1502 fn detect_rate_limit_ignores_normal_stdout() {
1503 let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1504 assert!(detect_rate_limit(stdout).is_none());
1505 }
1506
1507 #[test]
1508 fn claude_envelope_is_error_detected() {
1509 let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1510 let result = detect_claude_envelope_failure(stdout).unwrap();
1511 assert_eq!(result.status, AgentStatus::Failed);
1512 }
1513
1514 #[test]
1515 fn claude_is_error_overrides_success_marker() {
1516 let dir = tempfile::tempdir().unwrap();
1517 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1518 std::fs::write(
1519 stdout_path(dir.path(), 9),
1520 r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1521 )
1522 .unwrap();
1523
1524 let result = evaluate_layer1(dir.path(), 9).unwrap();
1525
1526 assert_eq!(result.status, AgentStatus::Failed);
1527 }
1528
1529 #[test]
1530 fn claude_envelope_is_error_false_defers() {
1531 let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1532 assert!(detect_claude_envelope_failure(stdout).is_none());
1533 }
1534
1535 #[test]
1536 fn claude_envelope_marker_still_wins() {
1537 let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1538 assert!(detect_claude_envelope_failure(stdout).is_none());
1539 let result = parse_devflow_result(stdout).unwrap();
1540 assert_eq!(result.status, AgentStatus::Success);
1541 assert_eq!(result.commits, Some(2));
1542 }
1543
1544 #[test]
1545 fn session_id_reads_top_level_string() {
1546 let stdout = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
1547 assert_eq!(
1548 claude_session_id(stdout).as_deref(),
1549 Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
1550 );
1551 }
1552
1553 #[test]
1559 fn session_id_in_devflow_result_marker_is_not_returned() {
1560 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"session_id\": \"forged-by-agent\"}","session_id":"real-top-level-id"}"#;
1561 assert_eq!(
1562 claude_session_id(stdout).as_deref(),
1563 Some("real-top-level-id")
1564 );
1565 }
1566
1567 #[test]
1568 fn session_id_plain_text_stdout_returns_none() {
1569 let stdout = "just some plain text output, not JSON\n";
1570 assert!(claude_session_id(stdout).is_none());
1571 }
1572
1573 #[test]
1574 fn session_id_missing_key_returns_none() {
1575 let stdout = r#"{"type":"result","result":"done, no session key"}"#;
1576 assert!(claude_session_id(stdout).is_none());
1577 }
1578
1579 #[test]
1580 fn session_id_non_string_type_returns_none_not_panic() {
1581 let stdout = r#"{"type":"result","result":"done","session_id":12345}"#;
1582 assert!(claude_session_id(stdout).is_none());
1583 }
1584
1585 #[test]
1586 fn session_id_from_capture_missing_file_returns_none() {
1587 let dir = tempfile::tempdir().unwrap();
1588 assert!(session_id_from_capture(dir.path(), 42).is_none());
1589 }
1590
1591 #[test]
1592 fn session_id_from_capture_lossy_reads_invalid_utf8() {
1593 let dir = tempfile::tempdir().unwrap();
1594 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1595 let mut bytes = br#"{"type":"result","result":"done "#.to_vec();
1596 bytes.push(0xFF); bytes.extend_from_slice(br#"","session_id":"lossy-ok"}"#);
1598 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1599
1600 assert_eq!(
1601 session_id_from_capture(dir.path(), 5).as_deref(),
1602 Some("lossy-ok")
1603 );
1604 }
1605
1606 #[test]
1613 fn blocking_human_checkpoint_reported_detects_human_gate_line() {
1614 let stdout = format!(
1615 "## CHECKPOINT REACHED\n\n**Type:** human-verify\n**Gate:** {HUMAN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\n"
1616 );
1617 assert!(blocking_human_checkpoint_reported(&stdout));
1618 }
1619
1620 #[test]
1625 fn blocking_human_checkpoint_reported_false_for_plain_blocking() {
1626 const PLAIN_GATE_VALUE: &str = "blocking";
1627 let stdout = format!(
1628 "## CHECKPOINT REACHED\n\n**Type:** human-verify\n**Gate:** {PLAIN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\n"
1629 );
1630 assert!(!blocking_human_checkpoint_reported(&stdout));
1631 }
1632
1633 #[test]
1634 fn blocking_human_checkpoint_reported_false_when_no_gate_field() {
1635 let stdout = "some ordinary agent failure output, no checkpoint at all\n";
1636 assert!(!blocking_human_checkpoint_reported(stdout));
1637 }
1638
1639 #[test]
1643 fn blocking_human_checkpoint_reported_true_inside_escaped_envelope() {
1644 let inner = format!(
1645 "## CHECKPOINT REACHED\\n\\n**Gate:** {HUMAN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\\n"
1646 );
1647 let stdout = format!(
1648 r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"abc"}}"#
1649 );
1650 assert!(blocking_human_checkpoint_reported(&stdout));
1651 }
1652
1653 #[test]
1654 fn blocking_human_checkpoint_reported_tolerates_whitespace_and_emphasis() {
1655 let stdout = format!(" **Gate:** {HUMAN_GATE_VALUE} \n");
1656 assert!(blocking_human_checkpoint_reported(&stdout));
1657 }
1658
1659 #[test]
1674 fn blocking_human_checkpoint_reported_matches_live_observed_rendering() {
1675 let stdout = format!(
1676 "---\n\n## Checkpoint: Decision\n\n**Plan:** 91-01 Emit the checkpoint\n**Gate:** `{HUMAN_GATE_VALUE}`\n**Progress:** 0/1 tasks complete\n**Task:** Task 1 — Ask the operator to authorize writing the marker file\n"
1677 );
1678 assert!(
1679 blocking_human_checkpoint_reported(&stdout),
1680 "the live-observed code-span rendering must be recognized; \
1681 a false negative here means real checkpoints fall through to \
1682 the generic gate (the 2026-07-31 A1 defect)"
1683 );
1684 }
1685
1686 #[test]
1690 fn blocking_human_checkpoint_reported_matches_live_rendering_in_envelope() {
1691 let inner = format!(
1692 "## Checkpoint: Decision\\n\\n**Gate:** `{HUMAN_GATE_VALUE}`\\n**Progress:** 0/1 tasks complete\\n"
1693 );
1694 let stdout = format!(
1695 r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"live-a1"}}"#
1696 );
1697 assert!(
1698 blocking_human_checkpoint_reported(&stdout),
1699 "the code-span rendering must also be found inside the escaped envelope"
1700 );
1701 }
1702
1703 #[test]
1707 fn blocking_human_checkpoint_reported_false_for_code_spanned_plain_blocking() {
1708 let stdout = "## Checkpoint: Decision\n\n**Gate:** `blocking`\n";
1709 assert!(!blocking_human_checkpoint_reported(stdout));
1710 }
1711
1712 #[test]
1713 fn checkpoint_reported_in_capture_missing_file_returns_false() {
1714 let dir = tempfile::tempdir().unwrap();
1715 assert!(!checkpoint_reported_in_capture(dir.path(), 42));
1716 }
1717
1718 #[test]
1719 fn checkpoint_reported_in_capture_reads_true_from_file() {
1720 let dir = tempfile::tempdir().unwrap();
1721 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1722 std::fs::write(
1723 stdout_path(dir.path(), 11),
1724 format!("**Gate:** {HUMAN_GATE_VALUE}\n"),
1725 )
1726 .unwrap();
1727 assert!(checkpoint_reported_in_capture(dir.path(), 11));
1728 }
1729
1730 #[test]
1731 fn codex_event_stream_parses_turn_failed() {
1732 let stdout = concat!(
1733 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1734 "{\"type\":\"turn.started\"}\n",
1735 "{\"type\":\"item.started\",\"item\":{}}\n",
1736 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1737 );
1738 let result = parse_codex_event_result(stdout).unwrap();
1739 assert_eq!(result.status, AgentStatus::Failed);
1740 assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1741 }
1742
1743 #[test]
1744 fn codex_turn_completed_no_marker_defers() {
1745 let stdout = concat!(
1746 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1747 "{\"type\":\"turn.started\"}\n",
1748 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1749 );
1750 assert!(parse_codex_event_result(stdout).is_none());
1751 }
1752
1753 #[test]
1759 fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1760 let stdout = concat!(
1761 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1762 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1763 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1764 );
1765 let result = parse_codex_event_result(stdout).unwrap();
1766 assert_eq!(result.status, AgentStatus::Failed);
1767 assert_eq!(
1768 result.reason.as_deref(),
1769 Some("interactive input unavailable")
1770 );
1771 }
1772
1773 #[test]
1774 fn codex_agent_message_marker_success_short_circuits() {
1775 let stdout = concat!(
1776 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1777 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1778 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1779 );
1780 let result = parse_codex_event_result(stdout).unwrap();
1781 assert_eq!(result.status, AgentStatus::Success);
1782 }
1783
1784 #[test]
1790 fn detect_rate_limit_ignores_json_event_lines() {
1791 let stdout = concat!(
1792 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1793 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1794 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1795 );
1796 assert_eq!(detect_rate_limit(stdout), None);
1797 }
1798
1799 #[test]
1800 fn detect_rate_limit_still_reads_codex_plain_text() {
1801 let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1802 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1803 }
1804
1805 #[test]
1806 fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1807 let stdout = concat!(
1808 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1809 "not json at all\n",
1810 "{\"type\":\"item.started\",\"item\":{}}\n",
1811 "{\"type\":\"item.updated\",\"item\":{}}\n",
1812 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1813 );
1814 let result = parse_codex_event_result(stdout).unwrap();
1815 assert_eq!(result.status, AgentStatus::Failed);
1816 assert_eq!(result.reason.as_deref(), Some("boom"));
1817 }
1818
1819 #[test]
1820 fn claude_envelope_not_consumed_by_codex_parser() {
1821 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1822 assert!(parse_codex_event_result(stdout).is_none());
1823 }
1824
1825 #[test]
1826 fn evaluate_layer1_reports_rate_limited_without_marker() {
1827 let dir = tempfile::tempdir().unwrap();
1828 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1829 std::fs::write(
1830 stdout_path(dir.path(), 7),
1831 r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1832 )
1833 .unwrap();
1834
1835 let result = evaluate_layer1(dir.path(), 7).unwrap();
1836
1837 assert_eq!(result.status, AgentStatus::RateLimited);
1838 assert_eq!(
1839 result.reason.as_deref(),
1840 Some("rate limited until 2026-06-18T15:45:30Z")
1841 );
1842 }
1843
1844 #[test]
1849 fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1850 let dir = tempfile::tempdir().unwrap();
1851 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1852 std::fs::write(
1853 stdout_path(dir.path(), 7),
1854 r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1855 )
1856 .unwrap();
1857
1858 let result = evaluate_layer1(dir.path(), 7).unwrap();
1859
1860 assert_eq!(result.status, AgentStatus::RateLimited);
1861 assert_eq!(
1862 result.reason.as_deref(),
1863 Some("rate limited until 2026-06-18T15:45:30Z")
1864 );
1865 }
1866
1867 #[test]
1873 fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1874 let dir = tempfile::tempdir().unwrap();
1875 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1876 let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1877 bytes.extend_from_slice(
1878 b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1879 );
1880 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1881
1882 let result = evaluate_layer1(dir.path(), 5).unwrap();
1883
1884 assert_eq!(result.status, AgentStatus::Failed);
1885 assert_eq!(result.reason.as_deref(), Some("review: bad"));
1886 }
1887
1888 #[test]
1889 fn failing_external_probe_outranks_success_marker() {
1890 let dir = tempfile::tempdir().unwrap();
1891 let phase_dir = dir
1892 .path()
1893 .join(".planning/phases/16-pipeline-reliability-hardening");
1894 std::fs::create_dir_all(&phase_dir).unwrap();
1895 std::fs::write(
1896 phase_dir.join("16-03-PLAN.md"),
1897 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1898 )
1899 .unwrap();
1900 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1901 std::fs::write(
1902 stdout_path(dir.path(), 16),
1903 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1904 )
1905 .unwrap();
1906 let state = state_in(dir.path(), 16);
1907
1908 let approval = vec!["test -f externally-shipped".to_string()];
1909 let result = evaluate_agent_result_inner(
1910 dir.path(),
1911 &state,
1912 &GitFlowConfig::default(),
1913 Some(&approval),
1914 )
1915 .unwrap();
1916
1917 assert_eq!(result.status, AgentStatus::Failed);
1918 assert!(
1919 result
1920 .reason
1921 .as_deref()
1922 .is_some_and(|reason| reason.contains("external verification failed"))
1923 );
1924 }
1925
1926 #[test]
1934 fn external_probe_discovers_from_project_root_across_every_stage_and_executes_in_worktree() {
1935 let dir = tempfile::tempdir().unwrap();
1936 let worktree = dir.path().join("phase-worktree");
1937 std::fs::create_dir_all(&worktree).unwrap();
1938 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1939 std::fs::create_dir_all(&phase_dir).unwrap();
1940 std::fs::write(
1941 phase_dir.join("16-01-PLAN.md"),
1942 "---\nexternal_verify: \"test -f implemented\"\n---\n",
1943 )
1944 .unwrap();
1945 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1946 std::fs::write(
1947 stdout_path(dir.path(), 16),
1948 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1949 )
1950 .unwrap();
1951 let mut state = state_in(dir.path(), 16);
1952 state.worktree_path = Some(worktree.clone());
1953 state.stage = Stage::Plan;
1954
1955 let approval = vec!["test -f implemented".to_string()];
1956
1957 let plan_result = evaluate_agent_result_inner(
1962 dir.path(),
1963 &state,
1964 &GitFlowConfig::default(),
1965 Some(&approval),
1966 )
1967 .unwrap();
1968 assert_eq!(plan_result.status, AgentStatus::Failed);
1969 assert!(
1970 plan_result
1971 .reason
1972 .as_deref()
1973 .is_some_and(|reason| reason.contains("external verification failed")),
1974 "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
1975 plan_result.reason
1976 );
1977
1978 state.stage = Stage::Code;
1979 let code_result = evaluate_agent_result_inner(
1980 dir.path(),
1981 &state,
1982 &GitFlowConfig::default(),
1983 Some(&approval),
1984 )
1985 .unwrap();
1986 assert_eq!(code_result.status, AgentStatus::Failed);
1987
1988 std::fs::write(worktree.join("implemented"), "done").unwrap();
1991 let passing = evaluate_agent_result_inner(
1992 dir.path(),
1993 &state,
1994 &GitFlowConfig::default(),
1995 Some(&approval),
1996 )
1997 .unwrap();
1998 assert_eq!(passing.status, AgentStatus::Success);
1999 assert_eq!(passing.decided_by_layer, Some(0));
2000 }
2001
2002 #[test]
2003 fn changed_external_probe_never_inherits_prior_approval() {
2004 let dir = tempfile::tempdir().unwrap();
2005 let phase_dir = dir.path().join(".planning/phases/16-reliability");
2006 std::fs::create_dir_all(&phase_dir).unwrap();
2007 std::fs::write(
2008 phase_dir.join("16-01-PLAN.md"),
2009 "---\nexternal_verify: \"touch escaped\"\n---\n",
2010 )
2011 .unwrap();
2012 let state = state_in(dir.path(), 16);
2013 let approved = vec!["test -f reviewed-artifact".to_string()];
2014
2015 let result = evaluate_agent_result_inner(
2016 dir.path(),
2017 &state,
2018 &GitFlowConfig::default(),
2019 Some(&approved),
2020 )
2021 .unwrap();
2022
2023 assert_eq!(result.status, AgentStatus::Failed);
2024 assert!(result.reason.unwrap().contains("approval mismatch"));
2025 assert!(!dir.path().join("escaped").exists());
2026 }
2027
2028 #[test]
2029 fn removed_external_probe_fails_closed_against_prior_approval() {
2030 let dir = tempfile::tempdir().unwrap();
2031 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2032 std::fs::write(
2033 stdout_path(dir.path(), 16),
2034 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
2035 )
2036 .unwrap();
2037 let state = state_in(dir.path(), 16);
2038 let approved = vec!["test -f shipped".to_string()];
2039
2040 let result = evaluate_agent_result_inner(
2041 dir.path(),
2042 &state,
2043 &GitFlowConfig::default(),
2044 Some(&approved),
2045 )
2046 .unwrap();
2047
2048 assert_eq!(result.status, AgentStatus::Failed);
2049 assert!(result.reason.unwrap().contains("declaration was removed"));
2050 }
2051
2052 #[test]
2053 fn no_external_declaration_preserves_layer1_result() {
2054 let dir = tempfile::tempdir().unwrap();
2055 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2056 std::fs::write(
2057 stdout_path(dir.path(), 16),
2058 "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
2059 )
2060 .unwrap();
2061 let state = state_in(dir.path(), 16);
2062 let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
2063
2064 let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2065
2066 assert_eq!(
2067 serde_json::to_value(full).unwrap(),
2068 serde_json::to_value(layer1).unwrap()
2069 );
2070 }
2071
2072 #[test]
2079 fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
2080 let dir = tempfile::tempdir().unwrap();
2081 let phase_dir = dir.path().join(".planning/phases/16-reliability");
2082 std::fs::create_dir_all(&phase_dir).unwrap();
2083 std::fs::write(
2084 phase_dir.join("16-01-PLAN.md"),
2085 "---\nexternal_verify: \"test -f shipped\"\n---\n",
2086 )
2087 .unwrap();
2088 std::fs::write(dir.path().join("shipped"), "done").unwrap();
2089 let mut state = state_in(dir.path(), 16);
2090 state.stage = Stage::Define;
2091
2092 let approval = vec!["test -f shipped".to_string()];
2093 let result = evaluate_agent_result_inner(
2094 dir.path(),
2095 &state,
2096 &GitFlowConfig::default(),
2097 Some(&approval),
2098 )
2099 .unwrap();
2100
2101 assert_eq!(result.status, AgentStatus::Success);
2102 assert_eq!(result.decided_by_layer, Some(0));
2103 assert_eq!(result.commits, None);
2104 assert_eq!(result.verdict, None);
2106 }
2107
2108 #[test]
2113 fn layer0_affirmative_success_outranks_layer1_failure_marker() {
2114 let dir = tempfile::tempdir().unwrap();
2115 let phase_dir = dir
2116 .path()
2117 .join(".planning/phases/16-pipeline-reliability-hardening");
2118 std::fs::create_dir_all(&phase_dir).unwrap();
2119 std::fs::write(
2120 phase_dir.join("16-03-PLAN.md"),
2121 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
2122 )
2123 .unwrap();
2124 std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
2125 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2126 std::fs::write(
2127 stdout_path(dir.path(), 16),
2128 "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
2129 )
2130 .unwrap();
2131 let state = state_in(dir.path(), 16);
2132
2133 let approval = vec!["test -f externally-shipped".to_string()];
2134 let result = evaluate_agent_result_inner(
2135 dir.path(),
2136 &state,
2137 &GitFlowConfig::default(),
2138 Some(&approval),
2139 )
2140 .unwrap();
2141
2142 assert_eq!(result.status, AgentStatus::Success);
2143 assert_eq!(result.decided_by_layer, Some(0));
2144 assert_eq!(result.verdict, None);
2147 }
2148
2149 #[test]
2154 fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
2155 let dir = tempfile::tempdir().unwrap();
2156 let phase_dir = dir.path().join(".planning/phases/16-reliability");
2157 std::fs::create_dir_all(&phase_dir).unwrap();
2158 std::fs::write(
2159 phase_dir.join("16-01-PLAN.md"),
2160 "---\nexternal_verify: \"test -f shipped\"\n---\n",
2161 )
2162 .unwrap();
2163 std::fs::write(dir.path().join("shipped"), "done").unwrap();
2164 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2165 let mut state = state_in(dir.path(), 16);
2166 state.stage = Stage::Validate;
2167 let approval = vec!["test -f shipped".to_string()];
2168
2169 std::fs::write(
2170 stdout_path(dir.path(), 16),
2171 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
2172 )
2173 .unwrap();
2174 let result = evaluate_agent_result_inner(
2175 dir.path(),
2176 &state,
2177 &GitFlowConfig::default(),
2178 Some(&approval),
2179 )
2180 .unwrap();
2181 assert_eq!(result.status, AgentStatus::Success);
2182 assert_eq!(result.decided_by_layer, Some(0));
2183 assert_eq!(result.verdict, Some(Verdict::Pass));
2184
2185 std::fs::write(
2186 stdout_path(dir.path(), 16),
2187 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
2188 )
2189 .unwrap();
2190 let result = evaluate_agent_result_inner(
2191 dir.path(),
2192 &state,
2193 &GitFlowConfig::default(),
2194 Some(&approval),
2195 )
2196 .unwrap();
2197 assert_eq!(result.verdict, Some(Verdict::Gaps));
2198
2199 std::fs::remove_file(stdout_path(dir.path(), 16)).unwrap();
2200 let result = evaluate_agent_result_inner(
2201 dir.path(),
2202 &state,
2203 &GitFlowConfig::default(),
2204 Some(&approval),
2205 )
2206 .unwrap();
2207 assert_eq!(result.verdict, None);
2208 }
2209
2210 #[test]
2215 fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
2216 let dir = tempfile::tempdir().unwrap();
2217 let phase_dir = dir.path().join(".planning/phases/16-reliability");
2218 std::fs::create_dir_all(&phase_dir).unwrap();
2219 std::fs::write(
2220 phase_dir.join("16-01-PLAN.md"),
2221 "---\nexternal_verify: \"test -f shipped\"\n---\n",
2222 )
2223 .unwrap();
2224 std::fs::write(dir.path().join("shipped"), "done").unwrap();
2225 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2226 std::fs::write(
2227 stdout_path(dir.path(), 16),
2228 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
2229 )
2230 .unwrap();
2231 let state = state_in(dir.path(), 16); let approval = vec!["test -f shipped".to_string()];
2233
2234 let result = evaluate_agent_result_inner(
2235 dir.path(),
2236 &state,
2237 &GitFlowConfig::default(),
2238 Some(&approval),
2239 )
2240 .unwrap();
2241
2242 assert_eq!(result.status, AgentStatus::Success);
2243 assert_eq!(result.decided_by_layer, Some(0));
2244 assert_eq!(result.verdict, None);
2245 }
2246
2247 #[test]
2251 fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
2252 let dir = tempfile::tempdir().unwrap();
2253 let phase_dir = dir.path().join(".planning/phases/16-reliability");
2254 std::fs::create_dir_all(&phase_dir).unwrap();
2255 std::fs::write(
2257 phase_dir.join("16-01-PLAN.md"),
2258 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
2259 )
2260 .unwrap();
2261 std::fs::write(
2262 phase_dir.join("16-02-PLAN.md"),
2263 "---\nexternal_verify: \"test -f never-created\"\n---\n",
2264 )
2265 .unwrap();
2266 std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
2267 let mut state = state_in(dir.path(), 16);
2268 state.stage = Stage::Define;
2269
2270 let approval = vec![
2271 "test -f passing-artifact".to_string(),
2272 "test -f never-created".to_string(),
2273 ];
2274 let result_a = evaluate_agent_result_inner(
2275 dir.path(),
2276 &state,
2277 &GitFlowConfig::default(),
2278 Some(&approval),
2279 )
2280 .unwrap();
2281 assert_eq!(result_a.status, AgentStatus::Failed);
2282 assert!(
2283 result_a
2284 .reason
2285 .as_deref()
2286 .is_some_and(|reason| reason.contains("never-created")),
2287 "unexpected reason: {:?}",
2288 result_a.reason
2289 );
2290
2291 std::fs::write(
2295 phase_dir.join("16-01-PLAN.md"),
2296 "---\nexternal_verify: \"test -f still-missing\"\n---\n",
2297 )
2298 .unwrap();
2299 std::fs::write(
2300 phase_dir.join("16-02-PLAN.md"),
2301 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
2302 )
2303 .unwrap();
2304 let approval_swapped = vec![
2305 "test -f still-missing".to_string(),
2306 "test -f passing-artifact".to_string(),
2307 ];
2308 let result_b = evaluate_agent_result_inner(
2309 dir.path(),
2310 &state,
2311 &GitFlowConfig::default(),
2312 Some(&approval_swapped),
2313 )
2314 .unwrap();
2315 assert_eq!(result_b.status, AgentStatus::Failed);
2316
2317 std::fs::write(dir.path().join("still-missing"), "done").unwrap();
2319 let result_c = evaluate_agent_result_inner(
2320 dir.path(),
2321 &state,
2322 &GitFlowConfig::default(),
2323 Some(&approval_swapped),
2324 )
2325 .unwrap();
2326 assert_eq!(result_c.status, AgentStatus::Success);
2327 assert_eq!(result_c.decided_by_layer, Some(0));
2328 }
2329
2330 #[test]
2331 fn archive_moves_captures_into_history_and_removes_pid_file() {
2332 let dir = tempfile::tempdir().unwrap();
2335 let root = dir.path();
2336 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2337 std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
2338 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2339 std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
2340
2341 archive_phase_files(root, root, 1, 5).unwrap();
2342
2343 assert!(!root.join(".devflow/phase-01-stdout").exists());
2345 assert!(!root.join(".devflow/phase-01-exit").exists());
2346 assert!(!root.join(".devflow/phase-01-agent-pid").exists());
2348
2349 let history = history_dir(root, 1);
2350 let archived: Vec<_> = std::fs::read_dir(&history)
2351 .unwrap()
2352 .flatten()
2353 .map(|e| e.file_name().to_string_lossy().into_owned())
2354 .collect();
2355 let archived_stdout = archived
2356 .iter()
2357 .find(|name| name.ends_with("-stdout"))
2358 .expect("stdout capture should be archived into history");
2359 assert!(archived.iter().any(|name| name.ends_with("-exit")));
2360 let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
2361 assert_eq!(contents, "prior stdout");
2362 }
2363
2364 #[test]
2365 fn archive_is_noop_when_nothing_to_archive() {
2366 let dir = tempfile::tempdir().unwrap();
2367 let root = dir.path();
2368 archive_phase_files(root, root, 1, 5).unwrap();
2370 assert!(!history_dir(root, 1).exists());
2371 }
2372
2373 #[test]
2374 fn archive_handles_missing_devflow_dir() {
2375 let dir = tempfile::tempdir().unwrap();
2376 let root = dir.path();
2377 archive_phase_files(root, root, 1, 5).unwrap();
2379 }
2380
2381 #[test]
2382 fn archive_failure_preserves_live_capture_for_retry() {
2383 let dir = tempfile::tempdir().unwrap();
2384 let root = dir.path();
2385 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2386 std::fs::write(stdout_path(root, 1), "evidence").unwrap();
2387 std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
2390
2391 assert!(archive_phase_files(root, root, 1, 5).is_err());
2392 assert_eq!(
2393 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2394 "evidence"
2395 );
2396 }
2397
2398 #[test]
2399 fn archive_second_publish_failure_rolls_back_complete_live_pair() {
2400 let dir = tempfile::tempdir().unwrap();
2401 let root = dir.path();
2402 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2403 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2404 std::fs::write(exit_code_path(root, 1), "17").unwrap();
2405 let history = history_dir(root, 1);
2406 std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
2407
2408 assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
2409
2410 assert_eq!(
2411 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2412 "stdout evidence"
2413 );
2414 assert_eq!(
2415 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2416 "17"
2417 );
2418 assert!(!history.join("fixed-stdout").exists());
2419 assert!(!history.join(".pending-fixed").exists());
2420 }
2421
2422 #[test]
2423 fn archive_review_copy_failure_rolls_back_complete_live_pair() {
2424 let dir = tempfile::tempdir().unwrap();
2425 let root = dir.path();
2426 let evidence_root = root.join("phase-worktree");
2427 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2428 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2429 std::fs::write(exit_code_path(root, 1), "23").unwrap();
2430 let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
2431 std::fs::create_dir_all(&review).unwrap();
2432
2433 assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
2434
2435 assert_eq!(
2436 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2437 "stdout evidence"
2438 );
2439 assert_eq!(
2440 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2441 "23"
2442 );
2443 let history = history_dir(root, 1);
2444 assert!(!history.join("review-copy-stdout").exists());
2445 assert!(!history.join("review-copy-exit").exists());
2446 assert!(!history.join(".pending-review-copy").exists());
2447 }
2448
2449 #[test]
2450 fn archive_snapshots_current_review_into_same_generation() {
2451 let dir = tempfile::tempdir().unwrap();
2452 let root = dir.path();
2453 let evidence_root = root.join("phase-worktree");
2454 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2455 std::fs::write(stdout_path(root, 1), "attempt").unwrap();
2456 let phase_dir = evidence_root.join(".planning/phases/01-example");
2457 std::fs::create_dir_all(&phase_dir).unwrap();
2458 std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
2459
2460 let stamp = archive_phase_files(root, &evidence_root, 1, 5)
2461 .unwrap()
2462 .unwrap();
2463
2464 assert_eq!(
2465 std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
2466 .unwrap(),
2467 "review one"
2468 );
2469 }
2470
2471 #[test]
2472 fn archive_prunes_history_to_retain_count() {
2473 let dir = tempfile::tempdir().unwrap();
2474 let root = dir.path();
2475 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2476
2477 for i in 0..7 {
2478 std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
2479 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2480 archive_phase_files(root, root, 1, 3).unwrap();
2481 }
2482
2483 let history = history_dir(root, 1);
2484 let stdout_count = std::fs::read_dir(&history)
2485 .unwrap()
2486 .flatten()
2487 .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
2488 .count();
2489 let exit_count = std::fs::read_dir(&history)
2490 .unwrap()
2491 .flatten()
2492 .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
2493 .count();
2494 assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
2495 assert_eq!(exit_count, 3, "expected at most 3 retained generations");
2496 }
2497
2498 #[test]
2499 fn evaluate_agent_result_reads_files_end_to_end() {
2500 let dir = tempfile::tempdir().unwrap();
2501 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2502 std::fs::write(
2503 stdout_path(dir.path(), 6),
2504 "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
2505 )
2506 .unwrap();
2507 std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
2508 let state = state_in(dir.path(), 6);
2509
2510 let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2511
2512 assert_eq!(result.status, AgentStatus::Success);
2513 assert_eq!(result.commits, Some(2));
2514 assert_eq!(result.summary.as_deref(), Some("ok"));
2515 }
2516
2517 #[test]
2518 fn evaluate_layer1_finds_devflow_result_in_file() {
2519 let dir = tempfile::tempdir().unwrap();
2520 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2521 std::fs::write(
2522 stdout_path(dir.path(), 3),
2523 "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
2524 )
2525 .unwrap();
2526
2527 let result = evaluate_layer1(dir.path(), 3).unwrap();
2528
2529 assert_eq!(result.status, AgentStatus::Failed);
2530 assert_eq!(result.reason.as_deref(), Some("bad output"));
2531 }
2532
2533 #[test]
2534 fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
2535 let dir = tempfile::tempdir().unwrap();
2536 init_repo_with_feature_commit(dir.path(), 4);
2537 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2538 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2539 let state = state_in(dir.path(), 4);
2540
2541 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2542 .unwrap()
2543 .unwrap();
2544
2545 assert_eq!(result.status, AgentStatus::Success);
2546 assert_eq!(result.exit_code, Some(0));
2547 assert_eq!(result.commits, Some(1));
2548 assert!(result.reason.unwrap().contains("1 commits"));
2549 }
2550
2551 #[test]
2552 fn evaluate_layer2_exit_zero_no_commits_is_failed() {
2553 let dir = tempfile::tempdir().unwrap();
2556 init_repo_with_feature_no_commit(dir.path(), 4);
2557 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2558 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2559 let state = state_in(dir.path(), 4);
2560
2561 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2562 .unwrap()
2563 .unwrap();
2564
2565 assert_eq!(result.status, AgentStatus::Failed);
2566 assert_eq!(result.exit_code, Some(0));
2567 assert_eq!(result.commits, Some(0));
2568 assert!(result.reason.unwrap().contains("no commits"));
2569 }
2570
2571 #[test]
2572 fn evaluate_layer2_nonzero_exit_is_failed() {
2573 let dir = tempfile::tempdir().unwrap();
2575 init_repo_with_feature_commit(dir.path(), 4);
2576 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2577 std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
2578 let state = state_in(dir.path(), 4);
2579
2580 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2581 .unwrap()
2582 .unwrap();
2583
2584 assert_eq!(result.status, AgentStatus::Failed);
2585 assert_eq!(result.exit_code, Some(1));
2586 assert!(result.reason.unwrap().contains("exited with code 1"));
2587 }
2588
2589 #[test]
2590 fn layer2_nonzero_exit_is_failed_all_stages() {
2591 let dir = tempfile::tempdir().unwrap();
2595 init_repo_with_feature_no_commit(dir.path(), 10);
2596 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2597 std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
2598
2599 for stage in [
2600 Stage::Define,
2601 Stage::Plan,
2602 Stage::Code,
2603 Stage::Validate,
2604 Stage::Ship,
2605 ] {
2606 let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
2607 .unwrap()
2608 .unwrap();
2609 assert_eq!(
2610 result.status,
2611 AgentStatus::Failed,
2612 "stage {stage:?} should be Failed on nonzero exit"
2613 );
2614 }
2615 }
2616
2617 #[test]
2618 fn layer2_skips_commit_gate_for_define_and_validate() {
2619 let dir = tempfile::tempdir().unwrap();
2620 init_repo_with_feature_no_commit(dir.path(), 11);
2621 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2622 std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
2623
2624 for stage in [Stage::Define, Stage::Validate] {
2625 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
2626 .unwrap()
2627 .unwrap();
2628 assert_ne!(
2629 result.status,
2630 AgentStatus::Failed,
2631 "stage {stage:?} should not be Failed for zero commits"
2632 );
2633 }
2634
2635 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
2638 .unwrap()
2639 .unwrap();
2640 assert_eq!(result.status, AgentStatus::Failed);
2641 }
2642
2643 #[test]
2644 fn evaluate_layer3_falls_back_to_commit_count() {
2645 let dir = tempfile::tempdir().unwrap();
2646 init_repo_with_feature_commit(dir.path(), 5);
2647
2648 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2649
2650 assert_eq!(result.status, AgentStatus::Unknown);
2651 assert_eq!(result.exit_code, None);
2652 assert_eq!(result.commits, Some(1));
2653 assert!(result.reason.unwrap().contains("1 commits"));
2654 assert_eq!(result.decided_by_layer, Some(3));
2655 }
2656
2657 #[test]
2664 fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
2665 let dir = tempfile::tempdir().unwrap();
2666 init_repo_with_feature_no_commit(dir.path(), 5);
2667
2668 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2669
2670 assert_eq!(result.status, AgentStatus::Failed);
2671 assert_eq!(result.exit_code, None);
2672 assert_eq!(result.commits, Some(0));
2673 assert_eq!(result.decided_by_layer, Some(3));
2674 let reason = result.reason.unwrap();
2675 assert!(reason.contains("no work"), "reason was: {reason}");
2676 assert!(
2677 reason.to_ascii_lowercase().contains("human review"),
2678 "reason was: {reason}"
2679 );
2680 }
2681
2682 #[test]
2683 fn parse_devflow_result_reads_verdict() {
2684 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
2685 let result = parse_devflow_result(stdout).unwrap();
2686 assert_eq!(result.status, AgentStatus::Success);
2687 assert_eq!(result.verdict, Some(Verdict::Gaps));
2688 }
2689
2690 #[test]
2691 fn parse_devflow_result_reads_verdict_pass() {
2692 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
2693 let result = parse_devflow_result(stdout).unwrap();
2694 assert_eq!(result.status, AgentStatus::Success);
2695 assert_eq!(result.verdict, Some(Verdict::Pass));
2696 }
2697
2698 #[test]
2699 fn parse_devflow_result_verdict_absent_is_none() {
2700 let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
2701 let result = parse_devflow_result(stdout).unwrap();
2702 assert_eq!(result.status, AgentStatus::Success);
2703 assert_eq!(result.verdict, None);
2704 }
2705
2706 #[test]
2707 fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
2708 let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
2711 let result = parse_devflow_result(unknown).unwrap();
2712 assert_eq!(result.status, AgentStatus::Success);
2713 assert_eq!(result.verdict, None);
2714
2715 let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
2717 let result = parse_devflow_result(miscased).unwrap();
2718 assert_eq!(result.status, AgentStatus::Success);
2719 assert_eq!(result.verdict, None);
2720 }
2721
2722 #[test]
2729 fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
2730 let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
2731 let result = parse_devflow_result(bool_verdict).unwrap();
2732 assert_eq!(result.status, AgentStatus::Success);
2733 assert_eq!(result.verdict, None);
2734
2735 let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
2736 let result = parse_devflow_result(numeric_verdict).unwrap();
2737 assert_eq!(result.status, AgentStatus::Success);
2738 assert_eq!(result.verdict, None);
2739
2740 let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
2741 let result = parse_devflow_result(object_verdict).unwrap();
2742 assert_eq!(result.status, AgentStatus::Success);
2743 assert_eq!(result.verdict, None);
2744 }
2745
2746 #[test]
2750 fn multi_word_variants_serialize_with_word_boundary() {
2751 assert_eq!(
2752 serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
2753 "\"resource_killed\""
2754 );
2755 assert_eq!(
2756 serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
2757 "\"agent_unavailable\""
2758 );
2759 assert_eq!(
2760 serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
2761 AgentStatus::ResourceKilled
2762 );
2763 assert_eq!(
2764 serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
2765 AgentStatus::AgentUnavailable
2766 );
2767 }
2768
2769 #[test]
2772 fn existing_variants_keep_wire_form() {
2773 assert_eq!(
2774 serde_json::to_string(&AgentStatus::Success).unwrap(),
2775 "\"success\""
2776 );
2777 assert_eq!(
2778 serde_json::to_string(&AgentStatus::Failed).unwrap(),
2779 "\"failed\""
2780 );
2781 assert_eq!(
2782 serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
2783 "\"ratelimited\""
2784 );
2785 assert_eq!(
2786 serde_json::to_string(&AgentStatus::Unknown).unwrap(),
2787 "\"unknown\""
2788 );
2789 }
2790
2791 #[test]
2795 fn as_wire_str_matches_serde_form_for_every_variant() {
2796 for variant in [
2797 AgentStatus::Success,
2798 AgentStatus::Failed,
2799 AgentStatus::RateLimited,
2800 AgentStatus::Unknown,
2801 AgentStatus::ResourceKilled,
2802 AgentStatus::AgentUnavailable,
2803 ] {
2804 let serde_form = serde_json::to_string(&variant).unwrap();
2805 let stripped = serde_form.trim_matches('"');
2806 assert_eq!(
2807 variant.as_wire_str(),
2808 stripped,
2809 "as_wire_str() diverged from serde form for {variant:?}"
2810 );
2811 }
2812 }
2813
2814 #[test]
2815 fn evaluate_layer2_exit_137_is_resource_killed() {
2816 let dir = tempfile::tempdir().unwrap();
2817 init_repo_with_feature_commit(dir.path(), 20);
2818 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2819 std::fs::write(exit_code_path(dir.path(), 20), "137").unwrap();
2820 let state = state_in(dir.path(), 20);
2821
2822 let result = evaluate_layer2(dir.path(), 20, &GitFlowConfig::default(), state.stage)
2823 .unwrap()
2824 .unwrap();
2825
2826 assert_eq!(result.status, AgentStatus::ResourceKilled);
2827 assert_eq!(result.exit_code, Some(137));
2828 }
2829
2830 #[test]
2831 fn evaluate_layer2_exit_127_is_agent_unavailable() {
2832 let dir = tempfile::tempdir().unwrap();
2833 init_repo_with_feature_commit(dir.path(), 21);
2834 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2835 std::fs::write(exit_code_path(dir.path(), 21), "127").unwrap();
2836 let state = state_in(dir.path(), 21);
2837
2838 let result = evaluate_layer2(dir.path(), 21, &GitFlowConfig::default(), state.stage)
2839 .unwrap()
2840 .unwrap();
2841
2842 assert_eq!(result.status, AgentStatus::AgentUnavailable);
2843 assert_eq!(result.exit_code, Some(127));
2844 }
2845
2846 #[test]
2896 fn branch_evidence_resolves_caller_root_under_a_hostile_git_dir() {
2897 const INNER_ROOT: &str = "DEVFLOW_27_03_BRANCH_EVIDENCE_INNER_ROOT";
2898
2899 if let Ok(root) = std::env::var(INNER_ROOT) {
2900 let root = std::path::PathBuf::from(root);
2904 let phase = 27;
2905 let state = state_in(&root, phase);
2906
2907 let result = evaluate_layer2(&root, phase, &GitFlowConfig::default(), state.stage)
2908 .unwrap()
2909 .unwrap();
2910
2911 assert_eq!(
2912 result.status,
2913 AgentStatus::Success,
2914 "evaluate_layer2 must see project_root's own branch/commits, \
2915 not a hostile GIT_DIR's repository: {result:?}"
2916 );
2917 assert_eq!(result.commits, Some(1));
2918 return;
2919 }
2920
2921 let dir = tempfile::tempdir().unwrap();
2927 let phase = 27;
2928 init_repo_with_feature_commit(dir.path(), phase);
2929 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2930 std::fs::write(exit_code_path(dir.path(), phase), "0").unwrap();
2931
2932 let foreign = tempfile::tempdir().unwrap();
2933 git(foreign.path(), &["init", "-q"]);
2934
2935 let exe = std::env::current_exe().expect("current_exe for child re-invocation");
2936 let out = std::process::Command::new(&exe)
2937 .arg("branch_evidence_resolves_caller_root_under_a_hostile_git_dir")
2942 .arg("--test-threads=1")
2943 .env(INNER_ROOT, dir.path().to_str().unwrap())
2944 .env("GIT_DIR", foreign.path().join(".git"))
2945 .output()
2946 .expect("spawn hostile child test process");
2947
2948 let stdout = String::from_utf8_lossy(&out.stdout);
2949 assert!(
2952 stdout.contains("1 passed"),
2953 "child test process must have run exactly the inner test; \
2954 stdout:\n{stdout}"
2955 );
2956 assert!(
2957 out.status.success(),
2958 "child test process (hostile GIT_DIR pointed at an unrelated \
2959 foreign repository) must still resolve project_root's own \
2960 branch and commits; child exit status {:?}\nstdout:\n{stdout}",
2961 out.status
2962 );
2963 }
2964}