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}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum AgentStatus {
42 Success,
44 Failed,
46 RateLimited,
48 Unknown,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Verdict {
56 Pass,
58 Gaps,
61}
62
63fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
81where
82 D: serde::Deserializer<'de>,
83{
84 let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
85 Ok(raw.and_then(|v| {
86 v.as_str().and_then(|s| match s {
87 "pass" => Some(Verdict::Pass),
88 "gaps" => Some(Verdict::Gaps),
89 _ => None,
90 })
91 }))
92}
93
94#[derive(Debug, thiserror::Error)]
96pub enum ResultError {
97 #[error("I/O error reading agent output: {0}")]
98 Io(#[from] std::io::Error),
99 #[error("phase directory not found")]
100 NoPhaseDir,
101}
102
103pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
114 if let Some(inner) = extract_json_result_text(stdout)
115 && let Some(result) = parse_marker_lines(&inner)
116 {
117 return Some(result);
118 }
119 parse_marker_lines(stdout)
120}
121
122pub fn detect_rate_limit(stdout: &str) -> Option<String> {
129 detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
130}
131
132fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
133 let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
134 let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
135 || json_has_i64(&value, "api_error_status", 429)
136 || json_has_i64(&value, "status", 429)
137 || json_has_i64(&value, "status_code", 429);
138 if !rate_limited {
139 return None;
140 }
141 json_find_key(&value, "retry_after")
142 .and_then(json_scalar_to_string)
143 .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
144 .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
145 .or_else(|| Some("usage limit".to_string()))
146}
147
148fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
149 let stdout: String = stdout
156 .lines()
157 .filter(|line| {
158 serde_json::from_str::<serde_json::Value>(line)
159 .map(|v| !v.is_object())
160 .unwrap_or(true)
161 })
162 .collect::<Vec<_>>()
163 .join("\n");
164 let stdout = stdout.as_str();
165 let lower = stdout.to_ascii_lowercase();
166 if let Some(idx) = lower.find("try again at ") {
167 let start = idx + "try again at ".len();
168 let retry = stdout[start..]
169 .lines()
170 .next()
171 .unwrap_or_default()
172 .trim()
173 .trim_end_matches(['.', ',', ';'])
174 .trim();
175 if !retry.is_empty() {
176 return Some(retry.to_string());
177 }
178 }
179
180 if lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("429") {
181 stdout
182 .lines()
183 .find(|line| {
184 let line = line.to_ascii_lowercase();
185 line.contains("usage limit") || line.contains("rate limit") || line.contains("429")
186 })
187 .map(str::trim)
188 .filter(|line| !line.is_empty())
189 .map(str::to_string)
190 .or_else(|| Some("usage limit".to_string()))
191 } else {
192 None
193 }
194}
195
196fn extract_json_result_text(stdout: &str) -> Option<String> {
199 let trimmed = stdout.trim();
200 if !trimmed.starts_with('{') {
201 return None;
202 }
203 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
204 value.get("result")?.as_str().map(str::to_string)
205}
206
207fn json_scan<'a, T>(
220 value: &'a serde_json::Value,
221 visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
222) -> Option<T> {
223 let mut stack = vec![value];
224 while let Some(current) = stack.pop() {
225 match current {
226 serde_json::Value::Object(map) => {
227 if let Some(found) = visit(map) {
228 return Some(found);
229 }
230 for child in map.values().rev() {
232 stack.push(child);
233 }
234 }
235 serde_json::Value::Array(values) => {
236 for child in values.iter().rev() {
237 stack.push(child);
238 }
239 }
240 _ => {}
241 }
242 }
243 None
244}
245
246fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
247 json_scan(value, |map| {
248 (map.get(key)?.as_str()? == expected).then_some(())
249 })
250 .is_some()
251}
252
253fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
254 json_scan(value, |map| {
255 (map.get(key)?.as_i64()? == expected).then_some(())
256 })
257 .is_some()
258}
259
260fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
261 json_scan(value, |map| map.get(key))
262}
263
264fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
265 match value {
266 serde_json::Value::String(s) => Some(s.clone()),
267 serde_json::Value::Number(n) => Some(n.to_string()),
268 _ => None,
269 }
270}
271
272fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
290 let trimmed = stdout.trim();
291 if !trimmed.starts_with('{') {
292 return None;
293 }
294 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
295 let is_error = value.get("is_error")?.as_bool()?;
296 if !is_error {
297 return None;
298 }
299
300 let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
301 let base_reason = value
302 .get("result")
303 .and_then(serde_json::Value::as_str)
304 .map(str::to_string)
305 .or_else(|| {
306 value
307 .get("subtype")
308 .and_then(serde_json::Value::as_str)
309 .map(str::to_string)
310 })
311 .unwrap_or_else(|| "agent reported is_error".to_string());
312 let reason = match num_turns {
313 Some(n) => format!("{base_reason} (num_turns: {n})"),
314 None => base_reason,
315 };
316
317 Some(AgentResult {
318 status: AgentStatus::Failed,
319 exit_code: None,
320 reason: Some(reason),
321 commits: None,
322 summary: None,
323 verdict: None,
324 })
325}
326
327fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
331 events.iter().any(|v| {
332 v.get("type")
333 .and_then(serde_json::Value::as_str)
334 .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
335 })
336}
337
338fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
359 let events: Vec<serde_json::Value> = stdout
360 .lines()
361 .filter(|line| !line.trim().is_empty())
362 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
363 .collect();
364
365 if !is_codex_event_stream(&events) {
366 return None;
367 }
368
369 let marker = events.iter().rev().find_map(|v| {
376 if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
377 return None;
378 }
379 let item = v.get("item")?;
380 if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
381 return None;
382 }
383 let text = item.get("text").and_then(serde_json::Value::as_str)?;
384 parse_marker_lines(text)
385 });
386 if marker.is_some() {
387 return marker;
388 }
389
390 let terminal = events.iter().rev().find(|v| {
391 matches!(
392 v.get("type").and_then(serde_json::Value::as_str),
393 Some("turn.completed") | Some("turn.failed")
394 )
395 })?;
396
397 if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
398 return None;
401 }
402
403 let reason = terminal
404 .get("error")
405 .and_then(|e| e.get("message"))
406 .and_then(serde_json::Value::as_str)
407 .map(str::to_string)
408 .unwrap_or_else(|| "codex turn failed".to_string());
409
410 Some(AgentResult {
411 status: AgentStatus::Failed,
412 exit_code: None,
413 reason: Some(reason),
414 commits: None,
415 summary: None,
416 verdict: None,
417 })
418}
419
420fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
426 let tail: String = stdout
429 .chars()
430 .rev()
431 .take(4000)
432 .collect::<Vec<_>>()
433 .into_iter()
434 .rev()
435 .collect();
436
437 for line in tail.lines().rev() {
438 let Some(json_str) = line
439 .strip_prefix("DEVFLOW_RESULT: ")
440 .or_else(|| line.strip_prefix("devflow_result: "))
441 .or_else(|| line.strip_prefix("DEVFLOW_RESULT:"))
442 .or_else(|| line.strip_prefix("devflow_result:"))
443 else {
444 continue;
445 };
446
447 let json_str = json_str.trim();
448 if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
449 return Some(result);
450 }
451 }
452 None
453}
454
455pub fn evaluate_layer1(project_root: &Path, phase: u32) -> Option<AgentResult> {
467 let stdout_path = devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase));
468 let bytes = std::fs::read(&stdout_path).ok()?;
474 let stdout = String::from_utf8_lossy(&bytes);
475 detect_claude_rate_limit(&stdout)
476 .map(rate_limited_result)
477 .or_else(|| detect_claude_envelope_failure(&stdout))
478 .or_else(|| parse_devflow_result(&stdout))
479 .or_else(|| parse_codex_event_result(&stdout))
480 .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
481}
482
483fn rate_limited_result(retry: String) -> AgentResult {
485 AgentResult {
486 status: AgentStatus::RateLimited,
487 exit_code: None,
488 reason: Some(format!("rate limited until {retry}")),
489 commits: None,
490 summary: None,
491 verdict: None,
492 }
493}
494
495pub fn evaluate_layer2(
522 project_root: &Path,
523 phase: u32,
524 git_flow: &GitFlowConfig,
525 stage: Stage,
526) -> Result<Option<AgentResult>, ResultError> {
527 let exit_path = devflow_dir(project_root).join(format!("phase-{:02}-exit", phase));
528 let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
529 Ok(s) => s.trim().parse().unwrap_or(-1),
530 Err(_) => return Ok(None), };
532
533 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
534
535 let branch_exists = std::process::Command::new("git")
537 .args(["rev-parse", "--verify", &branch])
538 .current_dir(project_root)
539 .output()
540 .map(|o| o.status.success())
541 .unwrap_or(false);
542
543 let commits: u32 = if branch_exists {
544 let range = format!("{}..{branch}", git_flow.develop);
545 std::process::Command::new("git")
546 .args(["rev-list", "--count", &range])
547 .current_dir(project_root)
548 .output()
549 .ok()
550 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
551 .unwrap_or(0)
552 } else {
553 0
554 };
555
556 let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
557 let no_work_done = commit_gated && commits == 0;
558
559 Ok(Some(AgentResult {
560 status: if exit_code != 0 || no_work_done {
561 AgentStatus::Failed
562 } else {
563 AgentStatus::Success
564 },
565 exit_code: Some(exit_code),
566 reason: if exit_code != 0 {
567 Some(format!(
568 "agent exited with code {} ({} commits on {})",
569 exit_code, commits, branch
570 ))
571 } else if no_work_done {
572 Some(format!(
573 "no commits found on {} (agent exit code was {})",
574 branch, exit_code
575 ))
576 } else {
577 Some(format!(
578 "{} commits on {} (agent exit code was {})",
579 commits, branch, exit_code
580 ))
581 },
582 commits: Some(commits),
583 summary: None,
584 verdict: None,
585 }))
586}
587
588pub fn evaluate_layer3(
593 project_root: &Path,
594 phase: u32,
595 git_flow: &GitFlowConfig,
596) -> Result<AgentResult, ResultError> {
597 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
598 let commits = std::process::Command::new("git")
599 .args([
600 "rev-list",
601 "--count",
602 &format!("{}..{branch}", git_flow.develop),
603 ])
604 .current_dir(project_root)
605 .output()
606 .ok()
607 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
608 .unwrap_or(0);
609
610 Ok(AgentResult {
611 status: AgentStatus::Unknown,
612 exit_code: None,
613 reason: if commits > 0 {
614 Some(format!(
615 "unverified — agent process is gone but {} commits exist on {}",
616 commits, branch
617 ))
618 } else {
619 Some("no work detected — agent process is gone with no commits".into())
620 },
621 commits: Some(commits),
622 summary: None,
623 verdict: None,
624 })
625}
626
627fn evaluate_layer0(
634 project_root: &Path,
635 state: &State,
636 approved_commands: Option<&[String]>,
637) -> Option<AgentResult> {
638 if state.stage != Stage::Code || !crate::config::external_verify_enabled(project_root) {
639 return None;
640 }
641
642 let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
643 let commands = crate::verify::external_verify_commands(execution_root, state.phase);
644 if commands.is_empty() {
645 return approved_commands.map(|_| AgentResult {
646 status: AgentStatus::Failed,
647 exit_code: None,
648 reason: Some(
649 "external verification approval mismatch; PLAN declaration was removed".into(),
650 ),
651 commits: None,
652 summary: None,
653 verdict: None,
654 });
655 }
656 let Some(approved_commands) = approved_commands else {
657 return Some(AgentResult {
658 status: AgentStatus::Failed,
659 exit_code: None,
660 reason: Some(format!(
661 "external verification is not approved; set {} to the reviewed JSON command array",
662 crate::verify::TRUST_EXTERNAL_VERIFY_ENV
663 )),
664 commits: None,
665 summary: None,
666 verdict: None,
667 });
668 };
669 if commands != approved_commands {
670 return Some(AgentResult {
671 status: AgentStatus::Failed,
672 exit_code: None,
673 reason: Some("external verification approval mismatch; PLAN commands changed".into()),
674 commits: None,
675 summary: None,
676 verdict: None,
677 });
678 }
679 commands
680 .into_iter()
681 .find(|command| !crate::verify::run_external_verification(command, execution_root))
682 .map(|command| AgentResult {
683 status: AgentStatus::Failed,
684 exit_code: None,
685 reason: Some(format!("external verification failed: {command}")),
686 commits: None,
687 summary: None,
688 verdict: None,
689 })
690}
691
692pub fn evaluate_agent_result(
694 project_root: &Path,
695 state: &State,
696 git_flow: &GitFlowConfig,
697) -> Result<AgentResult, ResultError> {
698 let approval = crate::verify::external_verification_approval();
699 evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
700}
701
702fn evaluate_agent_result_inner(
703 project_root: &Path,
704 state: &State,
705 git_flow: &GitFlowConfig,
706 approved_commands: Option<&[String]>,
707) -> Result<AgentResult, ResultError> {
708 if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
710 return Ok(result);
711 }
712
713 if let Some(result) = evaluate_layer1(project_root, state.phase) {
715 return Ok(result);
716 }
717
718 if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
720 return Ok(result);
721 }
722
723 evaluate_layer3(project_root, state.phase, git_flow)
725}
726
727fn devflow_dir(project_root: &Path) -> PathBuf {
729 project_root.join(".devflow")
730}
731
732pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
734 devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
735}
736
737pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
740 devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
741}
742
743pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
745 devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
746}
747
748pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
750 devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
751}
752
753pub fn history_dir(project_root: &Path, phase: u32) -> PathBuf {
761 devflow_dir(project_root)
762 .join("history")
763 .join(format!("phase-{:02}", phase))
764}
765
766static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
770
771fn archive_stamp() -> String {
776 let nanos = std::time::SystemTime::now()
777 .duration_since(std::time::UNIX_EPOCH)
778 .map(|d| d.as_nanos())
779 .unwrap_or(0);
780 let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
781 format!("{nanos}-{seq}")
782}
783
784pub fn archive_phase_files(
794 project_root: &Path,
795 evidence_root: &Path,
796 phase: u32,
797 retain: usize,
798) -> Result<Option<String>, std::io::Error> {
799 archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
800}
801
802fn archive_phase_files_with_stamp(
803 project_root: &Path,
804 evidence_root: &Path,
805 phase: u32,
806 retain: usize,
807 stamp: &str,
808) -> Result<Option<String>, std::io::Error> {
809 let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
810
811 let stdout_src = stdout_path(project_root, phase);
812 let exit_src = exit_code_path(project_root, phase);
813 let stdout_exists = stdout_src.exists();
814 let exit_exists = exit_src.exists();
815 if !stdout_exists && !exit_exists {
816 return Ok(None); }
818
819 let history_dir = history_dir(project_root, phase);
820 std::fs::create_dir_all(&history_dir)?;
821
822 let staging_dir = history_dir.join(format!(".pending-{stamp}"));
823 std::fs::create_dir(&staging_dir)?;
824 let stdout_stage = staging_dir.join("stdout");
825 let exit_stage = staging_dir.join("exit");
826 let review_stage = staging_dir.join("REVIEW.md");
827 let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
828 let exit_dest = history_dir.join(format!("{stamp}-exit"));
829 let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
830 let review_src = phase_review_path(evidence_root, phase);
831
832 let mut stdout_staged = false;
833 let mut exit_staged = false;
834 let mut stdout_published = false;
835 let mut exit_published = false;
836 let mut review_published = false;
837
838 let archive_result = (|| -> Result<(), std::io::Error> {
839 if stdout_exists {
840 std::fs::rename(&stdout_src, &stdout_stage)?;
841 stdout_staged = true;
842 }
843 if exit_exists {
844 std::fs::rename(&exit_src, &exit_stage)?;
845 exit_staged = true;
846 }
847 if let Some(review) = &review_src {
848 std::fs::copy(review, &review_stage)?;
849 }
850
851 if stdout_exists {
852 std::fs::rename(&stdout_stage, &stdout_dest)?;
853 stdout_staged = false;
854 stdout_published = true;
855 }
856 if exit_exists {
857 std::fs::rename(&exit_stage, &exit_dest)?;
858 exit_staged = false;
859 exit_published = true;
860 }
861 if review_src.is_some() {
862 std::fs::rename(&review_stage, &review_dest)?;
863 review_published = true;
864 }
865 Ok(())
866 })();
867
868 if let Err(error) = archive_result {
869 let mut rollback_error = None;
870 let mut restore = |from: &Path, to: &Path| {
871 if let Err(error) = std::fs::rename(from, to)
872 && rollback_error.is_none()
873 {
874 rollback_error = Some(error);
875 }
876 };
877 if stdout_published {
878 restore(&stdout_dest, &stdout_src);
879 } else if stdout_staged {
880 restore(&stdout_stage, &stdout_src);
881 }
882 if exit_published {
883 restore(&exit_dest, &exit_src);
884 } else if exit_staged {
885 restore(&exit_stage, &exit_src);
886 }
887 if review_published {
888 let _ = std::fs::remove_file(&review_dest);
889 }
890 let _ = std::fs::remove_dir_all(&staging_dir);
891
892 if let Some(rollback_error) = rollback_error {
893 return Err(std::io::Error::new(
894 error.kind(),
895 format!("{error}; archive rollback failed: {rollback_error}"),
896 ));
897 }
898 return Err(error);
899 }
900
901 let _ = std::fs::remove_dir(&staging_dir);
902
903 prune_history(&history_dir, retain);
904 Ok(Some(stamp.to_string()))
905}
906
907fn phase_review_path(project_root: &Path, phase: u32) -> Option<PathBuf> {
908 let phases = std::fs::read_dir(project_root.join(".planning/phases")).ok()?;
909 let prefix = format!("{phase:02}-");
910 for entry in phases.flatten() {
911 if entry
912 .file_name()
913 .to_str()
914 .is_some_and(|name| name.starts_with(&prefix))
915 {
916 let review = entry.path().join(format!("{phase:02}-REVIEW.md"));
917 if review.exists() {
918 return Some(review);
919 }
920 }
921 }
922 None
923}
924
925fn prune_history(history_dir: &Path, retain: usize) {
933 let Ok(entries) = std::fs::read_dir(history_dir) else {
934 return;
935 };
936
937 let mut stamps: Vec<String> = entries
938 .flatten()
939 .filter_map(|entry| {
940 let name = entry.file_name().to_str()?.to_string();
941 name.rsplit_once('-')
942 .map(|(stamp, _suffix)| stamp.to_string())
943 })
944 .collect();
945 stamps.sort_by_key(|stamp| {
946 let mut parts = stamp.split('-');
947 let nanos = parts
948 .next()
949 .and_then(|part| part.parse::<u128>().ok())
950 .unwrap_or(0);
951 let sequence = parts
952 .next()
953 .and_then(|part| part.parse::<u64>().ok())
954 .unwrap_or(0);
955 (nanos, sequence)
956 });
957 stamps.dedup();
958
959 if stamps.len() <= retain {
960 return;
961 }
962
963 let to_remove = stamps.len() - retain;
964 for stamp in &stamps[..to_remove] {
965 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
966 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
967 let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
968 }
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974 use crate::config::GitFlowConfig;
975 use crate::mode::Mode;
976 use crate::stage::Stage;
977 use crate::state::{AgentKind, State};
978 use std::process::Command;
979
980 fn state_in(root: &Path, phase: u32) -> State {
981 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
982 state.stage = Stage::Code;
983 state
984 }
985
986 fn git(root: &Path, args: &[&str]) {
987 let output = Command::new("git")
988 .args(args)
989 .current_dir(root)
990 .output()
991 .unwrap();
992 assert!(
993 output.status.success(),
994 "git {:?} failed\nstdout: {}\nstderr: {}",
995 args,
996 String::from_utf8_lossy(&output.stdout),
997 String::from_utf8_lossy(&output.stderr)
998 );
999 }
1000
1001 fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1002 git(root, &["init"]);
1003 git(root, &["config", "user.email", "devflow@example.com"]);
1004 git(root, &["config", "user.name", "DevFlow Tests"]);
1005 git(root, &["config", "commit.gpgsign", "false"]);
1006 git(root, &["config", "tag.gpgsign", "false"]);
1007 git(root, &["checkout", "-b", "develop"]);
1008 std::fs::write(root.join("README.md"), "base\n").unwrap();
1009 git(root, &["add", "README.md"]);
1010 git(root, &["commit", "-m", "base"]);
1011
1012 let branch = format!("feature/phase-{phase:02}");
1013 git(root, &["checkout", "-b", &branch]);
1014 std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1015 git(root, &["add", "phase.txt"]);
1016 git(root, &["commit", "-m", "feature work"]);
1017 }
1018
1019 fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1022 git(root, &["init"]);
1023 git(root, &["config", "user.email", "devflow@example.com"]);
1024 git(root, &["config", "user.name", "DevFlow Tests"]);
1025 git(root, &["config", "commit.gpgsign", "false"]);
1026 git(root, &["config", "tag.gpgsign", "false"]);
1027 git(root, &["checkout", "-b", "develop"]);
1028 std::fs::write(root.join("README.md"), "base\n").unwrap();
1029 git(root, &["add", "README.md"]);
1030 git(root, &["commit", "-m", "base"]);
1031
1032 let branch = format!("feature/phase-{phase:02}");
1033 git(root, &["checkout", "-b", &branch]);
1034 }
1035
1036 #[test]
1037 fn parse_success_marker() {
1038 let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1039 let result = parse_devflow_result(stdout).unwrap();
1040 assert_eq!(result.status, AgentStatus::Success);
1041 }
1042
1043 #[test]
1044 fn parse_failed_marker_with_reason() {
1045 let stdout =
1046 "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1047 let result = parse_devflow_result(stdout).unwrap();
1048 assert_eq!(result.status, AgentStatus::Failed);
1049 assert_eq!(result.reason.unwrap(), "clippy errors");
1050 }
1051
1052 #[test]
1053 fn parse_missing_marker_returns_none() {
1054 let stdout = "just some output\nno marker here\n";
1055 assert!(parse_devflow_result(stdout).is_none());
1056 }
1057
1058 #[test]
1059 fn parse_malformed_json_returns_none() {
1060 let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1061 assert!(parse_devflow_result(stdout).is_none());
1062 }
1063
1064 #[test]
1065 fn parse_lowercase_marker() {
1066 let stdout = "devflow_result: {\"status\":\"success\"}\n";
1067 let result = parse_devflow_result(stdout).unwrap();
1068 assert_eq!(result.status, AgentStatus::Success);
1069 }
1070
1071 #[test]
1072 fn parse_marker_without_space_after_colon() {
1073 let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1074 let result = parse_devflow_result(stdout).unwrap();
1075 assert_eq!(result.status, AgentStatus::Success);
1076 }
1077
1078 #[test]
1079 fn parse_lowercase_no_space_marker() {
1080 let stdout = "devflow_result:{\"status\":\"success\"}\n";
1083 let result = parse_devflow_result(stdout).unwrap();
1084 assert_eq!(result.status, AgentStatus::Success);
1085 }
1086
1087 #[test]
1088 fn parse_finds_last_marker_in_tail() {
1089 let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1091 let result = parse_devflow_result(stdout).unwrap();
1092 assert_eq!(result.status, AgentStatus::Success);
1093 }
1094
1095 #[test]
1096 fn parse_marker_lines_returns_last_marker_in_long_output() {
1097 let stdout = format!(
1098 "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1099 DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1100 "prefix".repeat(900),
1101 "tail output".repeat(100)
1102 );
1103
1104 let result = parse_marker_lines(&stdout).unwrap();
1105
1106 assert_eq!(result.status, AgentStatus::Success);
1107 }
1108
1109 #[test]
1110 fn parse_marker_only_in_last_4000_chars() {
1111 let prefix = "a".repeat(5000);
1113 let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1114 assert!(parse_devflow_result(&stdout).is_none());
1115 }
1116
1117 #[test]
1118 fn parse_marker_with_commits_and_summary() {
1119 let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1120 let result = parse_devflow_result(stdout).unwrap();
1121 assert_eq!(result.status, AgentStatus::Success);
1122 assert_eq!(result.commits, Some(3));
1123 assert_eq!(result.summary.unwrap(), "added tests");
1124 }
1125
1126 #[test]
1127 fn parse_marker_inside_json_result_envelope() {
1128 let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1131 let result = parse_devflow_result(stdout).unwrap();
1132 assert_eq!(result.status, AgentStatus::Success);
1133 assert_eq!(result.commits, Some(2));
1134 }
1135
1136 #[test]
1137 fn parse_failed_marker_inside_json_envelope() {
1138 let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1139 let result = parse_devflow_result(stdout).unwrap();
1140 assert_eq!(result.status, AgentStatus::Failed);
1141 assert_eq!(result.reason.unwrap(), "tests failed");
1142 }
1143
1144 #[test]
1145 fn parse_json_envelope_without_marker_returns_none() {
1146 let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1147 assert!(parse_devflow_result(stdout).is_none());
1148 }
1149
1150 #[test]
1151 fn detect_claude_json_rate_limit_by_subtype() {
1152 let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1153 assert_eq!(
1154 detect_rate_limit(stdout).as_deref(),
1155 Some("2026-06-18T15:45:30Z")
1156 );
1157 }
1158
1159 #[test]
1160 fn detect_claude_json_rate_limit_by_429() {
1161 let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1162 assert_eq!(
1163 detect_rate_limit(stdout).as_deref(),
1164 Some("Too many requests. Try later.")
1165 );
1166 }
1167
1168 #[test]
1169 fn detect_codex_try_again_rate_limit() {
1170 let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1171 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1172 }
1173
1174 #[test]
1183 fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1184 const DEPTH: usize = 100;
1187 let mut stdout = String::new();
1188 for _ in 0..DEPTH {
1189 stdout.push_str(r#"{"nested":"#);
1190 }
1191 stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1192 for _ in 0..DEPTH {
1193 stdout.push('}');
1194 }
1195
1196 assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1199 }
1200
1201 #[test]
1202 fn detect_rate_limit_ignores_normal_stdout() {
1203 let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1204 assert!(detect_rate_limit(stdout).is_none());
1205 }
1206
1207 #[test]
1208 fn claude_envelope_is_error_detected() {
1209 let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1210 let result = detect_claude_envelope_failure(stdout).unwrap();
1211 assert_eq!(result.status, AgentStatus::Failed);
1212 }
1213
1214 #[test]
1215 fn claude_is_error_overrides_success_marker() {
1216 let dir = tempfile::tempdir().unwrap();
1217 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1218 std::fs::write(
1219 stdout_path(dir.path(), 9),
1220 r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1221 )
1222 .unwrap();
1223
1224 let result = evaluate_layer1(dir.path(), 9).unwrap();
1225
1226 assert_eq!(result.status, AgentStatus::Failed);
1227 }
1228
1229 #[test]
1230 fn claude_envelope_is_error_false_defers() {
1231 let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1232 assert!(detect_claude_envelope_failure(stdout).is_none());
1233 }
1234
1235 #[test]
1236 fn claude_envelope_marker_still_wins() {
1237 let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1238 assert!(detect_claude_envelope_failure(stdout).is_none());
1239 let result = parse_devflow_result(stdout).unwrap();
1240 assert_eq!(result.status, AgentStatus::Success);
1241 assert_eq!(result.commits, Some(2));
1242 }
1243
1244 #[test]
1245 fn codex_event_stream_parses_turn_failed() {
1246 let stdout = concat!(
1247 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1248 "{\"type\":\"turn.started\"}\n",
1249 "{\"type\":\"item.started\",\"item\":{}}\n",
1250 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1251 );
1252 let result = parse_codex_event_result(stdout).unwrap();
1253 assert_eq!(result.status, AgentStatus::Failed);
1254 assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1255 }
1256
1257 #[test]
1258 fn codex_turn_completed_no_marker_defers() {
1259 let stdout = concat!(
1260 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1261 "{\"type\":\"turn.started\"}\n",
1262 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1263 );
1264 assert!(parse_codex_event_result(stdout).is_none());
1265 }
1266
1267 #[test]
1273 fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1274 let stdout = concat!(
1275 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1276 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1277 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1278 );
1279 let result = parse_codex_event_result(stdout).unwrap();
1280 assert_eq!(result.status, AgentStatus::Failed);
1281 assert_eq!(
1282 result.reason.as_deref(),
1283 Some("interactive input unavailable")
1284 );
1285 }
1286
1287 #[test]
1288 fn codex_agent_message_marker_success_short_circuits() {
1289 let stdout = concat!(
1290 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1291 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1292 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1293 );
1294 let result = parse_codex_event_result(stdout).unwrap();
1295 assert_eq!(result.status, AgentStatus::Success);
1296 }
1297
1298 #[test]
1304 fn detect_rate_limit_ignores_json_event_lines() {
1305 let stdout = concat!(
1306 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1307 "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1308 "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1309 );
1310 assert_eq!(detect_rate_limit(stdout), None);
1311 }
1312
1313 #[test]
1314 fn detect_rate_limit_still_reads_codex_plain_text() {
1315 let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1316 assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1317 }
1318
1319 #[test]
1320 fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1321 let stdout = concat!(
1322 "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1323 "not json at all\n",
1324 "{\"type\":\"item.started\",\"item\":{}}\n",
1325 "{\"type\":\"item.updated\",\"item\":{}}\n",
1326 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1327 );
1328 let result = parse_codex_event_result(stdout).unwrap();
1329 assert_eq!(result.status, AgentStatus::Failed);
1330 assert_eq!(result.reason.as_deref(), Some("boom"));
1331 }
1332
1333 #[test]
1334 fn claude_envelope_not_consumed_by_codex_parser() {
1335 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1336 assert!(parse_codex_event_result(stdout).is_none());
1337 }
1338
1339 #[test]
1340 fn evaluate_layer1_reports_rate_limited_without_marker() {
1341 let dir = tempfile::tempdir().unwrap();
1342 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1343 std::fs::write(
1344 stdout_path(dir.path(), 7),
1345 r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1346 )
1347 .unwrap();
1348
1349 let result = evaluate_layer1(dir.path(), 7).unwrap();
1350
1351 assert_eq!(result.status, AgentStatus::RateLimited);
1352 assert_eq!(
1353 result.reason.as_deref(),
1354 Some("rate limited until 2026-06-18T15:45:30Z")
1355 );
1356 }
1357
1358 #[test]
1363 fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1364 let dir = tempfile::tempdir().unwrap();
1365 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1366 std::fs::write(
1367 stdout_path(dir.path(), 7),
1368 r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1369 )
1370 .unwrap();
1371
1372 let result = evaluate_layer1(dir.path(), 7).unwrap();
1373
1374 assert_eq!(result.status, AgentStatus::RateLimited);
1375 assert_eq!(
1376 result.reason.as_deref(),
1377 Some("rate limited until 2026-06-18T15:45:30Z")
1378 );
1379 }
1380
1381 #[test]
1387 fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1388 let dir = tempfile::tempdir().unwrap();
1389 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1390 let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1391 bytes.extend_from_slice(
1392 b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1393 );
1394 std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1395
1396 let result = evaluate_layer1(dir.path(), 5).unwrap();
1397
1398 assert_eq!(result.status, AgentStatus::Failed);
1399 assert_eq!(result.reason.as_deref(), Some("review: bad"));
1400 }
1401
1402 #[test]
1403 fn failing_external_probe_outranks_success_marker() {
1404 let dir = tempfile::tempdir().unwrap();
1405 let phase_dir = dir
1406 .path()
1407 .join(".planning/phases/16-pipeline-reliability-hardening");
1408 std::fs::create_dir_all(&phase_dir).unwrap();
1409 std::fs::write(
1410 phase_dir.join("16-03-PLAN.md"),
1411 "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1412 )
1413 .unwrap();
1414 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1415 std::fs::write(
1416 stdout_path(dir.path(), 16),
1417 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1418 )
1419 .unwrap();
1420 let state = state_in(dir.path(), 16);
1421
1422 let approval = vec!["test -f externally-shipped".to_string()];
1423 let result = evaluate_agent_result_inner(
1424 dir.path(),
1425 &state,
1426 &GitFlowConfig::default(),
1427 Some(&approval),
1428 )
1429 .unwrap();
1430
1431 assert_eq!(result.status, AgentStatus::Failed);
1432 assert!(
1433 result
1434 .reason
1435 .as_deref()
1436 .is_some_and(|reason| reason.contains("external verification failed"))
1437 );
1438 }
1439
1440 #[test]
1441 fn external_probe_runs_only_after_code_and_reads_execution_worktree() {
1442 let dir = tempfile::tempdir().unwrap();
1443 let worktree = dir.path().join("phase-worktree");
1444 let phase_dir = worktree.join(".planning/phases/16-reliability");
1445 std::fs::create_dir_all(&phase_dir).unwrap();
1446 std::fs::write(
1447 phase_dir.join("16-01-PLAN.md"),
1448 "---\nexternal_verify: \"test -f implemented\"\n---\n",
1449 )
1450 .unwrap();
1451 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1452 std::fs::write(
1453 stdout_path(dir.path(), 16),
1454 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1455 )
1456 .unwrap();
1457 let mut state = state_in(dir.path(), 16);
1458 state.worktree_path = Some(worktree.clone());
1459 state.stage = Stage::Plan;
1460
1461 let approval = vec!["test -f implemented".to_string()];
1462 let plan_result = evaluate_agent_result_inner(
1463 dir.path(),
1464 &state,
1465 &GitFlowConfig::default(),
1466 Some(&approval),
1467 )
1468 .unwrap();
1469 assert_eq!(plan_result.status, AgentStatus::Success);
1470
1471 state.stage = Stage::Code;
1472 let code_result = evaluate_agent_result_inner(
1473 dir.path(),
1474 &state,
1475 &GitFlowConfig::default(),
1476 Some(&approval),
1477 )
1478 .unwrap();
1479 assert_eq!(code_result.status, AgentStatus::Failed);
1480
1481 std::fs::write(worktree.join("implemented"), "done").unwrap();
1482 let passing = evaluate_agent_result_inner(
1483 dir.path(),
1484 &state,
1485 &GitFlowConfig::default(),
1486 Some(&approval),
1487 )
1488 .unwrap();
1489 assert_eq!(passing.status, AgentStatus::Success);
1490 }
1491
1492 #[test]
1493 fn changed_external_probe_never_inherits_prior_approval() {
1494 let dir = tempfile::tempdir().unwrap();
1495 let phase_dir = dir.path().join(".planning/phases/16-reliability");
1496 std::fs::create_dir_all(&phase_dir).unwrap();
1497 std::fs::write(
1498 phase_dir.join("16-01-PLAN.md"),
1499 "---\nexternal_verify: \"touch escaped\"\n---\n",
1500 )
1501 .unwrap();
1502 let state = state_in(dir.path(), 16);
1503 let approved = vec!["test -f reviewed-artifact".to_string()];
1504
1505 let result = evaluate_agent_result_inner(
1506 dir.path(),
1507 &state,
1508 &GitFlowConfig::default(),
1509 Some(&approved),
1510 )
1511 .unwrap();
1512
1513 assert_eq!(result.status, AgentStatus::Failed);
1514 assert!(result.reason.unwrap().contains("approval mismatch"));
1515 assert!(!dir.path().join("escaped").exists());
1516 }
1517
1518 #[test]
1519 fn removed_external_probe_fails_closed_against_prior_approval() {
1520 let dir = tempfile::tempdir().unwrap();
1521 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1522 std::fs::write(
1523 stdout_path(dir.path(), 16),
1524 "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1525 )
1526 .unwrap();
1527 let state = state_in(dir.path(), 16);
1528 let approved = vec!["test -f shipped".to_string()];
1529
1530 let result = evaluate_agent_result_inner(
1531 dir.path(),
1532 &state,
1533 &GitFlowConfig::default(),
1534 Some(&approved),
1535 )
1536 .unwrap();
1537
1538 assert_eq!(result.status, AgentStatus::Failed);
1539 assert!(result.reason.unwrap().contains("declaration was removed"));
1540 }
1541
1542 #[test]
1543 fn no_external_declaration_preserves_layer1_result() {
1544 let dir = tempfile::tempdir().unwrap();
1545 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1546 std::fs::write(
1547 stdout_path(dir.path(), 16),
1548 "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
1549 )
1550 .unwrap();
1551 let state = state_in(dir.path(), 16);
1552 let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
1553
1554 let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1555
1556 assert_eq!(
1557 serde_json::to_value(full).unwrap(),
1558 serde_json::to_value(layer1).unwrap()
1559 );
1560 }
1561
1562 #[test]
1563 fn archive_moves_captures_into_history_and_removes_pid_file() {
1564 let dir = tempfile::tempdir().unwrap();
1567 let root = dir.path();
1568 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1569 std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
1570 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1571 std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
1572
1573 archive_phase_files(root, root, 1, 5).unwrap();
1574
1575 assert!(!root.join(".devflow/phase-01-stdout").exists());
1577 assert!(!root.join(".devflow/phase-01-exit").exists());
1578 assert!(!root.join(".devflow/phase-01-agent-pid").exists());
1580
1581 let history = history_dir(root, 1);
1582 let archived: Vec<_> = std::fs::read_dir(&history)
1583 .unwrap()
1584 .flatten()
1585 .map(|e| e.file_name().to_string_lossy().into_owned())
1586 .collect();
1587 let archived_stdout = archived
1588 .iter()
1589 .find(|name| name.ends_with("-stdout"))
1590 .expect("stdout capture should be archived into history");
1591 assert!(archived.iter().any(|name| name.ends_with("-exit")));
1592 let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
1593 assert_eq!(contents, "prior stdout");
1594 }
1595
1596 #[test]
1597 fn archive_is_noop_when_nothing_to_archive() {
1598 let dir = tempfile::tempdir().unwrap();
1599 let root = dir.path();
1600 archive_phase_files(root, root, 1, 5).unwrap();
1602 assert!(!history_dir(root, 1).exists());
1603 }
1604
1605 #[test]
1606 fn archive_handles_missing_devflow_dir() {
1607 let dir = tempfile::tempdir().unwrap();
1608 let root = dir.path();
1609 archive_phase_files(root, root, 1, 5).unwrap();
1611 }
1612
1613 #[test]
1614 fn archive_failure_preserves_live_capture_for_retry() {
1615 let dir = tempfile::tempdir().unwrap();
1616 let root = dir.path();
1617 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1618 std::fs::write(stdout_path(root, 1), "evidence").unwrap();
1619 std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
1622
1623 assert!(archive_phase_files(root, root, 1, 5).is_err());
1624 assert_eq!(
1625 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1626 "evidence"
1627 );
1628 }
1629
1630 #[test]
1631 fn archive_second_publish_failure_rolls_back_complete_live_pair() {
1632 let dir = tempfile::tempdir().unwrap();
1633 let root = dir.path();
1634 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1635 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
1636 std::fs::write(exit_code_path(root, 1), "17").unwrap();
1637 let history = history_dir(root, 1);
1638 std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
1639
1640 assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
1641
1642 assert_eq!(
1643 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1644 "stdout evidence"
1645 );
1646 assert_eq!(
1647 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
1648 "17"
1649 );
1650 assert!(!history.join("fixed-stdout").exists());
1651 assert!(!history.join(".pending-fixed").exists());
1652 }
1653
1654 #[test]
1655 fn archive_review_copy_failure_rolls_back_complete_live_pair() {
1656 let dir = tempfile::tempdir().unwrap();
1657 let root = dir.path();
1658 let evidence_root = root.join("phase-worktree");
1659 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1660 std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
1661 std::fs::write(exit_code_path(root, 1), "23").unwrap();
1662 let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
1663 std::fs::create_dir_all(&review).unwrap();
1664
1665 assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
1666
1667 assert_eq!(
1668 std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1669 "stdout evidence"
1670 );
1671 assert_eq!(
1672 std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
1673 "23"
1674 );
1675 let history = history_dir(root, 1);
1676 assert!(!history.join("review-copy-stdout").exists());
1677 assert!(!history.join("review-copy-exit").exists());
1678 assert!(!history.join(".pending-review-copy").exists());
1679 }
1680
1681 #[test]
1682 fn archive_snapshots_current_review_into_same_generation() {
1683 let dir = tempfile::tempdir().unwrap();
1684 let root = dir.path();
1685 let evidence_root = root.join("phase-worktree");
1686 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1687 std::fs::write(stdout_path(root, 1), "attempt").unwrap();
1688 let phase_dir = evidence_root.join(".planning/phases/01-example");
1689 std::fs::create_dir_all(&phase_dir).unwrap();
1690 std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
1691
1692 let stamp = archive_phase_files(root, &evidence_root, 1, 5)
1693 .unwrap()
1694 .unwrap();
1695
1696 assert_eq!(
1697 std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
1698 .unwrap(),
1699 "review one"
1700 );
1701 }
1702
1703 #[test]
1704 fn archive_prunes_history_to_retain_count() {
1705 let dir = tempfile::tempdir().unwrap();
1706 let root = dir.path();
1707 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1708
1709 for i in 0..7 {
1710 std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
1711 std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1712 archive_phase_files(root, root, 1, 3).unwrap();
1713 }
1714
1715 let history = history_dir(root, 1);
1716 let stdout_count = std::fs::read_dir(&history)
1717 .unwrap()
1718 .flatten()
1719 .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
1720 .count();
1721 let exit_count = std::fs::read_dir(&history)
1722 .unwrap()
1723 .flatten()
1724 .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
1725 .count();
1726 assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
1727 assert_eq!(exit_count, 3, "expected at most 3 retained generations");
1728 }
1729
1730 #[test]
1731 fn evaluate_agent_result_reads_files_end_to_end() {
1732 let dir = tempfile::tempdir().unwrap();
1733 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1734 std::fs::write(
1735 stdout_path(dir.path(), 6),
1736 "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
1737 )
1738 .unwrap();
1739 std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
1740 let state = state_in(dir.path(), 6);
1741
1742 let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1743
1744 assert_eq!(result.status, AgentStatus::Success);
1745 assert_eq!(result.commits, Some(2));
1746 assert_eq!(result.summary.as_deref(), Some("ok"));
1747 }
1748
1749 #[test]
1750 fn evaluate_layer1_finds_devflow_result_in_file() {
1751 let dir = tempfile::tempdir().unwrap();
1752 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1753 std::fs::write(
1754 stdout_path(dir.path(), 3),
1755 "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
1756 )
1757 .unwrap();
1758
1759 let result = evaluate_layer1(dir.path(), 3).unwrap();
1760
1761 assert_eq!(result.status, AgentStatus::Failed);
1762 assert_eq!(result.reason.as_deref(), Some("bad output"));
1763 }
1764
1765 #[test]
1766 fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
1767 let dir = tempfile::tempdir().unwrap();
1768 init_repo_with_feature_commit(dir.path(), 4);
1769 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1770 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
1771 let state = state_in(dir.path(), 4);
1772
1773 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1774 .unwrap()
1775 .unwrap();
1776
1777 assert_eq!(result.status, AgentStatus::Success);
1778 assert_eq!(result.exit_code, Some(0));
1779 assert_eq!(result.commits, Some(1));
1780 assert!(result.reason.unwrap().contains("1 commits"));
1781 }
1782
1783 #[test]
1784 fn evaluate_layer2_exit_zero_no_commits_is_failed() {
1785 let dir = tempfile::tempdir().unwrap();
1788 init_repo_with_feature_no_commit(dir.path(), 4);
1789 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1790 std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
1791 let state = state_in(dir.path(), 4);
1792
1793 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1794 .unwrap()
1795 .unwrap();
1796
1797 assert_eq!(result.status, AgentStatus::Failed);
1798 assert_eq!(result.exit_code, Some(0));
1799 assert_eq!(result.commits, Some(0));
1800 assert!(result.reason.unwrap().contains("no commits"));
1801 }
1802
1803 #[test]
1804 fn evaluate_layer2_nonzero_exit_is_failed() {
1805 let dir = tempfile::tempdir().unwrap();
1807 init_repo_with_feature_commit(dir.path(), 4);
1808 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1809 std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
1810 let state = state_in(dir.path(), 4);
1811
1812 let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1813 .unwrap()
1814 .unwrap();
1815
1816 assert_eq!(result.status, AgentStatus::Failed);
1817 assert_eq!(result.exit_code, Some(1));
1818 assert!(result.reason.unwrap().contains("exited with code 1"));
1819 }
1820
1821 #[test]
1822 fn layer2_nonzero_exit_is_failed_all_stages() {
1823 let dir = tempfile::tempdir().unwrap();
1827 init_repo_with_feature_no_commit(dir.path(), 10);
1828 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1829 std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
1830
1831 for stage in [
1832 Stage::Define,
1833 Stage::Plan,
1834 Stage::Code,
1835 Stage::Validate,
1836 Stage::Ship,
1837 ] {
1838 let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
1839 .unwrap()
1840 .unwrap();
1841 assert_eq!(
1842 result.status,
1843 AgentStatus::Failed,
1844 "stage {stage:?} should be Failed on nonzero exit"
1845 );
1846 }
1847 }
1848
1849 #[test]
1850 fn layer2_skips_commit_gate_for_define_and_validate() {
1851 let dir = tempfile::tempdir().unwrap();
1852 init_repo_with_feature_no_commit(dir.path(), 11);
1853 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1854 std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
1855
1856 for stage in [Stage::Define, Stage::Validate] {
1857 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
1858 .unwrap()
1859 .unwrap();
1860 assert_ne!(
1861 result.status,
1862 AgentStatus::Failed,
1863 "stage {stage:?} should not be Failed for zero commits"
1864 );
1865 }
1866
1867 let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
1870 .unwrap()
1871 .unwrap();
1872 assert_eq!(result.status, AgentStatus::Failed);
1873 }
1874
1875 #[test]
1876 fn evaluate_layer3_falls_back_to_commit_count() {
1877 let dir = tempfile::tempdir().unwrap();
1878 init_repo_with_feature_commit(dir.path(), 5);
1879
1880 let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
1881
1882 assert_eq!(result.status, AgentStatus::Unknown);
1883 assert_eq!(result.exit_code, None);
1884 assert_eq!(result.commits, Some(1));
1885 assert!(result.reason.unwrap().contains("1 commits"));
1886 }
1887
1888 #[test]
1889 fn parse_devflow_result_reads_verdict() {
1890 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
1891 let result = parse_devflow_result(stdout).unwrap();
1892 assert_eq!(result.status, AgentStatus::Success);
1893 assert_eq!(result.verdict, Some(Verdict::Gaps));
1894 }
1895
1896 #[test]
1897 fn parse_devflow_result_reads_verdict_pass() {
1898 let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
1899 let result = parse_devflow_result(stdout).unwrap();
1900 assert_eq!(result.status, AgentStatus::Success);
1901 assert_eq!(result.verdict, Some(Verdict::Pass));
1902 }
1903
1904 #[test]
1905 fn parse_devflow_result_verdict_absent_is_none() {
1906 let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
1907 let result = parse_devflow_result(stdout).unwrap();
1908 assert_eq!(result.status, AgentStatus::Success);
1909 assert_eq!(result.verdict, None);
1910 }
1911
1912 #[test]
1913 fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
1914 let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
1917 let result = parse_devflow_result(unknown).unwrap();
1918 assert_eq!(result.status, AgentStatus::Success);
1919 assert_eq!(result.verdict, None);
1920
1921 let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
1923 let result = parse_devflow_result(miscased).unwrap();
1924 assert_eq!(result.status, AgentStatus::Success);
1925 assert_eq!(result.verdict, None);
1926 }
1927
1928 #[test]
1935 fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
1936 let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
1937 let result = parse_devflow_result(bool_verdict).unwrap();
1938 assert_eq!(result.status, AgentStatus::Success);
1939 assert_eq!(result.verdict, None);
1940
1941 let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
1942 let result = parse_devflow_result(numeric_verdict).unwrap();
1943 assert_eq!(result.status, AgentStatus::Success);
1944 assert_eq!(result.verdict, None);
1945
1946 let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
1947 let result = parse_devflow_result(object_verdict).unwrap();
1948 assert_eq!(result.status, AgentStatus::Success);
1949 assert_eq!(result.verdict, None);
1950 }
1951}