use devflow_core::gates::{GateAction, GateFile, Gates};
use devflow_core::mode::Mode;
use devflow_core::stage::Stage;
use devflow_core::state::{AgentKind, State};
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
fn devflow_bin() -> &'static str {
env!("CARGO_BIN_EXE_devflow")
}
fn git(root: &Path, args: &[&str]) {
let output = devflow_core::test_support::git_command(root)
.args(args)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn init_repo(root: &Path, phase: u32) {
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "devflow@example.com"]);
git(root, &["config", "user.name", "DevFlow Tests"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
git(root, &["checkout", "-q", "-b", "develop"]);
std::fs::write(root.join("README.md"), "base\n").unwrap();
git(root, &["add", "README.md"]);
git(root, &["commit", "-q", "-m", "base"]);
let branch = format!("feature/phase-{phase:02}");
git(root, &["checkout", "-q", "-b", &branch]);
std::fs::write(root.join("work.txt"), "agent work\n").unwrap();
git(root, &["add", "work.txt"]);
git(root, &["commit", "-q", "-m", "agent work"]);
}
fn wait_for(mut predicate: impl FnMut() -> bool, timeout_secs: u64, what: &str) {
let start = Instant::now();
while !predicate() {
assert!(
start.elapsed() < Duration::from_secs(timeout_secs),
"timed out after {timeout_secs}s waiting for: {what}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn e2e_child_timeout() -> Duration {
let secs: u64 = std::env::var("DEVFLOW_E2E_CHILD_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(90);
Duration::from_secs(secs)
}
fn wait_for_child_exit(
child: &mut std::process::Child,
root: &Path,
phase: u32,
deadline: Duration,
) -> std::process::ExitStatus {
let start = Instant::now();
loop {
if let Some(status) = child.try_wait().expect("try_wait on devflow advance child") {
return status;
}
if start.elapsed() >= deadline {
let pid = child.id();
let lock_present = devflow_core::lock::holder(root, phase).is_some();
let gate_present = Gates::gate_path(root, phase, Stage::Code).exists();
let response_present = Gates::response_path(root, phase, Stage::Code).exists();
let _ = child.wait();
panic!(
"devflow advance (pid {pid}) did not exit within {deadline:?}; \
on disk: lock_present={lock_present} gate_present={gate_present} \
response_present={response_present}"
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn backdate_gate(root: &Path, phase: u32, stage: Stage, age_secs: u64) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let gate = GateFile {
phase,
stage,
context: "abandoned run".to_string(),
timestamp: now.saturating_sub(age_secs).to_string(),
};
std::fs::write(
Gates::gate_path(root, phase, stage),
serde_json::to_string_pretty(&gate).unwrap(),
)
.unwrap();
}
#[test]
fn sweep_reaps_an_aged_gate_and_a_real_poller_resolves_to_abort() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 91;
let stage = Stage::Ship;
Gates::write_gate(root, phase, stage, "approve merge?").unwrap();
backdate_gate(root, phase, stage, 7 * 60 * 60);
std::thread::scope(|scope| {
let poller = scope.spawn(move || Gates::poll_response(root, phase, stage, 30));
let output = Command::new(devflow_bin())
.args(["gate", "sweep", "--root"])
.arg(root)
.output()
.expect("run devflow gate sweep");
assert!(
output.status.success(),
"devflow gate sweep failed\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let response = poller
.join()
.expect("poller thread")
.expect("a live Gates::poll_response must observe the sweep's response within 30s");
assert!(
!response.approved,
"a reap must never write an approval (T-23-41)"
);
assert!(
matches!(GateAction::from_response(&response), GateAction::Abort(_)),
"an aged gate's reap must resolve to Abort, not a Code loop-back"
);
});
}
#[test]
fn sweep_leaves_a_fresh_gate_untouched() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 92;
let stage = Stage::Validate;
Gates::write_gate(root, phase, stage, "review gaps").unwrap();
let output = Command::new(devflow_bin())
.args(["gate", "sweep", "--root"])
.arg(root)
.output()
.expect("run devflow gate sweep");
assert!(
output.status.success(),
"devflow gate sweep failed\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let open = Gates::list_open(root);
assert_eq!(
open.len(),
1,
"a fresh gate must remain open after an invoked sweep"
);
assert_eq!(open[0].phase, phase);
assert_eq!(open[0].stage, stage);
assert!(
!Gates::response_path(root, phase, stage).exists(),
"a fresh gate must get no response file at all"
);
}
#[test]
fn sweep_help_documents_max_age_and_dry_run() {
let output = Command::new(devflow_bin())
.args(["gate", "sweep", "--help"])
.output()
.expect("run devflow gate sweep --help");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--max-age-secs"),
"--help missing --max-age-secs:\n{stdout}"
);
assert!(
stdout.contains("--dry-run"),
"--help missing --dry-run:\n{stdout}"
);
}
#[test]
fn sweep_ends_a_real_advance_process_through_its_own_abort_path() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 95;
init_repo(root, phase);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
devflow_core::workflow::save_state(&state).unwrap();
let mut child = Command::new(devflow_bin())
.args(["advance", "--phase", &phase.to_string()])
.arg(root)
.env("DEVFLOW_GATE_TIMEOUT_SECS", "15")
.spawn()
.expect("spawn devflow advance");
wait_for(
|| devflow_core::lock::holder(root, phase).is_some(),
10,
"the child to acquire .devflow/lock-95",
);
let gate_path = Gates::gate_path(root, phase, Stage::Code);
wait_for(
|| gate_path.exists(),
10,
"the child to write the Code gate",
);
assert!(
devflow_core::lock::holder(root, phase).is_some(),
"lock must still be held once the gate is written — this is the \
proof that a live poller is genuinely blocking on it"
);
Gates::reap(
root,
phase,
Stage::Code,
"abort: reaped by devflow gate sweep (unattended gate exceeded max age)",
"devflow-reap",
)
.expect("reap the aged Code gate");
let status = wait_for_child_exit(&mut child, root, phase, e2e_child_timeout());
assert!(
status.success(),
"devflow advance must exit cleanly once its own abort() path runs, got {status:?}"
);
assert!(
devflow_core::lock::holder(root, phase).is_none(),
"LockGuard's Drop must have released the per-phase lock — proof the \
process unwound cleanly through its own code, not by being \
terminated from outside"
);
let events = std::fs::read_to_string(devflow_core::events::events_path(root)).unwrap();
assert!(
events
.lines()
.any(|line| line.contains("\"workflow_aborted\"")),
"the target process must have run its own abort() path, recording \
workflow_aborted — the whole claim this plan rests on\nevents:\n{events}"
);
}