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
1216 fn state_in(root: &Path, phase: u32) -> State {
1217 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
1218 state.stage = Stage::Code;
1219 state
1220 }
1221
1222 fn git(root: &Path, args: &[&str]) {
1223 let output = crate::test_support::git_command(root)
1224 .args(args)
1225 .output()
1226 .unwrap();
1227 assert!(
1228 output.status.success(),
1229 "git {:?} failed\nstdout: {}\nstderr: {}",
1230 args,
1231 String::from_utf8_lossy(&output.stdout),
1232 String::from_utf8_lossy(&output.stderr)
1233 );
1234 }
1235
1236 fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1237 git(root, &["init"]);
1238 git(root, &["config", "user.email", "devflow@example.com"]);
1239 git(root, &["config", "user.name", "DevFlow Tests"]);
1240 git(root, &["config", "commit.gpgsign", "false"]);
1241 git(root, &["config", "tag.gpgsign", "false"]);
1242 git(root, &["config", "core.hooksPath", "/dev/null"]);
1243 git(root, &["checkout", "-b", "develop"]);
1244 std::fs::write(root.join("README.md"), "base\n").unwrap();
1245 git(root, &["add", "README.md"]);
1246 git(root, &["commit", "-m", "base"]);
1247
1248 let branch = format!("feature/phase-{phase:02}");
1249 git(root, &["checkout", "-b", &branch]);
1250 std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1251 git(root, &["add", "phase.txt"]);
1252 git(root, &["commit", "-m", "feature work"]);
1253 }
1254
1255 fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1258 git(root, &["init"]);
1259 git(root, &["config", "user.email", "devflow@example.com"]);
1260 git(root, &["config", "user.name", "DevFlow Tests"]);
1261 git(root, &["config", "commit.gpgsign", "false"]);
1262 git(root, &["config", "tag.gpgsign", "false"]);
1263 git(root, &["config", "core.hooksPath", "/dev/null"]);
1264 git(root, &["checkout", "-b", "develop"]);
1265 std::fs::write(root.join("README.md"), "base\n").unwrap();
1266 git(root, &["add", "README.md"]);
1267 git(root, &["commit", "-m", "base"]);
1268
1269 let branch = format!("feature/phase-{phase:02}");
1270 git(root, &["checkout", "-b", &branch]);
1271 }
1272
1273 #[test]
1274 fn parse_success_marker() {
1275 let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1276 let result = parse_devflow_result(stdout).unwrap();
1277 assert_eq!(result.status, AgentStatus::Success);
1278 }
1279
1280 #[test]
1281 fn parse_failed_marker_with_reason() {
1282 let stdout =
1283 "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1284 let result = parse_devflow_result(stdout).unwrap();
1285 assert_eq!(result.status, AgentStatus::Failed);
1286 assert_eq!(result.reason.unwrap(), "clippy errors");
1287 }
1288
1289 #[test]
1290 fn parse_missing_marker_returns_none() {
1291 let stdout = "just some output\nno marker here\n";
1292 assert!(parse_devflow_result(stdout).is_none());
1293 }
1294
1295 #[test]
1296 fn parse_malformed_json_returns_none() {
1297 let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1298 assert!(parse_devflow_result(stdout).is_none());
1299 }
1300
1301 #[test]
1302 fn parse_lowercase_marker() {
1303 let stdout = "devflow_result: {\"status\":\"success\"}\n";
1304 let result = parse_devflow_result(stdout).unwrap();
1305 assert_eq!(result.status, AgentStatus::Success);
1306 }
1307
1308 #[test]
1309 fn parse_marker_without_space_after_colon() {
1310 let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1311 let result = parse_devflow_result(stdout).unwrap();
1312 assert_eq!(result.status, AgentStatus::Success);
1313 }
1314
1315 #[test]
1316 fn parse_lowercase_no_space_marker() {
1317 let stdout = "devflow_result:{\"status\":\"success\"}\n";
1320 let result = parse_devflow_result(stdout).unwrap();
1321 assert_eq!(result.status, AgentStatus::Success);
1322 }
1323
1324 #[test]
1325 fn parse_finds_last_marker_in_tail() {
1326 let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1328 let result = parse_devflow_result(stdout).unwrap();
1329 assert_eq!(result.status, AgentStatus::Success);
1330 }
1331
1332 #[test]
1333 fn parse_marker_lines_returns_last_marker_in_long_output() {
1334 let stdout = format!(
1335 "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1336 DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1337 "prefix".repeat(900),
1338 "tail output".repeat(100)
1339 );
1340
1341 let result = parse_marker_lines(&stdout).unwrap();
1342
1343 assert_eq!(result.status, AgentStatus::Success);
1344 }
1345
1346 #[test]
1347 fn parse_marker_only_in_last_4000_chars() {
1348 let prefix = "a".repeat(5000);
1350 let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1351 assert!(parse_devflow_result(&stdout).is_none());
1352 }
1353
1354 #[test]
1355 fn parse_marker_with_commits_and_summary() {
1356 let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1357 let result = parse_devflow_result(stdout).unwrap();
1358 assert_eq!(result.status, AgentStatus::Success);
1359 assert_eq!(result.commits, Some(3));
1360 assert_eq!(result.summary.unwrap(), "added tests");
1361 }
1362
1363 #[test]
1364 fn parse_marker_inside_json_result_envelope() {
1365 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1368 let result = parse_devflow_result(stdout).unwrap();
1369 assert_eq!(result.status, AgentStatus::Success);
1370 assert_eq!(result.commits, Some(2));
1371 }
1372
1373 #[test]
1374 fn parse_failed_marker_inside_json_envelope() {
1375 let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1376 let result = parse_devflow_result(stdout).unwrap();
1377 assert_eq!(result.status, AgentStatus::Failed);
1378 assert_eq!(result.reason.unwrap(), "tests failed");
1379 }
1380
1381 #[test]
1382 fn parse_json_envelope_without_marker_returns_none() {
1383 let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1384 assert!(parse_devflow_result(stdout).is_none());
1385 }
1386
1387 #[test]
1388 fn detect_claude_json_rate_limit_by_subtype() {
1389 let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1390 assert_eq!(
1391 detect_rate_limit(stdout).as_deref(),
1392 Some("2026-06-18T15:45:30Z")
1393 );
1394 }
1395
1396 #[test]
1397 fn detect_claude_json_rate_limit_by_429() {
1398 let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1399 assert_eq!(
1400 detect_rate_limit(stdout).as_deref(),
1401 Some("Too many requests. Try later.")
1402 );
1403 }
1404
1405 #[test]
1406 fn detect_codex_try_again_rate_limit() {
1407 let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1408 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1409 }
1410
1411 #[test]
1420 fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1421 const DEPTH: usize = 100;
1424 let mut stdout = String::new();
1425 for _ in 0..DEPTH {
1426 stdout.push_str(r#"{"nested":"#);
1427 }
1428 stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1429 for _ in 0..DEPTH {
1430 stdout.push('}');
1431 }
1432
1433 assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1436 }
1437
1438 #[test]
1439 fn detect_rate_limit_ignores_normal_stdout() {
1440 let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1441 assert!(detect_rate_limit(stdout).is_none());
1442 }
1443
1444 #[test]
1445 fn claude_envelope_is_error_detected() {
1446 let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1447 let result = detect_claude_envelope_failure(stdout).unwrap();
1448 assert_eq!(result.status, AgentStatus::Failed);
1449 }
1450
1451 #[test]
1452 fn claude_is_error_overrides_success_marker() {
1453 let dir = tempfile::tempdir().unwrap();
1454 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1455 std::fs::write(
1456 stdout_path(dir.path(), 9),
1457 r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1458 )
1459 .unwrap();
1460
1461 let result = evaluate_layer1(dir.path(), 9).unwrap();
1462
1463 assert_eq!(result.status, AgentStatus::Failed);
1464 }
1465
1466 #[test]
1467 fn claude_envelope_is_error_false_defers() {
1468 let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1469 assert!(detect_claude_envelope_failure(stdout).is_none());
1470 }
1471
1472 #[test]
1473 fn claude_envelope_marker_still_wins() {
1474 let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1475 assert!(detect_claude_envelope_failure(stdout).is_none());
1476 let result = parse_devflow_result(stdout).unwrap();
1477 assert_eq!(result.status, AgentStatus::Success);
1478 assert_eq!(result.commits, Some(2));
1479 }
1480
1481 #[test]
1482 fn codex_event_stream_parses_turn_failed() {
1483 let stdout = concat!(
1484 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1485 "{\"type\":\"turn.started\"}\n",
1486 "{\"type\":\"item.started\",\"item\":{}}\n",
1487 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1488 );
1489 let result = parse_codex_event_result(stdout).unwrap();
1490 assert_eq!(result.status, AgentStatus::Failed);
1491 assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1492 }
1493
1494 #[test]
1495 fn codex_turn_completed_no_marker_defers() {
1496 let stdout = concat!(
1497 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1498 "{\"type\":\"turn.started\"}\n",
1499 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1500 );
1501 assert!(parse_codex_event_result(stdout).is_none());
1502 }
1503
1504 #[test]
1510 fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1511 let stdout = concat!(
1512 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1513 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1514 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1515 );
1516 let result = parse_codex_event_result(stdout).unwrap();
1517 assert_eq!(result.status, AgentStatus::Failed);
1518 assert_eq!(
1519 result.reason.as_deref(),
1520 Some("interactive input unavailable")
1521 );
1522 }
1523
1524 #[test]
1525 fn codex_agent_message_marker_success_short_circuits() {
1526 let stdout = concat!(
1527 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1528 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1529 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1530 );
1531 let result = parse_codex_event_result(stdout).unwrap();
1532 assert_eq!(result.status, AgentStatus::Success);
1533 }
1534
1535 #[test]
1541 fn detect_rate_limit_ignores_json_event_lines() {
1542 let stdout = concat!(
1543 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1544 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1545 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1546 );
1547 assert_eq!(detect_rate_limit(stdout), None);
1548 }
1549
1550 #[test]
1551 fn detect_rate_limit_still_reads_codex_plain_text() {
1552 let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1553 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1554 }
1555
1556 #[test]
1557 fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1558 let stdout = concat!(
1559 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1560 "not json at all\n",
1561 "{\"type\":\"item.started\",\"item\":{}}\n",
1562 "{\"type\":\"item.updated\",\"item\":{}}\n",
1563 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1564 );
1565 let result = parse_codex_event_result(stdout).unwrap();
1566 assert_eq!(result.status, AgentStatus::Failed);
1567 assert_eq!(result.reason.as_deref(), Some("boom"));
1568 }
1569
1570 #[test]
1571 fn claude_envelope_not_consumed_by_codex_parser() {
1572 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1573 assert!(parse_codex_event_result(stdout).is_none());
1574 }
1575
1576 #[test]
1577 fn evaluate_layer1_reports_rate_limited_without_marker() {
1578 let dir = tempfile::tempdir().unwrap();
1579 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1580 std::fs::write(
1581 stdout_path(dir.path(), 7),
1582 r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1583 )
1584 .unwrap();
1585
1586 let result = evaluate_layer1(dir.path(), 7).unwrap();
1587
1588 assert_eq!(result.status, AgentStatus::RateLimited);
1589 assert_eq!(
1590 result.reason.as_deref(),
1591 Some("rate limited until 2026-06-18T15:45:30Z")
1592 );
1593 }
1594
1595 #[test]
1600 fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1601 let dir = tempfile::tempdir().unwrap();
1602 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1603 std::fs::write(
1604 stdout_path(dir.path(), 7),
1605 r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1606 )
1607 .unwrap();
1608
1609 let result = evaluate_layer1(dir.path(), 7).unwrap();
1610
1611 assert_eq!(result.status, AgentStatus::RateLimited);
1612 assert_eq!(
1613 result.reason.as_deref(),
1614 Some("rate limited until 2026-06-18T15:45:30Z")
1615 );
1616 }
1617
1618 #[test]
1624 fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1625 let dir = tempfile::tempdir().unwrap();
1626 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1627 let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1628 bytes.extend_from_slice(
1629 b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1630 );
1631 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1632
1633 let result = evaluate_layer1(dir.path(), 5).unwrap();
1634
1635 assert_eq!(result.status, AgentStatus::Failed);
1636 assert_eq!(result.reason.as_deref(), Some("review: bad"));
1637 }
1638
1639 #[test]
1640 fn failing_external_probe_outranks_success_marker() {
1641 let dir = tempfile::tempdir().unwrap();
1642 let phase_dir = dir
1643 .path()
1644 .join(".planning/phases/16-pipeline-reliability-hardening");
1645 std::fs::create_dir_all(&phase_dir).unwrap();
1646 std::fs::write(
1647 phase_dir.join("16-03-PLAN.md"),
1648 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1649 )
1650 .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
1659 let approval = vec!["test -f externally-shipped".to_string()];
1660 let result = evaluate_agent_result_inner(
1661 dir.path(),
1662 &state,
1663 &GitFlowConfig::default(),
1664 Some(&approval),
1665 )
1666 .unwrap();
1667
1668 assert_eq!(result.status, AgentStatus::Failed);
1669 assert!(
1670 result
1671 .reason
1672 .as_deref()
1673 .is_some_and(|reason| reason.contains("external verification failed"))
1674 );
1675 }
1676
1677 #[test]
1685 fn external_probe_discovers_from_project_root_across_every_stage_and_executes_in_worktree() {
1686 let dir = tempfile::tempdir().unwrap();
1687 let worktree = dir.path().join("phase-worktree");
1688 std::fs::create_dir_all(&worktree).unwrap();
1689 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1690 std::fs::create_dir_all(&phase_dir).unwrap();
1691 std::fs::write(
1692 phase_dir.join("16-01-PLAN.md"),
1693 "---\nexternal_verify: \"test -f implemented\"\n---\n",
1694 )
1695 .unwrap();
1696 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1697 std::fs::write(
1698 stdout_path(dir.path(), 16),
1699 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1700 )
1701 .unwrap();
1702 let mut state = state_in(dir.path(), 16);
1703 state.worktree_path = Some(worktree.clone());
1704 state.stage = Stage::Plan;
1705
1706 let approval = vec!["test -f implemented".to_string()];
1707
1708 let plan_result = evaluate_agent_result_inner(
1713 dir.path(),
1714 &state,
1715 &GitFlowConfig::default(),
1716 Some(&approval),
1717 )
1718 .unwrap();
1719 assert_eq!(plan_result.status, AgentStatus::Failed);
1720 assert!(
1721 plan_result
1722 .reason
1723 .as_deref()
1724 .is_some_and(|reason| reason.contains("external verification failed")),
1725 "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
1726 plan_result.reason
1727 );
1728
1729 state.stage = Stage::Code;
1730 let code_result = evaluate_agent_result_inner(
1731 dir.path(),
1732 &state,
1733 &GitFlowConfig::default(),
1734 Some(&approval),
1735 )
1736 .unwrap();
1737 assert_eq!(code_result.status, AgentStatus::Failed);
1738
1739 std::fs::write(worktree.join("implemented"), "done").unwrap();
1742 let passing = evaluate_agent_result_inner(
1743 dir.path(),
1744 &state,
1745 &GitFlowConfig::default(),
1746 Some(&approval),
1747 )
1748 .unwrap();
1749 assert_eq!(passing.status, AgentStatus::Success);
1750 assert_eq!(passing.decided_by_layer, Some(0));
1751 }
1752
1753 #[test]
1754 fn changed_external_probe_never_inherits_prior_approval() {
1755 let dir = tempfile::tempdir().unwrap();
1756 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1757 std::fs::create_dir_all(&phase_dir).unwrap();
1758 std::fs::write(
1759 phase_dir.join("16-01-PLAN.md"),
1760 "---\nexternal_verify: \"touch escaped\"\n---\n",
1761 )
1762 .unwrap();
1763 let state = state_in(dir.path(), 16);
1764 let approved = vec!["test -f reviewed-artifact".to_string()];
1765
1766 let result = evaluate_agent_result_inner(
1767 dir.path(),
1768 &state,
1769 &GitFlowConfig::default(),
1770 Some(&approved),
1771 )
1772 .unwrap();
1773
1774 assert_eq!(result.status, AgentStatus::Failed);
1775 assert!(result.reason.unwrap().contains("approval mismatch"));
1776 assert!(!dir.path().join("escaped").exists());
1777 }
1778
1779 #[test]
1780 fn removed_external_probe_fails_closed_against_prior_approval() {
1781 let dir = tempfile::tempdir().unwrap();
1782 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1783 std::fs::write(
1784 stdout_path(dir.path(), 16),
1785 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1786 )
1787 .unwrap();
1788 let state = state_in(dir.path(), 16);
1789 let approved = vec!["test -f shipped".to_string()];
1790
1791 let result = evaluate_agent_result_inner(
1792 dir.path(),
1793 &state,
1794 &GitFlowConfig::default(),
1795 Some(&approved),
1796 )
1797 .unwrap();
1798
1799 assert_eq!(result.status, AgentStatus::Failed);
1800 assert!(result.reason.unwrap().contains("declaration was removed"));
1801 }
1802
1803 #[test]
1804 fn no_external_declaration_preserves_layer1_result() {
1805 let dir = tempfile::tempdir().unwrap();
1806 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1807 std::fs::write(
1808 stdout_path(dir.path(), 16),
1809 "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
1810 )
1811 .unwrap();
1812 let state = state_in(dir.path(), 16);
1813 let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
1814
1815 let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1816
1817 assert_eq!(
1818 serde_json::to_value(full).unwrap(),
1819 serde_json::to_value(layer1).unwrap()
1820 );
1821 }
1822
1823 #[test]
1830 fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
1831 let dir = tempfile::tempdir().unwrap();
1832 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1833 std::fs::create_dir_all(&phase_dir).unwrap();
1834 std::fs::write(
1835 phase_dir.join("16-01-PLAN.md"),
1836 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1837 )
1838 .unwrap();
1839 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1840 let mut state = state_in(dir.path(), 16);
1841 state.stage = Stage::Define;
1842
1843 let approval = vec!["test -f shipped".to_string()];
1844 let result = evaluate_agent_result_inner(
1845 dir.path(),
1846 &state,
1847 &GitFlowConfig::default(),
1848 Some(&approval),
1849 )
1850 .unwrap();
1851
1852 assert_eq!(result.status, AgentStatus::Success);
1853 assert_eq!(result.decided_by_layer, Some(0));
1854 assert_eq!(result.commits, None);
1855 assert_eq!(result.verdict, None);
1857 }
1858
1859 #[test]
1864 fn layer0_affirmative_success_outranks_layer1_failure_marker() {
1865 let dir = tempfile::tempdir().unwrap();
1866 let phase_dir = dir
1867 .path()
1868 .join(".planning/phases/16-pipeline-reliability-hardening");
1869 std::fs::create_dir_all(&phase_dir).unwrap();
1870 std::fs::write(
1871 phase_dir.join("16-03-PLAN.md"),
1872 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1873 )
1874 .unwrap();
1875 std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
1876 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1877 std::fs::write(
1878 stdout_path(dir.path(), 16),
1879 "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
1880 )
1881 .unwrap();
1882 let state = state_in(dir.path(), 16);
1883
1884 let approval = vec!["test -f externally-shipped".to_string()];
1885 let result = evaluate_agent_result_inner(
1886 dir.path(),
1887 &state,
1888 &GitFlowConfig::default(),
1889 Some(&approval),
1890 )
1891 .unwrap();
1892
1893 assert_eq!(result.status, AgentStatus::Success);
1894 assert_eq!(result.decided_by_layer, Some(0));
1895 assert_eq!(result.verdict, None);
1898 }
1899
1900 #[test]
1905 fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
1906 let dir = tempfile::tempdir().unwrap();
1907 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1908 std::fs::create_dir_all(&phase_dir).unwrap();
1909 std::fs::write(
1910 phase_dir.join("16-01-PLAN.md"),
1911 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1912 )
1913 .unwrap();
1914 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1915 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1916 let mut state = state_in(dir.path(), 16);
1917 state.stage = Stage::Validate;
1918 let approval = vec!["test -f shipped".to_string()];
1919
1920 std::fs::write(
1921 stdout_path(dir.path(), 16),
1922 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1923 )
1924 .unwrap();
1925 let result = evaluate_agent_result_inner(
1926 dir.path(),
1927 &state,
1928 &GitFlowConfig::default(),
1929 Some(&approval),
1930 )
1931 .unwrap();
1932 assert_eq!(result.status, AgentStatus::Success);
1933 assert_eq!(result.decided_by_layer, Some(0));
1934 assert_eq!(result.verdict, Some(Verdict::Pass));
1935
1936 std::fs::write(
1937 stdout_path(dir.path(), 16),
1938 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
1939 )
1940 .unwrap();
1941 let result = evaluate_agent_result_inner(
1942 dir.path(),
1943 &state,
1944 &GitFlowConfig::default(),
1945 Some(&approval),
1946 )
1947 .unwrap();
1948 assert_eq!(result.verdict, Some(Verdict::Gaps));
1949
1950 std::fs::remove_file(stdout_path(dir.path(), 16)).unwrap();
1951 let result = evaluate_agent_result_inner(
1952 dir.path(),
1953 &state,
1954 &GitFlowConfig::default(),
1955 Some(&approval),
1956 )
1957 .unwrap();
1958 assert_eq!(result.verdict, None);
1959 }
1960
1961 #[test]
1966 fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
1967 let dir = tempfile::tempdir().unwrap();
1968 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1969 std::fs::create_dir_all(&phase_dir).unwrap();
1970 std::fs::write(
1971 phase_dir.join("16-01-PLAN.md"),
1972 "---\nexternal_verify: \"test -f shipped\"\n---\n",
1973 )
1974 .unwrap();
1975 std::fs::write(dir.path().join("shipped"), "done").unwrap();
1976 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1977 std::fs::write(
1978 stdout_path(dir.path(), 16),
1979 "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1980 )
1981 .unwrap();
1982 let state = state_in(dir.path(), 16); let approval = vec!["test -f shipped".to_string()];
1984
1985 let result = evaluate_agent_result_inner(
1986 dir.path(),
1987 &state,
1988 &GitFlowConfig::default(),
1989 Some(&approval),
1990 )
1991 .unwrap();
1992
1993 assert_eq!(result.status, AgentStatus::Success);
1994 assert_eq!(result.decided_by_layer, Some(0));
1995 assert_eq!(result.verdict, None);
1996 }
1997
1998 #[test]
2002 fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
2003 let dir = tempfile::tempdir().unwrap();
2004 let phase_dir = dir.path().join(".planning/phases/16-reliability");
2005 std::fs::create_dir_all(&phase_dir).unwrap();
2006 std::fs::write(
2008 phase_dir.join("16-01-PLAN.md"),
2009 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
2010 )
2011 .unwrap();
2012 std::fs::write(
2013 phase_dir.join("16-02-PLAN.md"),
2014 "---\nexternal_verify: \"test -f never-created\"\n---\n",
2015 )
2016 .unwrap();
2017 std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
2018 let mut state = state_in(dir.path(), 16);
2019 state.stage = Stage::Define;
2020
2021 let approval = vec![
2022 "test -f passing-artifact".to_string(),
2023 "test -f never-created".to_string(),
2024 ];
2025 let result_a = evaluate_agent_result_inner(
2026 dir.path(),
2027 &state,
2028 &GitFlowConfig::default(),
2029 Some(&approval),
2030 )
2031 .unwrap();
2032 assert_eq!(result_a.status, AgentStatus::Failed);
2033 assert!(
2034 result_a
2035 .reason
2036 .as_deref()
2037 .is_some_and(|reason| reason.contains("never-created")),
2038 "unexpected reason: {:?}",
2039 result_a.reason
2040 );
2041
2042 std::fs::write(
2046 phase_dir.join("16-01-PLAN.md"),
2047 "---\nexternal_verify: \"test -f still-missing\"\n---\n",
2048 )
2049 .unwrap();
2050 std::fs::write(
2051 phase_dir.join("16-02-PLAN.md"),
2052 "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
2053 )
2054 .unwrap();
2055 let approval_swapped = vec![
2056 "test -f still-missing".to_string(),
2057 "test -f passing-artifact".to_string(),
2058 ];
2059 let result_b = evaluate_agent_result_inner(
2060 dir.path(),
2061 &state,
2062 &GitFlowConfig::default(),
2063 Some(&approval_swapped),
2064 )
2065 .unwrap();
2066 assert_eq!(result_b.status, AgentStatus::Failed);
2067
2068 std::fs::write(dir.path().join("still-missing"), "done").unwrap();
2070 let result_c = evaluate_agent_result_inner(
2071 dir.path(),
2072 &state,
2073 &GitFlowConfig::default(),
2074 Some(&approval_swapped),
2075 )
2076 .unwrap();
2077 assert_eq!(result_c.status, AgentStatus::Success);
2078 assert_eq!(result_c.decided_by_layer, Some(0));
2079 }
2080
2081 #[test]
2082 fn archive_moves_captures_into_history_and_removes_pid_file() {
2083 let dir = tempfile::tempdir().unwrap();
2086 let root = dir.path();
2087 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2088 std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
2089 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2090 std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
2091
2092 archive_phase_files(root, root, 1, 5).unwrap();
2093
2094 assert!(!root.join(".devflow/phase-01-stdout").exists());
2096 assert!(!root.join(".devflow/phase-01-exit").exists());
2097 assert!(!root.join(".devflow/phase-01-agent-pid").exists());
2099
2100 let history = history_dir(root, 1);
2101 let archived: Vec<_> = std::fs::read_dir(&history)
2102 .unwrap()
2103 .flatten()
2104 .map(|e| e.file_name().to_string_lossy().into_owned())
2105 .collect();
2106 let archived_stdout = archived
2107 .iter()
2108 .find(|name| name.ends_with("-stdout"))
2109 .expect("stdout capture should be archived into history");
2110 assert!(archived.iter().any(|name| name.ends_with("-exit")));
2111 let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
2112 assert_eq!(contents, "prior stdout");
2113 }
2114
2115 #[test]
2116 fn archive_is_noop_when_nothing_to_archive() {
2117 let dir = tempfile::tempdir().unwrap();
2118 let root = dir.path();
2119 archive_phase_files(root, root, 1, 5).unwrap();
2121 assert!(!history_dir(root, 1).exists());
2122 }
2123
2124 #[test]
2125 fn archive_handles_missing_devflow_dir() {
2126 let dir = tempfile::tempdir().unwrap();
2127 let root = dir.path();
2128 archive_phase_files(root, root, 1, 5).unwrap();
2130 }
2131
2132 #[test]
2133 fn archive_failure_preserves_live_capture_for_retry() {
2134 let dir = tempfile::tempdir().unwrap();
2135 let root = dir.path();
2136 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2137 std::fs::write(stdout_path(root, 1), "evidence").unwrap();
2138 std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
2141
2142 assert!(archive_phase_files(root, root, 1, 5).is_err());
2143 assert_eq!(
2144 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2145 "evidence"
2146 );
2147 }
2148
2149 #[test]
2150 fn archive_second_publish_failure_rolls_back_complete_live_pair() {
2151 let dir = tempfile::tempdir().unwrap();
2152 let root = dir.path();
2153 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2154 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2155 std::fs::write(exit_code_path(root, 1), "17").unwrap();
2156 let history = history_dir(root, 1);
2157 std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
2158
2159 assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
2160
2161 assert_eq!(
2162 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2163 "stdout evidence"
2164 );
2165 assert_eq!(
2166 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2167 "17"
2168 );
2169 assert!(!history.join("fixed-stdout").exists());
2170 assert!(!history.join(".pending-fixed").exists());
2171 }
2172
2173 #[test]
2174 fn archive_review_copy_failure_rolls_back_complete_live_pair() {
2175 let dir = tempfile::tempdir().unwrap();
2176 let root = dir.path();
2177 let evidence_root = root.join("phase-worktree");
2178 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2179 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2180 std::fs::write(exit_code_path(root, 1), "23").unwrap();
2181 let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
2182 std::fs::create_dir_all(&review).unwrap();
2183
2184 assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
2185
2186 assert_eq!(
2187 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2188 "stdout evidence"
2189 );
2190 assert_eq!(
2191 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2192 "23"
2193 );
2194 let history = history_dir(root, 1);
2195 assert!(!history.join("review-copy-stdout").exists());
2196 assert!(!history.join("review-copy-exit").exists());
2197 assert!(!history.join(".pending-review-copy").exists());
2198 }
2199
2200 #[test]
2201 fn archive_snapshots_current_review_into_same_generation() {
2202 let dir = tempfile::tempdir().unwrap();
2203 let root = dir.path();
2204 let evidence_root = root.join("phase-worktree");
2205 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2206 std::fs::write(stdout_path(root, 1), "attempt").unwrap();
2207 let phase_dir = evidence_root.join(".planning/phases/01-example");
2208 std::fs::create_dir_all(&phase_dir).unwrap();
2209 std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
2210
2211 let stamp = archive_phase_files(root, &evidence_root, 1, 5)
2212 .unwrap()
2213 .unwrap();
2214
2215 assert_eq!(
2216 std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
2217 .unwrap(),
2218 "review one"
2219 );
2220 }
2221
2222 #[test]
2223 fn archive_prunes_history_to_retain_count() {
2224 let dir = tempfile::tempdir().unwrap();
2225 let root = dir.path();
2226 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2227
2228 for i in 0..7 {
2229 std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
2230 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2231 archive_phase_files(root, root, 1, 3).unwrap();
2232 }
2233
2234 let history = history_dir(root, 1);
2235 let stdout_count = std::fs::read_dir(&history)
2236 .unwrap()
2237 .flatten()
2238 .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
2239 .count();
2240 let exit_count = std::fs::read_dir(&history)
2241 .unwrap()
2242 .flatten()
2243 .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
2244 .count();
2245 assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
2246 assert_eq!(exit_count, 3, "expected at most 3 retained generations");
2247 }
2248
2249 #[test]
2250 fn evaluate_agent_result_reads_files_end_to_end() {
2251 let dir = tempfile::tempdir().unwrap();
2252 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2253 std::fs::write(
2254 stdout_path(dir.path(), 6),
2255 "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
2256 )
2257 .unwrap();
2258 std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
2259 let state = state_in(dir.path(), 6);
2260
2261 let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2262
2263 assert_eq!(result.status, AgentStatus::Success);
2264 assert_eq!(result.commits, Some(2));
2265 assert_eq!(result.summary.as_deref(), Some("ok"));
2266 }
2267
2268 #[test]
2269 fn evaluate_layer1_finds_devflow_result_in_file() {
2270 let dir = tempfile::tempdir().unwrap();
2271 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2272 std::fs::write(
2273 stdout_path(dir.path(), 3),
2274 "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
2275 )
2276 .unwrap();
2277
2278 let result = evaluate_layer1(dir.path(), 3).unwrap();
2279
2280 assert_eq!(result.status, AgentStatus::Failed);
2281 assert_eq!(result.reason.as_deref(), Some("bad output"));
2282 }
2283
2284 #[test]
2285 fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
2286 let dir = tempfile::tempdir().unwrap();
2287 init_repo_with_feature_commit(dir.path(), 4);
2288 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2289 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2290 let state = state_in(dir.path(), 4);
2291
2292 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2293 .unwrap()
2294 .unwrap();
2295
2296 assert_eq!(result.status, AgentStatus::Success);
2297 assert_eq!(result.exit_code, Some(0));
2298 assert_eq!(result.commits, Some(1));
2299 assert!(result.reason.unwrap().contains("1 commits"));
2300 }
2301
2302 #[test]
2303 fn evaluate_layer2_exit_zero_no_commits_is_failed() {
2304 let dir = tempfile::tempdir().unwrap();
2307 init_repo_with_feature_no_commit(dir.path(), 4);
2308 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2309 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2310 let state = state_in(dir.path(), 4);
2311
2312 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2313 .unwrap()
2314 .unwrap();
2315
2316 assert_eq!(result.status, AgentStatus::Failed);
2317 assert_eq!(result.exit_code, Some(0));
2318 assert_eq!(result.commits, Some(0));
2319 assert!(result.reason.unwrap().contains("no commits"));
2320 }
2321
2322 #[test]
2323 fn evaluate_layer2_nonzero_exit_is_failed() {
2324 let dir = tempfile::tempdir().unwrap();
2326 init_repo_with_feature_commit(dir.path(), 4);
2327 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2328 std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
2329 let state = state_in(dir.path(), 4);
2330
2331 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2332 .unwrap()
2333 .unwrap();
2334
2335 assert_eq!(result.status, AgentStatus::Failed);
2336 assert_eq!(result.exit_code, Some(1));
2337 assert!(result.reason.unwrap().contains("exited with code 1"));
2338 }
2339
2340 #[test]
2341 fn layer2_nonzero_exit_is_failed_all_stages() {
2342 let dir = tempfile::tempdir().unwrap();
2346 init_repo_with_feature_no_commit(dir.path(), 10);
2347 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2348 std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
2349
2350 for stage in [
2351 Stage::Define,
2352 Stage::Plan,
2353 Stage::Code,
2354 Stage::Validate,
2355 Stage::Ship,
2356 ] {
2357 let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
2358 .unwrap()
2359 .unwrap();
2360 assert_eq!(
2361 result.status,
2362 AgentStatus::Failed,
2363 "stage {stage:?} should be Failed on nonzero exit"
2364 );
2365 }
2366 }
2367
2368 #[test]
2369 fn layer2_skips_commit_gate_for_define_and_validate() {
2370 let dir = tempfile::tempdir().unwrap();
2371 init_repo_with_feature_no_commit(dir.path(), 11);
2372 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2373 std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
2374
2375 for stage in [Stage::Define, Stage::Validate] {
2376 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
2377 .unwrap()
2378 .unwrap();
2379 assert_ne!(
2380 result.status,
2381 AgentStatus::Failed,
2382 "stage {stage:?} should not be Failed for zero commits"
2383 );
2384 }
2385
2386 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
2389 .unwrap()
2390 .unwrap();
2391 assert_eq!(result.status, AgentStatus::Failed);
2392 }
2393
2394 #[test]
2395 fn evaluate_layer3_falls_back_to_commit_count() {
2396 let dir = tempfile::tempdir().unwrap();
2397 init_repo_with_feature_commit(dir.path(), 5);
2398
2399 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2400
2401 assert_eq!(result.status, AgentStatus::Unknown);
2402 assert_eq!(result.exit_code, None);
2403 assert_eq!(result.commits, Some(1));
2404 assert!(result.reason.unwrap().contains("1 commits"));
2405 assert_eq!(result.decided_by_layer, Some(3));
2406 }
2407
2408 #[test]
2415 fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
2416 let dir = tempfile::tempdir().unwrap();
2417 init_repo_with_feature_no_commit(dir.path(), 5);
2418
2419 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2420
2421 assert_eq!(result.status, AgentStatus::Failed);
2422 assert_eq!(result.exit_code, None);
2423 assert_eq!(result.commits, Some(0));
2424 assert_eq!(result.decided_by_layer, Some(3));
2425 let reason = result.reason.unwrap();
2426 assert!(reason.contains("no work"), "reason was: {reason}");
2427 assert!(
2428 reason.to_ascii_lowercase().contains("human review"),
2429 "reason was: {reason}"
2430 );
2431 }
2432
2433 #[test]
2434 fn parse_devflow_result_reads_verdict() {
2435 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
2436 let result = parse_devflow_result(stdout).unwrap();
2437 assert_eq!(result.status, AgentStatus::Success);
2438 assert_eq!(result.verdict, Some(Verdict::Gaps));
2439 }
2440
2441 #[test]
2442 fn parse_devflow_result_reads_verdict_pass() {
2443 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
2444 let result = parse_devflow_result(stdout).unwrap();
2445 assert_eq!(result.status, AgentStatus::Success);
2446 assert_eq!(result.verdict, Some(Verdict::Pass));
2447 }
2448
2449 #[test]
2450 fn parse_devflow_result_verdict_absent_is_none() {
2451 let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
2452 let result = parse_devflow_result(stdout).unwrap();
2453 assert_eq!(result.status, AgentStatus::Success);
2454 assert_eq!(result.verdict, None);
2455 }
2456
2457 #[test]
2458 fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
2459 let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
2462 let result = parse_devflow_result(unknown).unwrap();
2463 assert_eq!(result.status, AgentStatus::Success);
2464 assert_eq!(result.verdict, None);
2465
2466 let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
2468 let result = parse_devflow_result(miscased).unwrap();
2469 assert_eq!(result.status, AgentStatus::Success);
2470 assert_eq!(result.verdict, None);
2471 }
2472
2473 #[test]
2480 fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
2481 let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
2482 let result = parse_devflow_result(bool_verdict).unwrap();
2483 assert_eq!(result.status, AgentStatus::Success);
2484 assert_eq!(result.verdict, None);
2485
2486 let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
2487 let result = parse_devflow_result(numeric_verdict).unwrap();
2488 assert_eq!(result.status, AgentStatus::Success);
2489 assert_eq!(result.verdict, None);
2490
2491 let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
2492 let result = parse_devflow_result(object_verdict).unwrap();
2493 assert_eq!(result.status, AgentStatus::Success);
2494 assert_eq!(result.verdict, None);
2495 }
2496
2497 #[test]
2501 fn multi_word_variants_serialize_with_word_boundary() {
2502 assert_eq!(
2503 serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
2504 "\"resource_killed\""
2505 );
2506 assert_eq!(
2507 serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
2508 "\"agent_unavailable\""
2509 );
2510 assert_eq!(
2511 serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
2512 AgentStatus::ResourceKilled
2513 );
2514 assert_eq!(
2515 serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
2516 AgentStatus::AgentUnavailable
2517 );
2518 }
2519
2520 #[test]
2523 fn existing_variants_keep_wire_form() {
2524 assert_eq!(
2525 serde_json::to_string(&AgentStatus::Success).unwrap(),
2526 "\"success\""
2527 );
2528 assert_eq!(
2529 serde_json::to_string(&AgentStatus::Failed).unwrap(),
2530 "\"failed\""
2531 );
2532 assert_eq!(
2533 serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
2534 "\"ratelimited\""
2535 );
2536 assert_eq!(
2537 serde_json::to_string(&AgentStatus::Unknown).unwrap(),
2538 "\"unknown\""
2539 );
2540 }
2541
2542 #[test]
2546 fn as_wire_str_matches_serde_form_for_every_variant() {
2547 for variant in [
2548 AgentStatus::Success,
2549 AgentStatus::Failed,
2550 AgentStatus::RateLimited,
2551 AgentStatus::Unknown,
2552 AgentStatus::ResourceKilled,
2553 AgentStatus::AgentUnavailable,
2554 ] {
2555 let serde_form = serde_json::to_string(&variant).unwrap();
2556 let stripped = serde_form.trim_matches('"');
2557 assert_eq!(
2558 variant.as_wire_str(),
2559 stripped,
2560 "as_wire_str() diverged from serde form for {variant:?}"
2561 );
2562 }
2563 }
2564
2565 #[test]
2566 fn evaluate_layer2_exit_137_is_resource_killed() {
2567 let dir = tempfile::tempdir().unwrap();
2568 init_repo_with_feature_commit(dir.path(), 20);
2569 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2570 std::fs::write(exit_code_path(dir.path(), 20), "137").unwrap();
2571 let state = state_in(dir.path(), 20);
2572
2573 let result = evaluate_layer2(dir.path(), 20, &GitFlowConfig::default(), state.stage)
2574 .unwrap()
2575 .unwrap();
2576
2577 assert_eq!(result.status, AgentStatus::ResourceKilled);
2578 assert_eq!(result.exit_code, Some(137));
2579 }
2580
2581 #[test]
2582 fn evaluate_layer2_exit_127_is_agent_unavailable() {
2583 let dir = tempfile::tempdir().unwrap();
2584 init_repo_with_feature_commit(dir.path(), 21);
2585 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2586 std::fs::write(exit_code_path(dir.path(), 21), "127").unwrap();
2587 let state = state_in(dir.path(), 21);
2588
2589 let result = evaluate_layer2(dir.path(), 21, &GitFlowConfig::default(), state.stage)
2590 .unwrap()
2591 .unwrap();
2592
2593 assert_eq!(result.status, AgentStatus::AgentUnavailable);
2594 assert_eq!(result.exit_code, Some(127));
2595 }
2596
2597 #[test]
2598 fn sequentagent_slot_round_trips() {
2599 let dir = tempfile::tempdir().unwrap();
2600
2601 write_sequentagent_slot(
2602 dir.path(),
2603 7,
2604 SequentagentSlotKind::B,
2605 crate::state::AgentKind::Codex,
2606 )
2607 .unwrap();
2608 let record = read_sequentagent_slot(dir.path(), 7).unwrap();
2609 assert_eq!(record.slot, "B");
2610 assert_eq!(record.agent, "codex");
2611
2612 clear_sequentagent_slot(dir.path(), 7);
2613 assert!(read_sequentagent_slot(dir.path(), 7).is_none());
2614 }
2615
2616 #[test]
2617 fn sequentagent_slot_is_path_free() {
2618 let dir = tempfile::tempdir().unwrap();
2619
2620 write_sequentagent_slot(
2621 dir.path(),
2622 9,
2623 SequentagentSlotKind::A,
2624 crate::state::AgentKind::Claude,
2625 )
2626 .unwrap();
2627
2628 let raw = std::fs::read_to_string(sequentagent_slot_path(dir.path(), 9)).unwrap();
2629 let dir_str = dir.path().display().to_string();
2630 assert!(
2631 !raw.contains(&dir_str),
2632 "slot record leaked the project root path: {raw:?}"
2633 );
2634 assert!(!raw.contains('/'), "slot record contains a path: {raw:?}");
2635 if let Ok(home) = std::env::var("HOME") {
2636 assert!(!raw.contains(&home), "slot record leaked $HOME: {raw:?}");
2637 }
2638 }
2639
2640 #[test]
2641 fn sequentagent_slot_write_creates_devflow_dir_and_gitignore() {
2642 let dir = tempfile::tempdir().unwrap();
2643 assert!(!dir.path().join(".devflow").exists());
2644
2645 write_sequentagent_slot(
2646 dir.path(),
2647 11,
2648 SequentagentSlotKind::A,
2649 crate::state::AgentKind::Claude,
2650 )
2651 .unwrap();
2652
2653 assert!(dir.path().join(".devflow").is_dir());
2654 let gitignore = std::fs::read_to_string(dir.path().join(".devflow/.gitignore")).unwrap();
2655 assert_eq!(gitignore, "*\n");
2656 }
2657
2658 #[test]
2659 fn sequentagent_slot_missing_record_reads_as_none() {
2660 let dir = tempfile::tempdir().unwrap();
2661 assert!(read_sequentagent_slot(dir.path(), 13).is_none());
2662 }
2663
2664 #[test]
2665 fn sequentagent_slot_kind_as_str() {
2666 assert_eq!(SequentagentSlotKind::A.as_str(), "A");
2667 assert_eq!(SequentagentSlotKind::B.as_str(), "B");
2668 }
2669}