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 crate::workflow::ensure_devflow_dir(&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, &["config", "core.hooksPath", "/dev/null"]);
1152 git(root, &["checkout", "-b", "develop"]);
1153 std::fs::write(root.join("README.md"), "base\n").unwrap();
1154 git(root, &["add", "README.md"]);
1155 git(root, &["commit", "-m", "base"]);
1156
1157 let branch = format!("feature/phase-{phase:02}");
1158 git(root, &["checkout", "-b", &branch]);
1159 std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1160 git(root, &["add", "phase.txt"]);
1161 git(root, &["commit", "-m", "feature work"]);
1162 }
1163
1164 fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1167 git(root, &["init"]);
1168 git(root, &["config", "user.email", "devflow@example.com"]);
1169 git(root, &["config", "user.name", "DevFlow Tests"]);
1170 git(root, &["config", "commit.gpgsign", "false"]);
1171 git(root, &["config", "tag.gpgsign", "false"]);
1172 git(root, &["config", "core.hooksPath", "/dev/null"]);
1173 git(root, &["checkout", "-b", "develop"]);
1174 std::fs::write(root.join("README.md"), "base\n").unwrap();
1175 git(root, &["add", "README.md"]);
1176 git(root, &["commit", "-m", "base"]);
1177
1178 let branch = format!("feature/phase-{phase:02}");
1179 git(root, &["checkout", "-b", &branch]);
1180 }
1181
1182 #[test]
1183 fn parse_success_marker() {
1184 let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1185 let result = parse_devflow_result(stdout).unwrap();
1186 assert_eq!(result.status, AgentStatus::Success);
1187 }
1188
1189 #[test]
1190 fn parse_failed_marker_with_reason() {
1191 let stdout =
1192 "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1193 let result = parse_devflow_result(stdout).unwrap();
1194 assert_eq!(result.status, AgentStatus::Failed);
1195 assert_eq!(result.reason.unwrap(), "clippy errors");
1196 }
1197
1198 #[test]
1199 fn parse_missing_marker_returns_none() {
1200 let stdout = "just some output\nno marker here\n";
1201 assert!(parse_devflow_result(stdout).is_none());
1202 }
1203
1204 #[test]
1205 fn parse_malformed_json_returns_none() {
1206 let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1207 assert!(parse_devflow_result(stdout).is_none());
1208 }
1209
1210 #[test]
1211 fn parse_lowercase_marker() {
1212 let stdout = "devflow_result: {\"status\":\"success\"}\n";
1213 let result = parse_devflow_result(stdout).unwrap();
1214 assert_eq!(result.status, AgentStatus::Success);
1215 }
1216
1217 #[test]
1218 fn parse_marker_without_space_after_colon() {
1219 let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1220 let result = parse_devflow_result(stdout).unwrap();
1221 assert_eq!(result.status, AgentStatus::Success);
1222 }
1223
1224 #[test]
1225 fn parse_lowercase_no_space_marker() {
1226 let stdout = "devflow_result:{\"status\":\"success\"}\n";
1229 let result = parse_devflow_result(stdout).unwrap();
1230 assert_eq!(result.status, AgentStatus::Success);
1231 }
1232
1233 #[test]
1234 fn parse_finds_last_marker_in_tail() {
1235 let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1237 let result = parse_devflow_result(stdout).unwrap();
1238 assert_eq!(result.status, AgentStatus::Success);
1239 }
1240
1241 #[test]
1242 fn parse_marker_lines_returns_last_marker_in_long_output() {
1243 let stdout = format!(
1244 "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1245 DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1246 "prefix".repeat(900),
1247 "tail output".repeat(100)
1248 );
1249
1250 let result = parse_marker_lines(&stdout).unwrap();
1251
1252 assert_eq!(result.status, AgentStatus::Success);
1253 }
1254
1255 #[test]
1256 fn parse_marker_only_in_last_4000_chars() {
1257 let prefix = "a".repeat(5000);
1259 let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1260 assert!(parse_devflow_result(&stdout).is_none());
1261 }
1262
1263 #[test]
1264 fn parse_marker_with_commits_and_summary() {
1265 let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1266 let result = parse_devflow_result(stdout).unwrap();
1267 assert_eq!(result.status, AgentStatus::Success);
1268 assert_eq!(result.commits, Some(3));
1269 assert_eq!(result.summary.unwrap(), "added tests");
1270 }
1271
1272 #[test]
1273 fn parse_marker_inside_json_result_envelope() {
1274 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1277 let result = parse_devflow_result(stdout).unwrap();
1278 assert_eq!(result.status, AgentStatus::Success);
1279 assert_eq!(result.commits, Some(2));
1280 }
1281
1282 #[test]
1283 fn parse_failed_marker_inside_json_envelope() {
1284 let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1285 let result = parse_devflow_result(stdout).unwrap();
1286 assert_eq!(result.status, AgentStatus::Failed);
1287 assert_eq!(result.reason.unwrap(), "tests failed");
1288 }
1289
1290 #[test]
1291 fn parse_json_envelope_without_marker_returns_none() {
1292 let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1293 assert!(parse_devflow_result(stdout).is_none());
1294 }
1295
1296 #[test]
1297 fn detect_claude_json_rate_limit_by_subtype() {
1298 let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1299 assert_eq!(
1300 detect_rate_limit(stdout).as_deref(),
1301 Some("2026-06-18T15:45:30Z")
1302 );
1303 }
1304
1305 #[test]
1306 fn detect_claude_json_rate_limit_by_429() {
1307 let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1308 assert_eq!(
1309 detect_rate_limit(stdout).as_deref(),
1310 Some("Too many requests. Try later.")
1311 );
1312 }
1313
1314 #[test]
1315 fn detect_codex_try_again_rate_limit() {
1316 let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1317 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1318 }
1319
1320 #[test]
1329 fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1330 const DEPTH: usize = 100;
1333 let mut stdout = String::new();
1334 for _ in 0..DEPTH {
1335 stdout.push_str(r#"{"nested":"#);
1336 }
1337 stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1338 for _ in 0..DEPTH {
1339 stdout.push('}');
1340 }
1341
1342 assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1345 }
1346
1347 #[test]
1348 fn detect_rate_limit_ignores_normal_stdout() {
1349 let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1350 assert!(detect_rate_limit(stdout).is_none());
1351 }
1352
1353 #[test]
1354 fn claude_envelope_is_error_detected() {
1355 let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1356 let result = detect_claude_envelope_failure(stdout).unwrap();
1357 assert_eq!(result.status, AgentStatus::Failed);
1358 }
1359
1360 #[test]
1361 fn claude_is_error_overrides_success_marker() {
1362 let dir = tempfile::tempdir().unwrap();
1363 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1364 std::fs::write(
1365 stdout_path(dir.path(), 9),
1366 r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1367 )
1368 .unwrap();
1369
1370 let result = evaluate_layer1(dir.path(), 9).unwrap();
1371
1372 assert_eq!(result.status, AgentStatus::Failed);
1373 }
1374
1375 #[test]
1376 fn claude_envelope_is_error_false_defers() {
1377 let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1378 assert!(detect_claude_envelope_failure(stdout).is_none());
1379 }
1380
1381 #[test]
1382 fn claude_envelope_marker_still_wins() {
1383 let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1384 assert!(detect_claude_envelope_failure(stdout).is_none());
1385 let result = parse_devflow_result(stdout).unwrap();
1386 assert_eq!(result.status, AgentStatus::Success);
1387 assert_eq!(result.commits, Some(2));
1388 }
1389
1390 #[test]
1391 fn codex_event_stream_parses_turn_failed() {
1392 let stdout = concat!(
1393 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1394 "{\"type\":\"turn.started\"}\n",
1395 "{\"type\":\"item.started\",\"item\":{}}\n",
1396 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1397 );
1398 let result = parse_codex_event_result(stdout).unwrap();
1399 assert_eq!(result.status, AgentStatus::Failed);
1400 assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1401 }
1402
1403 #[test]
1404 fn codex_turn_completed_no_marker_defers() {
1405 let stdout = concat!(
1406 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1407 "{\"type\":\"turn.started\"}\n",
1408 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1409 );
1410 assert!(parse_codex_event_result(stdout).is_none());
1411 }
1412
1413 #[test]
1419 fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1420 let stdout = concat!(
1421 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1422 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1423 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1424 );
1425 let result = parse_codex_event_result(stdout).unwrap();
1426 assert_eq!(result.status, AgentStatus::Failed);
1427 assert_eq!(
1428 result.reason.as_deref(),
1429 Some("interactive input unavailable")
1430 );
1431 }
1432
1433 #[test]
1434 fn codex_agent_message_marker_success_short_circuits() {
1435 let stdout = concat!(
1436 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1437 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1438 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1439 );
1440 let result = parse_codex_event_result(stdout).unwrap();
1441 assert_eq!(result.status, AgentStatus::Success);
1442 }
1443
1444 #[test]
1450 fn detect_rate_limit_ignores_json_event_lines() {
1451 let stdout = concat!(
1452 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1453 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1454 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1455 );
1456 assert_eq!(detect_rate_limit(stdout), None);
1457 }
1458
1459 #[test]
1460 fn detect_rate_limit_still_reads_codex_plain_text() {
1461 let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1462 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1463 }
1464
1465 #[test]
1466 fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1467 let stdout = concat!(
1468 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1469 "not json at all\n",
1470 "{\"type\":\"item.started\",\"item\":{}}\n",
1471 "{\"type\":\"item.updated\",\"item\":{}}\n",
1472 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1473 );
1474 let result = parse_codex_event_result(stdout).unwrap();
1475 assert_eq!(result.status, AgentStatus::Failed);
1476 assert_eq!(result.reason.as_deref(), Some("boom"));
1477 }
1478
1479 #[test]
1480 fn claude_envelope_not_consumed_by_codex_parser() {
1481 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1482 assert!(parse_codex_event_result(stdout).is_none());
1483 }
1484
1485 #[test]
1486 fn evaluate_layer1_reports_rate_limited_without_marker() {
1487 let dir = tempfile::tempdir().unwrap();
1488 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1489 std::fs::write(
1490 stdout_path(dir.path(), 7),
1491 r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1492 )
1493 .unwrap();
1494
1495 let result = evaluate_layer1(dir.path(), 7).unwrap();
1496
1497 assert_eq!(result.status, AgentStatus::RateLimited);
1498 assert_eq!(
1499 result.reason.as_deref(),
1500 Some("rate limited until 2026-06-18T15:45:30Z")
1501 );
1502 }
1503
1504 #[test]
1509 fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1510 let dir = tempfile::tempdir().unwrap();
1511 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1512 std::fs::write(
1513 stdout_path(dir.path(), 7),
1514 r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1515 )
1516 .unwrap();
1517
1518 let result = evaluate_layer1(dir.path(), 7).unwrap();
1519
1520 assert_eq!(result.status, AgentStatus::RateLimited);
1521 assert_eq!(
1522 result.reason.as_deref(),
1523 Some("rate limited until 2026-06-18T15:45:30Z")
1524 );
1525 }
1526
1527 #[test]
1533 fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1534 let dir = tempfile::tempdir().unwrap();
1535 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1536 let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1537 bytes.extend_from_slice(
1538 b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1539 );
1540 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1541
1542 let result = evaluate_layer1(dir.path(), 5).unwrap();
1543
1544 assert_eq!(result.status, AgentStatus::Failed);
1545 assert_eq!(result.reason.as_deref(), Some("review: bad"));
1546 }
1547
1548 #[test]
1549 fn failing_external_probe_outranks_success_marker() {
1550 let dir = tempfile::tempdir().unwrap();
1551 let phase_dir = dir
1552 .path()
1553 .join(".planning/phases/16-pipeline-reliability-hardening");
1554 std::fs::create_dir_all(&phase_dir).unwrap();
1555 std::fs::write(
1556 phase_dir.join("16-03-PLAN.md"),
1557 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1558 )
1559 .unwrap();
1560 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1561 std::fs::write(
1562 stdout_path(dir.path(), 16),
1563 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1564 )
1565 .unwrap();
1566 let state = state_in(dir.path(), 16);
1567
1568 let approval = vec!["test -f externally-shipped".to_string()];
1569 let result = evaluate_agent_result_inner(
1570 dir.path(),
1571 &state,
1572 &GitFlowConfig::default(),
1573 Some(&approval),
1574 )
1575 .unwrap();
1576
1577 assert_eq!(result.status, AgentStatus::Failed);
1578 assert!(
1579 result
1580 .reason
1581 .as_deref()
1582 .is_some_and(|reason| reason.contains("external verification failed"))
1583 );
1584 }
1585
1586 #[test]
1594 fn external_probe_discovers_from_project_root_across_every_stage_and_executes_in_worktree() {
1595 let dir = tempfile::tempdir().unwrap();
1596 let worktree = dir.path().join("phase-worktree");
1597 std::fs::create_dir_all(&worktree).unwrap();
1598 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1599 std::fs::create_dir_all(&phase_dir).unwrap();
1600 std::fs::write(
1601 phase_dir.join("16-01-PLAN.md"),
1602 "---\nexternal_verify: \"test -f implemented\"\n---\n",
1603 )
1604 .unwrap();
1605 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1606 std::fs::write(
1607 stdout_path(dir.path(), 16),
1608 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1609 )
1610 .unwrap();
1611 let mut state = state_in(dir.path(), 16);
1612 state.worktree_path = Some(worktree.clone());
1613 state.stage = Stage::Plan;
1614
1615 let approval = vec!["test -f implemented".to_string()];
1616
1617 let plan_result = evaluate_agent_result_inner(
1622 dir.path(),
1623 &state,
1624 &GitFlowConfig::default(),
1625 Some(&approval),
1626 )
1627 .unwrap();
1628 assert_eq!(plan_result.status, AgentStatus::Failed);
1629 assert!(
1630 plan_result
1631 .reason
1632 .as_deref()
1633 .is_some_and(|reason| reason.contains("external verification failed")),
1634 "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
1635 plan_result.reason
1636 );
1637
1638 state.stage = Stage::Code;
1639 let code_result = evaluate_agent_result_inner(
1640 dir.path(),
1641 &state,
1642 &GitFlowConfig::default(),
1643 Some(&approval),
1644 )
1645 .unwrap();
1646 assert_eq!(code_result.status, AgentStatus::Failed);
1647
1648 std::fs::write(worktree.join("implemented"), "done").unwrap();
1651 let passing = evaluate_agent_result_inner(
1652 dir.path(),
1653 &state,
1654 &GitFlowConfig::default(),
1655 Some(&approval),
1656 )
1657 .unwrap();
1658 assert_eq!(passing.status, AgentStatus::Success);
1659 assert_eq!(passing.decided_by_layer, Some(0));
1660 }
1661
1662 #[test]
1663 fn changed_external_probe_never_inherits_prior_approval() {
1664 let dir = tempfile::tempdir().unwrap();
1665 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1666 std::fs::create_dir_all(&phase_dir).unwrap();
1667 std::fs::write(
1668 phase_dir.join("16-01-PLAN.md"),
1669 "---\nexternal_verify: \"touch escaped\"\n---\n",
1670 )
1671 .unwrap();
1672 let state = state_in(dir.path(), 16);
1673 let approved = vec!["test -f reviewed-artifact".to_string()];
1674
1675 let result = evaluate_agent_result_inner(
1676 dir.path(),
1677 &state,
1678 &GitFlowConfig::default(),
1679 Some(&approved),
1680 )
1681 .unwrap();
1682
1683 assert_eq!(result.status, AgentStatus::Failed);
1684 assert!(result.reason.unwrap().contains("approval mismatch"));
1685 assert!(!dir.path().join("escaped").exists());
1686 }
1687
1688 #[test]
1689 fn removed_external_probe_fails_closed_against_prior_approval() {
1690 let dir = tempfile::tempdir().unwrap();
1691 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1692 std::fs::write(
1693 stdout_path(dir.path(), 16),
1694 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1695 )
1696 .unwrap();
1697 let state = state_in(dir.path(), 16);
1698 let approved = vec!["test -f shipped".to_string()];
1699
1700 let result = evaluate_agent_result_inner(
1701 dir.path(),
1702 &state,
1703 &GitFlowConfig::default(),
1704 Some(&approved),
1705 )
1706 .unwrap();
1707
1708 assert_eq!(result.status, AgentStatus::Failed);
1709 assert!(result.reason.unwrap().contains("declaration was removed"));
1710 }
1711
1712 #[test]
1713 fn no_external_declaration_preserves_layer1_result() {
1714 let dir = tempfile::tempdir().unwrap();
1715 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1716 std::fs::write(
1717 stdout_path(dir.path(), 16),
1718 "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
1719 )
1720 .unwrap();
1721 let state = state_in(dir.path(), 16);
1722 let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
1723
1724 let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1725
1726 assert_eq!(
1727 serde_json::to_value(full).unwrap(),
1728 serde_json::to_value(layer1).unwrap()
1729 );
1730 }
1731
1732 #[test]
1739 fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
1740 let dir = tempfile::tempdir().unwrap();
1741 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1742 std::fs::create_dir_all(&phase_dir).unwrap();
1743 std::fs::write(
1744 phase_dir.join("16-01-PLAN.md"),
1745 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1746 )
1747 .unwrap();
1748 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1749 let mut state = state_in(dir.path(), 16);
1750 state.stage = Stage::Define;
1751
1752 let approval = vec!["test -f shipped".to_string()];
1753 let result = evaluate_agent_result_inner(
1754 dir.path(),
1755 &state,
1756 &GitFlowConfig::default(),
1757 Some(&approval),
1758 )
1759 .unwrap();
1760
1761 assert_eq!(result.status, AgentStatus::Success);
1762 assert_eq!(result.decided_by_layer, Some(0));
1763 assert_eq!(result.commits, None);
1764 assert_eq!(result.verdict, None);
1766 }
1767
1768 #[test]
1773 fn layer0_affirmative_success_outranks_layer1_failure_marker() {
1774 let dir = tempfile::tempdir().unwrap();
1775 let phase_dir = dir
1776 .path()
1777 .join(".planning/phases/16-pipeline-reliability-hardening");
1778 std::fs::create_dir_all(&phase_dir).unwrap();
1779 std::fs::write(
1780 phase_dir.join("16-03-PLAN.md"),
1781 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1782 )
1783 .unwrap();
1784 std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
1785 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1786 std::fs::write(
1787 stdout_path(dir.path(), 16),
1788 "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
1789 )
1790 .unwrap();
1791 let state = state_in(dir.path(), 16);
1792
1793 let approval = vec!["test -f externally-shipped".to_string()];
1794 let result = evaluate_agent_result_inner(
1795 dir.path(),
1796 &state,
1797 &GitFlowConfig::default(),
1798 Some(&approval),
1799 )
1800 .unwrap();
1801
1802 assert_eq!(result.status, AgentStatus::Success);
1803 assert_eq!(result.decided_by_layer, Some(0));
1804 assert_eq!(result.verdict, None);
1807 }
1808
1809 #[test]
1814 fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
1815 let dir = tempfile::tempdir().unwrap();
1816 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1817 std::fs::create_dir_all(&phase_dir).unwrap();
1818 std::fs::write(
1819 phase_dir.join("16-01-PLAN.md"),
1820 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1821 )
1822 .unwrap();
1823 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1824 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1825 let mut state = state_in(dir.path(), 16);
1826 state.stage = Stage::Validate;
1827 let approval = vec!["test -f shipped".to_string()];
1828
1829 std::fs::write(
1830 stdout_path(dir.path(), 16),
1831 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1832 )
1833 .unwrap();
1834 let result = evaluate_agent_result_inner(
1835 dir.path(),
1836 &state,
1837 &GitFlowConfig::default(),
1838 Some(&approval),
1839 )
1840 .unwrap();
1841 assert_eq!(result.status, AgentStatus::Success);
1842 assert_eq!(result.decided_by_layer, Some(0));
1843 assert_eq!(result.verdict, Some(Verdict::Pass));
1844
1845 std::fs::write(
1846 stdout_path(dir.path(), 16),
1847 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
1848 )
1849 .unwrap();
1850 let result = evaluate_agent_result_inner(
1851 dir.path(),
1852 &state,
1853 &GitFlowConfig::default(),
1854 Some(&approval),
1855 )
1856 .unwrap();
1857 assert_eq!(result.verdict, Some(Verdict::Gaps));
1858
1859 std::fs::remove_file(stdout_path(dir.path(), 16)).unwrap();
1860 let result = evaluate_agent_result_inner(
1861 dir.path(),
1862 &state,
1863 &GitFlowConfig::default(),
1864 Some(&approval),
1865 )
1866 .unwrap();
1867 assert_eq!(result.verdict, None);
1868 }
1869
1870 #[test]
1875 fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
1876 let dir = tempfile::tempdir().unwrap();
1877 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1878 std::fs::create_dir_all(&phase_dir).unwrap();
1879 std::fs::write(
1880 phase_dir.join("16-01-PLAN.md"),
1881 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1882 )
1883 .unwrap();
1884 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1885 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1886 std::fs::write(
1887 stdout_path(dir.path(), 16),
1888 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1889 )
1890 .unwrap();
1891 let state = state_in(dir.path(), 16); let approval = vec!["test -f shipped".to_string()];
1893
1894 let result = evaluate_agent_result_inner(
1895 dir.path(),
1896 &state,
1897 &GitFlowConfig::default(),
1898 Some(&approval),
1899 )
1900 .unwrap();
1901
1902 assert_eq!(result.status, AgentStatus::Success);
1903 assert_eq!(result.decided_by_layer, Some(0));
1904 assert_eq!(result.verdict, None);
1905 }
1906
1907 #[test]
1911 fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
1912 let dir = tempfile::tempdir().unwrap();
1913 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1914 std::fs::create_dir_all(&phase_dir).unwrap();
1915 std::fs::write(
1917 phase_dir.join("16-01-PLAN.md"),
1918 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
1919 )
1920 .unwrap();
1921 std::fs::write(
1922 phase_dir.join("16-02-PLAN.md"),
1923 "---\nexternal_verify: \"test -f never-created\"\n---\n",
1924 )
1925 .unwrap();
1926 std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
1927 let mut state = state_in(dir.path(), 16);
1928 state.stage = Stage::Define;
1929
1930 let approval = vec![
1931 "test -f passing-artifact".to_string(),
1932 "test -f never-created".to_string(),
1933 ];
1934 let result_a = evaluate_agent_result_inner(
1935 dir.path(),
1936 &state,
1937 &GitFlowConfig::default(),
1938 Some(&approval),
1939 )
1940 .unwrap();
1941 assert_eq!(result_a.status, AgentStatus::Failed);
1942 assert!(
1943 result_a
1944 .reason
1945 .as_deref()
1946 .is_some_and(|reason| reason.contains("never-created")),
1947 "unexpected reason: {:?}",
1948 result_a.reason
1949 );
1950
1951 std::fs::write(
1955 phase_dir.join("16-01-PLAN.md"),
1956 "---\nexternal_verify: \"test -f still-missing\"\n---\n",
1957 )
1958 .unwrap();
1959 std::fs::write(
1960 phase_dir.join("16-02-PLAN.md"),
1961 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
1962 )
1963 .unwrap();
1964 let approval_swapped = vec![
1965 "test -f still-missing".to_string(),
1966 "test -f passing-artifact".to_string(),
1967 ];
1968 let result_b = evaluate_agent_result_inner(
1969 dir.path(),
1970 &state,
1971 &GitFlowConfig::default(),
1972 Some(&approval_swapped),
1973 )
1974 .unwrap();
1975 assert_eq!(result_b.status, AgentStatus::Failed);
1976
1977 std::fs::write(dir.path().join("still-missing"), "done").unwrap();
1979 let result_c = evaluate_agent_result_inner(
1980 dir.path(),
1981 &state,
1982 &GitFlowConfig::default(),
1983 Some(&approval_swapped),
1984 )
1985 .unwrap();
1986 assert_eq!(result_c.status, AgentStatus::Success);
1987 assert_eq!(result_c.decided_by_layer, Some(0));
1988 }
1989
1990 #[test]
1991 fn archive_moves_captures_into_history_and_removes_pid_file() {
1992 let dir = tempfile::tempdir().unwrap();
1995 let root = dir.path();
1996 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1997 std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
1998 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1999 std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
2000
2001 archive_phase_files(root, root, 1, 5).unwrap();
2002
2003 assert!(!root.join(".devflow/phase-01-stdout").exists());
2005 assert!(!root.join(".devflow/phase-01-exit").exists());
2006 assert!(!root.join(".devflow/phase-01-agent-pid").exists());
2008
2009 let history = history_dir(root, 1);
2010 let archived: Vec<_> = std::fs::read_dir(&history)
2011 .unwrap()
2012 .flatten()
2013 .map(|e| e.file_name().to_string_lossy().into_owned())
2014 .collect();
2015 let archived_stdout = archived
2016 .iter()
2017 .find(|name| name.ends_with("-stdout"))
2018 .expect("stdout capture should be archived into history");
2019 assert!(archived.iter().any(|name| name.ends_with("-exit")));
2020 let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
2021 assert_eq!(contents, "prior stdout");
2022 }
2023
2024 #[test]
2025 fn archive_is_noop_when_nothing_to_archive() {
2026 let dir = tempfile::tempdir().unwrap();
2027 let root = dir.path();
2028 archive_phase_files(root, root, 1, 5).unwrap();
2030 assert!(!history_dir(root, 1).exists());
2031 }
2032
2033 #[test]
2034 fn archive_handles_missing_devflow_dir() {
2035 let dir = tempfile::tempdir().unwrap();
2036 let root = dir.path();
2037 archive_phase_files(root, root, 1, 5).unwrap();
2039 }
2040
2041 #[test]
2042 fn archive_failure_preserves_live_capture_for_retry() {
2043 let dir = tempfile::tempdir().unwrap();
2044 let root = dir.path();
2045 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2046 std::fs::write(stdout_path(root, 1), "evidence").unwrap();
2047 std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
2050
2051 assert!(archive_phase_files(root, root, 1, 5).is_err());
2052 assert_eq!(
2053 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2054 "evidence"
2055 );
2056 }
2057
2058 #[test]
2059 fn archive_second_publish_failure_rolls_back_complete_live_pair() {
2060 let dir = tempfile::tempdir().unwrap();
2061 let root = dir.path();
2062 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2063 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2064 std::fs::write(exit_code_path(root, 1), "17").unwrap();
2065 let history = history_dir(root, 1);
2066 std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
2067
2068 assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
2069
2070 assert_eq!(
2071 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2072 "stdout evidence"
2073 );
2074 assert_eq!(
2075 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2076 "17"
2077 );
2078 assert!(!history.join("fixed-stdout").exists());
2079 assert!(!history.join(".pending-fixed").exists());
2080 }
2081
2082 #[test]
2083 fn archive_review_copy_failure_rolls_back_complete_live_pair() {
2084 let dir = tempfile::tempdir().unwrap();
2085 let root = dir.path();
2086 let evidence_root = root.join("phase-worktree");
2087 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2088 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2089 std::fs::write(exit_code_path(root, 1), "23").unwrap();
2090 let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
2091 std::fs::create_dir_all(&review).unwrap();
2092
2093 assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
2094
2095 assert_eq!(
2096 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2097 "stdout evidence"
2098 );
2099 assert_eq!(
2100 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2101 "23"
2102 );
2103 let history = history_dir(root, 1);
2104 assert!(!history.join("review-copy-stdout").exists());
2105 assert!(!history.join("review-copy-exit").exists());
2106 assert!(!history.join(".pending-review-copy").exists());
2107 }
2108
2109 #[test]
2110 fn archive_snapshots_current_review_into_same_generation() {
2111 let dir = tempfile::tempdir().unwrap();
2112 let root = dir.path();
2113 let evidence_root = root.join("phase-worktree");
2114 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2115 std::fs::write(stdout_path(root, 1), "attempt").unwrap();
2116 let phase_dir = evidence_root.join(".planning/phases/01-example");
2117 std::fs::create_dir_all(&phase_dir).unwrap();
2118 std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
2119
2120 let stamp = archive_phase_files(root, &evidence_root, 1, 5)
2121 .unwrap()
2122 .unwrap();
2123
2124 assert_eq!(
2125 std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
2126 .unwrap(),
2127 "review one"
2128 );
2129 }
2130
2131 #[test]
2132 fn archive_prunes_history_to_retain_count() {
2133 let dir = tempfile::tempdir().unwrap();
2134 let root = dir.path();
2135 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2136
2137 for i in 0..7 {
2138 std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
2139 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2140 archive_phase_files(root, root, 1, 3).unwrap();
2141 }
2142
2143 let history = history_dir(root, 1);
2144 let stdout_count = std::fs::read_dir(&history)
2145 .unwrap()
2146 .flatten()
2147 .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
2148 .count();
2149 let exit_count = std::fs::read_dir(&history)
2150 .unwrap()
2151 .flatten()
2152 .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
2153 .count();
2154 assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
2155 assert_eq!(exit_count, 3, "expected at most 3 retained generations");
2156 }
2157
2158 #[test]
2159 fn evaluate_agent_result_reads_files_end_to_end() {
2160 let dir = tempfile::tempdir().unwrap();
2161 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2162 std::fs::write(
2163 stdout_path(dir.path(), 6),
2164 "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
2165 )
2166 .unwrap();
2167 std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
2168 let state = state_in(dir.path(), 6);
2169
2170 let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2171
2172 assert_eq!(result.status, AgentStatus::Success);
2173 assert_eq!(result.commits, Some(2));
2174 assert_eq!(result.summary.as_deref(), Some("ok"));
2175 }
2176
2177 #[test]
2178 fn evaluate_layer1_finds_devflow_result_in_file() {
2179 let dir = tempfile::tempdir().unwrap();
2180 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2181 std::fs::write(
2182 stdout_path(dir.path(), 3),
2183 "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
2184 )
2185 .unwrap();
2186
2187 let result = evaluate_layer1(dir.path(), 3).unwrap();
2188
2189 assert_eq!(result.status, AgentStatus::Failed);
2190 assert_eq!(result.reason.as_deref(), Some("bad output"));
2191 }
2192
2193 #[test]
2194 fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
2195 let dir = tempfile::tempdir().unwrap();
2196 init_repo_with_feature_commit(dir.path(), 4);
2197 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2198 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2199 let state = state_in(dir.path(), 4);
2200
2201 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2202 .unwrap()
2203 .unwrap();
2204
2205 assert_eq!(result.status, AgentStatus::Success);
2206 assert_eq!(result.exit_code, Some(0));
2207 assert_eq!(result.commits, Some(1));
2208 assert!(result.reason.unwrap().contains("1 commits"));
2209 }
2210
2211 #[test]
2212 fn evaluate_layer2_exit_zero_no_commits_is_failed() {
2213 let dir = tempfile::tempdir().unwrap();
2216 init_repo_with_feature_no_commit(dir.path(), 4);
2217 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2218 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2219 let state = state_in(dir.path(), 4);
2220
2221 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2222 .unwrap()
2223 .unwrap();
2224
2225 assert_eq!(result.status, AgentStatus::Failed);
2226 assert_eq!(result.exit_code, Some(0));
2227 assert_eq!(result.commits, Some(0));
2228 assert!(result.reason.unwrap().contains("no commits"));
2229 }
2230
2231 #[test]
2232 fn evaluate_layer2_nonzero_exit_is_failed() {
2233 let dir = tempfile::tempdir().unwrap();
2235 init_repo_with_feature_commit(dir.path(), 4);
2236 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2237 std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
2238 let state = state_in(dir.path(), 4);
2239
2240 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2241 .unwrap()
2242 .unwrap();
2243
2244 assert_eq!(result.status, AgentStatus::Failed);
2245 assert_eq!(result.exit_code, Some(1));
2246 assert!(result.reason.unwrap().contains("exited with code 1"));
2247 }
2248
2249 #[test]
2250 fn layer2_nonzero_exit_is_failed_all_stages() {
2251 let dir = tempfile::tempdir().unwrap();
2255 init_repo_with_feature_no_commit(dir.path(), 10);
2256 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2257 std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
2258
2259 for stage in [
2260 Stage::Define,
2261 Stage::Plan,
2262 Stage::Code,
2263 Stage::Validate,
2264 Stage::Ship,
2265 ] {
2266 let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
2267 .unwrap()
2268 .unwrap();
2269 assert_eq!(
2270 result.status,
2271 AgentStatus::Failed,
2272 "stage {stage:?} should be Failed on nonzero exit"
2273 );
2274 }
2275 }
2276
2277 #[test]
2278 fn layer2_skips_commit_gate_for_define_and_validate() {
2279 let dir = tempfile::tempdir().unwrap();
2280 init_repo_with_feature_no_commit(dir.path(), 11);
2281 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2282 std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
2283
2284 for stage in [Stage::Define, Stage::Validate] {
2285 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
2286 .unwrap()
2287 .unwrap();
2288 assert_ne!(
2289 result.status,
2290 AgentStatus::Failed,
2291 "stage {stage:?} should not be Failed for zero commits"
2292 );
2293 }
2294
2295 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
2298 .unwrap()
2299 .unwrap();
2300 assert_eq!(result.status, AgentStatus::Failed);
2301 }
2302
2303 #[test]
2304 fn evaluate_layer3_falls_back_to_commit_count() {
2305 let dir = tempfile::tempdir().unwrap();
2306 init_repo_with_feature_commit(dir.path(), 5);
2307
2308 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2309
2310 assert_eq!(result.status, AgentStatus::Unknown);
2311 assert_eq!(result.exit_code, None);
2312 assert_eq!(result.commits, Some(1));
2313 assert!(result.reason.unwrap().contains("1 commits"));
2314 assert_eq!(result.decided_by_layer, Some(3));
2315 }
2316
2317 #[test]
2324 fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
2325 let dir = tempfile::tempdir().unwrap();
2326 init_repo_with_feature_no_commit(dir.path(), 5);
2327
2328 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2329
2330 assert_eq!(result.status, AgentStatus::Failed);
2331 assert_eq!(result.exit_code, None);
2332 assert_eq!(result.commits, Some(0));
2333 assert_eq!(result.decided_by_layer, Some(3));
2334 let reason = result.reason.unwrap();
2335 assert!(reason.contains("no work"), "reason was: {reason}");
2336 assert!(
2337 reason.to_ascii_lowercase().contains("human review"),
2338 "reason was: {reason}"
2339 );
2340 }
2341
2342 #[test]
2343 fn parse_devflow_result_reads_verdict() {
2344 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
2345 let result = parse_devflow_result(stdout).unwrap();
2346 assert_eq!(result.status, AgentStatus::Success);
2347 assert_eq!(result.verdict, Some(Verdict::Gaps));
2348 }
2349
2350 #[test]
2351 fn parse_devflow_result_reads_verdict_pass() {
2352 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
2353 let result = parse_devflow_result(stdout).unwrap();
2354 assert_eq!(result.status, AgentStatus::Success);
2355 assert_eq!(result.verdict, Some(Verdict::Pass));
2356 }
2357
2358 #[test]
2359 fn parse_devflow_result_verdict_absent_is_none() {
2360 let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
2361 let result = parse_devflow_result(stdout).unwrap();
2362 assert_eq!(result.status, AgentStatus::Success);
2363 assert_eq!(result.verdict, None);
2364 }
2365
2366 #[test]
2367 fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
2368 let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
2371 let result = parse_devflow_result(unknown).unwrap();
2372 assert_eq!(result.status, AgentStatus::Success);
2373 assert_eq!(result.verdict, None);
2374
2375 let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
2377 let result = parse_devflow_result(miscased).unwrap();
2378 assert_eq!(result.status, AgentStatus::Success);
2379 assert_eq!(result.verdict, None);
2380 }
2381
2382 #[test]
2389 fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
2390 let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
2391 let result = parse_devflow_result(bool_verdict).unwrap();
2392 assert_eq!(result.status, AgentStatus::Success);
2393 assert_eq!(result.verdict, None);
2394
2395 let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
2396 let result = parse_devflow_result(numeric_verdict).unwrap();
2397 assert_eq!(result.status, AgentStatus::Success);
2398 assert_eq!(result.verdict, None);
2399
2400 let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
2401 let result = parse_devflow_result(object_verdict).unwrap();
2402 assert_eq!(result.status, AgentStatus::Success);
2403 assert_eq!(result.verdict, None);
2404 }
2405
2406 #[test]
2410 fn multi_word_variants_serialize_with_word_boundary() {
2411 assert_eq!(
2412 serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
2413 "\"resource_killed\""
2414 );
2415 assert_eq!(
2416 serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
2417 "\"agent_unavailable\""
2418 );
2419 assert_eq!(
2420 serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
2421 AgentStatus::ResourceKilled
2422 );
2423 assert_eq!(
2424 serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
2425 AgentStatus::AgentUnavailable
2426 );
2427 }
2428
2429 #[test]
2432 fn existing_variants_keep_wire_form() {
2433 assert_eq!(
2434 serde_json::to_string(&AgentStatus::Success).unwrap(),
2435 "\"success\""
2436 );
2437 assert_eq!(
2438 serde_json::to_string(&AgentStatus::Failed).unwrap(),
2439 "\"failed\""
2440 );
2441 assert_eq!(
2442 serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
2443 "\"ratelimited\""
2444 );
2445 assert_eq!(
2446 serde_json::to_string(&AgentStatus::Unknown).unwrap(),
2447 "\"unknown\""
2448 );
2449 }
2450
2451 #[test]
2455 fn as_wire_str_matches_serde_form_for_every_variant() {
2456 for variant in [
2457 AgentStatus::Success,
2458 AgentStatus::Failed,
2459 AgentStatus::RateLimited,
2460 AgentStatus::Unknown,
2461 AgentStatus::ResourceKilled,
2462 AgentStatus::AgentUnavailable,
2463 ] {
2464 let serde_form = serde_json::to_string(&variant).unwrap();
2465 let stripped = serde_form.trim_matches('"');
2466 assert_eq!(
2467 variant.as_wire_str(),
2468 stripped,
2469 "as_wire_str() diverged from serde form for {variant:?}"
2470 );
2471 }
2472 }
2473
2474 #[test]
2475 fn evaluate_layer2_exit_137_is_resource_killed() {
2476 let dir = tempfile::tempdir().unwrap();
2477 init_repo_with_feature_commit(dir.path(), 20);
2478 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2479 std::fs::write(exit_code_path(dir.path(), 20), "137").unwrap();
2480 let state = state_in(dir.path(), 20);
2481
2482 let result = evaluate_layer2(dir.path(), 20, &GitFlowConfig::default(), state.stage)
2483 .unwrap()
2484 .unwrap();
2485
2486 assert_eq!(result.status, AgentStatus::ResourceKilled);
2487 assert_eq!(result.exit_code, Some(137));
2488 }
2489
2490 #[test]
2491 fn evaluate_layer2_exit_127_is_agent_unavailable() {
2492 let dir = tempfile::tempdir().unwrap();
2493 init_repo_with_feature_commit(dir.path(), 21);
2494 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2495 std::fs::write(exit_code_path(dir.path(), 21), "127").unwrap();
2496 let state = state_in(dir.path(), 21);
2497
2498 let result = evaluate_layer2(dir.path(), 21, &GitFlowConfig::default(), state.stage)
2499 .unwrap()
2500 .unwrap();
2501
2502 assert_eq!(result.status, AgentStatus::AgentUnavailable);
2503 assert_eq!(result.exit_code, Some(127));
2504 }
2505}