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::mode::Mode;
use devflow_core::outcome_policy::{self, Action};
use devflow_core::phase_id::PhaseId;
use devflow_core::prompt;
use devflow_core::stage::Stage;
use devflow_core::state::{AgentKind, State};
use devflow_core::{
agent_result, agents, canary, events, gsd_config, lock, mode, monitor, verify, workflow,
};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
fn stamp_validate_dispatch_window(state: &mut State) {
if state.stage != Stage::Validate {
return;
}
let evidence_root = state
.worktree_path
.as_deref()
.unwrap_or(&state.project_root);
state.verification_run_nonce =
Some(state.verification_run_nonce.unwrap_or(0).saturating_add(1));
state.last_verification_fingerprint =
devflow_core::agent_result::phase_verification_fingerprint(evidence_root, state.phase);
state.last_verification_mtime_nanos =
devflow_core::agent_result::phase_verification_mtime_nanos(evidence_root, state.phase);
state.verification_baseline_captured = true;
}
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 stream_launch =
claude_stream_launch_enabled(state.agent, state.stage, state.legacy_claude_launch);
if !stream_launch && claude_stream_launch_enabled(state.agent, state.stage, false) {
announce_forced_legacy_launch(state);
}
let canary_workdir = state
.worktree_path
.as_deref()
.unwrap_or(&state.project_root)
.to_path_buf();
let canary_capture_dir = state.project_root.join(".devflow");
canary_gate(state, stream_launch, move || {
canary::run_delivery_canary(
&canary::ClaudeCanaryLauncher {
workdir: canary_workdir,
},
&canary_capture_dir,
)
})?;
let (program, args, launch) = resolve_launch_shape(
state.agent,
adapter.as_ref(),
state.phase,
prompt,
&roots,
stream_launch,
);
state.checkpoint_resumes = 0;
stamp_validate_dispatch_window(state);
spawn_agent_and_record(
state,
program,
&args,
&adapter.extra_env(),
archived_stage,
launch,
)
}
fn resolve_launch_shape(
agent: AgentKind,
adapter: &dyn agents::AgentAdapter,
phase: PhaseId,
prompt: String,
roots: &[std::path::PathBuf],
stream_launch: bool,
) -> (&'static str, Vec<String>, monitor::MonitorLaunch) {
if stream_launch {
let (program, args) = adapter.exec_command(phase, &prompt, roots);
(program, args, monitor::MonitorLaunch::PipeOwning { prompt })
} else if agent == AgentKind::Claude {
let (program, args) = agents::ClaudeAgent::exec_command_single_document(&prompt);
(program, args, monitor::MonitorLaunch::Legacy)
} else {
let (program, args) = adapter.exec_command(phase, &prompt, roots);
(program, args, monitor::MonitorLaunch::Legacy)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LegacyLaunchSource {
Environment,
PersistedState,
}
impl LegacyLaunchSource {
fn as_str(self) -> &'static str {
match self {
LegacyLaunchSource::Environment => "env:DEVFLOW_CLAUDE_LEGACY_LAUNCH",
LegacyLaunchSource::PersistedState => "state:legacy_claude_launch",
}
}
}
fn legacy_launch_source() -> LegacyLaunchSource {
if devflow_core::config::claude_legacy_launch() {
LegacyLaunchSource::Environment
} else {
LegacyLaunchSource::PersistedState
}
}
fn forced_legacy_launch_notice(stage: Stage, source: LegacyLaunchSource) -> String {
format!(
"legacy launch: DevFlow is forcing the pre-31 single-document Claude launch for \
stage {stage} (source: {}). This path cannot deliver background-task \
notifications, so a multi-plan wave may ORPHAN delegated work (999.64, unfixed \
on this path). The stream-json transport, the pipe-owning monitor, the idle \
timeout and the delivery canary are all inactive for this launch. Unset the \
opt-out to return to the Phase 31 transport.",
source.as_str()
)
}
fn announce_forced_legacy_launch(state: &State) {
let source = legacy_launch_source();
let notice = forced_legacy_launch_notice(state.stage, source);
println!("warning: {notice}");
append_monitor_log(
&state.project_root,
state.phase,
&format!("[devflow] {notice}"),
);
events::emit(
&state.project_root,
state.phase,
"claude_legacy_launch_forced",
serde_json::json!({
"stage": state.stage.to_string(),
"source": source.as_str(),
"notice": truncate_reason(¬ice),
}),
);
}
fn append_monitor_log(project_root: &Path, phase: PhaseId, entry: &str) {
use std::io::Write;
let path = agent_result::monitor_log_path(project_root, phase);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(file, "{entry}");
}
}
pub(crate) fn apply_legacy_launch_opt_out(state: &mut State, flag: bool) -> bool {
let env = devflow_core::config::claude_legacy_launch();
state.legacy_claude_launch = state.legacy_claude_launch || flag || env;
env && !flag
}
pub(crate) const AUTO_CHAIN_REPAIR_FROM_START: &str = "start";
pub(crate) const AUTO_CHAIN_REPAIR_FROM_RESUME: &str = "resume";
pub(crate) fn repair_leaked_auto_chain_flag(
project_root: &Path,
launch_root: &Path,
phase: PhaseId,
entry_point: &'static str,
) {
let outcome = match gsd_config::force_clear_auto_chain(launch_root) {
Ok(outcome) => outcome,
Err(err) => {
println!(
"warning: could not certify phase {phase}'s GSD chain flag clear at {} \
({err}) — launching without the repair",
gsd_config::config_path(launch_root).display()
);
return;
}
};
if !outcome.repaired_anything() && outcome.commit_refused.is_none() {
return;
}
events::emit(
project_root,
phase,
"auto_chain_flag_repaired",
serde_json::json!({
"entry_point": entry_point,
"working_tree_repaired": outcome.working_tree_repaired,
"committed_tree_repaired": outcome.committed_tree_repaired,
"commit_refused": outcome.commit_refused,
}),
);
if outcome.repaired_anything() {
println!(
"note: devflow {entry_point} found phase {phase}'s GSD chain flag \
(workflow._auto_chain_active) still set — a previous run for this phase \
was killed before it could clear it. Cleared before launching \
(working tree: {}, this branch's tip: {})",
repaired_word(outcome.working_tree_repaired),
repaired_word(outcome.committed_tree_repaired),
);
}
if let Some(reason) = &outcome.commit_refused {
println!("warning: {reason}");
}
}
fn repaired_word(repaired: bool) -> &'static str {
if repaired {
"repaired"
} else {
"already clear"
}
}
fn canary_gate<F>(state: &mut State, stream_launch: bool, run_canary: F) -> Result<(), CliError>
where
F: FnOnce() -> canary::CanaryOutcome,
{
if !stream_launch {
return Ok(());
}
let outcome = match &state.canary {
Some(recorded) => recorded.clone(),
None => {
let outcome = run_canary();
state.canary = Some(outcome.clone());
workflow::save_state(state)?;
emit_canary_outcome(state, &outcome);
outcome
}
};
match outcome {
canary::CanaryOutcome::Confirmed => Ok(()),
canary::CanaryOutcome::Absent => refuse_launch(
state,
"background-task notification delivery is ABSENT: a token DevFlow planted in a \
throwaway startup task did not come back inside a top-level `result` event.\n\
\n\
DevFlow's multi-plan wave guarantee is NOT currently backed by observed \
behaviour. With delivery gone, a wave that dispatches several plans \
concurrently silently orphans their work — refusing to launch rather than \
discovering that after the fact.\n\
\n\
This is undocumented CLI behaviour, last observed on claude_code_version \
2.1.220; a CLI update can withdraw it. The capture the guard read is at \
`.devflow/delivery-canary.jsonl`."
.to_string(),
),
canary::CanaryOutcome::Unverified(reason) => refuse_launch(
state,
format!(
"the delivery canary COULD NOT RUN, so background-task notification \
delivery is unverified for this run. This is not a report that the \
behaviour is gone — the guard reached no conclusion either way.\n\
\n\
Reason: {reason}\n\
\n\
Refusing to launch: the multi-plan wave guarantee depends on that \
behaviour and this run has no evidence about it."
),
),
}
}
fn refuse_launch(state: &mut State, message: String) -> Result<(), CliError> {
state.monitor_pid = None;
workflow::save_state(state)?;
Err(CliError::Message(message))
}
fn emit_canary_outcome(state: &State, outcome: &canary::CanaryOutcome) {
let (event, reason) = match outcome {
canary::CanaryOutcome::Confirmed => ("claude_delivery_canary_confirmed", None),
canary::CanaryOutcome::Absent => ("claude_delivery_canary_absent", None),
canary::CanaryOutcome::Unverified(reason) => (
"claude_delivery_canary_unverified",
Some(truncate_reason(reason)),
),
};
events::emit(
&state.project_root,
state.phase,
event,
serde_json::json!({
"stage": state.stage.to_string(),
"token_prefix": canary::TOKEN_PREFIX,
"cli_version": canary::claude_cli_version(),
"reason": reason,
}),
);
}
const STREAM_JSON_STAGES: &[Stage] = &[
Stage::Define,
Stage::Plan,
Stage::Code,
Stage::Validate,
Stage::Ship,
];
pub(crate) fn claude_stream_launch_enabled(
agent: AgentKind,
stage: Stage,
legacy_opt_out: bool,
) -> bool {
!legacy_opt_out && agent == AgentKind::Claude && STREAM_JSON_STAGES.contains(&stage)
}
const AUTO_CHAIN_ELIGIBLE_STAGES: &[Stage] = &[Stage::Code];
fn auto_chain_flag_eligible(stage: Stage, mode: Mode) -> bool {
mode == Mode::Auto && AUTO_CHAIN_ELIGIBLE_STAGES.contains(&stage)
}
struct AutoChainGuard {
config_root: PathBuf,
}
impl AutoChainGuard {
fn engage(config_root: &Path, active: bool) -> Self {
match gsd_config::set_auto_chain_active(config_root, active) {
Ok(changed) => {
if changed {
info!(
"GSD chain flag set to {active} for this stage at {}",
gsd_config::config_path(config_root).display()
);
}
}
Err(err) => warn!(
"could not set the GSD chain flag at {}: {err} — proceeding without \
checkpoint auto-approval",
gsd_config::config_path(config_root).display()
),
}
Self {
config_root: config_root.to_path_buf(),
}
}
}
impl Drop for AutoChainGuard {
fn drop(&mut self) {
if let Err(err) = gsd_config::set_auto_chain_active(&self.config_root, false) {
warn!(
"could not clear the GSD chain flag at {}: {err}",
gsd_config::config_path(&self.config_root).display()
);
}
}
}
pub(crate) fn run_monitor(
project_root: &Path,
phase: PhaseId,
workdir: &Path,
prompt_file: &Path,
idle_timeout_secs: u64,
argv: &[String],
) -> Result<(), CliError> {
let prompt = std::fs::read_to_string(prompt_file).map_err(|err| {
CliError::Message(format!(
"monitor could not read the prompt file {}: {err}",
prompt_file.display()
))
})?;
let Some((program, args)) = argv.split_first() else {
return Err(CliError::Message(
"monitor was given no child program to supervise".to_string(),
));
};
let _auto_chain_guard = match workflow::load_state(project_root, phase) {
Ok(state) => Some(AutoChainGuard::engage(
workdir,
auto_chain_flag_eligible(state.stage, state.mode),
)),
Err(err) => {
warn!(
"monitor could not load state for phase {phase} ({err}) — running \
without the GSD chain-flag guard"
);
None
}
};
monitor::run_pipe_owning_monitor(
project_root,
phase,
workdir,
&prompt,
std::time::Duration::from_secs(idle_timeout_secs),
program,
args,
&[],
)
.map_err(|err| CliError::Message(format!("pipe-owning monitor failed: {err}")))?;
advance(project_root, Some(phase))
}
fn spawn_agent_and_record(
state: &mut State,
program: &str,
args: &[String],
extra_env: &[(String, String)],
archived_stage: Option<Stage>,
launch: monitor::MonitorLaunch,
) -> 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, launch)
.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,
monitor::MonitorLaunch::Legacy,
)
}
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: PhaseId,
legacy_claude_launch: bool,
) -> 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;
}
if apply_legacy_launch_opt_out(&mut state, legacy_claude_launch) {
println!(
"note: legacy Claude launch forced by DEVFLOW_CLAUDE_LEGACY_LAUNCH \
(D-11, 31-CONTEXT.md) — a persisted default is never a silent one"
);
}
let launch_root = state
.worktree_path
.clone()
.unwrap_or_else(|| project_root.to_path_buf());
repair_leaked_auto_chain_flag(
project_root,
&launch_root,
phase,
AUTO_CHAIN_REPAIR_FROM_RESUME,
);
workflow::save_state(&state)?;
launch_stage(&mut state, None, None)
}
pub(crate) fn single_active_phase(project_root: &Path) -> Result<Option<PhaseId>, 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<PhaseId, 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<PhaseId>) -> 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,
PhaseId::new(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 execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
let checkpoint_confirmed = state.agent == AgentKind::Claude
&& verify::phase_has_blocking_human_checkpoint(execution_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 auto_chain_eligibility_is_code_and_auto_mode_only() {
assert!(auto_chain_flag_eligible(Stage::Code, Mode::Auto));
assert!(!auto_chain_flag_eligible(Stage::Code, Mode::Supervise));
assert!(!auto_chain_flag_eligible(Stage::Define, Mode::Auto));
assert!(!auto_chain_flag_eligible(Stage::Plan, Mode::Auto));
assert!(!auto_chain_flag_eligible(Stage::Validate, Mode::Auto));
assert!(!auto_chain_flag_eligible(Stage::Ship, Mode::Auto));
for stage in [
Stage::Define,
Stage::Plan,
Stage::Code,
Stage::Validate,
Stage::Ship,
] {
let expected = match stage {
Stage::Code => true,
Stage::Define | Stage::Plan | Stage::Validate | Stage::Ship => false,
};
assert_eq!(auto_chain_flag_eligible(stage, Mode::Auto), expected);
}
}
fn seed_gsd_config(root: &Path, active: bool) {
std::fs::create_dir_all(root.join(".planning")).unwrap();
std::fs::write(
root.join(".planning/config.json"),
format!(
"{{\n \"commit_docs\": true,\n \"workflow\": {{\n \
\"granularity\": \"medium\",\n \"auto_advance\": true,\n \
\"_auto_chain_active\": {active}\n }}\n}}\n"
),
)
.unwrap();
let git = |args: &[&str]| {
let ok = devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
};
git(&["add", ".planning/config.json"]);
git(&["commit", "-q", "-m", "add gsd config"]);
}
fn repair_events(root: &Path) -> Vec<serde_json::Value> {
let path = devflow_core::events::events_path(root);
let Ok(raw) = std::fs::read_to_string(path) else {
return Vec::new();
};
raw.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|line| line["event"] == "auto_chain_flag_repaired")
.collect()
}
#[test]
fn auto_chain_flag_repaired_event_names_the_entry_point_that_found_the_leak() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
seed_gsd_config(root, true);
assert!(
gsd_config::auto_chain_active(root).unwrap(),
"the fixture must actually carry the leak, or this test is vacuous"
);
repair_leaked_auto_chain_flag(root, root, PhaseId::new(81), "resume");
let events = repair_events(root);
assert_eq!(events.len(), 1, "exactly one repair event: {events:?}");
assert_eq!(events[0]["entry_point"], "resume");
assert_eq!(events[0]["working_tree_repaired"], true);
assert!(
!gsd_config::auto_chain_active(root).unwrap(),
"the repair must actually clear the flag, not merely report it"
);
}
#[test]
fn auto_chain_flag_repaired_event_is_absent_on_a_clean_launch() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
seed_gsd_config(root, false);
repair_leaked_auto_chain_flag(root, root, PhaseId::new(82), "start");
assert!(
repair_events(root).is_empty(),
"a launch that found nothing to repair must write no event: {:?}",
repair_events(root)
);
}
#[test]
fn launch_stage_persists_monitor_pid_for_reload() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(65);
let mut state = State::new(
phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
state.legacy_claude_launch = true;
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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(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());
state.legacy_claude_launch = true;
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, false);
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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(67);
let mut state = State::new(
phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
state.stop_until = Some(Stage::Plan);
state.stopped = false;
state.stop_reason = None;
state.legacy_claude_launch = true;
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, false);
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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(68);
let mut state = State::new(
phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
state.stop_until = None;
state.stopped = false;
state.stop_reason = None;
state.legacy_claude_launch = true;
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, false);
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 = PhaseId::new(72);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(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 = PhaseId::new(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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(87);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.checkpoint_resumes = 2;
state.canary = Some(canary::CanaryOutcome::Confirmed);
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);
}
#[test]
fn launch_stage_inner_stamps_the_validate_dispatch_nonce_with_its_baseline() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(92);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
let worktree = root.join(format!(".worktrees/phase-{phase}"));
std::fs::create_dir_all(&worktree).unwrap();
state.worktree_path = Some(worktree.clone());
let phase_dir = worktree
.join(".planning/phases")
.join(format!("{padded}-stamp", padded = phase.padded()));
std::fs::create_dir_all(&phase_dir).unwrap();
std::fs::write(
phase_dir.join(format!("{padded}-VERIFICATION.md", padded = phase.padded())),
"verdict: pass\n",
)
.unwrap();
let fingerprint_before =
devflow_core::agent_result::phase_verification_fingerprint(&worktree, phase);
state.stage = Stage::Validate;
stamp_validate_dispatch_window(&mut state);
assert_eq!(state.verification_run_nonce, Some(1));
assert_eq!(
state.last_verification_fingerprint, fingerprint_before,
"fingerprint must match the on-disk artifact after stamp"
);
assert!(state.verification_baseline_captured);
let nonce_after_validate = state.verification_run_nonce;
let fp_after_validate = state.last_verification_fingerprint;
state.stage = Stage::Code;
stamp_validate_dispatch_window(&mut state);
assert_eq!(
state.verification_run_nonce, nonce_after_validate,
"nonce must not change on non-Validate stages"
);
assert_eq!(
state.last_verification_fingerprint, fp_after_validate,
"fingerprint must not change on non-Validate stages"
);
}
fn counting_canary(
calls: &std::cell::Cell<usize>,
outcome: canary::CanaryOutcome,
) -> impl FnOnce() -> canary::CanaryOutcome + '_ {
move || {
calls.set(calls.get() + 1);
outcome
}
}
fn canary_state(root: &Path, phase: PhaseId) -> State {
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
workflow::save_state(&state).unwrap();
state
}
#[test]
fn canary_runs_once_per_run() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(120);
let mut state = canary_state(root, phase);
let calls = std::cell::Cell::new(0usize);
canary_gate(
&mut state,
true,
counting_canary(&calls, canary::CanaryOutcome::Confirmed),
)
.unwrap();
assert_eq!(
calls.get(),
1,
"the first launch of a run must run the guard"
);
assert_eq!(state.canary, Some(canary::CanaryOutcome::Confirmed));
canary_gate(
&mut state,
true,
counting_canary(&calls, canary::CanaryOutcome::Confirmed),
)
.unwrap();
assert_eq!(
calls.get(),
1,
"a second stage launch in the same run must read the recorded outcome, \
not re-spend an agent invocation"
);
let mut fresh_run = canary_state(root, PhaseId::new(phase.major() + 1));
canary_gate(
&mut fresh_run,
true,
counting_canary(&calls, canary::CanaryOutcome::Confirmed),
)
.unwrap();
assert_eq!(
calls.get(),
2,
"a run with no recorded outcome must run the guard — if this fails, the \
assertion above is measuring a closure that is never invoked"
);
}
#[test]
fn canary_gate_only_applies_to_the_stream_launch_path() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(121);
let mut state = canary_state(root, phase);
state.stage = Stage::Code;
state.legacy_claude_launch = true;
let stream_launch =
claude_stream_launch_enabled(state.agent, state.stage, state.legacy_claude_launch);
assert!(
!stream_launch,
"the legacy opt-out must force this launch off the stream path for this test \
to mean anything"
);
assert!(
claude_stream_launch_enabled(AgentKind::Claude, state.stage, false),
"clearing the opt-out must flip the predicate back to true, or the check above \
is vacuous"
);
let calls = std::cell::Cell::new(0usize);
canary_gate(
&mut state,
stream_launch,
counting_canary(&calls, canary::CanaryOutcome::Absent),
)
.unwrap();
assert_eq!(
calls.get(),
0,
"a legacy launch must not spend an agent invocation on a premise it never relies on"
);
assert_eq!(
state.canary, None,
"a launch that never ran the guard must not record an outcome for it"
);
}
#[test]
fn canary_gate_still_fires_for_a_widened_stage_without_the_opt_out() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(123);
let mut state = canary_state(root, phase);
state.stage = Stage::Code;
state.legacy_claude_launch = false;
let stream_launch =
claude_stream_launch_enabled(state.agent, state.stage, state.legacy_claude_launch);
assert!(
stream_launch,
"without the opt-out this stage must be on the stream path, or this test is \
asserting the sibling test's case a second time"
);
let calls = std::cell::Cell::new(0usize);
canary_gate(
&mut state,
stream_launch,
counting_canary(&calls, canary::CanaryOutcome::Confirmed),
)
.unwrap();
assert_eq!(
calls.get(),
1,
"a stream launch with no recorded outcome must spend the guard exactly once"
);
assert_eq!(
state.canary,
Some(canary::CanaryOutcome::Confirmed),
"a launch that ran the guard must persist what it found"
);
}
#[test]
fn absent_canary_refuses_to_launch() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(122);
let mut state = canary_state(root, phase);
state.monitor_pid = Some(4_294_967_000);
workflow::save_state(&state).unwrap();
let calls = std::cell::Cell::new(0usize);
let err = canary_gate(
&mut state,
true,
counting_canary(&calls, canary::CanaryOutcome::Absent),
)
.unwrap_err();
let message = err.to_string();
assert!(
message.contains("ABSENT"),
"the refusal must name which of the two failure modes occurred, got: {message}"
);
assert!(
message.contains("multi-plan wave"),
"the refusal must say WHICH guarantee is no longer backed by observed behaviour, \
got: {message}"
);
assert!(
state.monitor_pid.is_none(),
"a refused launch must not leave the previous stage's monitor pid standing — \
liveness() would report Stuck and point at `devflow resume`, which cannot help"
);
let reloaded = workflow::load_state(root, phase).unwrap();
assert!(
reloaded.monitor_pid.is_none(),
"the cleared pid must be persisted, not only cleared in memory"
);
assert_eq!(
reloaded.canary,
Some(canary::CanaryOutcome::Absent),
"a refusal must still record what the guard found, or the next launch re-runs it"
);
}
#[test]
fn unverified_canary_refuses_to_launch_with_a_distinct_message() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let mut unverified_state = canary_state(root, PhaseId::new(123));
let calls = std::cell::Cell::new(0usize);
let unverified = canary_gate(
&mut unverified_state,
true,
counting_canary(
&calls,
canary::CanaryOutcome::Unverified(
"could not run `claude`: No such file or directory (os error 2)".to_string(),
),
),
)
.unwrap_err()
.to_string();
assert!(
unverified.contains("No such file or directory"),
"the reason the guard could not run must reach the operator, got: {unverified}"
);
assert!(
!unverified.contains("ABSENT"),
"an unverified guard must NOT claim the behaviour is gone, got: {unverified}"
);
let mut absent_state = canary_state(root, PhaseId::new(124));
let absent = canary_gate(&mut absent_state, true, || canary::CanaryOutcome::Absent)
.unwrap_err()
.to_string();
assert_ne!(
unverified, absent,
"the two failure modes must not render the same diagnosis"
);
}
#[test]
fn canary_outcome_is_persisted_and_emitted() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(125);
let mut state = canary_state(root, phase);
let calls = std::cell::Cell::new(0usize);
canary_gate(
&mut state,
true,
counting_canary(&calls, canary::CanaryOutcome::Confirmed),
)
.unwrap();
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(
reloaded.canary,
Some(canary::CanaryOutcome::Confirmed),
"the outcome must survive to the next `devflow` process — each stage launch is one"
);
let log = std::fs::read_to_string(devflow_core::events::events_path(root)).unwrap();
let line = log
.lines()
.find(|line| line.contains("claude_delivery_canary_confirmed"))
.expect("the run's provenance must carry the canary outcome");
let event: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(event["event"], "claude_delivery_canary_confirmed");
assert!(phase.matches_json(event.get("phase")));
assert_eq!(
event["token_prefix"],
canary::TOKEN_PREFIX,
"the payload must carry the token's prefix and nothing more"
);
assert_eq!(
line.matches(canary::TOKEN_PREFIX).count(),
1,
"the prefix must appear exactly once — a second occurrence means a token leaked in"
);
}
#[test]
fn launch_stage_inner_refuses_at_code_when_the_canary_cannot_confirm() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(126);
let mut state = canary_state(root, phase);
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);
let message = result
.expect_err("a launch whose canary cannot confirm must not proceed")
.to_string();
assert!(
message.contains("ABSENT"),
"the launch path must surface the guard's own diagnosis, got: {message}"
);
assert_eq!(state.canary, Some(canary::CanaryOutcome::Absent));
assert!(
state.monitor_pid.is_none(),
"a refused launch must record no monitor pid"
);
assert_eq!(
stage_launched_count(root, phase),
0,
"a refused launch must emit no stage_launched event — nothing was launched"
);
}
const HUMAN_GATE_VALUE_FOR_TEST: &str = "blocking-human";
const PLAIN_GATE_VALUE_FOR_TEST: &str = "blocking";
fn write_declared_checkpoint_plan(root: &Path, phase: PhaseId) {
let dir = root.join(".planning/phases").join(format!(
"{padded}-checkpoint-fixture",
padded = phase.padded()
));
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!("{padded}-01-PLAN.md", padded = phase.padded())),
body,
)
.unwrap();
}
fn write_plan_without_checkpoint(root: &Path, phase: PhaseId) {
let dir = root.join(".planning/phases").join(format!(
"{padded}-checkpoint-fixture",
padded = phase.padded()
));
std::fs::create_dir_all(&dir).unwrap();
let body = format!(
"---\nphase: {phase}\n---\n\n<task type=\"checkpoint:decision\" gate=\"{PLAIN_GATE_VALUE_FOR_TEST}\">\n</task>\n"
);
std::fs::write(
dir.join(format!("{padded}-01-PLAN.md", padded = phase.padded())),
body,
)
.unwrap();
}
fn write_confirmed_checkpoint_capture(root: &Path, phase: PhaseId) {
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: PhaseId) {
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: PhaseId, 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_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(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_with_worktree_declared_checkpoint_reads_the_execution_root() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(94);
let worktree = root.join("phase-worktree");
std::fs::create_dir_all(&worktree).unwrap();
write_declared_checkpoint_plan(&worktree, phase);
write_plan_without_checkpoint(root, phase);
write_confirmed_checkpoint_capture(root, phase);
write_abort_gate_response(root, phase, Stage::Code);
assert!(
verify::phase_has_blocking_human_checkpoint(&worktree, phase),
"the execution root holds the declaring PLAN, so the declaration must be found"
);
assert!(
!verify::phase_has_blocking_human_checkpoint(root, phase),
"opposite-result case: the project root holds ONLY the decoy, which declares \
no blocking-human gate, so it must return false — if both roots answered the \
same, this fixture would be measuring the presence of a PLAN somewhere rather \
than which root the call site reads"
);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.session_id = Some("sess-checkpoint-worktree".to_string());
state.worktree_path = Some(worktree.clone());
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 — the declaration lives \
in the worktree, so the arm must read the EXECUTION root: {auto_decided:?}"
);
assert_eq!(auto_decided[0]["session_id"], "sess-checkpoint-worktree");
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 = PhaseId::new(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 = PhaseId::new(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 = PhaseId::new(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 = PhaseId::new(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 = PhaseId::new(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());
}
struct LegacyEnvOverride(Option<std::ffi::OsString>);
impl LegacyEnvOverride {
fn set(value: &str) -> Self {
let prior = std::env::var_os("DEVFLOW_CLAUDE_LEGACY_LAUNCH");
unsafe { std::env::set_var("DEVFLOW_CLAUDE_LEGACY_LAUNCH", value) };
Self(prior)
}
}
impl Drop for LegacyEnvOverride {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(prior) => std::env::set_var("DEVFLOW_CLAUDE_LEGACY_LAUNCH", prior),
None => std::env::remove_var("DEVFLOW_CLAUDE_LEGACY_LAUNCH"),
}
}
}
}
fn legacy_state(root: &Path, phase: PhaseId, opt_out: bool) -> State {
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.legacy_claude_launch = opt_out;
state
}
#[test]
fn legacy_launch_flag_forces_the_single_document_path() {
let dir = tempfile::tempdir().unwrap();
let state = legacy_state(dir.path(), PhaseId::new(130), true);
assert!(
claude_stream_launch_enabled(state.agent, state.stage, false),
"Stage::Code must be in STREAM_JSON_STAGES for this test to mean anything"
);
assert!(!claude_stream_launch_enabled(
state.agent,
state.stage,
state.legacy_claude_launch
));
let adapter = agents::adapter_for(state.agent);
let (program, args, launch) = resolve_launch_shape(
state.agent,
adapter.as_ref(),
state.phase,
"the stage prompt".to_string(),
&[],
false,
);
assert!(matches!(launch, monitor::MonitorLaunch::Legacy));
assert_eq!(program, "claude");
assert_eq!(
(program, args),
agents::ClaudeAgent::exec_command_single_document("the stage prompt"),
"the forced path must be exec_command_single_document byte-for-byte, \
not an approximation of it"
);
}
#[test]
fn legacy_launch_is_off_by_default() {
let _guard = env_lock();
unsafe { std::env::remove_var("DEVFLOW_CLAUDE_LEGACY_LAUNCH") };
let dir = tempfile::tempdir().unwrap();
let state = legacy_state(dir.path(), PhaseId::new(131), false);
assert!(
!state.legacy_claude_launch,
"State::new must default the opt-out to off"
);
assert!(!devflow_core::config::claude_legacy_launch());
let stream_launch =
claude_stream_launch_enabled(state.agent, state.stage, state.legacy_claude_launch);
assert!(stream_launch);
let adapter = agents::adapter_for(state.agent);
let (_program, _args, launch) = resolve_launch_shape(
state.agent,
adapter.as_ref(),
state.phase,
"the stage prompt".to_string(),
&[],
stream_launch,
);
assert!(matches!(launch, monitor::MonitorLaunch::PipeOwning { .. }));
}
#[test]
fn legacy_launch_use_is_recorded_in_provenance() {
let _guard = env_lock();
unsafe { std::env::remove_var("DEVFLOW_CLAUDE_LEGACY_LAUNCH") };
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let state = legacy_state(root, PhaseId::new(132), true);
announce_forced_legacy_launch(&state);
let events = events_of_kind(root, "claude_legacy_launch_forced");
assert_eq!(events.len(), 1, "exactly one provenance record: {events:?}");
assert_eq!(events[0]["stage"].as_str(), Some("code"));
assert_eq!(
events[0]["source"].as_str(),
Some("state:legacy_claude_launch"),
"with no env var set in this process, the source is the persisted flag"
);
let log = std::fs::read_to_string(agent_result::monitor_log_path(root, PhaseId::new(132)))
.expect("the monitor log must exist — it is the only channel a detached run has");
assert!(log.contains("legacy launch"), "monitor log: {log}");
let notice = forced_legacy_launch_notice(state.stage, LegacyLaunchSource::PersistedState);
assert!(notice.contains("999.64"), "notice: {notice}");
assert!(
notice.to_ascii_lowercase().contains("orphan"),
"the notice must say delegated work may be orphaned, in plain words: {notice}"
);
assert!(
log.contains("999.64"),
"the durable channel must carry it too, not just stdout: {log}"
);
}
#[test]
fn legacy_launch_skips_the_delivery_canary() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(133);
let mut state = canary_state(root, phase);
state.legacy_claude_launch = true;
let stream_launch =
claude_stream_launch_enabled(state.agent, state.stage, state.legacy_claude_launch);
assert!(!stream_launch);
assert!(claude_stream_launch_enabled(
state.agent,
state.stage,
false
));
let calls = std::cell::Cell::new(0usize);
canary_gate(
&mut state,
stream_launch,
counting_canary(&calls, canary::CanaryOutcome::Absent),
)
.unwrap();
assert_eq!(calls.get(), 0);
assert_eq!(state.canary, None);
}
#[test]
fn parse_failure_does_not_trigger_a_fallback() {
let _guard = env_lock();
unsafe { std::env::remove_var("DEVFLOW_CLAUDE_LEGACY_LAUNCH") };
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(134);
let state = legacy_state(root, phase, false);
workflow::save_state(&state).unwrap();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(
agent_result::stdout_path(root, phase),
"{\"type\":\"system\",\"subtype\":\"init\"}\n{\"type\":\"assistant\"\n",
)
.unwrap();
let verdict = agent_result::evaluate_layer1(root, phase);
assert!(
verdict
.as_ref()
.is_none_or(|r| r.status != agent_result::AgentStatus::Success),
"fixture precondition: an unparseable capture must never read as success: {verdict:?}"
);
let stream_launch =
claude_stream_launch_enabled(state.agent, state.stage, state.legacy_claude_launch);
assert!(
stream_launch,
"a parse failure must not select the legacy path — D-11 rejects automatic fallback"
);
let adapter = agents::adapter_for(state.agent);
let (_program, _args, launch) = resolve_launch_shape(
state.agent,
adapter.as_ref(),
state.phase,
"the stage prompt".to_string(),
&[],
stream_launch,
);
assert!(matches!(launch, monitor::MonitorLaunch::PipeOwning { .. }));
assert!(events_of_kind(root, "claude_legacy_launch_forced").is_empty());
}
#[test]
fn legacy_launch_env_var_is_parsed_as_a_bool() {
let _guard = env_lock();
{
let _env = LegacyEnvOverride::set("true");
assert!(devflow_core::config::claude_legacy_launch());
}
{
let _env = LegacyEnvOverride::set("false");
assert!(
!devflow_core::config::claude_legacy_launch(),
"`=false` must not enable the legacy path"
);
}
{
let _env = LegacyEnvOverride::set("yes-please");
assert!(!devflow_core::config::claude_legacy_launch());
}
{
let _env = LegacyEnvOverride::set("");
assert!(!devflow_core::config::claude_legacy_launch());
}
}
#[test]
fn resume_does_not_clear_a_persisted_legacy_launch() {
let _guard = env_lock();
unsafe { std::env::remove_var("DEVFLOW_CLAUDE_LEGACY_LAUNCH") };
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(135);
let mut state = legacy_state(root, phase, true);
workflow::save_state(&state).unwrap();
apply_legacy_launch_opt_out(&mut state, false);
assert!(
state.legacy_claude_launch,
"a plain `devflow resume` must not drop the operator's opt-out"
);
let mut never_opted_out = legacy_state(root, PhaseId::new(phase.major() + 1), false);
apply_legacy_launch_opt_out(&mut never_opted_out, false);
assert!(!never_opted_out.legacy_claude_launch);
}
}