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
897#[derive(Debug, Clone, Copy, PartialEq, Eq)]
903pub enum SequentagentSlotKind {
904 A,
906 B,
908}
909
910impl SequentagentSlotKind {
911 pub fn as_str(&self) -> &'static str {
914 match self {
915 SequentagentSlotKind::A => "A",
916 SequentagentSlotKind::B => "B",
917 }
918 }
919}
920
921#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct SequentagentSlot {
931 pub slot: String,
932 pub agent: String,
933}
934
935pub fn sequentagent_slot_path(project_root: &Path, phase: u32) -> PathBuf {
942 devflow_dir(project_root).join(format!("phase-{:02}-sequentagent", phase))
943}
944
945pub fn write_sequentagent_slot(
955 project_root: &Path,
956 phase: u32,
957 slot: SequentagentSlotKind,
958 agent: crate::state::AgentKind,
959) -> std::io::Result<()> {
960 crate::workflow::ensure_devflow_dir(&devflow_dir(project_root))?;
961 let content = format!("{}\n{}\n", slot.as_str(), agent);
962 std::fs::write(sequentagent_slot_path(project_root, phase), content)
963}
964
965pub fn read_sequentagent_slot(project_root: &Path, phase: u32) -> Option<SequentagentSlot> {
972 let content = std::fs::read_to_string(sequentagent_slot_path(project_root, phase)).ok()?;
973 let mut lines = content.lines();
974 let slot = lines.next()?.trim().to_string();
975 let agent = lines.next()?.trim().to_string();
976 if slot.is_empty() || agent.is_empty() {
977 return None;
978 }
979 Some(SequentagentSlot { slot, agent })
980}
981
982pub fn clear_sequentagent_slot(project_root: &Path, phase: u32) {
987 let _ = std::fs::remove_file(sequentagent_slot_path(project_root, phase));
988}
989
990pub fn history_dir(project_root: &Path, phase: u32) -> PathBuf {
998 devflow_dir(project_root)
999 .join("history")
1000 .join(format!("phase-{:02}", phase))
1001}
1002
1003static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1007
1008fn archive_stamp() -> String {
1013 let nanos = std::time::SystemTime::now()
1014 .duration_since(std::time::UNIX_EPOCH)
1015 .map(|d| d.as_nanos())
1016 .unwrap_or(0);
1017 let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1018 format!("{nanos}-{seq}")
1019}
1020
1021pub fn archive_phase_files(
1031 project_root: &Path,
1032 evidence_root: &Path,
1033 phase: u32,
1034 retain: usize,
1035) -> Result<Option<String>, std::io::Error> {
1036 archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
1037}
1038
1039fn archive_phase_files_with_stamp(
1040 project_root: &Path,
1041 evidence_root: &Path,
1042 phase: u32,
1043 retain: usize,
1044 stamp: &str,
1045) -> Result<Option<String>, std::io::Error> {
1046 let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
1047
1048 let stdout_src = stdout_path(project_root, phase);
1049 let exit_src = exit_code_path(project_root, phase);
1050 let stdout_exists = stdout_src.exists();
1051 let exit_exists = exit_src.exists();
1052 if !stdout_exists && !exit_exists {
1053 return Ok(None); }
1055
1056 let history_dir = history_dir(project_root, phase);
1057 crate::workflow::ensure_devflow_dir(&history_dir)?;
1058
1059 let staging_dir = history_dir.join(format!(".pending-{stamp}"));
1060 std::fs::create_dir(&staging_dir)?;
1061 let stdout_stage = staging_dir.join("stdout");
1062 let exit_stage = staging_dir.join("exit");
1063 let review_stage = staging_dir.join("REVIEW.md");
1064 let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
1065 let exit_dest = history_dir.join(format!("{stamp}-exit"));
1066 let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
1067 let review_src = phase_review_path(evidence_root, phase);
1068
1069 let mut stdout_staged = false;
1070 let mut exit_staged = false;
1071 let mut stdout_published = false;
1072 let mut exit_published = false;
1073 let mut review_published = false;
1074
1075 let archive_result = (|| -> Result<(), std::io::Error> {
1076 if stdout_exists {
1077 std::fs::rename(&stdout_src, &stdout_stage)?;
1078 stdout_staged = true;
1079 }
1080 if exit_exists {
1081 std::fs::rename(&exit_src, &exit_stage)?;
1082 exit_staged = true;
1083 }
1084 if let Some(review) = &review_src {
1085 std::fs::copy(review, &review_stage)?;
1086 }
1087
1088 if stdout_exists {
1089 std::fs::rename(&stdout_stage, &stdout_dest)?;
1090 stdout_staged = false;
1091 stdout_published = true;
1092 }
1093 if exit_exists {
1094 std::fs::rename(&exit_stage, &exit_dest)?;
1095 exit_staged = false;
1096 exit_published = true;
1097 }
1098 if review_src.is_some() {
1099 std::fs::rename(&review_stage, &review_dest)?;
1100 review_published = true;
1101 }
1102 Ok(())
1103 })();
1104
1105 if let Err(error) = archive_result {
1106 let mut rollback_error = None;
1107 let mut restore = |from: &Path, to: &Path| {
1108 if let Err(error) = std::fs::rename(from, to)
1109 && rollback_error.is_none()
1110 {
1111 rollback_error = Some(error);
1112 }
1113 };
1114 if stdout_published {
1115 restore(&stdout_dest, &stdout_src);
1116 } else if stdout_staged {
1117 restore(&stdout_stage, &stdout_src);
1118 }
1119 if exit_published {
1120 restore(&exit_dest, &exit_src);
1121 } else if exit_staged {
1122 restore(&exit_stage, &exit_src);
1123 }
1124 if review_published {
1125 let _ = std::fs::remove_file(&review_dest);
1126 }
1127 let _ = std::fs::remove_dir_all(&staging_dir);
1128
1129 if let Some(rollback_error) = rollback_error {
1130 return Err(std::io::Error::new(
1131 error.kind(),
1132 format!("{error}; archive rollback failed: {rollback_error}"),
1133 ));
1134 }
1135 return Err(error);
1136 }
1137
1138 let _ = std::fs::remove_dir(&staging_dir);
1139
1140 prune_history(&history_dir, retain);
1141 Ok(Some(stamp.to_string()))
1142}
1143
1144fn phase_review_path(project_root: &Path, phase: u32) -> Option<PathBuf> {
1145 let phases = std::fs::read_dir(project_root.join(".planning/phases")).ok()?;
1146 let prefix = format!("{phase:02}-");
1147 for entry in phases.flatten() {
1148 if entry
1149 .file_name()
1150 .to_str()
1151 .is_some_and(|name| name.starts_with(&prefix))
1152 {
1153 let review = entry.path().join(format!("{phase:02}-REVIEW.md"));
1154 if review.exists() {
1155 return Some(review);
1156 }
1157 }
1158 }
1159 None
1160}
1161
1162fn prune_history(history_dir: &Path, retain: usize) {
1170 let Ok(entries) = std::fs::read_dir(history_dir) else {
1171 return;
1172 };
1173
1174 let mut stamps: Vec<String> = entries
1175 .flatten()
1176 .filter_map(|entry| {
1177 let name = entry.file_name().to_str()?.to_string();
1178 name.rsplit_once('-')
1179 .map(|(stamp, _suffix)| stamp.to_string())
1180 })
1181 .collect();
1182 stamps.sort_by_key(|stamp| {
1183 let mut parts = stamp.split('-');
1184 let nanos = parts
1185 .next()
1186 .and_then(|part| part.parse::<u128>().ok())
1187 .unwrap_or(0);
1188 let sequence = parts
1189 .next()
1190 .and_then(|part| part.parse::<u64>().ok())
1191 .unwrap_or(0);
1192 (nanos, sequence)
1193 });
1194 stamps.dedup();
1195
1196 if stamps.len() <= retain {
1197 return;
1198 }
1199
1200 let to_remove = stamps.len() - retain;
1201 for stamp in &stamps[..to_remove] {
1202 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
1203 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
1204 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
1205 }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::*;
1211 use crate::config::GitFlowConfig;
1212 use crate::mode::Mode;
1213 use crate::stage::Stage;
1214 use crate::state::{AgentKind, State};
1215 use std::process::Command;
1216
1217 fn state_in(root: &Path, phase: u32) -> State {
1218 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
1219 state.stage = Stage::Code;
1220 state
1221 }
1222
1223 fn git(root: &Path, args: &[&str]) {
1224 let output = Command::new("git")
1225 .args(args)
1226 .current_dir(root)
1227 .output()
1228 .unwrap();
1229 assert!(
1230 output.status.success(),
1231 "git {:?} failed\nstdout: {}\nstderr: {}",
1232 args,
1233 String::from_utf8_lossy(&output.stdout),
1234 String::from_utf8_lossy(&output.stderr)
1235 );
1236 }
1237
1238 fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1239 git(root, &["init"]);
1240 git(root, &["config", "user.email", "devflow@example.com"]);
1241 git(root, &["config", "user.name", "DevFlow Tests"]);
1242 git(root, &["config", "commit.gpgsign", "false"]);
1243 git(root, &["config", "tag.gpgsign", "false"]);
1244 git(root, &["config", "core.hooksPath", "/dev/null"]);
1245 git(root, &["checkout", "-b", "develop"]);
1246 std::fs::write(root.join("README.md"), "base\n").unwrap();
1247 git(root, &["add", "README.md"]);
1248 git(root, &["commit", "-m", "base"]);
1249
1250 let branch = format!("feature/phase-{phase:02}");
1251 git(root, &["checkout", "-b", &branch]);
1252 std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1253 git(root, &["add", "phase.txt"]);
1254 git(root, &["commit", "-m", "feature work"]);
1255 }
1256
1257 fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1260 git(root, &["init"]);
1261 git(root, &["config", "user.email", "devflow@example.com"]);
1262 git(root, &["config", "user.name", "DevFlow Tests"]);
1263 git(root, &["config", "commit.gpgsign", "false"]);
1264 git(root, &["config", "tag.gpgsign", "false"]);
1265 git(root, &["config", "core.hooksPath", "/dev/null"]);
1266 git(root, &["checkout", "-b", "develop"]);
1267 std::fs::write(root.join("README.md"), "base\n").unwrap();
1268 git(root, &["add", "README.md"]);
1269 git(root, &["commit", "-m", "base"]);
1270
1271 let branch = format!("feature/phase-{phase:02}");
1272 git(root, &["checkout", "-b", &branch]);
1273 }
1274
1275 #[test]
1276 fn parse_success_marker() {
1277 let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1278 let result = parse_devflow_result(stdout).unwrap();
1279 assert_eq!(result.status, AgentStatus::Success);
1280 }
1281
1282 #[test]
1283 fn parse_failed_marker_with_reason() {
1284 let stdout =
1285 "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1286 let result = parse_devflow_result(stdout).unwrap();
1287 assert_eq!(result.status, AgentStatus::Failed);
1288 assert_eq!(result.reason.unwrap(), "clippy errors");
1289 }
1290
1291 #[test]
1292 fn parse_missing_marker_returns_none() {
1293 let stdout = "just some output\nno marker here\n";
1294 assert!(parse_devflow_result(stdout).is_none());
1295 }
1296
1297 #[test]
1298 fn parse_malformed_json_returns_none() {
1299 let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1300 assert!(parse_devflow_result(stdout).is_none());
1301 }
1302
1303 #[test]
1304 fn parse_lowercase_marker() {
1305 let stdout = "devflow_result: {\"status\":\"success\"}\n";
1306 let result = parse_devflow_result(stdout).unwrap();
1307 assert_eq!(result.status, AgentStatus::Success);
1308 }
1309
1310 #[test]
1311 fn parse_marker_without_space_after_colon() {
1312 let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1313 let result = parse_devflow_result(stdout).unwrap();
1314 assert_eq!(result.status, AgentStatus::Success);
1315 }
1316
1317 #[test]
1318 fn parse_lowercase_no_space_marker() {
1319 let stdout = "devflow_result:{\"status\":\"success\"}\n";
1322 let result = parse_devflow_result(stdout).unwrap();
1323 assert_eq!(result.status, AgentStatus::Success);
1324 }
1325
1326 #[test]
1327 fn parse_finds_last_marker_in_tail() {
1328 let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1330 let result = parse_devflow_result(stdout).unwrap();
1331 assert_eq!(result.status, AgentStatus::Success);
1332 }
1333
1334 #[test]
1335 fn parse_marker_lines_returns_last_marker_in_long_output() {
1336 let stdout = format!(
1337 "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1338 DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1339 "prefix".repeat(900),
1340 "tail output".repeat(100)
1341 );
1342
1343 let result = parse_marker_lines(&stdout).unwrap();
1344
1345 assert_eq!(result.status, AgentStatus::Success);
1346 }
1347
1348 #[test]
1349 fn parse_marker_only_in_last_4000_chars() {
1350 let prefix = "a".repeat(5000);
1352 let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1353 assert!(parse_devflow_result(&stdout).is_none());
1354 }
1355
1356 #[test]
1357 fn parse_marker_with_commits_and_summary() {
1358 let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1359 let result = parse_devflow_result(stdout).unwrap();
1360 assert_eq!(result.status, AgentStatus::Success);
1361 assert_eq!(result.commits, Some(3));
1362 assert_eq!(result.summary.unwrap(), "added tests");
1363 }
1364
1365 #[test]
1366 fn parse_marker_inside_json_result_envelope() {
1367 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1370 let result = parse_devflow_result(stdout).unwrap();
1371 assert_eq!(result.status, AgentStatus::Success);
1372 assert_eq!(result.commits, Some(2));
1373 }
1374
1375 #[test]
1376 fn parse_failed_marker_inside_json_envelope() {
1377 let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1378 let result = parse_devflow_result(stdout).unwrap();
1379 assert_eq!(result.status, AgentStatus::Failed);
1380 assert_eq!(result.reason.unwrap(), "tests failed");
1381 }
1382
1383 #[test]
1384 fn parse_json_envelope_without_marker_returns_none() {
1385 let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1386 assert!(parse_devflow_result(stdout).is_none());
1387 }
1388
1389 #[test]
1390 fn detect_claude_json_rate_limit_by_subtype() {
1391 let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1392 assert_eq!(
1393 detect_rate_limit(stdout).as_deref(),
1394 Some("2026-06-18T15:45:30Z")
1395 );
1396 }
1397
1398 #[test]
1399 fn detect_claude_json_rate_limit_by_429() {
1400 let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1401 assert_eq!(
1402 detect_rate_limit(stdout).as_deref(),
1403 Some("Too many requests. Try later.")
1404 );
1405 }
1406
1407 #[test]
1408 fn detect_codex_try_again_rate_limit() {
1409 let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1410 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1411 }
1412
1413 #[test]
1422 fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1423 const DEPTH: usize = 100;
1426 let mut stdout = String::new();
1427 for _ in 0..DEPTH {
1428 stdout.push_str(r#"{"nested":"#);
1429 }
1430 stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1431 for _ in 0..DEPTH {
1432 stdout.push('}');
1433 }
1434
1435 assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1438 }
1439
1440 #[test]
1441 fn detect_rate_limit_ignores_normal_stdout() {
1442 let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1443 assert!(detect_rate_limit(stdout).is_none());
1444 }
1445
1446 #[test]
1447 fn claude_envelope_is_error_detected() {
1448 let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1449 let result = detect_claude_envelope_failure(stdout).unwrap();
1450 assert_eq!(result.status, AgentStatus::Failed);
1451 }
1452
1453 #[test]
1454 fn claude_is_error_overrides_success_marker() {
1455 let dir = tempfile::tempdir().unwrap();
1456 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1457 std::fs::write(
1458 stdout_path(dir.path(), 9),
1459 r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1460 )
1461 .unwrap();
1462
1463 let result = evaluate_layer1(dir.path(), 9).unwrap();
1464
1465 assert_eq!(result.status, AgentStatus::Failed);
1466 }
1467
1468 #[test]
1469 fn claude_envelope_is_error_false_defers() {
1470 let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1471 assert!(detect_claude_envelope_failure(stdout).is_none());
1472 }
1473
1474 #[test]
1475 fn claude_envelope_marker_still_wins() {
1476 let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1477 assert!(detect_claude_envelope_failure(stdout).is_none());
1478 let result = parse_devflow_result(stdout).unwrap();
1479 assert_eq!(result.status, AgentStatus::Success);
1480 assert_eq!(result.commits, Some(2));
1481 }
1482
1483 #[test]
1484 fn codex_event_stream_parses_turn_failed() {
1485 let stdout = concat!(
1486 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1487 "{\"type\":\"turn.started\"}\n",
1488 "{\"type\":\"item.started\",\"item\":{}}\n",
1489 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1490 );
1491 let result = parse_codex_event_result(stdout).unwrap();
1492 assert_eq!(result.status, AgentStatus::Failed);
1493 assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1494 }
1495
1496 #[test]
1497 fn codex_turn_completed_no_marker_defers() {
1498 let stdout = concat!(
1499 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1500 "{\"type\":\"turn.started\"}\n",
1501 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1502 );
1503 assert!(parse_codex_event_result(stdout).is_none());
1504 }
1505
1506 #[test]
1512 fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1513 let stdout = concat!(
1514 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1515 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1516 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1517 );
1518 let result = parse_codex_event_result(stdout).unwrap();
1519 assert_eq!(result.status, AgentStatus::Failed);
1520 assert_eq!(
1521 result.reason.as_deref(),
1522 Some("interactive input unavailable")
1523 );
1524 }
1525
1526 #[test]
1527 fn codex_agent_message_marker_success_short_circuits() {
1528 let stdout = concat!(
1529 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1530 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1531 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1532 );
1533 let result = parse_codex_event_result(stdout).unwrap();
1534 assert_eq!(result.status, AgentStatus::Success);
1535 }
1536
1537 #[test]
1543 fn detect_rate_limit_ignores_json_event_lines() {
1544 let stdout = concat!(
1545 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1546 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1547 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1548 );
1549 assert_eq!(detect_rate_limit(stdout), None);
1550 }
1551
1552 #[test]
1553 fn detect_rate_limit_still_reads_codex_plain_text() {
1554 let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1555 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1556 }
1557
1558 #[test]
1559 fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1560 let stdout = concat!(
1561 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1562 "not json at all\n",
1563 "{\"type\":\"item.started\",\"item\":{}}\n",
1564 "{\"type\":\"item.updated\",\"item\":{}}\n",
1565 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1566 );
1567 let result = parse_codex_event_result(stdout).unwrap();
1568 assert_eq!(result.status, AgentStatus::Failed);
1569 assert_eq!(result.reason.as_deref(), Some("boom"));
1570 }
1571
1572 #[test]
1573 fn claude_envelope_not_consumed_by_codex_parser() {
1574 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1575 assert!(parse_codex_event_result(stdout).is_none());
1576 }
1577
1578 #[test]
1579 fn evaluate_layer1_reports_rate_limited_without_marker() {
1580 let dir = tempfile::tempdir().unwrap();
1581 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1582 std::fs::write(
1583 stdout_path(dir.path(), 7),
1584 r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1585 )
1586 .unwrap();
1587
1588 let result = evaluate_layer1(dir.path(), 7).unwrap();
1589
1590 assert_eq!(result.status, AgentStatus::RateLimited);
1591 assert_eq!(
1592 result.reason.as_deref(),
1593 Some("rate limited until 2026-06-18T15:45:30Z")
1594 );
1595 }
1596
1597 #[test]
1602 fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1603 let dir = tempfile::tempdir().unwrap();
1604 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1605 std::fs::write(
1606 stdout_path(dir.path(), 7),
1607 r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1608 )
1609 .unwrap();
1610
1611 let result = evaluate_layer1(dir.path(), 7).unwrap();
1612
1613 assert_eq!(result.status, AgentStatus::RateLimited);
1614 assert_eq!(
1615 result.reason.as_deref(),
1616 Some("rate limited until 2026-06-18T15:45:30Z")
1617 );
1618 }
1619
1620 #[test]
1626 fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1627 let dir = tempfile::tempdir().unwrap();
1628 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1629 let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1630 bytes.extend_from_slice(
1631 b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1632 );
1633 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1634
1635 let result = evaluate_layer1(dir.path(), 5).unwrap();
1636
1637 assert_eq!(result.status, AgentStatus::Failed);
1638 assert_eq!(result.reason.as_deref(), Some("review: bad"));
1639 }
1640
1641 #[test]
1642 fn failing_external_probe_outranks_success_marker() {
1643 let dir = tempfile::tempdir().unwrap();
1644 let phase_dir = dir
1645 .path()
1646 .join(".planning/phases/16-pipeline-reliability-hardening");
1647 std::fs::create_dir_all(&phase_dir).unwrap();
1648 std::fs::write(
1649 phase_dir.join("16-03-PLAN.md"),
1650 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1651 )
1652 .unwrap();
1653 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1654 std::fs::write(
1655 stdout_path(dir.path(), 16),
1656 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1657 )
1658 .unwrap();
1659 let state = state_in(dir.path(), 16);
1660
1661 let approval = vec!["test -f externally-shipped".to_string()];
1662 let result = evaluate_agent_result_inner(
1663 dir.path(),
1664 &state,
1665 &GitFlowConfig::default(),
1666 Some(&approval),
1667 )
1668 .unwrap();
1669
1670 assert_eq!(result.status, AgentStatus::Failed);
1671 assert!(
1672 result
1673 .reason
1674 .as_deref()
1675 .is_some_and(|reason| reason.contains("external verification failed"))
1676 );
1677 }
1678
1679 #[test]
1687 fn external_probe_discovers_from_project_root_across_every_stage_and_executes_in_worktree() {
1688 let dir = tempfile::tempdir().unwrap();
1689 let worktree = dir.path().join("phase-worktree");
1690 std::fs::create_dir_all(&worktree).unwrap();
1691 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1692 std::fs::create_dir_all(&phase_dir).unwrap();
1693 std::fs::write(
1694 phase_dir.join("16-01-PLAN.md"),
1695 "---\nexternal_verify: \"test -f implemented\"\n---\n",
1696 )
1697 .unwrap();
1698 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1699 std::fs::write(
1700 stdout_path(dir.path(), 16),
1701 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1702 )
1703 .unwrap();
1704 let mut state = state_in(dir.path(), 16);
1705 state.worktree_path = Some(worktree.clone());
1706 state.stage = Stage::Plan;
1707
1708 let approval = vec!["test -f implemented".to_string()];
1709
1710 let plan_result = evaluate_agent_result_inner(
1715 dir.path(),
1716 &state,
1717 &GitFlowConfig::default(),
1718 Some(&approval),
1719 )
1720 .unwrap();
1721 assert_eq!(plan_result.status, AgentStatus::Failed);
1722 assert!(
1723 plan_result
1724 .reason
1725 .as_deref()
1726 .is_some_and(|reason| reason.contains("external verification failed")),
1727 "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
1728 plan_result.reason
1729 );
1730
1731 state.stage = Stage::Code;
1732 let code_result = evaluate_agent_result_inner(
1733 dir.path(),
1734 &state,
1735 &GitFlowConfig::default(),
1736 Some(&approval),
1737 )
1738 .unwrap();
1739 assert_eq!(code_result.status, AgentStatus::Failed);
1740
1741 std::fs::write(worktree.join("implemented"), "done").unwrap();
1744 let passing = evaluate_agent_result_inner(
1745 dir.path(),
1746 &state,
1747 &GitFlowConfig::default(),
1748 Some(&approval),
1749 )
1750 .unwrap();
1751 assert_eq!(passing.status, AgentStatus::Success);
1752 assert_eq!(passing.decided_by_layer, Some(0));
1753 }
1754
1755 #[test]
1756 fn changed_external_probe_never_inherits_prior_approval() {
1757 let dir = tempfile::tempdir().unwrap();
1758 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1759 std::fs::create_dir_all(&phase_dir).unwrap();
1760 std::fs::write(
1761 phase_dir.join("16-01-PLAN.md"),
1762 "---\nexternal_verify: \"touch escaped\"\n---\n",
1763 )
1764 .unwrap();
1765 let state = state_in(dir.path(), 16);
1766 let approved = vec!["test -f reviewed-artifact".to_string()];
1767
1768 let result = evaluate_agent_result_inner(
1769 dir.path(),
1770 &state,
1771 &GitFlowConfig::default(),
1772 Some(&approved),
1773 )
1774 .unwrap();
1775
1776 assert_eq!(result.status, AgentStatus::Failed);
1777 assert!(result.reason.unwrap().contains("approval mismatch"));
1778 assert!(!dir.path().join("escaped").exists());
1779 }
1780
1781 #[test]
1782 fn removed_external_probe_fails_closed_against_prior_approval() {
1783 let dir = tempfile::tempdir().unwrap();
1784 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1785 std::fs::write(
1786 stdout_path(dir.path(), 16),
1787 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1788 )
1789 .unwrap();
1790 let state = state_in(dir.path(), 16);
1791 let approved = vec!["test -f shipped".to_string()];
1792
1793 let result = evaluate_agent_result_inner(
1794 dir.path(),
1795 &state,
1796 &GitFlowConfig::default(),
1797 Some(&approved),
1798 )
1799 .unwrap();
1800
1801 assert_eq!(result.status, AgentStatus::Failed);
1802 assert!(result.reason.unwrap().contains("declaration was removed"));
1803 }
1804
1805 #[test]
1806 fn no_external_declaration_preserves_layer1_result() {
1807 let dir = tempfile::tempdir().unwrap();
1808 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1809 std::fs::write(
1810 stdout_path(dir.path(), 16),
1811 "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
1812 )
1813 .unwrap();
1814 let state = state_in(dir.path(), 16);
1815 let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
1816
1817 let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1818
1819 assert_eq!(
1820 serde_json::to_value(full).unwrap(),
1821 serde_json::to_value(layer1).unwrap()
1822 );
1823 }
1824
1825 #[test]
1832 fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
1833 let dir = tempfile::tempdir().unwrap();
1834 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1835 std::fs::create_dir_all(&phase_dir).unwrap();
1836 std::fs::write(
1837 phase_dir.join("16-01-PLAN.md"),
1838 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1839 )
1840 .unwrap();
1841 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1842 let mut state = state_in(dir.path(), 16);
1843 state.stage = Stage::Define;
1844
1845 let approval = vec!["test -f shipped".to_string()];
1846 let result = evaluate_agent_result_inner(
1847 dir.path(),
1848 &state,
1849 &GitFlowConfig::default(),
1850 Some(&approval),
1851 )
1852 .unwrap();
1853
1854 assert_eq!(result.status, AgentStatus::Success);
1855 assert_eq!(result.decided_by_layer, Some(0));
1856 assert_eq!(result.commits, None);
1857 assert_eq!(result.verdict, None);
1859 }
1860
1861 #[test]
1866 fn layer0_affirmative_success_outranks_layer1_failure_marker() {
1867 let dir = tempfile::tempdir().unwrap();
1868 let phase_dir = dir
1869 .path()
1870 .join(".planning/phases/16-pipeline-reliability-hardening");
1871 std::fs::create_dir_all(&phase_dir).unwrap();
1872 std::fs::write(
1873 phase_dir.join("16-03-PLAN.md"),
1874 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1875 )
1876 .unwrap();
1877 std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
1878 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1879 std::fs::write(
1880 stdout_path(dir.path(), 16),
1881 "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
1882 )
1883 .unwrap();
1884 let state = state_in(dir.path(), 16);
1885
1886 let approval = vec!["test -f externally-shipped".to_string()];
1887 let result = evaluate_agent_result_inner(
1888 dir.path(),
1889 &state,
1890 &GitFlowConfig::default(),
1891 Some(&approval),
1892 )
1893 .unwrap();
1894
1895 assert_eq!(result.status, AgentStatus::Success);
1896 assert_eq!(result.decided_by_layer, Some(0));
1897 assert_eq!(result.verdict, None);
1900 }
1901
1902 #[test]
1907 fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
1908 let dir = tempfile::tempdir().unwrap();
1909 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1910 std::fs::create_dir_all(&phase_dir).unwrap();
1911 std::fs::write(
1912 phase_dir.join("16-01-PLAN.md"),
1913 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1914 )
1915 .unwrap();
1916 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1917 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1918 let mut state = state_in(dir.path(), 16);
1919 state.stage = Stage::Validate;
1920 let approval = vec!["test -f shipped".to_string()];
1921
1922 std::fs::write(
1923 stdout_path(dir.path(), 16),
1924 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1925 )
1926 .unwrap();
1927 let result = evaluate_agent_result_inner(
1928 dir.path(),
1929 &state,
1930 &GitFlowConfig::default(),
1931 Some(&approval),
1932 )
1933 .unwrap();
1934 assert_eq!(result.status, AgentStatus::Success);
1935 assert_eq!(result.decided_by_layer, Some(0));
1936 assert_eq!(result.verdict, Some(Verdict::Pass));
1937
1938 std::fs::write(
1939 stdout_path(dir.path(), 16),
1940 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
1941 )
1942 .unwrap();
1943 let result = evaluate_agent_result_inner(
1944 dir.path(),
1945 &state,
1946 &GitFlowConfig::default(),
1947 Some(&approval),
1948 )
1949 .unwrap();
1950 assert_eq!(result.verdict, Some(Verdict::Gaps));
1951
1952 std::fs::remove_file(stdout_path(dir.path(), 16)).unwrap();
1953 let result = evaluate_agent_result_inner(
1954 dir.path(),
1955 &state,
1956 &GitFlowConfig::default(),
1957 Some(&approval),
1958 )
1959 .unwrap();
1960 assert_eq!(result.verdict, None);
1961 }
1962
1963 #[test]
1968 fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
1969 let dir = tempfile::tempdir().unwrap();
1970 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1971 std::fs::create_dir_all(&phase_dir).unwrap();
1972 std::fs::write(
1973 phase_dir.join("16-01-PLAN.md"),
1974 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1975 )
1976 .unwrap();
1977 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1978 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1979 std::fs::write(
1980 stdout_path(dir.path(), 16),
1981 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1982 )
1983 .unwrap();
1984 let state = state_in(dir.path(), 16); let approval = vec!["test -f shipped".to_string()];
1986
1987 let result = evaluate_agent_result_inner(
1988 dir.path(),
1989 &state,
1990 &GitFlowConfig::default(),
1991 Some(&approval),
1992 )
1993 .unwrap();
1994
1995 assert_eq!(result.status, AgentStatus::Success);
1996 assert_eq!(result.decided_by_layer, Some(0));
1997 assert_eq!(result.verdict, None);
1998 }
1999
2000 #[test]
2004 fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
2005 let dir = tempfile::tempdir().unwrap();
2006 let phase_dir = dir.path().join(".planning/phases/16-reliability");
2007 std::fs::create_dir_all(&phase_dir).unwrap();
2008 std::fs::write(
2010 phase_dir.join("16-01-PLAN.md"),
2011 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
2012 )
2013 .unwrap();
2014 std::fs::write(
2015 phase_dir.join("16-02-PLAN.md"),
2016 "---\nexternal_verify: \"test -f never-created\"\n---\n",
2017 )
2018 .unwrap();
2019 std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
2020 let mut state = state_in(dir.path(), 16);
2021 state.stage = Stage::Define;
2022
2023 let approval = vec![
2024 "test -f passing-artifact".to_string(),
2025 "test -f never-created".to_string(),
2026 ];
2027 let result_a = evaluate_agent_result_inner(
2028 dir.path(),
2029 &state,
2030 &GitFlowConfig::default(),
2031 Some(&approval),
2032 )
2033 .unwrap();
2034 assert_eq!(result_a.status, AgentStatus::Failed);
2035 assert!(
2036 result_a
2037 .reason
2038 .as_deref()
2039 .is_some_and(|reason| reason.contains("never-created")),
2040 "unexpected reason: {:?}",
2041 result_a.reason
2042 );
2043
2044 std::fs::write(
2048 phase_dir.join("16-01-PLAN.md"),
2049 "---\nexternal_verify: \"test -f still-missing\"\n---\n",
2050 )
2051 .unwrap();
2052 std::fs::write(
2053 phase_dir.join("16-02-PLAN.md"),
2054 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
2055 )
2056 .unwrap();
2057 let approval_swapped = vec![
2058 "test -f still-missing".to_string(),
2059 "test -f passing-artifact".to_string(),
2060 ];
2061 let result_b = evaluate_agent_result_inner(
2062 dir.path(),
2063 &state,
2064 &GitFlowConfig::default(),
2065 Some(&approval_swapped),
2066 )
2067 .unwrap();
2068 assert_eq!(result_b.status, AgentStatus::Failed);
2069
2070 std::fs::write(dir.path().join("still-missing"), "done").unwrap();
2072 let result_c = evaluate_agent_result_inner(
2073 dir.path(),
2074 &state,
2075 &GitFlowConfig::default(),
2076 Some(&approval_swapped),
2077 )
2078 .unwrap();
2079 assert_eq!(result_c.status, AgentStatus::Success);
2080 assert_eq!(result_c.decided_by_layer, Some(0));
2081 }
2082
2083 #[test]
2084 fn archive_moves_captures_into_history_and_removes_pid_file() {
2085 let dir = tempfile::tempdir().unwrap();
2088 let root = dir.path();
2089 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2090 std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
2091 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2092 std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
2093
2094 archive_phase_files(root, root, 1, 5).unwrap();
2095
2096 assert!(!root.join(".devflow/phase-01-stdout").exists());
2098 assert!(!root.join(".devflow/phase-01-exit").exists());
2099 assert!(!root.join(".devflow/phase-01-agent-pid").exists());
2101
2102 let history = history_dir(root, 1);
2103 let archived: Vec<_> = std::fs::read_dir(&history)
2104 .unwrap()
2105 .flatten()
2106 .map(|e| e.file_name().to_string_lossy().into_owned())
2107 .collect();
2108 let archived_stdout = archived
2109 .iter()
2110 .find(|name| name.ends_with("-stdout"))
2111 .expect("stdout capture should be archived into history");
2112 assert!(archived.iter().any(|name| name.ends_with("-exit")));
2113 let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
2114 assert_eq!(contents, "prior stdout");
2115 }
2116
2117 #[test]
2118 fn archive_is_noop_when_nothing_to_archive() {
2119 let dir = tempfile::tempdir().unwrap();
2120 let root = dir.path();
2121 archive_phase_files(root, root, 1, 5).unwrap();
2123 assert!(!history_dir(root, 1).exists());
2124 }
2125
2126 #[test]
2127 fn archive_handles_missing_devflow_dir() {
2128 let dir = tempfile::tempdir().unwrap();
2129 let root = dir.path();
2130 archive_phase_files(root, root, 1, 5).unwrap();
2132 }
2133
2134 #[test]
2135 fn archive_failure_preserves_live_capture_for_retry() {
2136 let dir = tempfile::tempdir().unwrap();
2137 let root = dir.path();
2138 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2139 std::fs::write(stdout_path(root, 1), "evidence").unwrap();
2140 std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
2143
2144 assert!(archive_phase_files(root, root, 1, 5).is_err());
2145 assert_eq!(
2146 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2147 "evidence"
2148 );
2149 }
2150
2151 #[test]
2152 fn archive_second_publish_failure_rolls_back_complete_live_pair() {
2153 let dir = tempfile::tempdir().unwrap();
2154 let root = dir.path();
2155 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2156 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2157 std::fs::write(exit_code_path(root, 1), "17").unwrap();
2158 let history = history_dir(root, 1);
2159 std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
2160
2161 assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
2162
2163 assert_eq!(
2164 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2165 "stdout evidence"
2166 );
2167 assert_eq!(
2168 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2169 "17"
2170 );
2171 assert!(!history.join("fixed-stdout").exists());
2172 assert!(!history.join(".pending-fixed").exists());
2173 }
2174
2175 #[test]
2176 fn archive_review_copy_failure_rolls_back_complete_live_pair() {
2177 let dir = tempfile::tempdir().unwrap();
2178 let root = dir.path();
2179 let evidence_root = root.join("phase-worktree");
2180 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2181 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2182 std::fs::write(exit_code_path(root, 1), "23").unwrap();
2183 let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
2184 std::fs::create_dir_all(&review).unwrap();
2185
2186 assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
2187
2188 assert_eq!(
2189 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2190 "stdout evidence"
2191 );
2192 assert_eq!(
2193 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2194 "23"
2195 );
2196 let history = history_dir(root, 1);
2197 assert!(!history.join("review-copy-stdout").exists());
2198 assert!(!history.join("review-copy-exit").exists());
2199 assert!(!history.join(".pending-review-copy").exists());
2200 }
2201
2202 #[test]
2203 fn archive_snapshots_current_review_into_same_generation() {
2204 let dir = tempfile::tempdir().unwrap();
2205 let root = dir.path();
2206 let evidence_root = root.join("phase-worktree");
2207 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2208 std::fs::write(stdout_path(root, 1), "attempt").unwrap();
2209 let phase_dir = evidence_root.join(".planning/phases/01-example");
2210 std::fs::create_dir_all(&phase_dir).unwrap();
2211 std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
2212
2213 let stamp = archive_phase_files(root, &evidence_root, 1, 5)
2214 .unwrap()
2215 .unwrap();
2216
2217 assert_eq!(
2218 std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
2219 .unwrap(),
2220 "review one"
2221 );
2222 }
2223
2224 #[test]
2225 fn archive_prunes_history_to_retain_count() {
2226 let dir = tempfile::tempdir().unwrap();
2227 let root = dir.path();
2228 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2229
2230 for i in 0..7 {
2231 std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
2232 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2233 archive_phase_files(root, root, 1, 3).unwrap();
2234 }
2235
2236 let history = history_dir(root, 1);
2237 let stdout_count = std::fs::read_dir(&history)
2238 .unwrap()
2239 .flatten()
2240 .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
2241 .count();
2242 let exit_count = std::fs::read_dir(&history)
2243 .unwrap()
2244 .flatten()
2245 .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
2246 .count();
2247 assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
2248 assert_eq!(exit_count, 3, "expected at most 3 retained generations");
2249 }
2250
2251 #[test]
2252 fn evaluate_agent_result_reads_files_end_to_end() {
2253 let dir = tempfile::tempdir().unwrap();
2254 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2255 std::fs::write(
2256 stdout_path(dir.path(), 6),
2257 "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
2258 )
2259 .unwrap();
2260 std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
2261 let state = state_in(dir.path(), 6);
2262
2263 let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2264
2265 assert_eq!(result.status, AgentStatus::Success);
2266 assert_eq!(result.commits, Some(2));
2267 assert_eq!(result.summary.as_deref(), Some("ok"));
2268 }
2269
2270 #[test]
2271 fn evaluate_layer1_finds_devflow_result_in_file() {
2272 let dir = tempfile::tempdir().unwrap();
2273 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2274 std::fs::write(
2275 stdout_path(dir.path(), 3),
2276 "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
2277 )
2278 .unwrap();
2279
2280 let result = evaluate_layer1(dir.path(), 3).unwrap();
2281
2282 assert_eq!(result.status, AgentStatus::Failed);
2283 assert_eq!(result.reason.as_deref(), Some("bad output"));
2284 }
2285
2286 #[test]
2287 fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
2288 let dir = tempfile::tempdir().unwrap();
2289 init_repo_with_feature_commit(dir.path(), 4);
2290 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2291 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2292 let state = state_in(dir.path(), 4);
2293
2294 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2295 .unwrap()
2296 .unwrap();
2297
2298 assert_eq!(result.status, AgentStatus::Success);
2299 assert_eq!(result.exit_code, Some(0));
2300 assert_eq!(result.commits, Some(1));
2301 assert!(result.reason.unwrap().contains("1 commits"));
2302 }
2303
2304 #[test]
2305 fn evaluate_layer2_exit_zero_no_commits_is_failed() {
2306 let dir = tempfile::tempdir().unwrap();
2309 init_repo_with_feature_no_commit(dir.path(), 4);
2310 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2311 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2312 let state = state_in(dir.path(), 4);
2313
2314 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2315 .unwrap()
2316 .unwrap();
2317
2318 assert_eq!(result.status, AgentStatus::Failed);
2319 assert_eq!(result.exit_code, Some(0));
2320 assert_eq!(result.commits, Some(0));
2321 assert!(result.reason.unwrap().contains("no commits"));
2322 }
2323
2324 #[test]
2325 fn evaluate_layer2_nonzero_exit_is_failed() {
2326 let dir = tempfile::tempdir().unwrap();
2328 init_repo_with_feature_commit(dir.path(), 4);
2329 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2330 std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
2331 let state = state_in(dir.path(), 4);
2332
2333 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2334 .unwrap()
2335 .unwrap();
2336
2337 assert_eq!(result.status, AgentStatus::Failed);
2338 assert_eq!(result.exit_code, Some(1));
2339 assert!(result.reason.unwrap().contains("exited with code 1"));
2340 }
2341
2342 #[test]
2343 fn layer2_nonzero_exit_is_failed_all_stages() {
2344 let dir = tempfile::tempdir().unwrap();
2348 init_repo_with_feature_no_commit(dir.path(), 10);
2349 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2350 std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
2351
2352 for stage in [
2353 Stage::Define,
2354 Stage::Plan,
2355 Stage::Code,
2356 Stage::Validate,
2357 Stage::Ship,
2358 ] {
2359 let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
2360 .unwrap()
2361 .unwrap();
2362 assert_eq!(
2363 result.status,
2364 AgentStatus::Failed,
2365 "stage {stage:?} should be Failed on nonzero exit"
2366 );
2367 }
2368 }
2369
2370 #[test]
2371 fn layer2_skips_commit_gate_for_define_and_validate() {
2372 let dir = tempfile::tempdir().unwrap();
2373 init_repo_with_feature_no_commit(dir.path(), 11);
2374 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2375 std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
2376
2377 for stage in [Stage::Define, Stage::Validate] {
2378 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
2379 .unwrap()
2380 .unwrap();
2381 assert_ne!(
2382 result.status,
2383 AgentStatus::Failed,
2384 "stage {stage:?} should not be Failed for zero commits"
2385 );
2386 }
2387
2388 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
2391 .unwrap()
2392 .unwrap();
2393 assert_eq!(result.status, AgentStatus::Failed);
2394 }
2395
2396 #[test]
2397 fn evaluate_layer3_falls_back_to_commit_count() {
2398 let dir = tempfile::tempdir().unwrap();
2399 init_repo_with_feature_commit(dir.path(), 5);
2400
2401 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2402
2403 assert_eq!(result.status, AgentStatus::Unknown);
2404 assert_eq!(result.exit_code, None);
2405 assert_eq!(result.commits, Some(1));
2406 assert!(result.reason.unwrap().contains("1 commits"));
2407 assert_eq!(result.decided_by_layer, Some(3));
2408 }
2409
2410 #[test]
2417 fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
2418 let dir = tempfile::tempdir().unwrap();
2419 init_repo_with_feature_no_commit(dir.path(), 5);
2420
2421 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2422
2423 assert_eq!(result.status, AgentStatus::Failed);
2424 assert_eq!(result.exit_code, None);
2425 assert_eq!(result.commits, Some(0));
2426 assert_eq!(result.decided_by_layer, Some(3));
2427 let reason = result.reason.unwrap();
2428 assert!(reason.contains("no work"), "reason was: {reason}");
2429 assert!(
2430 reason.to_ascii_lowercase().contains("human review"),
2431 "reason was: {reason}"
2432 );
2433 }
2434
2435 #[test]
2436 fn parse_devflow_result_reads_verdict() {
2437 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
2438 let result = parse_devflow_result(stdout).unwrap();
2439 assert_eq!(result.status, AgentStatus::Success);
2440 assert_eq!(result.verdict, Some(Verdict::Gaps));
2441 }
2442
2443 #[test]
2444 fn parse_devflow_result_reads_verdict_pass() {
2445 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
2446 let result = parse_devflow_result(stdout).unwrap();
2447 assert_eq!(result.status, AgentStatus::Success);
2448 assert_eq!(result.verdict, Some(Verdict::Pass));
2449 }
2450
2451 #[test]
2452 fn parse_devflow_result_verdict_absent_is_none() {
2453 let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
2454 let result = parse_devflow_result(stdout).unwrap();
2455 assert_eq!(result.status, AgentStatus::Success);
2456 assert_eq!(result.verdict, None);
2457 }
2458
2459 #[test]
2460 fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
2461 let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
2464 let result = parse_devflow_result(unknown).unwrap();
2465 assert_eq!(result.status, AgentStatus::Success);
2466 assert_eq!(result.verdict, None);
2467
2468 let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
2470 let result = parse_devflow_result(miscased).unwrap();
2471 assert_eq!(result.status, AgentStatus::Success);
2472 assert_eq!(result.verdict, None);
2473 }
2474
2475 #[test]
2482 fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
2483 let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
2484 let result = parse_devflow_result(bool_verdict).unwrap();
2485 assert_eq!(result.status, AgentStatus::Success);
2486 assert_eq!(result.verdict, None);
2487
2488 let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
2489 let result = parse_devflow_result(numeric_verdict).unwrap();
2490 assert_eq!(result.status, AgentStatus::Success);
2491 assert_eq!(result.verdict, None);
2492
2493 let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
2494 let result = parse_devflow_result(object_verdict).unwrap();
2495 assert_eq!(result.status, AgentStatus::Success);
2496 assert_eq!(result.verdict, None);
2497 }
2498
2499 #[test]
2503 fn multi_word_variants_serialize_with_word_boundary() {
2504 assert_eq!(
2505 serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
2506 "\"resource_killed\""
2507 );
2508 assert_eq!(
2509 serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
2510 "\"agent_unavailable\""
2511 );
2512 assert_eq!(
2513 serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
2514 AgentStatus::ResourceKilled
2515 );
2516 assert_eq!(
2517 serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
2518 AgentStatus::AgentUnavailable
2519 );
2520 }
2521
2522 #[test]
2525 fn existing_variants_keep_wire_form() {
2526 assert_eq!(
2527 serde_json::to_string(&AgentStatus::Success).unwrap(),
2528 "\"success\""
2529 );
2530 assert_eq!(
2531 serde_json::to_string(&AgentStatus::Failed).unwrap(),
2532 "\"failed\""
2533 );
2534 assert_eq!(
2535 serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
2536 "\"ratelimited\""
2537 );
2538 assert_eq!(
2539 serde_json::to_string(&AgentStatus::Unknown).unwrap(),
2540 "\"unknown\""
2541 );
2542 }
2543
2544 #[test]
2548 fn as_wire_str_matches_serde_form_for_every_variant() {
2549 for variant in [
2550 AgentStatus::Success,
2551 AgentStatus::Failed,
2552 AgentStatus::RateLimited,
2553 AgentStatus::Unknown,
2554 AgentStatus::ResourceKilled,
2555 AgentStatus::AgentUnavailable,
2556 ] {
2557 let serde_form = serde_json::to_string(&variant).unwrap();
2558 let stripped = serde_form.trim_matches('"');
2559 assert_eq!(
2560 variant.as_wire_str(),
2561 stripped,
2562 "as_wire_str() diverged from serde form for {variant:?}"
2563 );
2564 }
2565 }
2566
2567 #[test]
2568 fn evaluate_layer2_exit_137_is_resource_killed() {
2569 let dir = tempfile::tempdir().unwrap();
2570 init_repo_with_feature_commit(dir.path(), 20);
2571 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2572 std::fs::write(exit_code_path(dir.path(), 20), "137").unwrap();
2573 let state = state_in(dir.path(), 20);
2574
2575 let result = evaluate_layer2(dir.path(), 20, &GitFlowConfig::default(), state.stage)
2576 .unwrap()
2577 .unwrap();
2578
2579 assert_eq!(result.status, AgentStatus::ResourceKilled);
2580 assert_eq!(result.exit_code, Some(137));
2581 }
2582
2583 #[test]
2584 fn evaluate_layer2_exit_127_is_agent_unavailable() {
2585 let dir = tempfile::tempdir().unwrap();
2586 init_repo_with_feature_commit(dir.path(), 21);
2587 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2588 std::fs::write(exit_code_path(dir.path(), 21), "127").unwrap();
2589 let state = state_in(dir.path(), 21);
2590
2591 let result = evaluate_layer2(dir.path(), 21, &GitFlowConfig::default(), state.stage)
2592 .unwrap()
2593 .unwrap();
2594
2595 assert_eq!(result.status, AgentStatus::AgentUnavailable);
2596 assert_eq!(result.exit_code, Some(127));
2597 }
2598
2599 #[test]
2600 fn sequentagent_slot_round_trips() {
2601 let dir = tempfile::tempdir().unwrap();
2602
2603 write_sequentagent_slot(
2604 dir.path(),
2605 7,
2606 SequentagentSlotKind::B,
2607 crate::state::AgentKind::Codex,
2608 )
2609 .unwrap();
2610 let record = read_sequentagent_slot(dir.path(), 7).unwrap();
2611 assert_eq!(record.slot, "B");
2612 assert_eq!(record.agent, "codex");
2613
2614 clear_sequentagent_slot(dir.path(), 7);
2615 assert!(read_sequentagent_slot(dir.path(), 7).is_none());
2616 }
2617
2618 #[test]
2619 fn sequentagent_slot_is_path_free() {
2620 let dir = tempfile::tempdir().unwrap();
2621
2622 write_sequentagent_slot(
2623 dir.path(),
2624 9,
2625 SequentagentSlotKind::A,
2626 crate::state::AgentKind::Claude,
2627 )
2628 .unwrap();
2629
2630 let raw = std::fs::read_to_string(sequentagent_slot_path(dir.path(), 9)).unwrap();
2631 let dir_str = dir.path().display().to_string();
2632 assert!(
2633 !raw.contains(&dir_str),
2634 "slot record leaked the project root path: {raw:?}"
2635 );
2636 assert!(!raw.contains('/'), "slot record contains a path: {raw:?}");
2637 if let Ok(home) = std::env::var("HOME") {
2638 assert!(!raw.contains(&home), "slot record leaked $HOME: {raw:?}");
2639 }
2640 }
2641
2642 #[test]
2643 fn sequentagent_slot_write_creates_devflow_dir_and_gitignore() {
2644 let dir = tempfile::tempdir().unwrap();
2645 assert!(!dir.path().join(".devflow").exists());
2646
2647 write_sequentagent_slot(
2648 dir.path(),
2649 11,
2650 SequentagentSlotKind::A,
2651 crate::state::AgentKind::Claude,
2652 )
2653 .unwrap();
2654
2655 assert!(dir.path().join(".devflow").is_dir());
2656 let gitignore = std::fs::read_to_string(dir.path().join(".devflow/.gitignore")).unwrap();
2657 assert_eq!(gitignore, "*\n");
2658 }
2659
2660 #[test]
2661 fn sequentagent_slot_missing_record_reads_as_none() {
2662 let dir = tempfile::tempdir().unwrap();
2663 assert!(read_sequentagent_slot(dir.path(), 13).is_none());
2664 }
2665
2666 #[test]
2667 fn sequentagent_slot_kind_as_str() {
2668 assert_eq!(SequentagentSlotKind::A.as_str(), "A");
2669 assert_eq!(SequentagentSlotKind::B.as_str(), "B");
2670 }
2671}