1use crate::config::GitFlowConfig;
11use crate::stage::Stage;
12use crate::state::State;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
17pub struct AgentResult {
18 pub status: AgentStatus,
19 pub exit_code: Option<i32>,
20 pub reason: Option<String>,
21 pub commits: Option<u32>,
22 pub summary: Option<String>,
23 #[serde(default, deserialize_with = "deserialize_verdict_lenient")]
35 pub verdict: Option<Verdict>,
36 #[serde(default)]
40 pub decided_by_layer: Option<u8>,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum AgentStatus {
47 Success,
49 Failed,
51 RateLimited,
53 Unknown,
55 #[serde(rename = "resource_killed")]
58 ResourceKilled,
59 #[serde(rename = "agent_unavailable")]
62 AgentUnavailable,
63}
64
65impl AgentStatus {
66 pub fn as_wire_str(&self) -> &'static str {
74 match self {
75 AgentStatus::Success => "success",
76 AgentStatus::Failed => "failed",
77 AgentStatus::RateLimited => "ratelimited",
78 AgentStatus::Unknown => "unknown",
79 AgentStatus::ResourceKilled => "resource_killed",
80 AgentStatus::AgentUnavailable => "agent_unavailable",
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum Verdict {
89 Pass,
91 Gaps,
94}
95
96fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
114where
115 D: serde::Deserializer<'de>,
116{
117 let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
118 Ok(raw.and_then(|v| {
119 v.as_str().and_then(|s| match s {
120 "pass" => Some(Verdict::Pass),
121 "gaps" => Some(Verdict::Gaps),
122 _ => None,
123 })
124 }))
125}
126
127#[derive(Debug, thiserror::Error)]
129pub enum ResultError {
130 #[error("I/O error reading agent output: {0}")]
131 Io(#[from] std::io::Error),
132 #[error("phase directory not found")]
133 NoPhaseDir,
134}
135
136pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
147 if let Some(inner) = extract_json_result_text(stdout)
148 && let Some(result) = parse_marker_lines(&inner)
149 {
150 return Some(result);
151 }
152 parse_marker_lines(stdout)
153}
154
155pub fn detect_rate_limit(stdout: &str) -> Option<String> {
162 detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
163}
164
165fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
166 let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
167 let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
168 || json_has_i64(&value, "api_error_status", 429)
169 || json_has_i64(&value, "status", 429)
170 || json_has_i64(&value, "status_code", 429);
171 if !rate_limited {
172 return None;
173 }
174 json_find_key(&value, "retry_after")
175 .and_then(json_scalar_to_string)
176 .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
177 .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
178 .or_else(|| Some("usage limit".to_string()))
179}
180
181fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
182 let stdout: String = stdout
189 .lines()
190 .filter(|line| {
191 serde_json::from_str::<serde_json::Value>(line)
192 .map(|v| !v.is_object())
193 .unwrap_or(true)
194 })
195 .collect::<Vec<_>>()
196 .join("\n");
197 let stdout = stdout.as_str();
198 let lower = stdout.to_ascii_lowercase();
199 if let Some(idx) = lower.find("try again at ") {
200 let start = idx + "try again at ".len();
201 let retry = stdout[start..]
202 .lines()
203 .next()
204 .unwrap_or_default()
205 .trim()
206 .trim_end_matches(['.', ',', ';'])
207 .trim();
208 if !retry.is_empty() {
209 return Some(retry.to_string());
210 }
211 }
212
213 if lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("429") {
214 stdout
215 .lines()
216 .find(|line| {
217 let line = line.to_ascii_lowercase();
218 line.contains("usage limit") || line.contains("rate limit") || line.contains("429")
219 })
220 .map(str::trim)
221 .filter(|line| !line.is_empty())
222 .map(str::to_string)
223 .or_else(|| Some("usage limit".to_string()))
224 } else {
225 None
226 }
227}
228
229fn extract_json_result_text(stdout: &str) -> Option<String> {
232 let trimmed = stdout.trim();
233 if !trimmed.starts_with('{') {
234 return None;
235 }
236 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
237 value.get("result")?.as_str().map(str::to_string)
238}
239
240fn json_scan<'a, T>(
253 value: &'a serde_json::Value,
254 visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
255) -> Option<T> {
256 let mut stack = vec![value];
257 while let Some(current) = stack.pop() {
258 match current {
259 serde_json::Value::Object(map) => {
260 if let Some(found) = visit(map) {
261 return Some(found);
262 }
263 for child in map.values().rev() {
265 stack.push(child);
266 }
267 }
268 serde_json::Value::Array(values) => {
269 for child in values.iter().rev() {
270 stack.push(child);
271 }
272 }
273 _ => {}
274 }
275 }
276 None
277}
278
279fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
280 json_scan(value, |map| {
281 (map.get(key)?.as_str()? == expected).then_some(())
282 })
283 .is_some()
284}
285
286fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
287 json_scan(value, |map| {
288 (map.get(key)?.as_i64()? == expected).then_some(())
289 })
290 .is_some()
291}
292
293fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
294 json_scan(value, |map| map.get(key))
295}
296
297fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
298 match value {
299 serde_json::Value::String(s) => Some(s.clone()),
300 serde_json::Value::Number(n) => Some(n.to_string()),
301 _ => None,
302 }
303}
304
305fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
323 let trimmed = stdout.trim();
324 if !trimmed.starts_with('{') {
325 return None;
326 }
327 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
328 let is_error = value.get("is_error")?.as_bool()?;
329 if !is_error {
330 return None;
331 }
332
333 let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
334 let base_reason = value
335 .get("result")
336 .and_then(serde_json::Value::as_str)
337 .map(str::to_string)
338 .or_else(|| {
339 value
340 .get("subtype")
341 .and_then(serde_json::Value::as_str)
342 .map(str::to_string)
343 })
344 .unwrap_or_else(|| "agent reported is_error".to_string());
345 let reason = match num_turns {
346 Some(n) => format!("{base_reason} (num_turns: {n})"),
347 None => base_reason,
348 };
349
350 Some(AgentResult {
351 status: AgentStatus::Failed,
352 exit_code: None,
353 reason: Some(reason),
354 commits: None,
355 summary: None,
356 verdict: None,
357 decided_by_layer: Some(1),
358 })
359}
360
361fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
365 events.iter().any(|v| {
366 v.get("type")
367 .and_then(serde_json::Value::as_str)
368 .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
369 })
370}
371
372fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
393 let events: Vec<serde_json::Value> = stdout
394 .lines()
395 .filter(|line| !line.trim().is_empty())
396 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
397 .collect();
398
399 if !is_codex_event_stream(&events) {
400 return None;
401 }
402
403 let marker = events.iter().rev().find_map(|v| {
410 if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
411 return None;
412 }
413 let item = v.get("item")?;
414 if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
415 return None;
416 }
417 let text = item.get("text").and_then(serde_json::Value::as_str)?;
418 parse_marker_lines(text)
419 });
420 if marker.is_some() {
421 return marker;
422 }
423
424 let terminal = events.iter().rev().find(|v| {
425 matches!(
426 v.get("type").and_then(serde_json::Value::as_str),
427 Some("turn.completed") | Some("turn.failed")
428 )
429 })?;
430
431 if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
432 return None;
435 }
436
437 let reason = terminal
438 .get("error")
439 .and_then(|e| e.get("message"))
440 .and_then(serde_json::Value::as_str)
441 .map(str::to_string)
442 .unwrap_or_else(|| "codex turn failed".to_string());
443
444 Some(AgentResult {
445 status: AgentStatus::Failed,
446 exit_code: None,
447 reason: Some(reason),
448 commits: None,
449 summary: None,
450 verdict: None,
451 decided_by_layer: Some(1),
452 })
453}
454
455fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
461 let tail: String = stdout
464 .chars()
465 .rev()
466 .take(4000)
467 .collect::<Vec<_>>()
468 .into_iter()
469 .rev()
470 .collect();
471
472 for line in tail.lines().rev() {
473 let Some(json_str) = line
474 .strip_prefix("DEVFLOW_RESULT: ")
475 .or_else(|| line.strip_prefix("devflow_result: "))
476 .or_else(|| line.strip_prefix("DEVFLOW_RESULT:"))
477 .or_else(|| line.strip_prefix("devflow_result:"))
478 else {
479 continue;
480 };
481
482 let json_str = json_str.trim();
483 if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
484 return Some(result);
485 }
486 }
487 None
488}
489
490pub fn evaluate_layer1(project_root: &Path, phase: u32) -> Option<AgentResult> {
502 let stdout_path = devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase));
503 let bytes = std::fs::read(&stdout_path).ok()?;
509 let stdout = String::from_utf8_lossy(&bytes);
510 detect_claude_rate_limit(&stdout)
511 .map(rate_limited_result)
512 .or_else(|| detect_claude_envelope_failure(&stdout))
513 .or_else(|| parse_devflow_result(&stdout))
514 .or_else(|| parse_codex_event_result(&stdout))
515 .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
516}
517
518fn rate_limited_result(retry: String) -> AgentResult {
520 AgentResult {
521 status: AgentStatus::RateLimited,
522 exit_code: None,
523 reason: Some(format!("rate limited until {retry}")),
524 commits: None,
525 summary: None,
526 verdict: None,
527 decided_by_layer: Some(1),
528 }
529}
530
531pub fn evaluate_layer2(
560 project_root: &Path,
561 phase: u32,
562 git_flow: &GitFlowConfig,
563 stage: Stage,
564) -> Result<Option<AgentResult>, ResultError> {
565 let exit_path = devflow_dir(project_root).join(format!("phase-{:02}-exit", phase));
566 let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
567 Ok(s) => s.trim().parse().unwrap_or(-1),
568 Err(_) => return Ok(None), };
570
571 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
572
573 let branch_exists = std::process::Command::new("git")
575 .args(["rev-parse", "--verify", &branch])
576 .current_dir(project_root)
577 .output()
578 .map(|o| o.status.success())
579 .unwrap_or(false);
580
581 let commits: u32 = if branch_exists {
582 let range = format!("{}..{branch}", git_flow.develop);
583 std::process::Command::new("git")
584 .args(["rev-list", "--count", &range])
585 .current_dir(project_root)
586 .output()
587 .ok()
588 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
589 .unwrap_or(0)
590 } else {
591 0
592 };
593
594 let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
595 let no_work_done = commit_gated && commits == 0;
596
597 let status = if exit_code == 137 {
602 AgentStatus::ResourceKilled
603 } else if exit_code == 127 {
604 AgentStatus::AgentUnavailable
605 } else if exit_code != 0 || no_work_done {
606 AgentStatus::Failed
607 } else {
608 AgentStatus::Success
609 };
610
611 Ok(Some(AgentResult {
612 status,
613 exit_code: Some(exit_code),
614 reason: if exit_code == 137 {
615 Some(format!(
616 "agent process was killed (exit code 137, likely OOM) ({} commits on {})",
617 commits, branch
618 ))
619 } else if exit_code == 127 {
620 Some(format!(
621 "agent command was unavailable (exit code 127, command not found) ({} commits on {})",
622 commits, branch
623 ))
624 } else if exit_code != 0 {
625 Some(format!(
626 "agent exited with code {} ({} commits on {})",
627 exit_code, commits, branch
628 ))
629 } else if no_work_done {
630 Some(format!(
631 "no commits found on {} (agent exit code was {})",
632 branch, exit_code
633 ))
634 } else {
635 Some(format!(
636 "{} commits on {} (agent exit code was {})",
637 commits, branch, exit_code
638 ))
639 },
640 commits: Some(commits),
641 summary: None,
642 verdict: None,
643 decided_by_layer: Some(2),
644 }))
645}
646
647pub fn evaluate_layer3(
659 project_root: &Path,
660 phase: u32,
661 git_flow: &GitFlowConfig,
662) -> Result<AgentResult, ResultError> {
663 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
664 let commits = std::process::Command::new("git")
665 .args([
666 "rev-list",
667 "--count",
668 &format!("{}..{branch}", git_flow.develop),
669 ])
670 .current_dir(project_root)
671 .output()
672 .ok()
673 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
674 .unwrap_or(0);
675
676 let (status, reason) = if commits > 0 {
677 (
678 AgentStatus::Unknown,
679 format!(
680 "unverified — agent process is gone but {} commits exist on {}",
681 commits, branch
682 ),
683 )
684 } else {
685 (
686 AgentStatus::Failed,
687 "no work accounted for — agent process is gone with no commits and no declared \
688 external post-condition; human review needed"
689 .to_string(),
690 )
691 };
692
693 Ok(AgentResult {
694 status,
695 exit_code: None,
696 reason: Some(reason),
697 commits: Some(commits),
698 summary: None,
699 verdict: None,
700 decided_by_layer: Some(3),
701 })
702}
703
704fn evaluate_layer0(
721 project_root: &Path,
722 state: &State,
723 approved_commands: Option<&[String]>,
724) -> Option<AgentResult> {
725 if !crate::config::external_verify_enabled(project_root) {
726 return None;
727 }
728
729 let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
730 let commands = crate::verify::external_verify_commands(project_root, state.phase);
731 if commands.is_empty() {
732 return approved_commands.map(|_| AgentResult {
733 status: AgentStatus::Failed,
734 exit_code: None,
735 reason: Some(
736 "external verification approval mismatch; PLAN declaration was removed".into(),
737 ),
738 commits: None,
739 summary: None,
740 verdict: None,
741 decided_by_layer: Some(0),
742 });
743 }
744 let Some(approved_commands) = approved_commands else {
745 return Some(AgentResult {
746 status: AgentStatus::Failed,
747 exit_code: None,
748 reason: Some(format!(
749 "external verification is not approved; set {} to the reviewed JSON command array",
750 crate::verify::TRUST_EXTERNAL_VERIFY_ENV
751 )),
752 commits: None,
753 summary: None,
754 verdict: None,
755 decided_by_layer: Some(0),
756 });
757 };
758 if commands != approved_commands {
759 return Some(AgentResult {
760 status: AgentStatus::Failed,
761 exit_code: None,
762 reason: Some("external verification approval mismatch; PLAN commands changed".into()),
763 commits: None,
764 summary: None,
765 verdict: None,
766 decided_by_layer: Some(0),
767 });
768 }
769 match commands
770 .into_iter()
771 .find(|command| !crate::verify::run_external_verification(command, execution_root))
772 {
773 Some(command) => Some(AgentResult {
774 status: AgentStatus::Failed,
775 exit_code: None,
776 reason: Some(format!("external verification failed: {command}")),
777 commits: None,
778 summary: None,
779 verdict: None,
780 decided_by_layer: Some(0),
781 }),
782 None => Some(AgentResult {
785 status: AgentStatus::Success,
786 exit_code: None,
787 reason: Some(
788 "external verification passed — all declared, approved probes succeeded".into(),
789 ),
790 commits: None,
791 summary: None,
792 verdict: None,
793 decided_by_layer: Some(0),
794 }),
795 }
796}
797
798fn reconcile_layer0_verdict(
822 project_root: &Path,
823 state: &State,
824 result: AgentResult,
825) -> AgentResult {
826 if state.stage != Stage::Validate
827 || result.status != AgentStatus::Success
828 || result.decided_by_layer != Some(0)
829 {
830 return result;
831 }
832 let verdict = evaluate_layer1(project_root, state.phase).and_then(|layer1| layer1.verdict);
833 AgentResult { verdict, ..result }
834}
835
836pub fn evaluate_agent_result(
838 project_root: &Path,
839 state: &State,
840 git_flow: &GitFlowConfig,
841) -> Result<AgentResult, ResultError> {
842 let approval = crate::verify::external_verification_approval();
843 evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
844}
845
846fn evaluate_agent_result_inner(
847 project_root: &Path,
848 state: &State,
849 git_flow: &GitFlowConfig,
850 approved_commands: Option<&[String]>,
851) -> Result<AgentResult, ResultError> {
852 if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
854 return Ok(reconcile_layer0_verdict(project_root, state, result));
855 }
856
857 if let Some(result) = evaluate_layer1(project_root, state.phase) {
859 return Ok(result);
860 }
861
862 if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
864 return Ok(result);
865 }
866
867 evaluate_layer3(project_root, state.phase, git_flow)
869}
870
871fn devflow_dir(project_root: &Path) -> PathBuf {
873 project_root.join(".devflow")
874}
875
876pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
878 devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
879}
880
881pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
884 devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
885}
886
887pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
889 devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
890}
891
892pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
894 devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
895}
896
897pub fn history_dir(project_root: &Path, phase: u32) -> PathBuf {
905 devflow_dir(project_root)
906 .join("history")
907 .join(format!("phase-{:02}", phase))
908}
909
910static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
914
915fn archive_stamp() -> String {
920 let nanos = std::time::SystemTime::now()
921 .duration_since(std::time::UNIX_EPOCH)
922 .map(|d| d.as_nanos())
923 .unwrap_or(0);
924 let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
925 format!("{nanos}-{seq}")
926}
927
928pub fn archive_phase_files(
938 project_root: &Path,
939 evidence_root: &Path,
940 phase: u32,
941 retain: usize,
942) -> Result<Option<String>, std::io::Error> {
943 archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
944}
945
946fn archive_phase_files_with_stamp(
947 project_root: &Path,
948 evidence_root: &Path,
949 phase: u32,
950 retain: usize,
951 stamp: &str,
952) -> Result<Option<String>, std::io::Error> {
953 let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
954
955 let stdout_src = stdout_path(project_root, phase);
956 let exit_src = exit_code_path(project_root, phase);
957 let stdout_exists = stdout_src.exists();
958 let exit_exists = exit_src.exists();
959 if !stdout_exists && !exit_exists {
960 return Ok(None); }
962
963 let history_dir = history_dir(project_root, phase);
964 std::fs::create_dir_all(&history_dir)?;
965
966 let staging_dir = history_dir.join(format!(".pending-{stamp}"));
967 std::fs::create_dir(&staging_dir)?;
968 let stdout_stage = staging_dir.join("stdout");
969 let exit_stage = staging_dir.join("exit");
970 let review_stage = staging_dir.join("REVIEW.md");
971 let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
972 let exit_dest = history_dir.join(format!("{stamp}-exit"));
973 let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
974 let review_src = phase_review_path(evidence_root, phase);
975
976 let mut stdout_staged = false;
977 let mut exit_staged = false;
978 let mut stdout_published = false;
979 let mut exit_published = false;
980 let mut review_published = false;
981
982 let archive_result = (|| -> Result<(), std::io::Error> {
983 if stdout_exists {
984 std::fs::rename(&stdout_src, &stdout_stage)?;
985 stdout_staged = true;
986 }
987 if exit_exists {
988 std::fs::rename(&exit_src, &exit_stage)?;
989 exit_staged = true;
990 }
991 if let Some(review) = &review_src {
992 std::fs::copy(review, &review_stage)?;
993 }
994
995 if stdout_exists {
996 std::fs::rename(&stdout_stage, &stdout_dest)?;
997 stdout_staged = false;
998 stdout_published = true;
999 }
1000 if exit_exists {
1001 std::fs::rename(&exit_stage, &exit_dest)?;
1002 exit_staged = false;
1003 exit_published = true;
1004 }
1005 if review_src.is_some() {
1006 std::fs::rename(&review_stage, &review_dest)?;
1007 review_published = true;
1008 }
1009 Ok(())
1010 })();
1011
1012 if let Err(error) = archive_result {
1013 let mut rollback_error = None;
1014 let mut restore = |from: &Path, to: &Path| {
1015 if let Err(error) = std::fs::rename(from, to)
1016 && rollback_error.is_none()
1017 {
1018 rollback_error = Some(error);
1019 }
1020 };
1021 if stdout_published {
1022 restore(&stdout_dest, &stdout_src);
1023 } else if stdout_staged {
1024 restore(&stdout_stage, &stdout_src);
1025 }
1026 if exit_published {
1027 restore(&exit_dest, &exit_src);
1028 } else if exit_staged {
1029 restore(&exit_stage, &exit_src);
1030 }
1031 if review_published {
1032 let _ = std::fs::remove_file(&review_dest);
1033 }
1034 let _ = std::fs::remove_dir_all(&staging_dir);
1035
1036 if let Some(rollback_error) = rollback_error {
1037 return Err(std::io::Error::new(
1038 error.kind(),
1039 format!("{error}; archive rollback failed: {rollback_error}"),
1040 ));
1041 }
1042 return Err(error);
1043 }
1044
1045 let _ = std::fs::remove_dir(&staging_dir);
1046
1047 prune_history(&history_dir, retain);
1048 Ok(Some(stamp.to_string()))
1049}
1050
1051fn phase_review_path(project_root: &Path, phase: u32) -> Option<PathBuf> {
1052 let phases = std::fs::read_dir(project_root.join(".planning/phases")).ok()?;
1053 let prefix = format!("{phase:02}-");
1054 for entry in phases.flatten() {
1055 if entry
1056 .file_name()
1057 .to_str()
1058 .is_some_and(|name| name.starts_with(&prefix))
1059 {
1060 let review = entry.path().join(format!("{phase:02}-REVIEW.md"));
1061 if review.exists() {
1062 return Some(review);
1063 }
1064 }
1065 }
1066 None
1067}
1068
1069fn prune_history(history_dir: &Path, retain: usize) {
1077 let Ok(entries) = std::fs::read_dir(history_dir) else {
1078 return;
1079 };
1080
1081 let mut stamps: Vec<String> = entries
1082 .flatten()
1083 .filter_map(|entry| {
1084 let name = entry.file_name().to_str()?.to_string();
1085 name.rsplit_once('-')
1086 .map(|(stamp, _suffix)| stamp.to_string())
1087 })
1088 .collect();
1089 stamps.sort_by_key(|stamp| {
1090 let mut parts = stamp.split('-');
1091 let nanos = parts
1092 .next()
1093 .and_then(|part| part.parse::<u128>().ok())
1094 .unwrap_or(0);
1095 let sequence = parts
1096 .next()
1097 .and_then(|part| part.parse::<u64>().ok())
1098 .unwrap_or(0);
1099 (nanos, sequence)
1100 });
1101 stamps.dedup();
1102
1103 if stamps.len() <= retain {
1104 return;
1105 }
1106
1107 let to_remove = stamps.len() - retain;
1108 for stamp in &stamps[..to_remove] {
1109 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
1110 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
1111 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
1112 }
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117 use super::*;
1118 use crate::config::GitFlowConfig;
1119 use crate::mode::Mode;
1120 use crate::stage::Stage;
1121 use crate::state::{AgentKind, State};
1122 use std::process::Command;
1123
1124 fn state_in(root: &Path, phase: u32) -> State {
1125 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
1126 state.stage = Stage::Code;
1127 state
1128 }
1129
1130 fn git(root: &Path, args: &[&str]) {
1131 let output = Command::new("git")
1132 .args(args)
1133 .current_dir(root)
1134 .output()
1135 .unwrap();
1136 assert!(
1137 output.status.success(),
1138 "git {:?} failed\nstdout: {}\nstderr: {}",
1139 args,
1140 String::from_utf8_lossy(&output.stdout),
1141 String::from_utf8_lossy(&output.stderr)
1142 );
1143 }
1144
1145 fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1146 git(root, &["init"]);
1147 git(root, &["config", "user.email", "devflow@example.com"]);
1148 git(root, &["config", "user.name", "DevFlow Tests"]);
1149 git(root, &["config", "commit.gpgsign", "false"]);
1150 git(root, &["config", "tag.gpgsign", "false"]);
1151 git(root, &["checkout", "-b", "develop"]);
1152 std::fs::write(root.join("README.md"), "base\n").unwrap();
1153 git(root, &["add", "README.md"]);
1154 git(root, &["commit", "-m", "base"]);
1155
1156 let branch = format!("feature/phase-{phase:02}");
1157 git(root, &["checkout", "-b", &branch]);
1158 std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1159 git(root, &["add", "phase.txt"]);
1160 git(root, &["commit", "-m", "feature work"]);
1161 }
1162
1163 fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1166 git(root, &["init"]);
1167 git(root, &["config", "user.email", "devflow@example.com"]);
1168 git(root, &["config", "user.name", "DevFlow Tests"]);
1169 git(root, &["config", "commit.gpgsign", "false"]);
1170 git(root, &["config", "tag.gpgsign", "false"]);
1171 git(root, &["checkout", "-b", "develop"]);
1172 std::fs::write(root.join("README.md"), "base\n").unwrap();
1173 git(root, &["add", "README.md"]);
1174 git(root, &["commit", "-m", "base"]);
1175
1176 let branch = format!("feature/phase-{phase:02}");
1177 git(root, &["checkout", "-b", &branch]);
1178 }
1179
1180 #[test]
1181 fn parse_success_marker() {
1182 let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1183 let result = parse_devflow_result(stdout).unwrap();
1184 assert_eq!(result.status, AgentStatus::Success);
1185 }
1186
1187 #[test]
1188 fn parse_failed_marker_with_reason() {
1189 let stdout =
1190 "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1191 let result = parse_devflow_result(stdout).unwrap();
1192 assert_eq!(result.status, AgentStatus::Failed);
1193 assert_eq!(result.reason.unwrap(), "clippy errors");
1194 }
1195
1196 #[test]
1197 fn parse_missing_marker_returns_none() {
1198 let stdout = "just some output\nno marker here\n";
1199 assert!(parse_devflow_result(stdout).is_none());
1200 }
1201
1202 #[test]
1203 fn parse_malformed_json_returns_none() {
1204 let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1205 assert!(parse_devflow_result(stdout).is_none());
1206 }
1207
1208 #[test]
1209 fn parse_lowercase_marker() {
1210 let stdout = "devflow_result: {\"status\":\"success\"}\n";
1211 let result = parse_devflow_result(stdout).unwrap();
1212 assert_eq!(result.status, AgentStatus::Success);
1213 }
1214
1215 #[test]
1216 fn parse_marker_without_space_after_colon() {
1217 let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1218 let result = parse_devflow_result(stdout).unwrap();
1219 assert_eq!(result.status, AgentStatus::Success);
1220 }
1221
1222 #[test]
1223 fn parse_lowercase_no_space_marker() {
1224 let stdout = "devflow_result:{\"status\":\"success\"}\n";
1227 let result = parse_devflow_result(stdout).unwrap();
1228 assert_eq!(result.status, AgentStatus::Success);
1229 }
1230
1231 #[test]
1232 fn parse_finds_last_marker_in_tail() {
1233 let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1235 let result = parse_devflow_result(stdout).unwrap();
1236 assert_eq!(result.status, AgentStatus::Success);
1237 }
1238
1239 #[test]
1240 fn parse_marker_lines_returns_last_marker_in_long_output() {
1241 let stdout = format!(
1242 "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1243 DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1244 "prefix".repeat(900),
1245 "tail output".repeat(100)
1246 );
1247
1248 let result = parse_marker_lines(&stdout).unwrap();
1249
1250 assert_eq!(result.status, AgentStatus::Success);
1251 }
1252
1253 #[test]
1254 fn parse_marker_only_in_last_4000_chars() {
1255 let prefix = "a".repeat(5000);
1257 let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1258 assert!(parse_devflow_result(&stdout).is_none());
1259 }
1260
1261 #[test]
1262 fn parse_marker_with_commits_and_summary() {
1263 let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1264 let result = parse_devflow_result(stdout).unwrap();
1265 assert_eq!(result.status, AgentStatus::Success);
1266 assert_eq!(result.commits, Some(3));
1267 assert_eq!(result.summary.unwrap(), "added tests");
1268 }
1269
1270 #[test]
1271 fn parse_marker_inside_json_result_envelope() {
1272 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1275 let result = parse_devflow_result(stdout).unwrap();
1276 assert_eq!(result.status, AgentStatus::Success);
1277 assert_eq!(result.commits, Some(2));
1278 }
1279
1280 #[test]
1281 fn parse_failed_marker_inside_json_envelope() {
1282 let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1283 let result = parse_devflow_result(stdout).unwrap();
1284 assert_eq!(result.status, AgentStatus::Failed);
1285 assert_eq!(result.reason.unwrap(), "tests failed");
1286 }
1287
1288 #[test]
1289 fn parse_json_envelope_without_marker_returns_none() {
1290 let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1291 assert!(parse_devflow_result(stdout).is_none());
1292 }
1293
1294 #[test]
1295 fn detect_claude_json_rate_limit_by_subtype() {
1296 let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1297 assert_eq!(
1298 detect_rate_limit(stdout).as_deref(),
1299 Some("2026-06-18T15:45:30Z")
1300 );
1301 }
1302
1303 #[test]
1304 fn detect_claude_json_rate_limit_by_429() {
1305 let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1306 assert_eq!(
1307 detect_rate_limit(stdout).as_deref(),
1308 Some("Too many requests. Try later.")
1309 );
1310 }
1311
1312 #[test]
1313 fn detect_codex_try_again_rate_limit() {
1314 let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1315 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1316 }
1317
1318 #[test]
1327 fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1328 const DEPTH: usize = 100;
1331 let mut stdout = String::new();
1332 for _ in 0..DEPTH {
1333 stdout.push_str(r#"{"nested":"#);
1334 }
1335 stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1336 for _ in 0..DEPTH {
1337 stdout.push('}');
1338 }
1339
1340 assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1343 }
1344
1345 #[test]
1346 fn detect_rate_limit_ignores_normal_stdout() {
1347 let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1348 assert!(detect_rate_limit(stdout).is_none());
1349 }
1350
1351 #[test]
1352 fn claude_envelope_is_error_detected() {
1353 let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1354 let result = detect_claude_envelope_failure(stdout).unwrap();
1355 assert_eq!(result.status, AgentStatus::Failed);
1356 }
1357
1358 #[test]
1359 fn claude_is_error_overrides_success_marker() {
1360 let dir = tempfile::tempdir().unwrap();
1361 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1362 std::fs::write(
1363 stdout_path(dir.path(), 9),
1364 r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1365 )
1366 .unwrap();
1367
1368 let result = evaluate_layer1(dir.path(), 9).unwrap();
1369
1370 assert_eq!(result.status, AgentStatus::Failed);
1371 }
1372
1373 #[test]
1374 fn claude_envelope_is_error_false_defers() {
1375 let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1376 assert!(detect_claude_envelope_failure(stdout).is_none());
1377 }
1378
1379 #[test]
1380 fn claude_envelope_marker_still_wins() {
1381 let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1382 assert!(detect_claude_envelope_failure(stdout).is_none());
1383 let result = parse_devflow_result(stdout).unwrap();
1384 assert_eq!(result.status, AgentStatus::Success);
1385 assert_eq!(result.commits, Some(2));
1386 }
1387
1388 #[test]
1389 fn codex_event_stream_parses_turn_failed() {
1390 let stdout = concat!(
1391 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1392 "{\"type\":\"turn.started\"}\n",
1393 "{\"type\":\"item.started\",\"item\":{}}\n",
1394 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1395 );
1396 let result = parse_codex_event_result(stdout).unwrap();
1397 assert_eq!(result.status, AgentStatus::Failed);
1398 assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1399 }
1400
1401 #[test]
1402 fn codex_turn_completed_no_marker_defers() {
1403 let stdout = concat!(
1404 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1405 "{\"type\":\"turn.started\"}\n",
1406 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1407 );
1408 assert!(parse_codex_event_result(stdout).is_none());
1409 }
1410
1411 #[test]
1417 fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1418 let stdout = concat!(
1419 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1420 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1421 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1422 );
1423 let result = parse_codex_event_result(stdout).unwrap();
1424 assert_eq!(result.status, AgentStatus::Failed);
1425 assert_eq!(
1426 result.reason.as_deref(),
1427 Some("interactive input unavailable")
1428 );
1429 }
1430
1431 #[test]
1432 fn codex_agent_message_marker_success_short_circuits() {
1433 let stdout = concat!(
1434 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1435 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1436 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1437 );
1438 let result = parse_codex_event_result(stdout).unwrap();
1439 assert_eq!(result.status, AgentStatus::Success);
1440 }
1441
1442 #[test]
1448 fn detect_rate_limit_ignores_json_event_lines() {
1449 let stdout = concat!(
1450 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1451 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1452 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1453 );
1454 assert_eq!(detect_rate_limit(stdout), None);
1455 }
1456
1457 #[test]
1458 fn detect_rate_limit_still_reads_codex_plain_text() {
1459 let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1460 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1461 }
1462
1463 #[test]
1464 fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1465 let stdout = concat!(
1466 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1467 "not json at all\n",
1468 "{\"type\":\"item.started\",\"item\":{}}\n",
1469 "{\"type\":\"item.updated\",\"item\":{}}\n",
1470 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1471 );
1472 let result = parse_codex_event_result(stdout).unwrap();
1473 assert_eq!(result.status, AgentStatus::Failed);
1474 assert_eq!(result.reason.as_deref(), Some("boom"));
1475 }
1476
1477 #[test]
1478 fn claude_envelope_not_consumed_by_codex_parser() {
1479 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1480 assert!(parse_codex_event_result(stdout).is_none());
1481 }
1482
1483 #[test]
1484 fn evaluate_layer1_reports_rate_limited_without_marker() {
1485 let dir = tempfile::tempdir().unwrap();
1486 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1487 std::fs::write(
1488 stdout_path(dir.path(), 7),
1489 r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1490 )
1491 .unwrap();
1492
1493 let result = evaluate_layer1(dir.path(), 7).unwrap();
1494
1495 assert_eq!(result.status, AgentStatus::RateLimited);
1496 assert_eq!(
1497 result.reason.as_deref(),
1498 Some("rate limited until 2026-06-18T15:45:30Z")
1499 );
1500 }
1501
1502 #[test]
1507 fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1508 let dir = tempfile::tempdir().unwrap();
1509 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1510 std::fs::write(
1511 stdout_path(dir.path(), 7),
1512 r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1513 )
1514 .unwrap();
1515
1516 let result = evaluate_layer1(dir.path(), 7).unwrap();
1517
1518 assert_eq!(result.status, AgentStatus::RateLimited);
1519 assert_eq!(
1520 result.reason.as_deref(),
1521 Some("rate limited until 2026-06-18T15:45:30Z")
1522 );
1523 }
1524
1525 #[test]
1531 fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1532 let dir = tempfile::tempdir().unwrap();
1533 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1534 let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1535 bytes.extend_from_slice(
1536 b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1537 );
1538 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1539
1540 let result = evaluate_layer1(dir.path(), 5).unwrap();
1541
1542 assert_eq!(result.status, AgentStatus::Failed);
1543 assert_eq!(result.reason.as_deref(), Some("review: bad"));
1544 }
1545
1546 #[test]
1547 fn failing_external_probe_outranks_success_marker() {
1548 let dir = tempfile::tempdir().unwrap();
1549 let phase_dir = dir
1550 .path()
1551 .join(".planning/phases/16-pipeline-reliability-hardening");
1552 std::fs::create_dir_all(&phase_dir).unwrap();
1553 std::fs::write(
1554 phase_dir.join("16-03-PLAN.md"),
1555 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1556 )
1557 .unwrap();
1558 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1559 std::fs::write(
1560 stdout_path(dir.path(), 16),
1561 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1562 )
1563 .unwrap();
1564 let state = state_in(dir.path(), 16);
1565
1566 let approval = vec!["test -f externally-shipped".to_string()];
1567 let result = evaluate_agent_result_inner(
1568 dir.path(),
1569 &state,
1570 &GitFlowConfig::default(),
1571 Some(&approval),
1572 )
1573 .unwrap();
1574
1575 assert_eq!(result.status, AgentStatus::Failed);
1576 assert!(
1577 result
1578 .reason
1579 .as_deref()
1580 .is_some_and(|reason| reason.contains("external verification failed"))
1581 );
1582 }
1583
1584 #[test]
1592 fn external_probe_discovers_from_project_root_across_every_stage_and_executes_in_worktree() {
1593 let dir = tempfile::tempdir().unwrap();
1594 let worktree = dir.path().join("phase-worktree");
1595 std::fs::create_dir_all(&worktree).unwrap();
1596 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1597 std::fs::create_dir_all(&phase_dir).unwrap();
1598 std::fs::write(
1599 phase_dir.join("16-01-PLAN.md"),
1600 "---\nexternal_verify: \"test -f implemented\"\n---\n",
1601 )
1602 .unwrap();
1603 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1604 std::fs::write(
1605 stdout_path(dir.path(), 16),
1606 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1607 )
1608 .unwrap();
1609 let mut state = state_in(dir.path(), 16);
1610 state.worktree_path = Some(worktree.clone());
1611 state.stage = Stage::Plan;
1612
1613 let approval = vec!["test -f implemented".to_string()];
1614
1615 let plan_result = evaluate_agent_result_inner(
1620 dir.path(),
1621 &state,
1622 &GitFlowConfig::default(),
1623 Some(&approval),
1624 )
1625 .unwrap();
1626 assert_eq!(plan_result.status, AgentStatus::Failed);
1627 assert!(
1628 plan_result
1629 .reason
1630 .as_deref()
1631 .is_some_and(|reason| reason.contains("external verification failed")),
1632 "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
1633 plan_result.reason
1634 );
1635
1636 state.stage = Stage::Code;
1637 let code_result = evaluate_agent_result_inner(
1638 dir.path(),
1639 &state,
1640 &GitFlowConfig::default(),
1641 Some(&approval),
1642 )
1643 .unwrap();
1644 assert_eq!(code_result.status, AgentStatus::Failed);
1645
1646 std::fs::write(worktree.join("implemented"), "done").unwrap();
1649 let passing = evaluate_agent_result_inner(
1650 dir.path(),
1651 &state,
1652 &GitFlowConfig::default(),
1653 Some(&approval),
1654 )
1655 .unwrap();
1656 assert_eq!(passing.status, AgentStatus::Success);
1657 assert_eq!(passing.decided_by_layer, Some(0));
1658 }
1659
1660 #[test]
1661 fn changed_external_probe_never_inherits_prior_approval() {
1662 let dir = tempfile::tempdir().unwrap();
1663 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1664 std::fs::create_dir_all(&phase_dir).unwrap();
1665 std::fs::write(
1666 phase_dir.join("16-01-PLAN.md"),
1667 "---\nexternal_verify: \"touch escaped\"\n---\n",
1668 )
1669 .unwrap();
1670 let state = state_in(dir.path(), 16);
1671 let approved = vec!["test -f reviewed-artifact".to_string()];
1672
1673 let result = evaluate_agent_result_inner(
1674 dir.path(),
1675 &state,
1676 &GitFlowConfig::default(),
1677 Some(&approved),
1678 )
1679 .unwrap();
1680
1681 assert_eq!(result.status, AgentStatus::Failed);
1682 assert!(result.reason.unwrap().contains("approval mismatch"));
1683 assert!(!dir.path().join("escaped").exists());
1684 }
1685
1686 #[test]
1687 fn removed_external_probe_fails_closed_against_prior_approval() {
1688 let dir = tempfile::tempdir().unwrap();
1689 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1690 std::fs::write(
1691 stdout_path(dir.path(), 16),
1692 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1693 )
1694 .unwrap();
1695 let state = state_in(dir.path(), 16);
1696 let approved = vec!["test -f shipped".to_string()];
1697
1698 let result = evaluate_agent_result_inner(
1699 dir.path(),
1700 &state,
1701 &GitFlowConfig::default(),
1702 Some(&approved),
1703 )
1704 .unwrap();
1705
1706 assert_eq!(result.status, AgentStatus::Failed);
1707 assert!(result.reason.unwrap().contains("declaration was removed"));
1708 }
1709
1710 #[test]
1711 fn no_external_declaration_preserves_layer1_result() {
1712 let dir = tempfile::tempdir().unwrap();
1713 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1714 std::fs::write(
1715 stdout_path(dir.path(), 16),
1716 "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
1717 )
1718 .unwrap();
1719 let state = state_in(dir.path(), 16);
1720 let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
1721
1722 let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1723
1724 assert_eq!(
1725 serde_json::to_value(full).unwrap(),
1726 serde_json::to_value(layer1).unwrap()
1727 );
1728 }
1729
1730 #[test]
1737 fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
1738 let dir = tempfile::tempdir().unwrap();
1739 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1740 std::fs::create_dir_all(&phase_dir).unwrap();
1741 std::fs::write(
1742 phase_dir.join("16-01-PLAN.md"),
1743 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1744 )
1745 .unwrap();
1746 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1747 let mut state = state_in(dir.path(), 16);
1748 state.stage = Stage::Define;
1749
1750 let approval = vec!["test -f shipped".to_string()];
1751 let result = evaluate_agent_result_inner(
1752 dir.path(),
1753 &state,
1754 &GitFlowConfig::default(),
1755 Some(&approval),
1756 )
1757 .unwrap();
1758
1759 assert_eq!(result.status, AgentStatus::Success);
1760 assert_eq!(result.decided_by_layer, Some(0));
1761 assert_eq!(result.commits, None);
1762 assert_eq!(result.verdict, None);
1764 }
1765
1766 #[test]
1771 fn layer0_affirmative_success_outranks_layer1_failure_marker() {
1772 let dir = tempfile::tempdir().unwrap();
1773 let phase_dir = dir
1774 .path()
1775 .join(".planning/phases/16-pipeline-reliability-hardening");
1776 std::fs::create_dir_all(&phase_dir).unwrap();
1777 std::fs::write(
1778 phase_dir.join("16-03-PLAN.md"),
1779 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1780 )
1781 .unwrap();
1782 std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
1783 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1784 std::fs::write(
1785 stdout_path(dir.path(), 16),
1786 "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
1787 )
1788 .unwrap();
1789 let state = state_in(dir.path(), 16);
1790
1791 let approval = vec!["test -f externally-shipped".to_string()];
1792 let result = evaluate_agent_result_inner(
1793 dir.path(),
1794 &state,
1795 &GitFlowConfig::default(),
1796 Some(&approval),
1797 )
1798 .unwrap();
1799
1800 assert_eq!(result.status, AgentStatus::Success);
1801 assert_eq!(result.decided_by_layer, Some(0));
1802 assert_eq!(result.verdict, None);
1805 }
1806
1807 #[test]
1812 fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1815 std::fs::create_dir_all(&phase_dir).unwrap();
1816 std::fs::write(
1817 phase_dir.join("16-01-PLAN.md"),
1818 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1819 )
1820 .unwrap();
1821 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1822 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1823 let mut state = state_in(dir.path(), 16);
1824 state.stage = Stage::Validate;
1825 let approval = vec!["test -f shipped".to_string()];
1826
1827 std::fs::write(
1828 stdout_path(dir.path(), 16),
1829 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1830 )
1831 .unwrap();
1832 let result = evaluate_agent_result_inner(
1833 dir.path(),
1834 &state,
1835 &GitFlowConfig::default(),
1836 Some(&approval),
1837 )
1838 .unwrap();
1839 assert_eq!(result.status, AgentStatus::Success);
1840 assert_eq!(result.decided_by_layer, Some(0));
1841 assert_eq!(result.verdict, Some(Verdict::Pass));
1842
1843 std::fs::write(
1844 stdout_path(dir.path(), 16),
1845 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
1846 )
1847 .unwrap();
1848 let result = evaluate_agent_result_inner(
1849 dir.path(),
1850 &state,
1851 &GitFlowConfig::default(),
1852 Some(&approval),
1853 )
1854 .unwrap();
1855 assert_eq!(result.verdict, Some(Verdict::Gaps));
1856
1857 std::fs::remove_file(stdout_path(dir.path(), 16)).unwrap();
1858 let result = evaluate_agent_result_inner(
1859 dir.path(),
1860 &state,
1861 &GitFlowConfig::default(),
1862 Some(&approval),
1863 )
1864 .unwrap();
1865 assert_eq!(result.verdict, None);
1866 }
1867
1868 #[test]
1873 fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
1874 let dir = tempfile::tempdir().unwrap();
1875 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1876 std::fs::create_dir_all(&phase_dir).unwrap();
1877 std::fs::write(
1878 phase_dir.join("16-01-PLAN.md"),
1879 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1880 )
1881 .unwrap();
1882 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1883 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1884 std::fs::write(
1885 stdout_path(dir.path(), 16),
1886 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1887 )
1888 .unwrap();
1889 let state = state_in(dir.path(), 16); let approval = vec!["test -f shipped".to_string()];
1891
1892 let result = evaluate_agent_result_inner(
1893 dir.path(),
1894 &state,
1895 &GitFlowConfig::default(),
1896 Some(&approval),
1897 )
1898 .unwrap();
1899
1900 assert_eq!(result.status, AgentStatus::Success);
1901 assert_eq!(result.decided_by_layer, Some(0));
1902 assert_eq!(result.verdict, None);
1903 }
1904
1905 #[test]
1909 fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
1910 let dir = tempfile::tempdir().unwrap();
1911 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1912 std::fs::create_dir_all(&phase_dir).unwrap();
1913 std::fs::write(
1915 phase_dir.join("16-01-PLAN.md"),
1916 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
1917 )
1918 .unwrap();
1919 std::fs::write(
1920 phase_dir.join("16-02-PLAN.md"),
1921 "---\nexternal_verify: \"test -f never-created\"\n---\n",
1922 )
1923 .unwrap();
1924 std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
1925 let mut state = state_in(dir.path(), 16);
1926 state.stage = Stage::Define;
1927
1928 let approval = vec![
1929 "test -f passing-artifact".to_string(),
1930 "test -f never-created".to_string(),
1931 ];
1932 let result_a = evaluate_agent_result_inner(
1933 dir.path(),
1934 &state,
1935 &GitFlowConfig::default(),
1936 Some(&approval),
1937 )
1938 .unwrap();
1939 assert_eq!(result_a.status, AgentStatus::Failed);
1940 assert!(
1941 result_a
1942 .reason
1943 .as_deref()
1944 .is_some_and(|reason| reason.contains("never-created")),
1945 "unexpected reason: {:?}",
1946 result_a.reason
1947 );
1948
1949 std::fs::write(
1953 phase_dir.join("16-01-PLAN.md"),
1954 "---\nexternal_verify: \"test -f still-missing\"\n---\n",
1955 )
1956 .unwrap();
1957 std::fs::write(
1958 phase_dir.join("16-02-PLAN.md"),
1959 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
1960 )
1961 .unwrap();
1962 let approval_swapped = vec![
1963 "test -f still-missing".to_string(),
1964 "test -f passing-artifact".to_string(),
1965 ];
1966 let result_b = evaluate_agent_result_inner(
1967 dir.path(),
1968 &state,
1969 &GitFlowConfig::default(),
1970 Some(&approval_swapped),
1971 )
1972 .unwrap();
1973 assert_eq!(result_b.status, AgentStatus::Failed);
1974
1975 std::fs::write(dir.path().join("still-missing"), "done").unwrap();
1977 let result_c = evaluate_agent_result_inner(
1978 dir.path(),
1979 &state,
1980 &GitFlowConfig::default(),
1981 Some(&approval_swapped),
1982 )
1983 .unwrap();
1984 assert_eq!(result_c.status, AgentStatus::Success);
1985 assert_eq!(result_c.decided_by_layer, Some(0));
1986 }
1987
1988 #[test]
1989 fn archive_moves_captures_into_history_and_removes_pid_file() {
1990 let dir = tempfile::tempdir().unwrap();
1993 let root = dir.path();
1994 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1995 std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
1996 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1997 std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
1998
1999 archive_phase_files(root, root, 1, 5).unwrap();
2000
2001 assert!(!root.join(".devflow/phase-01-stdout").exists());
2003 assert!(!root.join(".devflow/phase-01-exit").exists());
2004 assert!(!root.join(".devflow/phase-01-agent-pid").exists());
2006
2007 let history = history_dir(root, 1);
2008 let archived: Vec<_> = std::fs::read_dir(&history)
2009 .unwrap()
2010 .flatten()
2011 .map(|e| e.file_name().to_string_lossy().into_owned())
2012 .collect();
2013 let archived_stdout = archived
2014 .iter()
2015 .find(|name| name.ends_with("-stdout"))
2016 .expect("stdout capture should be archived into history");
2017 assert!(archived.iter().any(|name| name.ends_with("-exit")));
2018 let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
2019 assert_eq!(contents, "prior stdout");
2020 }
2021
2022 #[test]
2023 fn archive_is_noop_when_nothing_to_archive() {
2024 let dir = tempfile::tempdir().unwrap();
2025 let root = dir.path();
2026 archive_phase_files(root, root, 1, 5).unwrap();
2028 assert!(!history_dir(root, 1).exists());
2029 }
2030
2031 #[test]
2032 fn archive_handles_missing_devflow_dir() {
2033 let dir = tempfile::tempdir().unwrap();
2034 let root = dir.path();
2035 archive_phase_files(root, root, 1, 5).unwrap();
2037 }
2038
2039 #[test]
2040 fn archive_failure_preserves_live_capture_for_retry() {
2041 let dir = tempfile::tempdir().unwrap();
2042 let root = dir.path();
2043 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2044 std::fs::write(stdout_path(root, 1), "evidence").unwrap();
2045 std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
2048
2049 assert!(archive_phase_files(root, root, 1, 5).is_err());
2050 assert_eq!(
2051 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2052 "evidence"
2053 );
2054 }
2055
2056 #[test]
2057 fn archive_second_publish_failure_rolls_back_complete_live_pair() {
2058 let dir = tempfile::tempdir().unwrap();
2059 let root = dir.path();
2060 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2061 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2062 std::fs::write(exit_code_path(root, 1), "17").unwrap();
2063 let history = history_dir(root, 1);
2064 std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
2065
2066 assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
2067
2068 assert_eq!(
2069 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2070 "stdout evidence"
2071 );
2072 assert_eq!(
2073 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2074 "17"
2075 );
2076 assert!(!history.join("fixed-stdout").exists());
2077 assert!(!history.join(".pending-fixed").exists());
2078 }
2079
2080 #[test]
2081 fn archive_review_copy_failure_rolls_back_complete_live_pair() {
2082 let dir = tempfile::tempdir().unwrap();
2083 let root = dir.path();
2084 let evidence_root = root.join("phase-worktree");
2085 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2086 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2087 std::fs::write(exit_code_path(root, 1), "23").unwrap();
2088 let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
2089 std::fs::create_dir_all(&review).unwrap();
2090
2091 assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
2092
2093 assert_eq!(
2094 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2095 "stdout evidence"
2096 );
2097 assert_eq!(
2098 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2099 "23"
2100 );
2101 let history = history_dir(root, 1);
2102 assert!(!history.join("review-copy-stdout").exists());
2103 assert!(!history.join("review-copy-exit").exists());
2104 assert!(!history.join(".pending-review-copy").exists());
2105 }
2106
2107 #[test]
2108 fn archive_snapshots_current_review_into_same_generation() {
2109 let dir = tempfile::tempdir().unwrap();
2110 let root = dir.path();
2111 let evidence_root = root.join("phase-worktree");
2112 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2113 std::fs::write(stdout_path(root, 1), "attempt").unwrap();
2114 let phase_dir = evidence_root.join(".planning/phases/01-example");
2115 std::fs::create_dir_all(&phase_dir).unwrap();
2116 std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
2117
2118 let stamp = archive_phase_files(root, &evidence_root, 1, 5)
2119 .unwrap()
2120 .unwrap();
2121
2122 assert_eq!(
2123 std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
2124 .unwrap(),
2125 "review one"
2126 );
2127 }
2128
2129 #[test]
2130 fn archive_prunes_history_to_retain_count() {
2131 let dir = tempfile::tempdir().unwrap();
2132 let root = dir.path();
2133 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2134
2135 for i in 0..7 {
2136 std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
2137 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2138 archive_phase_files(root, root, 1, 3).unwrap();
2139 }
2140
2141 let history = history_dir(root, 1);
2142 let stdout_count = std::fs::read_dir(&history)
2143 .unwrap()
2144 .flatten()
2145 .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
2146 .count();
2147 let exit_count = std::fs::read_dir(&history)
2148 .unwrap()
2149 .flatten()
2150 .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
2151 .count();
2152 assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
2153 assert_eq!(exit_count, 3, "expected at most 3 retained generations");
2154 }
2155
2156 #[test]
2157 fn evaluate_agent_result_reads_files_end_to_end() {
2158 let dir = tempfile::tempdir().unwrap();
2159 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2160 std::fs::write(
2161 stdout_path(dir.path(), 6),
2162 "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
2163 )
2164 .unwrap();
2165 std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
2166 let state = state_in(dir.path(), 6);
2167
2168 let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2169
2170 assert_eq!(result.status, AgentStatus::Success);
2171 assert_eq!(result.commits, Some(2));
2172 assert_eq!(result.summary.as_deref(), Some("ok"));
2173 }
2174
2175 #[test]
2176 fn evaluate_layer1_finds_devflow_result_in_file() {
2177 let dir = tempfile::tempdir().unwrap();
2178 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2179 std::fs::write(
2180 stdout_path(dir.path(), 3),
2181 "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
2182 )
2183 .unwrap();
2184
2185 let result = evaluate_layer1(dir.path(), 3).unwrap();
2186
2187 assert_eq!(result.status, AgentStatus::Failed);
2188 assert_eq!(result.reason.as_deref(), Some("bad output"));
2189 }
2190
2191 #[test]
2192 fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
2193 let dir = tempfile::tempdir().unwrap();
2194 init_repo_with_feature_commit(dir.path(), 4);
2195 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2196 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2197 let state = state_in(dir.path(), 4);
2198
2199 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2200 .unwrap()
2201 .unwrap();
2202
2203 assert_eq!(result.status, AgentStatus::Success);
2204 assert_eq!(result.exit_code, Some(0));
2205 assert_eq!(result.commits, Some(1));
2206 assert!(result.reason.unwrap().contains("1 commits"));
2207 }
2208
2209 #[test]
2210 fn evaluate_layer2_exit_zero_no_commits_is_failed() {
2211 let dir = tempfile::tempdir().unwrap();
2214 init_repo_with_feature_no_commit(dir.path(), 4);
2215 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2216 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2217 let state = state_in(dir.path(), 4);
2218
2219 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2220 .unwrap()
2221 .unwrap();
2222
2223 assert_eq!(result.status, AgentStatus::Failed);
2224 assert_eq!(result.exit_code, Some(0));
2225 assert_eq!(result.commits, Some(0));
2226 assert!(result.reason.unwrap().contains("no commits"));
2227 }
2228
2229 #[test]
2230 fn evaluate_layer2_nonzero_exit_is_failed() {
2231 let dir = tempfile::tempdir().unwrap();
2233 init_repo_with_feature_commit(dir.path(), 4);
2234 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2235 std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
2236 let state = state_in(dir.path(), 4);
2237
2238 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2239 .unwrap()
2240 .unwrap();
2241
2242 assert_eq!(result.status, AgentStatus::Failed);
2243 assert_eq!(result.exit_code, Some(1));
2244 assert!(result.reason.unwrap().contains("exited with code 1"));
2245 }
2246
2247 #[test]
2248 fn layer2_nonzero_exit_is_failed_all_stages() {
2249 let dir = tempfile::tempdir().unwrap();
2253 init_repo_with_feature_no_commit(dir.path(), 10);
2254 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2255 std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
2256
2257 for stage in [
2258 Stage::Define,
2259 Stage::Plan,
2260 Stage::Code,
2261 Stage::Validate,
2262 Stage::Ship,
2263 ] {
2264 let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
2265 .unwrap()
2266 .unwrap();
2267 assert_eq!(
2268 result.status,
2269 AgentStatus::Failed,
2270 "stage {stage:?} should be Failed on nonzero exit"
2271 );
2272 }
2273 }
2274
2275 #[test]
2276 fn layer2_skips_commit_gate_for_define_and_validate() {
2277 let dir = tempfile::tempdir().unwrap();
2278 init_repo_with_feature_no_commit(dir.path(), 11);
2279 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2280 std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
2281
2282 for stage in [Stage::Define, Stage::Validate] {
2283 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
2284 .unwrap()
2285 .unwrap();
2286 assert_ne!(
2287 result.status,
2288 AgentStatus::Failed,
2289 "stage {stage:?} should not be Failed for zero commits"
2290 );
2291 }
2292
2293 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
2296 .unwrap()
2297 .unwrap();
2298 assert_eq!(result.status, AgentStatus::Failed);
2299 }
2300
2301 #[test]
2302 fn evaluate_layer3_falls_back_to_commit_count() {
2303 let dir = tempfile::tempdir().unwrap();
2304 init_repo_with_feature_commit(dir.path(), 5);
2305
2306 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2307
2308 assert_eq!(result.status, AgentStatus::Unknown);
2309 assert_eq!(result.exit_code, None);
2310 assert_eq!(result.commits, Some(1));
2311 assert!(result.reason.unwrap().contains("1 commits"));
2312 assert_eq!(result.decided_by_layer, Some(3));
2313 }
2314
2315 #[test]
2322 fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
2323 let dir = tempfile::tempdir().unwrap();
2324 init_repo_with_feature_no_commit(dir.path(), 5);
2325
2326 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2327
2328 assert_eq!(result.status, AgentStatus::Failed);
2329 assert_eq!(result.exit_code, None);
2330 assert_eq!(result.commits, Some(0));
2331 assert_eq!(result.decided_by_layer, Some(3));
2332 let reason = result.reason.unwrap();
2333 assert!(reason.contains("no work"), "reason was: {reason}");
2334 assert!(
2335 reason.to_ascii_lowercase().contains("human review"),
2336 "reason was: {reason}"
2337 );
2338 }
2339
2340 #[test]
2341 fn parse_devflow_result_reads_verdict() {
2342 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
2343 let result = parse_devflow_result(stdout).unwrap();
2344 assert_eq!(result.status, AgentStatus::Success);
2345 assert_eq!(result.verdict, Some(Verdict::Gaps));
2346 }
2347
2348 #[test]
2349 fn parse_devflow_result_reads_verdict_pass() {
2350 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
2351 let result = parse_devflow_result(stdout).unwrap();
2352 assert_eq!(result.status, AgentStatus::Success);
2353 assert_eq!(result.verdict, Some(Verdict::Pass));
2354 }
2355
2356 #[test]
2357 fn parse_devflow_result_verdict_absent_is_none() {
2358 let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
2359 let result = parse_devflow_result(stdout).unwrap();
2360 assert_eq!(result.status, AgentStatus::Success);
2361 assert_eq!(result.verdict, None);
2362 }
2363
2364 #[test]
2365 fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
2366 let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
2369 let result = parse_devflow_result(unknown).unwrap();
2370 assert_eq!(result.status, AgentStatus::Success);
2371 assert_eq!(result.verdict, None);
2372
2373 let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
2375 let result = parse_devflow_result(miscased).unwrap();
2376 assert_eq!(result.status, AgentStatus::Success);
2377 assert_eq!(result.verdict, None);
2378 }
2379
2380 #[test]
2387 fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
2388 let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
2389 let result = parse_devflow_result(bool_verdict).unwrap();
2390 assert_eq!(result.status, AgentStatus::Success);
2391 assert_eq!(result.verdict, None);
2392
2393 let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
2394 let result = parse_devflow_result(numeric_verdict).unwrap();
2395 assert_eq!(result.status, AgentStatus::Success);
2396 assert_eq!(result.verdict, None);
2397
2398 let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
2399 let result = parse_devflow_result(object_verdict).unwrap();
2400 assert_eq!(result.status, AgentStatus::Success);
2401 assert_eq!(result.verdict, None);
2402 }
2403
2404 #[test]
2408 fn multi_word_variants_serialize_with_word_boundary() {
2409 assert_eq!(
2410 serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
2411 "\"resource_killed\""
2412 );
2413 assert_eq!(
2414 serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
2415 "\"agent_unavailable\""
2416 );
2417 assert_eq!(
2418 serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
2419 AgentStatus::ResourceKilled
2420 );
2421 assert_eq!(
2422 serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
2423 AgentStatus::AgentUnavailable
2424 );
2425 }
2426
2427 #[test]
2430 fn existing_variants_keep_wire_form() {
2431 assert_eq!(
2432 serde_json::to_string(&AgentStatus::Success).unwrap(),
2433 "\"success\""
2434 );
2435 assert_eq!(
2436 serde_json::to_string(&AgentStatus::Failed).unwrap(),
2437 "\"failed\""
2438 );
2439 assert_eq!(
2440 serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
2441 "\"ratelimited\""
2442 );
2443 assert_eq!(
2444 serde_json::to_string(&AgentStatus::Unknown).unwrap(),
2445 "\"unknown\""
2446 );
2447 }
2448
2449 #[test]
2453 fn as_wire_str_matches_serde_form_for_every_variant() {
2454 for variant in [
2455 AgentStatus::Success,
2456 AgentStatus::Failed,
2457 AgentStatus::RateLimited,
2458 AgentStatus::Unknown,
2459 AgentStatus::ResourceKilled,
2460 AgentStatus::AgentUnavailable,
2461 ] {
2462 let serde_form = serde_json::to_string(&variant).unwrap();
2463 let stripped = serde_form.trim_matches('"');
2464 assert_eq!(
2465 variant.as_wire_str(),
2466 stripped,
2467 "as_wire_str() diverged from serde form for {variant:?}"
2468 );
2469 }
2470 }
2471
2472 #[test]
2473 fn evaluate_layer2_exit_137_is_resource_killed() {
2474 let dir = tempfile::tempdir().unwrap();
2475 init_repo_with_feature_commit(dir.path(), 20);
2476 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2477 std::fs::write(exit_code_path(dir.path(), 20), "137").unwrap();
2478 let state = state_in(dir.path(), 20);
2479
2480 let result = evaluate_layer2(dir.path(), 20, &GitFlowConfig::default(), state.stage)
2481 .unwrap()
2482 .unwrap();
2483
2484 assert_eq!(result.status, AgentStatus::ResourceKilled);
2485 assert_eq!(result.exit_code, Some(137));
2486 }
2487
2488 #[test]
2489 fn evaluate_layer2_exit_127_is_agent_unavailable() {
2490 let dir = tempfile::tempdir().unwrap();
2491 init_repo_with_feature_commit(dir.path(), 21);
2492 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2493 std::fs::write(exit_code_path(dir.path(), 21), "127").unwrap();
2494 let state = state_in(dir.path(), 21);
2495
2496 let result = evaluate_layer2(dir.path(), 21, &GitFlowConfig::default(), state.stage)
2497 .unwrap()
2498 .unwrap();
2499
2500 assert_eq!(result.status, AgentStatus::AgentUnavailable);
2501 assert_eq!(result.exit_code, Some(127));
2502 }
2503
2504 #[test]
2508 fn evaluate_layer2_exit_0_zero_commits_still_failed() {
2509 let dir = tempfile::tempdir().unwrap();
2510 init_repo_with_feature_no_commit(dir.path(), 22);
2511 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2512 std::fs::write(exit_code_path(dir.path(), 22), "0").unwrap();
2513 let state = state_in(dir.path(), 22);
2514
2515 let result = evaluate_layer2(dir.path(), 22, &GitFlowConfig::default(), state.stage)
2516 .unwrap()
2517 .unwrap();
2518
2519 assert_eq!(result.status, AgentStatus::Failed);
2520 }
2521
2522 #[test]
2525 fn evaluate_layer2_exit_1_still_failed() {
2526 let dir = tempfile::tempdir().unwrap();
2527 init_repo_with_feature_commit(dir.path(), 23);
2528 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2529 std::fs::write(exit_code_path(dir.path(), 23), "1").unwrap();
2530 let state = state_in(dir.path(), 23);
2531
2532 let result = evaluate_layer2(dir.path(), 23, &GitFlowConfig::default(), state.stage)
2533 .unwrap()
2534 .unwrap();
2535
2536 assert_eq!(result.status, AgentStatus::Failed);
2537 }
2538}