use devflow_core::mode::Mode;
use devflow_core::phase_id::PhaseId;
use devflow_core::stage::Stage;
use devflow_core::state::{AgentKind, State};
use devflow_core::workflow::save_state;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
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: PhaseId) {
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"]);
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-{padded}", padded = phase.padded());
git(root, &["checkout", "-q", "-b", &branch]);
fs::write(root.join("work.txt"), "agent work\n").unwrap();
git(root, &["add", "work.txt"]);
git(root, &["commit", "-q", "-m", "agent work"]);
}
fn real_shape_config(active: bool) -> String {
format!(
r#"{{
"commit_docs": true,
"workflow": {{
"granularity": "medium",
"auto_mode": true,
"auto_advance": true,
"commit_docs": true,
"subagent_timeout": 300000,
"_auto_chain_active": {active},
"nyquist_validation": true,
"tdd_mode": true
}},
"git": {{
"main": "main",
"develop": "develop",
"feature_prefix": "feature/"
}},
"intel": {{
"enabled": true
}},
"review": {{
"default_reviewers": [
"codex"
]
}},
"model_overrides": {{
"gsd-executor": "inherit"
}},
"mempalace": {{
"enabled": true
}}
}}
"#
)
}
fn observer_script(obs_path: &Path) -> String {
format!(
"#!/bin/sh\n\
line=$(grep '\"_auto_chain_active\"' .planning/config.json 2>/dev/null | head -n1)\n\
if [ -z \"$line\" ]; then\n\
\x20 printf 'MISSING' > '{obs}'\n\
else\n\
\x20 printf '%s' \"$line\" | sed 's/.*: *//; s/[,[:space:]]*$//' > '{obs}'\n\
fi\n\
printf 'DEVFLOW_RESULT: {{\"status\":\"success\"}}\\n'\n",
obs = obs_path.display()
)
}
fn write_executable(path: &Path, contents: &str) {
fs::write(path, contents).unwrap();
let mut perms = fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).unwrap();
}
struct Fixture {
_repo: tempfile::TempDir,
_aux: tempfile::TempDir,
root: PathBuf,
phase: PhaseId,
config: PathBuf,
observation: PathBuf,
prompt_file: PathBuf,
script: PathBuf,
}
impl Fixture {
fn new(mode: Mode, flag_before: bool) -> Self {
let repo = tempfile::tempdir().unwrap();
let aux = tempfile::tempdir().unwrap();
let root = repo.path().canonicalize().unwrap();
let phase = PhaseId::new(77);
init_repo(&root, phase);
let planning = root.join(".planning");
fs::create_dir_all(&planning).unwrap();
let config = planning.join("config.json");
fs::write(&config, real_shape_config(flag_before)).unwrap();
let mut state = State::new(phase, AgentKind::Claude, mode, root.clone());
state.stage = Stage::Code;
state.stop_until = Some(Stage::Code);
state.stopped = false;
save_state(&state).unwrap();
let observation = aux.path().join("child-observation.txt");
let script = aux.path().join("agent.sh");
write_executable(&script, &observer_script(&observation));
let prompt_file = aux.path().join("prompt.txt");
fs::write(&prompt_file, "run the code stage\n").unwrap();
Self {
_repo: repo,
_aux: aux,
root,
phase,
config,
observation,
prompt_file,
script,
}
}
fn run_monitor(&self, argv: &[&str]) -> Output {
Command::new(devflow_bin())
.arg("__monitor")
.arg("--project")
.arg(&self.root)
.arg("--phase")
.arg(self.phase.to_string())
.arg("--workdir")
.arg(&self.root)
.arg("--prompt-file")
.arg(&self.prompt_file)
.arg("--idle-timeout-secs")
.arg("30")
.arg("--")
.args(argv)
.output()
.expect("spawn devflow __monitor")
}
fn run_observer(&self) -> Output {
let script = self.script.to_str().unwrap().to_string();
self.run_monitor(&["sh", &script])
}
fn child_observed(&self) -> String {
fs::read_to_string(&self.observation).unwrap_or_else(|err| {
panic!(
"the supervised child never wrote its observation to {}: {err}",
self.observation.display()
)
})
}
fn flag_now(&self) -> String {
let raw = fs::read_to_string(&self.config).unwrap();
let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
value["workflow"]["_auto_chain_active"].to_string()
}
}
#[test]
fn auto_mode_code_stage_child_observes_the_flag_set() {
let fixture = Fixture::new(Mode::Auto, false);
let output = fixture.run_observer();
assert!(
output.status.success(),
"monitor exited {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fixture.child_observed(),
"true",
"the supervised child must see workflow._auto_chain_active set WHILE it \
runs — an after-the-fact read by this test process would pass even with \
the guard scoped to the wrong frame (RESEARCH Pitfall 2)"
);
assert_eq!(
fixture.flag_now(),
"false",
"the guard must clear the flag when the monitor process returns"
);
}
#[test]
fn supervise_mode_code_stage_child_observes_the_flag_clear() {
let fixture = Fixture::new(Mode::Supervise, false);
let before = fs::read(&fixture.config).unwrap();
let output = fixture.run_observer();
assert!(
output.status.success(),
"monitor exited {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fixture.child_observed(),
"false",
"a run the operator chose to SUPERVISE must never get checkpoint \
auto-approval — the child must see the flag clear while it runs"
);
let after = fs::read(&fixture.config).unwrap();
assert_eq!(
before, after,
"an ineligible launch must be a genuine no-op on an already-false file \
(F-3), not a rewrite of a tracked file on every stage launch"
);
}
#[test]
fn guard_clears_the_flag_when_the_supervised_child_fails() {
let fixture = Fixture::new(Mode::Auto, true);
let output = fixture.run_monitor(&["definitely-not-a-real-program-35-1-01"]);
assert!(
!output.status.success(),
"a child that cannot be spawned must fail the monitor, not pass silently"
);
assert_eq!(
fixture.flag_now(),
"false",
"the guard's Drop must clear the flag on the error exit path too, not \
only on the path that reaches advance()"
);
}