use crate::CliError;
use crate::pipeline_gate::transition;
use crate::pipeline_outcomes::{
ValidateOutcome, classify_validate_outcome, handle_infra_outcome, handle_rate_limited_outcome,
handle_ship_failure, handle_ship_outcome, handle_stage_failure, handle_validate_outcome,
truncate_reason,
};
use crate::preflight::{ensure_agent_binary, run_preflight, worktree_writable_roots};
use devflow_core::config::{GitFlowConfig, capture_retention};
use devflow_core::outcome_policy::{self, Action};
use devflow_core::prompt;
use devflow_core::stage::Stage;
use devflow_core::state::{AgentKind, State};
use devflow_core::{agent_result, agents, events, lock, mode, monitor, verify, workflow};
use std::path::Path;
pub(crate) fn launch_stage_inner(
state: &mut State,
prompt_override: Option<String>,
archived_stage: Option<Stage>,
) -> Result<(), CliError> {
let prompt = prompt_override.unwrap_or_else(|| {
prompt::stage_prompt_for_project(state.stage, state.phase, &state.project_root)
});
let adapter = agents::adapter_for(state.agent);
let roots = state
.worktree_path
.as_deref()
.map(|wt| worktree_writable_roots(&state.project_root, wt))
.unwrap_or_default();
let (program, args) = adapter.exec_command(state.phase, &prompt, &roots);
state.checkpoint_resumes = 0;
spawn_agent_and_record(state, program, &args, &adapter.extra_env(), archived_stage)
}
fn spawn_agent_and_record(
state: &mut State,
program: &str,
args: &[String],
extra_env: &[(String, String)],
archived_stage: Option<Stage>,
) -> Result<(), CliError> {
state.monitor_pid = None;
workflow::save_state(state)?;
ensure_agent_binary(program)?;
if let Some(stamp) = agent_result::archive_phase_files(
&state.project_root,
state
.worktree_path
.as_deref()
.unwrap_or(&state.project_root),
state.phase,
capture_retention(&state.project_root),
)
.map_err(|err| {
CliError::Message(format!(
"could not archive phase {} capture before rollover: {err}",
state.phase
))
})? {
events::emit(
&state.project_root,
state.phase,
"capture_archived",
serde_json::json!({
"stage": archived_stage.unwrap_or(state.stage).to_string(),
"to_stage": state.stage.to_string(),
"stamp": stamp,
}),
);
}
let pid = monitor::spawn_monitor(state, program, args, extra_env)
.map_err(|err| CliError::Message(format!("could not spawn monitor: {err}")))?;
state.monitor_pid = Some(pid);
workflow::save_state(state)?;
let _ = devflow_core::registry::register(&state.project_root, state.phase);
events::emit(
&state.project_root,
state.phase,
"stage_launched",
serde_json::json!({
"stage": state.stage.to_string(),
"agent": state.agent.to_string(),
"monitor_pid": pid,
}),
);
println!(
"stage {} → launched {} (monitor pid {pid})",
state.stage,
agents::adapter_for(state.agent).name()
);
Ok(())
}
pub(crate) fn relaunch_checkpoint_session(
state: &mut State,
session_id: &str,
) -> Result<(), CliError> {
state.checkpoint_resumes = state.checkpoint_resumes.saturating_add(1);
let instruction = prompt::checkpoint_auto_decide_prompt(state.phase);
events::emit(
&state.project_root,
state.phase,
"checkpoint_auto_decided",
serde_json::json!({
"stage": state.stage.to_string(),
"session_id": session_id,
"instruction": truncate_reason(&instruction),
"attempt": state.checkpoint_resumes,
"policy": "D-03: unconditional agent auto-decide, no flag/config toggle",
}),
);
let (program, args) = agents::ClaudeAgent::exec_resume_command(session_id, &instruction);
spawn_agent_and_record(state, program, &args, &[], None)
}
pub(crate) fn launch_stage(
state: &mut State,
prompt_override: Option<String>,
archived_stage: Option<Stage>,
) -> Result<(), CliError> {
let adapter = agents::adapter_for(state.agent);
let prompt = prompt_override.clone().unwrap_or_else(|| {
prompt::stage_prompt_for_project(state.stage, state.phase, &state.project_root)
});
let roots = state
.worktree_path
.as_deref()
.map(|wt| worktree_writable_roots(&state.project_root, wt))
.unwrap_or_default();
let (program, _args) = adapter.exec_command(state.phase, &prompt, &roots);
ensure_agent_binary(program)?;
let project_root = state.project_root.clone();
if !run_preflight(&project_root, state, adapter.as_ref())? {
return Ok(());
}
launch_stage_inner(state, prompt_override, archived_stage)
}
pub(crate) fn resume(project_root: &Path, phase: u32) -> Result<(), CliError> {
let _lock = match lock::acquire(project_root, phase) {
Ok(guard) => guard,
Err(lock::LockError::Contended { pid, path: _ }) => {
return Err(CliError::Message(format!(
"another devflow process (pid {pid}) is already running"
)));
}
Err(err) => return Err(CliError::Message(format!("lock error: {err}"))),
};
let mut state = workflow::load_state(project_root, phase)?;
if state.stopped {
state.stopped = false;
state.stop_reason = None;
state.stop_until = None;
}
workflow::save_state(&state)?;
launch_stage(&mut state, None, None)
}
pub(crate) fn single_active_phase(project_root: &Path) -> Result<Option<u32>, CliError> {
let states = workflow::list_states(project_root);
match states.as_slice() {
[] => Ok(None),
[one] => Ok(Some(one.phase)),
many => Err(CliError::Message(format!(
"multiple active phases ({}) — pass --phase to pick one",
many.iter()
.map(|s| s.phase.to_string())
.collect::<Vec<_>>()
.join(", ")
))),
}
}
pub(crate) fn resolve_sole_active_phase(project_root: &Path) -> Result<u32, CliError> {
single_active_phase(project_root)?
.ok_or_else(|| CliError::Message("no active DevFlow state — nothing to advance".into()))
}
fn augment_unresolved_checkpoint_reason(reason: Option<String>, why: &str) -> String {
match reason {
Some(r) if !r.is_empty() => {
format!("{r} — confirmed checkpoint could not auto-resolve: {why}")
}
_ => format!("confirmed checkpoint could not auto-resolve: {why}"),
}
}
pub(crate) fn advance(project_root: &Path, phase: Option<u32>) -> Result<(), CliError> {
let phase = match phase {
Some(phase) => phase,
None => match resolve_sole_active_phase(project_root) {
Ok(phase) => phase,
Err(err) => {
events::emit(
project_root,
0,
"advance_failed",
serde_json::json!({ "reason": err.to_string() }),
);
return Err(err);
}
},
};
let _lock = match lock::acquire(project_root, phase) {
Ok(guard) => guard,
Err(lock::LockError::Contended { pid, path: _ }) => {
return Err(CliError::Message(format!(
"another devflow process (pid {pid}) is already running"
)));
}
Err(err) => return Err(CliError::Message(format!("lock error: {err}"))),
};
let mut state = workflow::load_state(project_root, phase)?;
let git_flow = GitFlowConfig::default();
let result = agent_result::evaluate_agent_result(project_root, &state, &git_flow)
.map_err(|err| CliError::Message(format!("could not evaluate agent result: {err}")))?;
let stage = state.stage;
println!("stage {stage} finished with status {:?}", result.status);
if let Some(reason) = &result.reason {
println!(" detail: {reason}");
}
events::emit(
project_root,
phase,
"advance_evaluated",
serde_json::json!({
"stage": stage.to_string(),
"status": result.status.as_wire_str(),
"verdict": result.verdict.map(|v| format!("{v:?}").to_ascii_lowercase()),
"decided_by_layer": result.decided_by_layer,
"reason": result.reason.as_deref().map(truncate_reason),
}),
);
if let Some(session_id) = agent_result::session_id_from_capture(project_root, phase) {
state.session_id = Some(session_id);
workflow::save_state(&state)?;
}
match outcome_policy::decide_action(stage, result.status) {
Action::Advance => match stage {
Stage::Define => transition(project_root, &mut state, Stage::Plan),
Stage::Plan => transition(project_root, &mut state, Stage::Code),
Stage::Code => transition(project_root, &mut state, Stage::Validate),
Stage::Validate => {
handle_validate_outcome(
project_root,
&mut state,
classify_validate_outcome(&result),
)
}
Stage::Ship => handle_ship_outcome(project_root, &mut state),
},
Action::GateReview => {
let mut reason = result.reason.clone();
let checkpoint_confirmed = state.agent == AgentKind::Claude
&& verify::phase_has_blocking_human_checkpoint(project_root, phase)
&& agent_result::checkpoint_reported_in_capture(project_root, phase);
if checkpoint_confirmed {
let ceiling_ok = state.checkpoint_resumes < mode::MAX_CHECKPOINT_RESUMES;
match (&state.session_id, ceiling_ok) {
(Some(session_id), true) => {
let session_id = session_id.clone();
return relaunch_checkpoint_session(&mut state, &session_id);
}
(Some(_), false) => {
reason = Some(augment_unresolved_checkpoint_reason(
reason,
&format!(
"resume ceiling ({}) exhausted",
mode::MAX_CHECKPOINT_RESUMES
),
));
}
(None, _) => {
reason = Some(augment_unresolved_checkpoint_reason(
reason,
"no session id on record",
));
}
}
}
match stage {
Stage::Validate => {
handle_validate_outcome(project_root, &mut state, ValidateOutcome::Failed)
}
Stage::Ship => handle_ship_failure(project_root, &mut state, reason),
_ => handle_stage_failure(project_root, &mut state, stage, reason),
}
}
Action::GateInfra => handle_infra_outcome(project_root, &mut state, stage, result.reason),
Action::AutoResume => {
handle_rate_limited_outcome(project_root, &mut state, phase, stage, result.reason)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::*;
use devflow_core::gates::Gates;
use devflow_core::mode::Mode;
use devflow_core::state::AgentKind;
#[test]
fn launch_stage_persists_monitor_pid_for_reload() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 65;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = launch_stage(&mut state, None, None);
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
result.unwrap();
assert!(
state.monitor_pid.is_some(),
"launch_stage must record the monitor pid on the in-memory state"
);
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(
reloaded.monitor_pid, state.monitor_pid,
"the monitor pid recorded by launch_stage must be persisted to disk, \
since transition() saves state before launch_stage runs"
);
}
#[test]
fn resume_clears_stop_marker_and_advances_past_stop_point() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 66;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Plan;
state.stop_until = Some(Stage::Plan);
state.stopped = true;
state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = resume(root, phase);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let reloaded_for_reap = workflow::load_state(root, phase).ok();
let _reap_guard = reloaded_for_reap
.as_ref()
.map(ReapMonitorOnDrop::after_launch);
result.unwrap();
let reloaded = workflow::load_state(root, phase).unwrap();
assert!(
!reloaded.stopped,
"resume must clear stopped so the phase is no longer marked halted"
);
assert_eq!(
reloaded.stop_reason, None,
"resume must clear stop_reason alongside stopped"
);
assert_eq!(
reloaded.stop_until, None,
"resume must clear stop_until so the phase does not immediately re-stop \
the next time it advances past Plan"
);
assert!(
reloaded.monitor_pid.is_some(),
"resume() must have spawned a monitor whose pid is recorded in state — if this \
fails, the reap guard above is silently reaping nothing and this test has \
stopped covering the launch path it was written to cover"
);
}
#[test]
fn resume_preserves_unfired_until_cap() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 67;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stop_until = Some(Stage::Plan);
state.stopped = false;
state.stop_reason = None;
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = resume(root, phase);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let reloaded_for_reap = workflow::load_state(root, phase).ok();
let _reap_guard = reloaded_for_reap
.as_ref()
.map(ReapMonitorOnDrop::after_launch);
result.unwrap();
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(
reloaded.stop_until,
Some(Stage::Plan),
"resume must NOT discard an unfired --until cap: stopped was false, so the \
cap has not yet done its job and the operator's boundary must survive"
);
assert!(
!reloaded.stopped,
"an unfired cap must not itself flip stopped to true — resume only relaunches"
);
assert!(
reloaded.monitor_pid.is_some(),
"resume() must still have spawned a monitor whose pid is recorded in state"
);
}
#[test]
fn resume_without_a_cap_is_unchanged() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 68;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stop_until = None;
state.stopped = false;
state.stop_reason = None;
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = resume(root, phase);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let reloaded_for_reap = workflow::load_state(root, phase).ok();
let _reap_guard = reloaded_for_reap
.as_ref()
.map(ReapMonitorOnDrop::after_launch);
result.unwrap();
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(
reloaded.stop_until, None,
"no cap was ever set, so none must appear after resume"
);
assert!(!reloaded.stopped);
assert_eq!(reloaded.stop_reason, None);
assert!(
reloaded.monitor_pid.is_some(),
"resume() must still relaunch and record a monitor pid with no cap present"
);
}
#[test]
fn code_unknown_does_not_transition_to_validate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 72;
let branch = format!("feature/phase-{phase:02}");
let git = |args: &[&str]| {
assert!(
devflow_core::test_support::git_command(root)
.args(args)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
git(&["checkout", "-q", "-b", &branch, "develop"]);
std::fs::write(root.join("work.txt"), "wip\n").unwrap();
git(&["add", "work.txt"]);
git(&["commit", "-q", "-m", "wip commit"]);
git(&["checkout", "-q", "develop"]);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
workflow::save_state(&state).unwrap();
let code_gate = Gates::gate_path(root, phase, Stage::Code);
let validate_gate = Gates::gate_path(root, phase, Stage::Validate);
let response_path = Gates::response_path(root, phase, Stage::Code);
std::thread::scope(|scope| {
scope.spawn(|| {
advance(root, Some(phase)).unwrap();
});
let mut seen = false;
for _ in 0..150 {
if code_gate.exists() {
seen = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(
seen,
"an Unknown Code outcome must fire a never-silent gate, not advance silently"
);
assert!(
!validate_gate.exists(),
"an Unknown Code outcome must never transition to Validate"
);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
});
}
#[test]
fn launch_stage_inner_clears_monitor_pid_on_early_failure() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 93;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.monitor_pid = Some(999_999);
workflow::save_state(&state).unwrap();
let neutral_path_dir = agent_free_git_only_path_dir();
let original_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", neutral_path_dir.path());
}
let result = launch_stage_inner(&mut state, None, None);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert!(
result.is_err(),
"ensure_agent_binary must fail against the neutralized, agent-free PATH"
);
assert_eq!(
state.monitor_pid, None,
"an early launch failure must clear the stale monitor_pid in-memory, not carry it \
forward from the previous stage"
);
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(
reloaded.monitor_pid, None,
"the monitor_pid clear must be persisted to state.json, not just in-memory"
);
}
#[test]
fn advance_evaluated_emits_wire_status_and_decided_by_layer_for_resource_killed() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 78;
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(agent_result::exit_code_path(root, phase), "137").unwrap();
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Code);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
advance(root, Some(phase)).unwrap();
let contents = std::fs::read_to_string(events::events_path(root)).unwrap();
let event = contents
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|e| e["event"] == "advance_evaluated")
.expect("advance_evaluated event recorded");
assert_eq!(event["status"], "resource_killed");
assert_ne!(event["status"], "resourcekilled");
assert_eq!(event["decided_by_layer"], 2);
}
fn events_of_kind(root: &Path, kind: &str) -> Vec<serde_json::Value> {
let contents = std::fs::read_to_string(events::events_path(root)).unwrap_or_default();
contents
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|event| event["event"] == kind)
.collect()
}
#[test]
fn relaunch_checkpoint_session_emits_exactly_one_audit_event() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 84;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = relaunch_checkpoint_session(&mut state, "sess-abc-123");
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
result.unwrap();
let matches = events_of_kind(root, "checkpoint_auto_decided");
assert_eq!(
matches.len(),
1,
"expected exactly one checkpoint_auto_decided event: {matches:?}"
);
assert_eq!(matches[0]["session_id"], "sess-abc-123");
assert_eq!(matches[0]["stage"], "code");
assert_eq!(matches[0]["attempt"], 1);
}
#[test]
fn relaunch_checkpoint_session_increments_and_persists_counter() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 85;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.checkpoint_resumes = 1;
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = relaunch_checkpoint_session(&mut state, "sess-xyz");
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
result.unwrap();
assert_eq!(state.checkpoint_resumes, 2);
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(
reloaded.checkpoint_resumes, 2,
"the incremented counter must persist to disk"
);
}
#[test]
fn relaunch_checkpoint_session_does_not_change_stage() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 86;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = relaunch_checkpoint_session(&mut state, "sess-stage");
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
result.unwrap();
assert_eq!(
state.stage,
Stage::Code,
"a checkpoint resume must not advance the stage"
);
}
#[test]
fn launch_stage_inner_resets_checkpoint_resumes_counter() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 87;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.checkpoint_resumes = 2;
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = launch_stage_inner(&mut state, None, None);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
result.unwrap();
assert_eq!(
state.checkpoint_resumes, 0,
"an ordinary stage launch must reset the checkpoint-resume budget"
);
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(reloaded.checkpoint_resumes, 0);
}
const HUMAN_GATE_VALUE_FOR_TEST: &str = "blocking-human";
fn write_declared_checkpoint_plan(root: &Path, phase: u32) {
let dir = root
.join(".planning/phases")
.join(format!("{phase:02}-checkpoint-fixture"));
std::fs::create_dir_all(&dir).unwrap();
let body = format!(
"---\nphase: {phase}\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE_FOR_TEST}\">\n</task>\n"
);
std::fs::write(dir.join(format!("{phase:02}-01-PLAN.md")), body).unwrap();
}
fn write_confirmed_checkpoint_capture(root: &Path, phase: u32) {
std::fs::create_dir_all(root.join(".devflow")).unwrap();
let stdout = format!(
"## CHECKPOINT REACHED\n\n**Type:** human-verify\n**Gate:** {HUMAN_GATE_VALUE_FOR_TEST}\n\nDEVFLOW_RESULT: {{\"status\": \"failed\", \"reason\": \"checkpoint pending\"}}\n"
);
std::fs::write(agent_result::stdout_path(root, phase), stdout).unwrap();
}
fn write_unreported_failure_capture(root: &Path, phase: u32) {
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(
agent_result::stdout_path(root, phase),
"DEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"ordinary failure\"}\n",
)
.unwrap();
}
fn write_abort_gate_response(root: &Path, phase: u32, stage: Stage) {
let response_path = Gates::response_path(root, phase, stage);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
}
#[test]
fn advance_with_declared_checkpoint_and_reported_gate_relaunches_and_records() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 88;
write_declared_checkpoint_plan(root, phase);
write_confirmed_checkpoint_capture(root, phase);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.session_id = Some("sess-checkpoint-1".to_string());
workflow::save_state(&state).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let result = advance(root, Some(phase));
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let reloaded_for_reap = workflow::load_state(root, phase).ok();
let _reap_guard = reloaded_for_reap
.as_ref()
.map(ReapMonitorOnDrop::after_launch);
result.unwrap();
let auto_decided = events_of_kind(root, "checkpoint_auto_decided");
assert_eq!(
auto_decided.len(),
1,
"expected exactly one checkpoint_auto_decided event: {auto_decided:?}"
);
assert_eq!(auto_decided[0]["session_id"], "sess-checkpoint-1");
assert_eq!(auto_decided[0]["stage"], "code");
let gate_fired = events_of_kind(root, "gate_fired");
assert!(
gate_fired.iter().all(|e| e["stage"] != "code"),
"a confirmed, auto-resolved checkpoint must never also fire the \
generic gate for the same stage: {gate_fired:?}"
);
}
#[test]
fn advance_without_declared_checkpoint_falls_through_to_generic_gate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 89;
write_confirmed_checkpoint_capture(root, phase);
write_abort_gate_response(root, phase, Stage::Code);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.session_id = Some("sess-should-not-resume".to_string());
workflow::save_state(&state).unwrap();
advance(root, Some(phase)).unwrap();
assert!(
events_of_kind(root, "checkpoint_auto_decided").is_empty(),
"a phase whose plans never declared a checkpoint must never auto-resume, \
even if its capture LOOKS like it reported one"
);
assert!(
!events_of_kind(root, "gate_fired").is_empty(),
"the ordinary never-silent gate must still fire"
);
}
#[test]
fn advance_with_declared_checkpoint_but_unreported_gate_falls_through() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 90;
write_declared_checkpoint_plan(root, phase);
write_unreported_failure_capture(root, phase);
write_abort_gate_response(root, phase, Stage::Code);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.session_id = Some("sess-unreported".to_string());
workflow::save_state(&state).unwrap();
advance(root, Some(phase)).unwrap();
assert!(events_of_kind(root, "checkpoint_auto_decided").is_empty());
assert!(!events_of_kind(root, "gate_fired").is_empty());
}
#[test]
fn advance_with_confirmed_checkpoint_and_no_session_id_falls_through() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 91;
write_declared_checkpoint_plan(root, phase);
write_confirmed_checkpoint_capture(root, phase);
write_abort_gate_response(root, phase, Stage::Code);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.session_id = None;
workflow::save_state(&state).unwrap();
advance(root, Some(phase)).unwrap();
assert!(events_of_kind(root, "checkpoint_auto_decided").is_empty());
let gate_fired = events_of_kind(root, "gate_fired");
assert!(!gate_fired.is_empty());
assert!(
gate_fired.iter().any(|e| e["context"]
.as_str()
.unwrap_or_default()
.contains("session id")),
"the never-silent gate's context must name the missing session id: {gate_fired:?}"
);
}
#[test]
fn advance_at_checkpoint_resume_ceiling_falls_through_to_generic_gate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 92;
write_declared_checkpoint_plan(root, phase);
write_confirmed_checkpoint_capture(root, phase);
write_abort_gate_response(root, phase, Stage::Code);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.session_id = Some("sess-at-ceiling".to_string());
state.checkpoint_resumes = mode::MAX_CHECKPOINT_RESUMES;
workflow::save_state(&state).unwrap();
advance(root, Some(phase)).unwrap();
assert!(events_of_kind(root, "checkpoint_auto_decided").is_empty());
let gate_fired = events_of_kind(root, "gate_fired");
assert!(!gate_fired.is_empty());
assert!(
gate_fired.iter().any(|e| e["context"]
.as_str()
.unwrap_or_default()
.contains("ceiling")),
"the never-silent gate's context must name the exhausted ceiling: {gate_fired:?}"
);
}
#[test]
fn advance_with_non_claude_agent_never_resumes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 93;
write_declared_checkpoint_plan(root, phase);
write_confirmed_checkpoint_capture(root, phase);
write_abort_gate_response(root, phase, Stage::Code);
let mut state = State::new(phase, AgentKind::Codex, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.session_id = Some("sess-non-claude".to_string());
workflow::save_state(&state).unwrap();
advance(root, Some(phase)).unwrap();
assert!(
events_of_kind(root, "checkpoint_auto_decided").is_empty(),
"a non-Claude agent must never take the resume path (D-05)"
);
assert!(!events_of_kind(root, "gate_fired").is_empty());
}
}