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
798pub fn evaluate_agent_result(
800 project_root: &Path,
801 state: &State,
802 git_flow: &GitFlowConfig,
803) -> Result<AgentResult, ResultError> {
804 let approval = crate::verify::external_verification_approval();
805 evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
806}
807
808fn evaluate_agent_result_inner(
809 project_root: &Path,
810 state: &State,
811 git_flow: &GitFlowConfig,
812 approved_commands: Option<&[String]>,
813) -> Result<AgentResult, ResultError> {
814 if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
816 return Ok(result);
817 }
818
819 if let Some(result) = evaluate_layer1(project_root, state.phase) {
821 return Ok(result);
822 }
823
824 if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
826 return Ok(result);
827 }
828
829 evaluate_layer3(project_root, state.phase, git_flow)
831}
832
833fn devflow_dir(project_root: &Path) -> PathBuf {
835 project_root.join(".devflow")
836}
837
838pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
840 devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
841}
842
843pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
846 devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
847}
848
849pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
851 devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
852}
853
854pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
856 devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
857}
858
859pub fn history_dir(project_root: &Path, phase: u32) -> PathBuf {
867 devflow_dir(project_root)
868 .join("history")
869 .join(format!("phase-{:02}", phase))
870}
871
872static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
876
877fn archive_stamp() -> String {
882 let nanos = std::time::SystemTime::now()
883 .duration_since(std::time::UNIX_EPOCH)
884 .map(|d| d.as_nanos())
885 .unwrap_or(0);
886 let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
887 format!("{nanos}-{seq}")
888}
889
890pub fn archive_phase_files(
900 project_root: &Path,
901 evidence_root: &Path,
902 phase: u32,
903 retain: usize,
904) -> Result<Option<String>, std::io::Error> {
905 archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
906}
907
908fn archive_phase_files_with_stamp(
909 project_root: &Path,
910 evidence_root: &Path,
911 phase: u32,
912 retain: usize,
913 stamp: &str,
914) -> Result<Option<String>, std::io::Error> {
915 let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
916
917 let stdout_src = stdout_path(project_root, phase);
918 let exit_src = exit_code_path(project_root, phase);
919 let stdout_exists = stdout_src.exists();
920 let exit_exists = exit_src.exists();
921 if !stdout_exists && !exit_exists {
922 return Ok(None); }
924
925 let history_dir = history_dir(project_root, phase);
926 std::fs::create_dir_all(&history_dir)?;
927
928 let staging_dir = history_dir.join(format!(".pending-{stamp}"));
929 std::fs::create_dir(&staging_dir)?;
930 let stdout_stage = staging_dir.join("stdout");
931 let exit_stage = staging_dir.join("exit");
932 let review_stage = staging_dir.join("REVIEW.md");
933 let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
934 let exit_dest = history_dir.join(format!("{stamp}-exit"));
935 let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
936 let review_src = phase_review_path(evidence_root, phase);
937
938 let mut stdout_staged = false;
939 let mut exit_staged = false;
940 let mut stdout_published = false;
941 let mut exit_published = false;
942 let mut review_published = false;
943
944 let archive_result = (|| -> Result<(), std::io::Error> {
945 if stdout_exists {
946 std::fs::rename(&stdout_src, &stdout_stage)?;
947 stdout_staged = true;
948 }
949 if exit_exists {
950 std::fs::rename(&exit_src, &exit_stage)?;
951 exit_staged = true;
952 }
953 if let Some(review) = &review_src {
954 std::fs::copy(review, &review_stage)?;
955 }
956
957 if stdout_exists {
958 std::fs::rename(&stdout_stage, &stdout_dest)?;
959 stdout_staged = false;
960 stdout_published = true;
961 }
962 if exit_exists {
963 std::fs::rename(&exit_stage, &exit_dest)?;
964 exit_staged = false;
965 exit_published = true;
966 }
967 if review_src.is_some() {
968 std::fs::rename(&review_stage, &review_dest)?;
969 review_published = true;
970 }
971 Ok(())
972 })();
973
974 if let Err(error) = archive_result {
975 let mut rollback_error = None;
976 let mut restore = |from: &Path, to: &Path| {
977 if let Err(error) = std::fs::rename(from, to)
978 && rollback_error.is_none()
979 {
980 rollback_error = Some(error);
981 }
982 };
983 if stdout_published {
984 restore(&stdout_dest, &stdout_src);
985 } else if stdout_staged {
986 restore(&stdout_stage, &stdout_src);
987 }
988 if exit_published {
989 restore(&exit_dest, &exit_src);
990 } else if exit_staged {
991 restore(&exit_stage, &exit_src);
992 }
993 if review_published {
994 let _ = std::fs::remove_file(&review_dest);
995 }
996 let _ = std::fs::remove_dir_all(&staging_dir);
997
998 if let Some(rollback_error) = rollback_error {
999 return Err(std::io::Error::new(
1000 error.kind(),
1001 format!("{error}; archive rollback failed: {rollback_error}"),
1002 ));
1003 }
1004 return Err(error);
1005 }
1006
1007 let _ = std::fs::remove_dir(&staging_dir);
1008
1009 prune_history(&history_dir, retain);
1010 Ok(Some(stamp.to_string()))
1011}
1012
1013fn phase_review_path(project_root: &Path, phase: u32) -> Option<PathBuf> {
1014 let phases = std::fs::read_dir(project_root.join(".planning/phases")).ok()?;
1015 let prefix = format!("{phase:02}-");
1016 for entry in phases.flatten() {
1017 if entry
1018 .file_name()
1019 .to_str()
1020 .is_some_and(|name| name.starts_with(&prefix))
1021 {
1022 let review = entry.path().join(format!("{phase:02}-REVIEW.md"));
1023 if review.exists() {
1024 return Some(review);
1025 }
1026 }
1027 }
1028 None
1029}
1030
1031fn prune_history(history_dir: &Path, retain: usize) {
1039 let Ok(entries) = std::fs::read_dir(history_dir) else {
1040 return;
1041 };
1042
1043 let mut stamps: Vec<String> = entries
1044 .flatten()
1045 .filter_map(|entry| {
1046 let name = entry.file_name().to_str()?.to_string();
1047 name.rsplit_once('-')
1048 .map(|(stamp, _suffix)| stamp.to_string())
1049 })
1050 .collect();
1051 stamps.sort_by_key(|stamp| {
1052 let mut parts = stamp.split('-');
1053 let nanos = parts
1054 .next()
1055 .and_then(|part| part.parse::<u128>().ok())
1056 .unwrap_or(0);
1057 let sequence = parts
1058 .next()
1059 .and_then(|part| part.parse::<u64>().ok())
1060 .unwrap_or(0);
1061 (nanos, sequence)
1062 });
1063 stamps.dedup();
1064
1065 if stamps.len() <= retain {
1066 return;
1067 }
1068
1069 let to_remove = stamps.len() - retain;
1070 for stamp in &stamps[..to_remove] {
1071 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
1072 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
1073 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
1074 }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080 use crate::config::GitFlowConfig;
1081 use crate::mode::Mode;
1082 use crate::stage::Stage;
1083 use crate::state::{AgentKind, State};
1084 use std::process::Command;
1085
1086 fn state_in(root: &Path, phase: u32) -> State {
1087 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
1088 state.stage = Stage::Code;
1089 state
1090 }
1091
1092 fn git(root: &Path, args: &[&str]) {
1093 let output = Command::new("git")
1094 .args(args)
1095 .current_dir(root)
1096 .output()
1097 .unwrap();
1098 assert!(
1099 output.status.success(),
1100 "git {:?} failed\nstdout: {}\nstderr: {}",
1101 args,
1102 String::from_utf8_lossy(&output.stdout),
1103 String::from_utf8_lossy(&output.stderr)
1104 );
1105 }
1106
1107 fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1108 git(root, &["init"]);
1109 git(root, &["config", "user.email", "devflow@example.com"]);
1110 git(root, &["config", "user.name", "DevFlow Tests"]);
1111 git(root, &["config", "commit.gpgsign", "false"]);
1112 git(root, &["config", "tag.gpgsign", "false"]);
1113 git(root, &["checkout", "-b", "develop"]);
1114 std::fs::write(root.join("README.md"), "base\n").unwrap();
1115 git(root, &["add", "README.md"]);
1116 git(root, &["commit", "-m", "base"]);
1117
1118 let branch = format!("feature/phase-{phase:02}");
1119 git(root, &["checkout", "-b", &branch]);
1120 std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1121 git(root, &["add", "phase.txt"]);
1122 git(root, &["commit", "-m", "feature work"]);
1123 }
1124
1125 fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1128 git(root, &["init"]);
1129 git(root, &["config", "user.email", "devflow@example.com"]);
1130 git(root, &["config", "user.name", "DevFlow Tests"]);
1131 git(root, &["config", "commit.gpgsign", "false"]);
1132 git(root, &["config", "tag.gpgsign", "false"]);
1133 git(root, &["checkout", "-b", "develop"]);
1134 std::fs::write(root.join("README.md"), "base\n").unwrap();
1135 git(root, &["add", "README.md"]);
1136 git(root, &["commit", "-m", "base"]);
1137
1138 let branch = format!("feature/phase-{phase:02}");
1139 git(root, &["checkout", "-b", &branch]);
1140 }
1141
1142 #[test]
1143 fn parse_success_marker() {
1144 let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1145 let result = parse_devflow_result(stdout).unwrap();
1146 assert_eq!(result.status, AgentStatus::Success);
1147 }
1148
1149 #[test]
1150 fn parse_failed_marker_with_reason() {
1151 let stdout =
1152 "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1153 let result = parse_devflow_result(stdout).unwrap();
1154 assert_eq!(result.status, AgentStatus::Failed);
1155 assert_eq!(result.reason.unwrap(), "clippy errors");
1156 }
1157
1158 #[test]
1159 fn parse_missing_marker_returns_none() {
1160 let stdout = "just some output\nno marker here\n";
1161 assert!(parse_devflow_result(stdout).is_none());
1162 }
1163
1164 #[test]
1165 fn parse_malformed_json_returns_none() {
1166 let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1167 assert!(parse_devflow_result(stdout).is_none());
1168 }
1169
1170 #[test]
1171 fn parse_lowercase_marker() {
1172 let stdout = "devflow_result: {\"status\":\"success\"}\n";
1173 let result = parse_devflow_result(stdout).unwrap();
1174 assert_eq!(result.status, AgentStatus::Success);
1175 }
1176
1177 #[test]
1178 fn parse_marker_without_space_after_colon() {
1179 let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1180 let result = parse_devflow_result(stdout).unwrap();
1181 assert_eq!(result.status, AgentStatus::Success);
1182 }
1183
1184 #[test]
1185 fn parse_lowercase_no_space_marker() {
1186 let stdout = "devflow_result:{\"status\":\"success\"}\n";
1189 let result = parse_devflow_result(stdout).unwrap();
1190 assert_eq!(result.status, AgentStatus::Success);
1191 }
1192
1193 #[test]
1194 fn parse_finds_last_marker_in_tail() {
1195 let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1197 let result = parse_devflow_result(stdout).unwrap();
1198 assert_eq!(result.status, AgentStatus::Success);
1199 }
1200
1201 #[test]
1202 fn parse_marker_lines_returns_last_marker_in_long_output() {
1203 let stdout = format!(
1204 "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1205 DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1206 "prefix".repeat(900),
1207 "tail output".repeat(100)
1208 );
1209
1210 let result = parse_marker_lines(&stdout).unwrap();
1211
1212 assert_eq!(result.status, AgentStatus::Success);
1213 }
1214
1215 #[test]
1216 fn parse_marker_only_in_last_4000_chars() {
1217 let prefix = "a".repeat(5000);
1219 let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1220 assert!(parse_devflow_result(&stdout).is_none());
1221 }
1222
1223 #[test]
1224 fn parse_marker_with_commits_and_summary() {
1225 let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1226 let result = parse_devflow_result(stdout).unwrap();
1227 assert_eq!(result.status, AgentStatus::Success);
1228 assert_eq!(result.commits, Some(3));
1229 assert_eq!(result.summary.unwrap(), "added tests");
1230 }
1231
1232 #[test]
1233 fn parse_marker_inside_json_result_envelope() {
1234 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1237 let result = parse_devflow_result(stdout).unwrap();
1238 assert_eq!(result.status, AgentStatus::Success);
1239 assert_eq!(result.commits, Some(2));
1240 }
1241
1242 #[test]
1243 fn parse_failed_marker_inside_json_envelope() {
1244 let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1245 let result = parse_devflow_result(stdout).unwrap();
1246 assert_eq!(result.status, AgentStatus::Failed);
1247 assert_eq!(result.reason.unwrap(), "tests failed");
1248 }
1249
1250 #[test]
1251 fn parse_json_envelope_without_marker_returns_none() {
1252 let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1253 assert!(parse_devflow_result(stdout).is_none());
1254 }
1255
1256 #[test]
1257 fn detect_claude_json_rate_limit_by_subtype() {
1258 let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1259 assert_eq!(
1260 detect_rate_limit(stdout).as_deref(),
1261 Some("2026-06-18T15:45:30Z")
1262 );
1263 }
1264
1265 #[test]
1266 fn detect_claude_json_rate_limit_by_429() {
1267 let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1268 assert_eq!(
1269 detect_rate_limit(stdout).as_deref(),
1270 Some("Too many requests. Try later.")
1271 );
1272 }
1273
1274 #[test]
1275 fn detect_codex_try_again_rate_limit() {
1276 let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1277 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1278 }
1279
1280 #[test]
1289 fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1290 const DEPTH: usize = 100;
1293 let mut stdout = String::new();
1294 for _ in 0..DEPTH {
1295 stdout.push_str(r#"{"nested":"#);
1296 }
1297 stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1298 for _ in 0..DEPTH {
1299 stdout.push('}');
1300 }
1301
1302 assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1305 }
1306
1307 #[test]
1308 fn detect_rate_limit_ignores_normal_stdout() {
1309 let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1310 assert!(detect_rate_limit(stdout).is_none());
1311 }
1312
1313 #[test]
1314 fn claude_envelope_is_error_detected() {
1315 let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1316 let result = detect_claude_envelope_failure(stdout).unwrap();
1317 assert_eq!(result.status, AgentStatus::Failed);
1318 }
1319
1320 #[test]
1321 fn claude_is_error_overrides_success_marker() {
1322 let dir = tempfile::tempdir().unwrap();
1323 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1324 std::fs::write(
1325 stdout_path(dir.path(), 9),
1326 r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1327 )
1328 .unwrap();
1329
1330 let result = evaluate_layer1(dir.path(), 9).unwrap();
1331
1332 assert_eq!(result.status, AgentStatus::Failed);
1333 }
1334
1335 #[test]
1336 fn claude_envelope_is_error_false_defers() {
1337 let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1338 assert!(detect_claude_envelope_failure(stdout).is_none());
1339 }
1340
1341 #[test]
1342 fn claude_envelope_marker_still_wins() {
1343 let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1344 assert!(detect_claude_envelope_failure(stdout).is_none());
1345 let result = parse_devflow_result(stdout).unwrap();
1346 assert_eq!(result.status, AgentStatus::Success);
1347 assert_eq!(result.commits, Some(2));
1348 }
1349
1350 #[test]
1351 fn codex_event_stream_parses_turn_failed() {
1352 let stdout = concat!(
1353 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1354 "{\"type\":\"turn.started\"}\n",
1355 "{\"type\":\"item.started\",\"item\":{}}\n",
1356 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1357 );
1358 let result = parse_codex_event_result(stdout).unwrap();
1359 assert_eq!(result.status, AgentStatus::Failed);
1360 assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1361 }
1362
1363 #[test]
1364 fn codex_turn_completed_no_marker_defers() {
1365 let stdout = concat!(
1366 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1367 "{\"type\":\"turn.started\"}\n",
1368 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1369 );
1370 assert!(parse_codex_event_result(stdout).is_none());
1371 }
1372
1373 #[test]
1379 fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1380 let stdout = concat!(
1381 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1382 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1383 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1384 );
1385 let result = parse_codex_event_result(stdout).unwrap();
1386 assert_eq!(result.status, AgentStatus::Failed);
1387 assert_eq!(
1388 result.reason.as_deref(),
1389 Some("interactive input unavailable")
1390 );
1391 }
1392
1393 #[test]
1394 fn codex_agent_message_marker_success_short_circuits() {
1395 let stdout = concat!(
1396 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1397 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1398 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1399 );
1400 let result = parse_codex_event_result(stdout).unwrap();
1401 assert_eq!(result.status, AgentStatus::Success);
1402 }
1403
1404 #[test]
1410 fn detect_rate_limit_ignores_json_event_lines() {
1411 let stdout = concat!(
1412 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1413 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1414 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1415 );
1416 assert_eq!(detect_rate_limit(stdout), None);
1417 }
1418
1419 #[test]
1420 fn detect_rate_limit_still_reads_codex_plain_text() {
1421 let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1422 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1423 }
1424
1425 #[test]
1426 fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1427 let stdout = concat!(
1428 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1429 "not json at all\n",
1430 "{\"type\":\"item.started\",\"item\":{}}\n",
1431 "{\"type\":\"item.updated\",\"item\":{}}\n",
1432 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1433 );
1434 let result = parse_codex_event_result(stdout).unwrap();
1435 assert_eq!(result.status, AgentStatus::Failed);
1436 assert_eq!(result.reason.as_deref(), Some("boom"));
1437 }
1438
1439 #[test]
1440 fn claude_envelope_not_consumed_by_codex_parser() {
1441 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1442 assert!(parse_codex_event_result(stdout).is_none());
1443 }
1444
1445 #[test]
1446 fn evaluate_layer1_reports_rate_limited_without_marker() {
1447 let dir = tempfile::tempdir().unwrap();
1448 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1449 std::fs::write(
1450 stdout_path(dir.path(), 7),
1451 r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1452 )
1453 .unwrap();
1454
1455 let result = evaluate_layer1(dir.path(), 7).unwrap();
1456
1457 assert_eq!(result.status, AgentStatus::RateLimited);
1458 assert_eq!(
1459 result.reason.as_deref(),
1460 Some("rate limited until 2026-06-18T15:45:30Z")
1461 );
1462 }
1463
1464 #[test]
1469 fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1470 let dir = tempfile::tempdir().unwrap();
1471 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1472 std::fs::write(
1473 stdout_path(dir.path(), 7),
1474 r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1475 )
1476 .unwrap();
1477
1478 let result = evaluate_layer1(dir.path(), 7).unwrap();
1479
1480 assert_eq!(result.status, AgentStatus::RateLimited);
1481 assert_eq!(
1482 result.reason.as_deref(),
1483 Some("rate limited until 2026-06-18T15:45:30Z")
1484 );
1485 }
1486
1487 #[test]
1493 fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1494 let dir = tempfile::tempdir().unwrap();
1495 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1496 let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1497 bytes.extend_from_slice(
1498 b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1499 );
1500 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1501
1502 let result = evaluate_layer1(dir.path(), 5).unwrap();
1503
1504 assert_eq!(result.status, AgentStatus::Failed);
1505 assert_eq!(result.reason.as_deref(), Some("review: bad"));
1506 }
1507
1508 #[test]
1509 fn failing_external_probe_outranks_success_marker() {
1510 let dir = tempfile::tempdir().unwrap();
1511 let phase_dir = dir
1512 .path()
1513 .join(".planning/phases/16-pipeline-reliability-hardening");
1514 std::fs::create_dir_all(&phase_dir).unwrap();
1515 std::fs::write(
1516 phase_dir.join("16-03-PLAN.md"),
1517 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1518 )
1519 .unwrap();
1520 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1521 std::fs::write(
1522 stdout_path(dir.path(), 16),
1523 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1524 )
1525 .unwrap();
1526 let state = state_in(dir.path(), 16);
1527
1528 let approval = vec!["test -f externally-shipped".to_string()];
1529 let result = evaluate_agent_result_inner(
1530 dir.path(),
1531 &state,
1532 &GitFlowConfig::default(),
1533 Some(&approval),
1534 )
1535 .unwrap();
1536
1537 assert_eq!(result.status, AgentStatus::Failed);
1538 assert!(
1539 result
1540 .reason
1541 .as_deref()
1542 .is_some_and(|reason| reason.contains("external verification failed"))
1543 );
1544 }
1545
1546 #[test]
1554 fn external_probe_discovers_from_project_root_across_every_stage_and_executes_in_worktree() {
1555 let dir = tempfile::tempdir().unwrap();
1556 let worktree = dir.path().join("phase-worktree");
1557 std::fs::create_dir_all(&worktree).unwrap();
1558 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1559 std::fs::create_dir_all(&phase_dir).unwrap();
1560 std::fs::write(
1561 phase_dir.join("16-01-PLAN.md"),
1562 "---\nexternal_verify: \"test -f implemented\"\n---\n",
1563 )
1564 .unwrap();
1565 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1566 std::fs::write(
1567 stdout_path(dir.path(), 16),
1568 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1569 )
1570 .unwrap();
1571 let mut state = state_in(dir.path(), 16);
1572 state.worktree_path = Some(worktree.clone());
1573 state.stage = Stage::Plan;
1574
1575 let approval = vec!["test -f implemented".to_string()];
1576
1577 let plan_result = evaluate_agent_result_inner(
1582 dir.path(),
1583 &state,
1584 &GitFlowConfig::default(),
1585 Some(&approval),
1586 )
1587 .unwrap();
1588 assert_eq!(plan_result.status, AgentStatus::Failed);
1589 assert!(
1590 plan_result
1591 .reason
1592 .as_deref()
1593 .is_some_and(|reason| reason.contains("external verification failed")),
1594 "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
1595 plan_result.reason
1596 );
1597
1598 state.stage = Stage::Code;
1599 let code_result = evaluate_agent_result_inner(
1600 dir.path(),
1601 &state,
1602 &GitFlowConfig::default(),
1603 Some(&approval),
1604 )
1605 .unwrap();
1606 assert_eq!(code_result.status, AgentStatus::Failed);
1607
1608 std::fs::write(worktree.join("implemented"), "done").unwrap();
1611 let passing = evaluate_agent_result_inner(
1612 dir.path(),
1613 &state,
1614 &GitFlowConfig::default(),
1615 Some(&approval),
1616 )
1617 .unwrap();
1618 assert_eq!(passing.status, AgentStatus::Success);
1619 assert_eq!(passing.decided_by_layer, Some(0));
1620 }
1621
1622 #[test]
1623 fn changed_external_probe_never_inherits_prior_approval() {
1624 let dir = tempfile::tempdir().unwrap();
1625 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1626 std::fs::create_dir_all(&phase_dir).unwrap();
1627 std::fs::write(
1628 phase_dir.join("16-01-PLAN.md"),
1629 "---\nexternal_verify: \"touch escaped\"\n---\n",
1630 )
1631 .unwrap();
1632 let state = state_in(dir.path(), 16);
1633 let approved = vec!["test -f reviewed-artifact".to_string()];
1634
1635 let result = evaluate_agent_result_inner(
1636 dir.path(),
1637 &state,
1638 &GitFlowConfig::default(),
1639 Some(&approved),
1640 )
1641 .unwrap();
1642
1643 assert_eq!(result.status, AgentStatus::Failed);
1644 assert!(result.reason.unwrap().contains("approval mismatch"));
1645 assert!(!dir.path().join("escaped").exists());
1646 }
1647
1648 #[test]
1649 fn removed_external_probe_fails_closed_against_prior_approval() {
1650 let dir = tempfile::tempdir().unwrap();
1651 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1652 std::fs::write(
1653 stdout_path(dir.path(), 16),
1654 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1655 )
1656 .unwrap();
1657 let state = state_in(dir.path(), 16);
1658 let approved = vec!["test -f shipped".to_string()];
1659
1660 let result = evaluate_agent_result_inner(
1661 dir.path(),
1662 &state,
1663 &GitFlowConfig::default(),
1664 Some(&approved),
1665 )
1666 .unwrap();
1667
1668 assert_eq!(result.status, AgentStatus::Failed);
1669 assert!(result.reason.unwrap().contains("declaration was removed"));
1670 }
1671
1672 #[test]
1673 fn no_external_declaration_preserves_layer1_result() {
1674 let dir = tempfile::tempdir().unwrap();
1675 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1676 std::fs::write(
1677 stdout_path(dir.path(), 16),
1678 "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
1679 )
1680 .unwrap();
1681 let state = state_in(dir.path(), 16);
1682 let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
1683
1684 let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1685
1686 assert_eq!(
1687 serde_json::to_value(full).unwrap(),
1688 serde_json::to_value(layer1).unwrap()
1689 );
1690 }
1691
1692 #[test]
1699 fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
1700 let dir = tempfile::tempdir().unwrap();
1701 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1702 std::fs::create_dir_all(&phase_dir).unwrap();
1703 std::fs::write(
1704 phase_dir.join("16-01-PLAN.md"),
1705 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1706 )
1707 .unwrap();
1708 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1709 let mut state = state_in(dir.path(), 16);
1710 state.stage = Stage::Define;
1711
1712 let approval = vec!["test -f shipped".to_string()];
1713 let result = evaluate_agent_result_inner(
1714 dir.path(),
1715 &state,
1716 &GitFlowConfig::default(),
1717 Some(&approval),
1718 )
1719 .unwrap();
1720
1721 assert_eq!(result.status, AgentStatus::Success);
1722 assert_eq!(result.decided_by_layer, Some(0));
1723 assert_eq!(result.commits, None);
1724 }
1725
1726 #[test]
1731 fn layer0_affirmative_success_outranks_layer1_failure_marker() {
1732 let dir = tempfile::tempdir().unwrap();
1733 let phase_dir = dir
1734 .path()
1735 .join(".planning/phases/16-pipeline-reliability-hardening");
1736 std::fs::create_dir_all(&phase_dir).unwrap();
1737 std::fs::write(
1738 phase_dir.join("16-03-PLAN.md"),
1739 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1740 )
1741 .unwrap();
1742 std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
1743 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1744 std::fs::write(
1745 stdout_path(dir.path(), 16),
1746 "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
1747 )
1748 .unwrap();
1749 let state = state_in(dir.path(), 16);
1750
1751 let approval = vec!["test -f externally-shipped".to_string()];
1752 let result = evaluate_agent_result_inner(
1753 dir.path(),
1754 &state,
1755 &GitFlowConfig::default(),
1756 Some(&approval),
1757 )
1758 .unwrap();
1759
1760 assert_eq!(result.status, AgentStatus::Success);
1761 assert_eq!(result.decided_by_layer, Some(0));
1762 }
1763
1764 #[test]
1768 fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
1769 let dir = tempfile::tempdir().unwrap();
1770 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1771 std::fs::create_dir_all(&phase_dir).unwrap();
1772 std::fs::write(
1774 phase_dir.join("16-01-PLAN.md"),
1775 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
1776 )
1777 .unwrap();
1778 std::fs::write(
1779 phase_dir.join("16-02-PLAN.md"),
1780 "---\nexternal_verify: \"test -f never-created\"\n---\n",
1781 )
1782 .unwrap();
1783 std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
1784 let mut state = state_in(dir.path(), 16);
1785 state.stage = Stage::Define;
1786
1787 let approval = vec![
1788 "test -f passing-artifact".to_string(),
1789 "test -f never-created".to_string(),
1790 ];
1791 let result_a = evaluate_agent_result_inner(
1792 dir.path(),
1793 &state,
1794 &GitFlowConfig::default(),
1795 Some(&approval),
1796 )
1797 .unwrap();
1798 assert_eq!(result_a.status, AgentStatus::Failed);
1799 assert!(
1800 result_a
1801 .reason
1802 .as_deref()
1803 .is_some_and(|reason| reason.contains("never-created")),
1804 "unexpected reason: {:?}",
1805 result_a.reason
1806 );
1807
1808 std::fs::write(
1812 phase_dir.join("16-01-PLAN.md"),
1813 "---\nexternal_verify: \"test -f still-missing\"\n---\n",
1814 )
1815 .unwrap();
1816 std::fs::write(
1817 phase_dir.join("16-02-PLAN.md"),
1818 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
1819 )
1820 .unwrap();
1821 let approval_swapped = vec![
1822 "test -f still-missing".to_string(),
1823 "test -f passing-artifact".to_string(),
1824 ];
1825 let result_b = evaluate_agent_result_inner(
1826 dir.path(),
1827 &state,
1828 &GitFlowConfig::default(),
1829 Some(&approval_swapped),
1830 )
1831 .unwrap();
1832 assert_eq!(result_b.status, AgentStatus::Failed);
1833
1834 std::fs::write(dir.path().join("still-missing"), "done").unwrap();
1836 let result_c = evaluate_agent_result_inner(
1837 dir.path(),
1838 &state,
1839 &GitFlowConfig::default(),
1840 Some(&approval_swapped),
1841 )
1842 .unwrap();
1843 assert_eq!(result_c.status, AgentStatus::Success);
1844 assert_eq!(result_c.decided_by_layer, Some(0));
1845 }
1846
1847 #[test]
1848 fn archive_moves_captures_into_history_and_removes_pid_file() {
1849 let dir = tempfile::tempdir().unwrap();
1852 let root = dir.path();
1853 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1854 std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
1855 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1856 std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
1857
1858 archive_phase_files(root, root, 1, 5).unwrap();
1859
1860 assert!(!root.join(".devflow/phase-01-stdout").exists());
1862 assert!(!root.join(".devflow/phase-01-exit").exists());
1863 assert!(!root.join(".devflow/phase-01-agent-pid").exists());
1865
1866 let history = history_dir(root, 1);
1867 let archived: Vec<_> = std::fs::read_dir(&history)
1868 .unwrap()
1869 .flatten()
1870 .map(|e| e.file_name().to_string_lossy().into_owned())
1871 .collect();
1872 let archived_stdout = archived
1873 .iter()
1874 .find(|name| name.ends_with("-stdout"))
1875 .expect("stdout capture should be archived into history");
1876 assert!(archived.iter().any(|name| name.ends_with("-exit")));
1877 let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
1878 assert_eq!(contents, "prior stdout");
1879 }
1880
1881 #[test]
1882 fn archive_is_noop_when_nothing_to_archive() {
1883 let dir = tempfile::tempdir().unwrap();
1884 let root = dir.path();
1885 archive_phase_files(root, root, 1, 5).unwrap();
1887 assert!(!history_dir(root, 1).exists());
1888 }
1889
1890 #[test]
1891 fn archive_handles_missing_devflow_dir() {
1892 let dir = tempfile::tempdir().unwrap();
1893 let root = dir.path();
1894 archive_phase_files(root, root, 1, 5).unwrap();
1896 }
1897
1898 #[test]
1899 fn archive_failure_preserves_live_capture_for_retry() {
1900 let dir = tempfile::tempdir().unwrap();
1901 let root = dir.path();
1902 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1903 std::fs::write(stdout_path(root, 1), "evidence").unwrap();
1904 std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
1907
1908 assert!(archive_phase_files(root, root, 1, 5).is_err());
1909 assert_eq!(
1910 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1911 "evidence"
1912 );
1913 }
1914
1915 #[test]
1916 fn archive_second_publish_failure_rolls_back_complete_live_pair() {
1917 let dir = tempfile::tempdir().unwrap();
1918 let root = dir.path();
1919 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1920 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
1921 std::fs::write(exit_code_path(root, 1), "17").unwrap();
1922 let history = history_dir(root, 1);
1923 std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
1924
1925 assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
1926
1927 assert_eq!(
1928 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1929 "stdout evidence"
1930 );
1931 assert_eq!(
1932 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
1933 "17"
1934 );
1935 assert!(!history.join("fixed-stdout").exists());
1936 assert!(!history.join(".pending-fixed").exists());
1937 }
1938
1939 #[test]
1940 fn archive_review_copy_failure_rolls_back_complete_live_pair() {
1941 let dir = tempfile::tempdir().unwrap();
1942 let root = dir.path();
1943 let evidence_root = root.join("phase-worktree");
1944 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1945 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
1946 std::fs::write(exit_code_path(root, 1), "23").unwrap();
1947 let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
1948 std::fs::create_dir_all(&review).unwrap();
1949
1950 assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
1951
1952 assert_eq!(
1953 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1954 "stdout evidence"
1955 );
1956 assert_eq!(
1957 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
1958 "23"
1959 );
1960 let history = history_dir(root, 1);
1961 assert!(!history.join("review-copy-stdout").exists());
1962 assert!(!history.join("review-copy-exit").exists());
1963 assert!(!history.join(".pending-review-copy").exists());
1964 }
1965
1966 #[test]
1967 fn archive_snapshots_current_review_into_same_generation() {
1968 let dir = tempfile::tempdir().unwrap();
1969 let root = dir.path();
1970 let evidence_root = root.join("phase-worktree");
1971 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1972 std::fs::write(stdout_path(root, 1), "attempt").unwrap();
1973 let phase_dir = evidence_root.join(".planning/phases/01-example");
1974 std::fs::create_dir_all(&phase_dir).unwrap();
1975 std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
1976
1977 let stamp = archive_phase_files(root, &evidence_root, 1, 5)
1978 .unwrap()
1979 .unwrap();
1980
1981 assert_eq!(
1982 std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
1983 .unwrap(),
1984 "review one"
1985 );
1986 }
1987
1988 #[test]
1989 fn archive_prunes_history_to_retain_count() {
1990 let dir = tempfile::tempdir().unwrap();
1991 let root = dir.path();
1992 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1993
1994 for i in 0..7 {
1995 std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
1996 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1997 archive_phase_files(root, root, 1, 3).unwrap();
1998 }
1999
2000 let history = history_dir(root, 1);
2001 let stdout_count = std::fs::read_dir(&history)
2002 .unwrap()
2003 .flatten()
2004 .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
2005 .count();
2006 let exit_count = std::fs::read_dir(&history)
2007 .unwrap()
2008 .flatten()
2009 .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
2010 .count();
2011 assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
2012 assert_eq!(exit_count, 3, "expected at most 3 retained generations");
2013 }
2014
2015 #[test]
2016 fn evaluate_agent_result_reads_files_end_to_end() {
2017 let dir = tempfile::tempdir().unwrap();
2018 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2019 std::fs::write(
2020 stdout_path(dir.path(), 6),
2021 "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
2022 )
2023 .unwrap();
2024 std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
2025 let state = state_in(dir.path(), 6);
2026
2027 let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2028
2029 assert_eq!(result.status, AgentStatus::Success);
2030 assert_eq!(result.commits, Some(2));
2031 assert_eq!(result.summary.as_deref(), Some("ok"));
2032 }
2033
2034 #[test]
2035 fn evaluate_layer1_finds_devflow_result_in_file() {
2036 let dir = tempfile::tempdir().unwrap();
2037 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2038 std::fs::write(
2039 stdout_path(dir.path(), 3),
2040 "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
2041 )
2042 .unwrap();
2043
2044 let result = evaluate_layer1(dir.path(), 3).unwrap();
2045
2046 assert_eq!(result.status, AgentStatus::Failed);
2047 assert_eq!(result.reason.as_deref(), Some("bad output"));
2048 }
2049
2050 #[test]
2051 fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
2052 let dir = tempfile::tempdir().unwrap();
2053 init_repo_with_feature_commit(dir.path(), 4);
2054 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2055 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2056 let state = state_in(dir.path(), 4);
2057
2058 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2059 .unwrap()
2060 .unwrap();
2061
2062 assert_eq!(result.status, AgentStatus::Success);
2063 assert_eq!(result.exit_code, Some(0));
2064 assert_eq!(result.commits, Some(1));
2065 assert!(result.reason.unwrap().contains("1 commits"));
2066 }
2067
2068 #[test]
2069 fn evaluate_layer2_exit_zero_no_commits_is_failed() {
2070 let dir = tempfile::tempdir().unwrap();
2073 init_repo_with_feature_no_commit(dir.path(), 4);
2074 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2075 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2076 let state = state_in(dir.path(), 4);
2077
2078 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2079 .unwrap()
2080 .unwrap();
2081
2082 assert_eq!(result.status, AgentStatus::Failed);
2083 assert_eq!(result.exit_code, Some(0));
2084 assert_eq!(result.commits, Some(0));
2085 assert!(result.reason.unwrap().contains("no commits"));
2086 }
2087
2088 #[test]
2089 fn evaluate_layer2_nonzero_exit_is_failed() {
2090 let dir = tempfile::tempdir().unwrap();
2092 init_repo_with_feature_commit(dir.path(), 4);
2093 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2094 std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
2095 let state = state_in(dir.path(), 4);
2096
2097 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2098 .unwrap()
2099 .unwrap();
2100
2101 assert_eq!(result.status, AgentStatus::Failed);
2102 assert_eq!(result.exit_code, Some(1));
2103 assert!(result.reason.unwrap().contains("exited with code 1"));
2104 }
2105
2106 #[test]
2107 fn layer2_nonzero_exit_is_failed_all_stages() {
2108 let dir = tempfile::tempdir().unwrap();
2112 init_repo_with_feature_no_commit(dir.path(), 10);
2113 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2114 std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
2115
2116 for stage in [
2117 Stage::Define,
2118 Stage::Plan,
2119 Stage::Code,
2120 Stage::Validate,
2121 Stage::Ship,
2122 ] {
2123 let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
2124 .unwrap()
2125 .unwrap();
2126 assert_eq!(
2127 result.status,
2128 AgentStatus::Failed,
2129 "stage {stage:?} should be Failed on nonzero exit"
2130 );
2131 }
2132 }
2133
2134 #[test]
2135 fn layer2_skips_commit_gate_for_define_and_validate() {
2136 let dir = tempfile::tempdir().unwrap();
2137 init_repo_with_feature_no_commit(dir.path(), 11);
2138 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2139 std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
2140
2141 for stage in [Stage::Define, Stage::Validate] {
2142 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
2143 .unwrap()
2144 .unwrap();
2145 assert_ne!(
2146 result.status,
2147 AgentStatus::Failed,
2148 "stage {stage:?} should not be Failed for zero commits"
2149 );
2150 }
2151
2152 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
2155 .unwrap()
2156 .unwrap();
2157 assert_eq!(result.status, AgentStatus::Failed);
2158 }
2159
2160 #[test]
2161 fn evaluate_layer3_falls_back_to_commit_count() {
2162 let dir = tempfile::tempdir().unwrap();
2163 init_repo_with_feature_commit(dir.path(), 5);
2164
2165 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2166
2167 assert_eq!(result.status, AgentStatus::Unknown);
2168 assert_eq!(result.exit_code, None);
2169 assert_eq!(result.commits, Some(1));
2170 assert!(result.reason.unwrap().contains("1 commits"));
2171 assert_eq!(result.decided_by_layer, Some(3));
2172 }
2173
2174 #[test]
2181 fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
2182 let dir = tempfile::tempdir().unwrap();
2183 init_repo_with_feature_no_commit(dir.path(), 5);
2184
2185 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2186
2187 assert_eq!(result.status, AgentStatus::Failed);
2188 assert_eq!(result.exit_code, None);
2189 assert_eq!(result.commits, Some(0));
2190 assert_eq!(result.decided_by_layer, Some(3));
2191 let reason = result.reason.unwrap();
2192 assert!(reason.contains("no work"), "reason was: {reason}");
2193 assert!(
2194 reason.to_ascii_lowercase().contains("human review"),
2195 "reason was: {reason}"
2196 );
2197 }
2198
2199 #[test]
2200 fn parse_devflow_result_reads_verdict() {
2201 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
2202 let result = parse_devflow_result(stdout).unwrap();
2203 assert_eq!(result.status, AgentStatus::Success);
2204 assert_eq!(result.verdict, Some(Verdict::Gaps));
2205 }
2206
2207 #[test]
2208 fn parse_devflow_result_reads_verdict_pass() {
2209 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
2210 let result = parse_devflow_result(stdout).unwrap();
2211 assert_eq!(result.status, AgentStatus::Success);
2212 assert_eq!(result.verdict, Some(Verdict::Pass));
2213 }
2214
2215 #[test]
2216 fn parse_devflow_result_verdict_absent_is_none() {
2217 let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
2218 let result = parse_devflow_result(stdout).unwrap();
2219 assert_eq!(result.status, AgentStatus::Success);
2220 assert_eq!(result.verdict, None);
2221 }
2222
2223 #[test]
2224 fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
2225 let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
2228 let result = parse_devflow_result(unknown).unwrap();
2229 assert_eq!(result.status, AgentStatus::Success);
2230 assert_eq!(result.verdict, None);
2231
2232 let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
2234 let result = parse_devflow_result(miscased).unwrap();
2235 assert_eq!(result.status, AgentStatus::Success);
2236 assert_eq!(result.verdict, None);
2237 }
2238
2239 #[test]
2246 fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
2247 let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
2248 let result = parse_devflow_result(bool_verdict).unwrap();
2249 assert_eq!(result.status, AgentStatus::Success);
2250 assert_eq!(result.verdict, None);
2251
2252 let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
2253 let result = parse_devflow_result(numeric_verdict).unwrap();
2254 assert_eq!(result.status, AgentStatus::Success);
2255 assert_eq!(result.verdict, None);
2256
2257 let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
2258 let result = parse_devflow_result(object_verdict).unwrap();
2259 assert_eq!(result.status, AgentStatus::Success);
2260 assert_eq!(result.verdict, None);
2261 }
2262
2263 #[test]
2267 fn multi_word_variants_serialize_with_word_boundary() {
2268 assert_eq!(
2269 serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
2270 "\"resource_killed\""
2271 );
2272 assert_eq!(
2273 serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
2274 "\"agent_unavailable\""
2275 );
2276 assert_eq!(
2277 serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
2278 AgentStatus::ResourceKilled
2279 );
2280 assert_eq!(
2281 serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
2282 AgentStatus::AgentUnavailable
2283 );
2284 }
2285
2286 #[test]
2289 fn existing_variants_keep_wire_form() {
2290 assert_eq!(
2291 serde_json::to_string(&AgentStatus::Success).unwrap(),
2292 "\"success\""
2293 );
2294 assert_eq!(
2295 serde_json::to_string(&AgentStatus::Failed).unwrap(),
2296 "\"failed\""
2297 );
2298 assert_eq!(
2299 serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
2300 "\"ratelimited\""
2301 );
2302 assert_eq!(
2303 serde_json::to_string(&AgentStatus::Unknown).unwrap(),
2304 "\"unknown\""
2305 );
2306 }
2307
2308 #[test]
2312 fn as_wire_str_matches_serde_form_for_every_variant() {
2313 for variant in [
2314 AgentStatus::Success,
2315 AgentStatus::Failed,
2316 AgentStatus::RateLimited,
2317 AgentStatus::Unknown,
2318 AgentStatus::ResourceKilled,
2319 AgentStatus::AgentUnavailable,
2320 ] {
2321 let serde_form = serde_json::to_string(&variant).unwrap();
2322 let stripped = serde_form.trim_matches('"');
2323 assert_eq!(
2324 variant.as_wire_str(),
2325 stripped,
2326 "as_wire_str() diverged from serde form for {variant:?}"
2327 );
2328 }
2329 }
2330
2331 #[test]
2332 fn evaluate_layer2_exit_137_is_resource_killed() {
2333 let dir = tempfile::tempdir().unwrap();
2334 init_repo_with_feature_commit(dir.path(), 20);
2335 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2336 std::fs::write(exit_code_path(dir.path(), 20), "137").unwrap();
2337 let state = state_in(dir.path(), 20);
2338
2339 let result = evaluate_layer2(dir.path(), 20, &GitFlowConfig::default(), state.stage)
2340 .unwrap()
2341 .unwrap();
2342
2343 assert_eq!(result.status, AgentStatus::ResourceKilled);
2344 assert_eq!(result.exit_code, Some(137));
2345 }
2346
2347 #[test]
2348 fn evaluate_layer2_exit_127_is_agent_unavailable() {
2349 let dir = tempfile::tempdir().unwrap();
2350 init_repo_with_feature_commit(dir.path(), 21);
2351 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2352 std::fs::write(exit_code_path(dir.path(), 21), "127").unwrap();
2353 let state = state_in(dir.path(), 21);
2354
2355 let result = evaluate_layer2(dir.path(), 21, &GitFlowConfig::default(), state.stage)
2356 .unwrap()
2357 .unwrap();
2358
2359 assert_eq!(result.status, AgentStatus::AgentUnavailable);
2360 assert_eq!(result.exit_code, Some(127));
2361 }
2362
2363 #[test]
2367 fn evaluate_layer2_exit_0_zero_commits_still_failed() {
2368 let dir = tempfile::tempdir().unwrap();
2369 init_repo_with_feature_no_commit(dir.path(), 22);
2370 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2371 std::fs::write(exit_code_path(dir.path(), 22), "0").unwrap();
2372 let state = state_in(dir.path(), 22);
2373
2374 let result = evaluate_layer2(dir.path(), 22, &GitFlowConfig::default(), state.stage)
2375 .unwrap()
2376 .unwrap();
2377
2378 assert_eq!(result.status, AgentStatus::Failed);
2379 }
2380
2381 #[test]
2384 fn evaluate_layer2_exit_1_still_failed() {
2385 let dir = tempfile::tempdir().unwrap();
2386 init_repo_with_feature_commit(dir.path(), 23);
2387 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2388 std::fs::write(exit_code_path(dir.path(), 23), "1").unwrap();
2389 let state = state_in(dir.path(), 23);
2390
2391 let result = evaluate_layer2(dir.path(), 23, &GitFlowConfig::default(), state.stage)
2392 .unwrap()
2393 .unwrap();
2394
2395 assert_eq!(result.status, AgentStatus::Failed);
2396 }
2397}