use crate::CliError;
use crate::config_parse::{checkout_lock_timeout, gate_timeout_secs};
use crate::parallel::retry_after_from_reason;
use crate::pipeline_gate::{
LoopBackReason, abort, finish_workflow, loop_back_to_code, run_gate, run_gate_with_timeout,
transition,
};
use crate::pipeline_launch::launch_stage;
use devflow_core::config::GitFlowConfig;
use devflow_core::gates::{GateAction, GateResponse, Gates};
use devflow_core::hooks::{self, HookContext};
use devflow_core::mode;
use devflow_core::phase_id::PhaseId;
use devflow_core::prompt::FixType;
use devflow_core::stage::Stage;
use devflow_core::state::State;
use devflow_core::{
agent_result,
agent_result::{AgentStatus, Verdict},
events, lock, workflow,
};
use std::path::{Path, PathBuf};
pub(crate) fn handle_infra_outcome(
project_root: &Path,
state: &mut State,
stage: Stage,
reason: Option<String>,
) -> Result<(), CliError> {
state.infra_failures = state.infra_failures.saturating_add(1);
workflow::save_state(state)?;
gate_or_abort_infra(project_root, state, stage, reason)
}
pub(crate) fn gate_or_abort_infra(
project_root: &Path,
state: &mut State,
stage: Stage,
reason: Option<String>,
) -> Result<(), CliError> {
if state.infra_failures >= mode::MAX_INFRA_FAILURES {
return abort(
project_root,
state,
&format!(
"infrastructure failures reached the ceiling ({} of {}) — aborting rather than gating again",
state.infra_failures,
mode::MAX_INFRA_FAILURES
),
);
}
handle_stage_failure(project_root, state, stage, reason)
}
pub(crate) fn handle_rate_limited_outcome(
project_root: &Path,
state: &mut State,
phase: PhaseId,
stage: Stage,
reason: Option<String>,
) -> Result<(), CliError> {
let retry_after = retry_after_from_reason(reason.as_deref());
let projected_infra_failures = state.infra_failures.saturating_add(1);
if projected_infra_failures >= mode::MAX_INFRA_FAILURES {
return handle_infra_outcome(project_root, state, stage, reason);
}
state.infra_failures = projected_infra_failures;
workflow::save_state(state)?;
let instructions =
devflow_core::ship::build_single_agent_cron_instructions(project_root, phase, &retry_after);
devflow_core::ship::write_cron_instructions(project_root, &instructions)?;
if instructions.hermes_cron.schedule.is_empty() {
return gate_or_abort_infra(
project_root,
state,
stage,
Some(format!(
"rate limited with no parseable retry time ({retry_after}) — auto-resume cron not scheduled; resume manually"
)),
);
}
println!(
"rate limited — wrote {}",
devflow_core::ship::cron_instructions_path(project_root, phase)
.strip_prefix(project_root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| {
devflow_core::ship::cron_instructions_path(project_root, phase)
.display()
.to_string()
})
);
events::emit(
project_root,
phase,
"rate_limit_resume_scheduled",
serde_json::json!({
"stage": stage.to_string(),
"retry_after": retry_after,
"infra_failures": state.infra_failures,
}),
);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ValidateOutcome {
Passed,
Failed,
Ambiguous(String),
}
pub(crate) fn classify_validate_outcome(result: &agent_result::AgentResult) -> ValidateOutcome {
let layer0 = result.decided_by_layer == Some(0);
match (layer0, result.status, result.verdict) {
(_, AgentStatus::Success, Some(Verdict::Pass)) => ValidateOutcome::Passed,
(true, AgentStatus::Success, Some(Verdict::Gaps)) => ValidateOutcome::Ambiguous(
"external verification passed but the agent reported gaps".to_string(),
),
(true, AgentStatus::Success, None) => ValidateOutcome::Ambiguous(
"external verification passed but no agent verdict arrived".to_string(),
),
(false, AgentStatus::Success, Some(Verdict::Gaps) | None) => ValidateOutcome::Failed,
(
_,
AgentStatus::Failed
| AgentStatus::Unknown
| AgentStatus::RateLimited
| AgentStatus::ResourceKilled
| AgentStatus::AgentUnavailable
| AgentStatus::IdleTimeout,
_,
) => ValidateOutcome::Failed,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ValidateResult {
Passed,
Failed,
}
fn select_loop_back_fix(evidence_root: &Path, phase: PhaseId, state: &mut State) -> FixType {
let current = agent_result::phase_verification_fingerprint(evidence_root, phase);
let current_mtime = agent_result::phase_verification_mtime_nanos(evidence_root, phase);
if current.is_some() && !state.verification_baseline_captured {
events::emit(
&state.project_root.clone(),
phase,
"verification_baseline_absent",
serde_json::json!({
"dispatch": "full_execute",
"why": "no run-start baseline was captured for this phase's \
{N}-VERIFICATION.md, so its provenance is unknown",
}),
);
}
if verification_authored_this_run(
(current, current_mtime),
(
state.last_verification_fingerprint,
state.last_verification_mtime_nanos,
),
state.verification_baseline_captured,
state.verification_run_nonce,
) {
state.last_verification_fingerprint = current;
state.last_verification_mtime_nanos = current_mtime;
FixType::GapsOnly
} else {
FixType::FullExecute
}
}
fn verification_authored_this_run(
current: (Option<u64>, Option<u64>),
dispatch_baseline: (Option<u64>, Option<u64>),
baseline_captured: bool,
validate_dispatch_nonce: Option<u64>,
) -> bool {
if validate_dispatch_nonce.is_none() {
return false;
}
let (current_hash, current_mtime) = current;
let (baseline_hash, baseline_mtime) = dispatch_baseline;
let written_since_baseline = match (current_mtime, baseline_mtime) {
(Some(now), Some(baseline)) => now != baseline,
_ => false,
};
match (current_hash, baseline_hash) {
(None, _) => false,
(Some(_), None) => baseline_captured,
(Some(now), Some(baseline)) => now != baseline || written_since_baseline,
}
}
pub(crate) fn handle_validate_outcome(
project_root: &Path,
state: &mut State,
outcome: ValidateOutcome,
) -> Result<(), CliError> {
let evidence_root: PathBuf = state
.worktree_path
.clone()
.unwrap_or_else(|| project_root.to_path_buf());
let result = match outcome {
ValidateOutcome::Ambiguous(detail) => {
let context = format!(
"[never-silent] validate ambiguous: {}",
truncate_reason(&detail)
);
return match run_gate(project_root, state, Stage::Validate, &context)? {
GateAction::Advance => transition(project_root, state, Stage::Ship),
GateAction::LoopBack(_) => {
let fix = select_loop_back_fix(&evidence_root, state.phase, state);
loop_back_to_code(project_root, state, fix, LoopBackReason::GateResponse)
}
GateAction::Abort(reason) => abort(project_root, state, &reason),
};
}
ValidateOutcome::Passed => ValidateResult::Passed,
ValidateOutcome::Failed => ValidateResult::Failed,
};
let baseline_absent = state.last_validate_failure_commit_count.is_none();
if result == ValidateResult::Failed {
state.phase_validate_failures = state.phase_validate_failures.saturating_add(1);
match agent_result::phase_commit_count(project_root, &GitFlowConfig::default(), state.phase)
{
Some(current) => {
if mode::consecutive_failures_made_progress(
state.last_validate_failure_commit_count,
current,
) {
state.consecutive_failures = 1;
} else {
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
}
state.last_validate_failure_commit_count = Some(current);
}
None => {
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
}
}
workflow::save_state(state)?;
state.verification_run_nonce =
Some(state.verification_run_nonce.unwrap_or(0).saturating_add(1));
workflow::save_state(state)?;
}
let ceiling_gate = mode::phase_failure_ceiling_reached(state.phase_validate_failures);
if state.mode.should_gate(
Stage::Validate,
state.consecutive_failures,
state.phase_validate_failures,
) {
let context = match result {
ValidateResult::Passed => {
let mut message = "Validation passed — approve to ship?".to_string();
if ceiling_gate {
message.push_str(&format!(
" (This phase recorded {} Validate failure(s), at the per-phase ceiling of {} — that is why this gate fired. Answering it restarts the count.)",
state.phase_validate_failures,
mode::MAX_PHASE_VALIDATE_FAILURES
));
}
message
}
ValidateResult::Failed => {
let mut message = format!(
"Validation has failed {} time(s) for this phase ({} in the current consecutive streak) — human review needed.",
state.phase_validate_failures, state.consecutive_failures
);
if ceiling_gate {
message.push_str(&format!(
" The per-phase ceiling of {} is reached: this run is paused for a human, not aborted — approve to ship, reject to loop back for another pass, or abort.",
mode::MAX_PHASE_VALIDATE_FAILURES
));
}
message
}
};
return match run_gate(project_root, state, Stage::Validate, &context)? {
GateAction::Advance => {
reset_phase_failures_at_ceiling(project_root, state, ceiling_gate);
transition(project_root, state, Stage::Ship)
}
GateAction::LoopBack(_) => {
let fix = select_loop_back_fix(&evidence_root, state.phase, state);
reset_phase_failures_at_ceiling(project_root, state, ceiling_gate);
loop_back_to_code(project_root, state, fix, loop_back_reason(baseline_absent))
}
GateAction::Abort(reason) => abort(project_root, state, &reason),
};
}
match result {
ValidateResult::Passed => transition(project_root, state, Stage::Ship),
ValidateResult::Failed => {
let fix = select_loop_back_fix(&evidence_root, state.phase, state);
loop_back_to_code(project_root, state, fix, loop_back_reason(baseline_absent))
}
}
}
fn reset_phase_failures_at_ceiling(project_root: &Path, state: &mut State, ceiling_gate: bool) {
if !ceiling_gate {
return;
}
let spent = state.phase_validate_failures;
state.phase_validate_failures = 0;
events::emit(
project_root,
state.phase,
"phase_failure_budget_reset",
serde_json::json!({
"phase_validate_failures_before": spent,
"ceiling": mode::MAX_PHASE_VALIDATE_FAILURES,
}),
);
println!(
"per-phase Validate-failure budget reset: {spent} failure(s) recorded, \
ceiling {} reached and answered by a human — the count restarts at zero",
mode::MAX_PHASE_VALIDATE_FAILURES
);
}
fn loop_back_reason(baseline_absent: bool) -> LoopBackReason {
if baseline_absent {
LoopBackReason::ValidateFailureNoBaseline
} else {
LoopBackReason::ValidateFailure
}
}
pub(crate) fn handle_ship_outcome(project_root: &Path, state: &mut State) -> Result<(), CliError> {
let auto_response = state.yes_ship.then(|| GateResponse {
approved: true,
note: Some("pre-authorized by --yes-ship".to_string()),
responded_by: Some("--yes-ship".to_string()),
});
match run_gate_with_timeout(
project_root,
state,
Stage::Ship,
"Ship complete — approve merge?",
gate_timeout_secs(),
auto_response.as_ref(),
)? {
GateAction::Advance => finish_workflow(project_root, state),
GateAction::LoopBack(_) => loop_back_to_code(
project_root,
state,
FixType::GapsOnly,
LoopBackReason::GateResponse,
),
GateAction::Abort(reason) => abort(project_root, state, &reason),
}
}
pub(crate) fn truncate_reason(reason: &str) -> String {
render_gate_context(reason, 300)
}
pub(crate) fn render_gate_context(context: &str, max_chars: usize) -> String {
const TRUNCATED: &str = "… [truncated; full output in .devflow/]";
let sanitized: String = context
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect();
if sanitized.chars().count() <= max_chars {
return sanitized;
}
let suffix_len = TRUNCATED.chars().count().min(max_chars);
let head_len = max_chars.saturating_sub(suffix_len);
let head: String = sanitized.chars().take(head_len).collect();
let suffix: String = TRUNCATED.chars().take(suffix_len).collect();
format!("{head}{suffix}")
}
pub(crate) fn handle_stage_failure(
project_root: &Path,
state: &mut State,
stage: Stage,
reason: Option<String>,
) -> Result<(), CliError> {
let context = format!(
"[never-silent] stage {stage} failed: {} — human review needed (retry, loop-to-code, or abort)",
truncate_reason(&reason.unwrap_or_else(|| "no details available".into()))
);
match run_gate(project_root, state, stage, &context)? {
GateAction::Advance => {
let _ = Gates::cleanup(project_root, state.phase, stage);
state.gate_pending = false;
launch_stage(state, None, Some(stage))
}
GateAction::LoopBack(_) => {
let _ = Gates::cleanup(project_root, state.phase, stage);
launch_stage(state, None, Some(stage))
}
GateAction::Abort(reason) => abort(project_root, state, &reason),
}
}
pub(crate) fn handle_ship_failure(
project_root: &Path,
state: &mut State,
reason: Option<String>,
) -> Result<(), CliError> {
if is_ship_review_failure(&reason) {
return loop_back_to_code(
project_root,
state,
FixType::AuditFix,
LoopBackReason::GateResponse,
);
}
handle_stage_failure(project_root, state, Stage::Ship, reason)
}
pub(crate) fn is_ship_review_failure(reason: &Option<String>) -> bool {
reason
.as_deref()
.map(|r| r.trim().to_ascii_lowercase().starts_with("review:"))
.unwrap_or(false)
}
pub(crate) fn hook_context_root(
project_root: &Path,
state: &State,
terminal_batch: bool,
) -> PathBuf {
if terminal_batch {
return project_root.to_path_buf();
}
state
.worktree_path
.as_ref()
.filter(|path| path.exists())
.map(|path| path.to_path_buf())
.unwrap_or_else(|| project_root.to_path_buf())
}
pub(crate) fn run_checkout_hooks(
project_root: &Path,
state: &State,
batch: &[hooks::Hook],
stage: Stage,
) -> bool {
if batch.is_empty() {
return true;
}
let _checkout_lock = match lock::acquire_project_blocking(project_root, checkout_lock_timeout())
{
Ok(guard) => guard,
Err(err) => {
println!(
"warning: could not acquire the checkout lock ({err}) — \
SKIPPING hooks {batch:?} rather than mutating the checkout \
unserialized. Re-run them once the holder finishes."
);
events::emit(
project_root,
state.phase,
"checkout_lock_timeout",
serde_json::json!({ "stage": stage.to_string(), "error": err.to_string() }),
);
for hook in batch {
events::emit(
project_root,
state.phase,
"hook_run",
serde_json::json!({
"hook": format!("{hook:?}"),
"ok": false,
"skipped": "checkout lock timeout",
}),
);
}
return false;
}
};
let git_flow = GitFlowConfig::default();
let mut all_succeeded = true;
let terminal_batch = batch == hooks::hooks_after_ship().as_slice();
let hook_root = hook_context_root(project_root, state, terminal_batch);
let mut ctx = HookContext {
phase: state.phase,
project_root: hook_root.clone(),
stage,
git_flow: git_flow.clone(),
shipped_version: None,
shipped_changelog_body: None,
};
for hook in batch {
let outcome = hook.run(&mut ctx);
if let Err(ref err) = outcome {
println!("warning: hook {hook:?} failed: {err}");
all_succeeded = false;
}
events::emit(
project_root,
state.phase,
"hook_run",
serde_json::json!({
"hook": format!("{hook:?}"),
"ok": outcome.is_ok(),
}),
);
if terminal_batch && outcome.is_err() {
break;
}
}
all_succeeded
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline_gate::prepare_loop_back_to_code;
use crate::pipeline_launch::advance;
use crate::test_support::*;
use devflow_core::git::GitFlow;
use devflow_core::mode::Mode;
use devflow_core::prompt;
use devflow_core::state::AgentKind;
#[test]
fn checkout_hooks_skip_instead_of_running_unserialized_on_lock_timeout() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let _held = lock::acquire_project(root).expect("hold checkout lock");
unsafe {
std::env::set_var("DEVFLOW_CHECKOUT_LOCK_TIMEOUT_SECS", "0");
}
let state = State::new(
PhaseId::new(33),
AgentKind::Claude,
Mode::Auto,
root.to_path_buf(),
);
run_checkout_hooks(root, &state, &hooks::hooks_after_ship(), Stage::Ship);
unsafe {
std::env::remove_var("DEVFLOW_CHECKOUT_LOCK_TIMEOUT_SECS");
}
assert!(
!root.join("CHANGELOG.md").exists(),
"hooks must not run while the checkout lock is held elsewhere"
);
let last = devflow_core::events::last_event_for_phase(root, PhaseId::new(33))
.expect("skip must be recorded in events.jsonl");
assert_eq!(last["event"], "hook_run");
assert_eq!(last["ok"], false);
assert_eq!(last["skipped"], "checkout lock timeout");
}
#[test]
fn terminal_hook_failure_stops_before_branch_cleanup() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(34);
let branch = "feature/phase-34";
let git = |args: &[&str]| {
let output = devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "git {args:?} failed");
};
git(&["branch", branch, "develop"]);
std::fs::remove_file(root.join("Cargo.toml")).unwrap();
std::fs::create_dir(root.join("Cargo.toml")).unwrap();
let state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
let succeeded = run_checkout_hooks(root, &state, &hooks::hooks_after_ship(), Stage::Ship);
assert!(!succeeded);
assert!(
GitFlow::new(root).branch_exists(branch),
"a failed terminal batch must preserve the branch for retry"
);
}
#[test]
fn run_checkout_hooks_keeps_changelog_in_sync_with_tag_when_no_version_file() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo_no_version_file(root);
let phase = PhaseId::new(47);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
let git = |args: &[&str]| {
let output = devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "git {args:?} failed");
};
git(&["branch", &branch, "develop"]);
std::fs::write(root.join(".gitignore"), ".devflow/\n").unwrap();
git(&["checkout", &branch]);
std::fs::write(root.join("feature.txt"), "phase work\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "phase work"]);
git(&["checkout", "develop"]);
let state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
let succeeded = run_checkout_hooks(root, &state, &hooks::hooks_after_ship(), Stage::Ship);
assert!(
succeeded,
"after-ship batch must succeed against a clean repo"
);
let all_tags = devflow_core::test_support::git_command(root)
.arg("tag")
.output()
.unwrap();
let all_tags = String::from_utf8_lossy(&all_tags.stdout);
assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
let tag = all_tags.trim().to_string();
let tag_version = tag
.strip_prefix('v')
.expect("tag should be prefixed with v")
.to_string();
let changelog = std::fs::read_to_string(root.join("CHANGELOG.md")).unwrap();
let changelog_version = changelog
.lines()
.find(|l| l.starts_with("## "))
.and_then(|l| l.trim_start_matches("## ").split(' ').next())
.unwrap()
.to_string();
assert_ne!(
changelog_version, "unreleased",
"changelog heading must name the tagged version, not fall back to the literal"
);
assert_eq!(
changelog_version, tag_version,
"changelog heading must match the git tag ({tag}) produced by the same \
run_checkout_hooks call, even with no version file"
);
}
#[test]
fn validate_failure_threshold_forces_gate_then_aborts() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(22);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
state.consecutive_failures = mode::MAX_CONSECUTIVE_FAILURES - 1;
state.last_validate_failure_commit_count = Some(0);
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: requirements changed","responded_by":"test"}"#,
)
.unwrap();
handle_validate_outcome(root, &mut state, ValidateOutcome::Failed).unwrap();
assert_eq!(state.consecutive_failures, mode::MAX_CONSECUTIVE_FAILURES);
assert!(
!Gates::gate_path(root, phase, Stage::Validate).exists(),
"forced gate's files must be cleaned up once it resolves to Abort"
);
let err = workflow::load_state(root, phase).unwrap_err();
assert!(matches!(err, workflow::WorkflowError::MissingState(_)));
}
fn drive_validate_advance_and_read_gate_context(
root: &Path,
phase: PhaseId,
consecutive_failures: u32,
verdict_json: Option<&str>,
) -> String {
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
state.consecutive_failures = consecutive_failures;
state.last_validate_failure_commit_count = Some(0);
workflow::save_state(&state).unwrap();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
let marker = match verdict_json {
Some(verdict) => {
format!(r#"DEVFLOW_RESULT: {{"status":"success","verdict":"{verdict}"}}"#)
}
None => r#"DEVFLOW_RESULT: {"status":"success"}"#.to_string(),
};
std::fs::write(agent_result::stdout_path(root, phase), marker).unwrap();
let gate_path = Gates::gate_path(root, phase, Stage::Validate);
let response_path = Gates::response_path(root, phase, Stage::Validate);
let mut context = String::new();
std::thread::scope(|scope| {
scope.spawn(|| {
advance(root, Some(phase)).unwrap();
});
let mut seen = false;
for _ in 0..150 {
if gate_path.exists() {
seen = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(
seen,
"advance() must force a Validate gate, not advance silently"
);
context = std::fs::read_to_string(&gate_path).unwrap();
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();
});
context
}
#[test]
fn validate_gaps_does_not_advance_to_ship() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let context = drive_validate_advance_and_read_gate_context(
root,
PhaseId::new(60),
mode::MAX_CONSECUTIVE_FAILURES - 1,
Some("gaps"),
);
assert!(
context.contains("Validation has failed"),
"a gaps verdict must be treated as a failed validation, not a pass: {context}"
);
}
#[test]
fn validate_missing_verdict_does_not_advance() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let context = drive_validate_advance_and_read_gate_context(
root,
PhaseId::new(61),
mode::MAX_CONSECUTIVE_FAILURES - 1,
None,
);
assert!(
context.contains("Validation has failed"),
"a missing verdict must be treated as a failed validation, not a pass: {context}"
);
}
#[test]
fn validate_pass_advances() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let context = drive_validate_advance_and_read_gate_context(
root,
PhaseId::new(62),
mode::MAX_CONSECUTIVE_FAILURES,
Some("pass"),
);
assert!(
context.contains("Validation passed"),
"an explicit pass verdict must advance to Ship: {context}"
);
}
#[test]
fn external_verify_agreement_advances_to_ship() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(90);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
workflow::save_state(&state).unwrap();
let result = agent_result::AgentResult {
status: AgentStatus::Success,
exit_code: None,
reason: None,
commits: None,
summary: None,
verdict: Some(Verdict::Pass),
decided_by_layer: Some(0),
};
let outcome = classify_validate_outcome(&result);
assert_eq!(outcome, ValidateOutcome::Passed);
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 _ = handle_validate_outcome(root, &mut state, outcome);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert_eq!(state.stage, Stage::Ship);
assert_eq!(
state.consecutive_failures, 0,
"an agreeing outcome must never touch the failure counter"
);
}
#[test]
fn external_verify_disagreement_gates_immediately() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(91);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
workflow::save_state(&state).unwrap();
let result = agent_result::AgentResult {
status: AgentStatus::Success,
exit_code: None,
reason: None,
commits: None,
summary: None,
verdict: Some(Verdict::Gaps),
decided_by_layer: Some(0),
};
let outcome = classify_validate_outcome(&result);
assert!(matches!(outcome, ValidateOutcome::Ambiguous(_)));
let response_path = Gates::response_path(root, phase, Stage::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();
handle_validate_outcome(root, &mut state, outcome).unwrap();
assert_eq!(
state.consecutive_failures, 0,
"an ambiguous outcome must gate on cycle one without touching the counter"
);
assert!(
!Gates::gate_path(root, phase, Stage::Validate).exists(),
"the immediate gate must resolve (and clean up) via the same abort path as any other gate"
);
}
#[test]
fn external_verify_no_verdict_gates_immediately() {
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());
state.stage = Stage::Validate;
workflow::save_state(&state).unwrap();
let result = agent_result::AgentResult {
status: AgentStatus::Success,
exit_code: None,
reason: None,
commits: None,
summary: None,
verdict: None,
decided_by_layer: Some(0),
};
let outcome = classify_validate_outcome(&result);
assert!(matches!(outcome, ValidateOutcome::Ambiguous(_)));
let response_path = Gates::response_path(root, phase, Stage::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();
handle_validate_outcome(root, &mut state, outcome).unwrap();
assert_eq!(
state.consecutive_failures, 0,
"an ambiguous outcome must gate on cycle one without touching the counter"
);
}
#[test]
fn non_success_status_never_classifies_as_passed_even_with_verdict_pass() {
for status in [
AgentStatus::Failed,
AgentStatus::Unknown,
AgentStatus::RateLimited,
AgentStatus::ResourceKilled,
AgentStatus::AgentUnavailable,
AgentStatus::IdleTimeout,
] {
let result = agent_result::AgentResult {
status,
exit_code: None,
reason: None,
commits: None,
summary: None,
verdict: Some(Verdict::Pass),
decided_by_layer: Some(0),
};
assert_eq!(
classify_validate_outcome(&result),
ValidateOutcome::Failed,
"an agent-written verdict:pass must not outrank the derived status {status:?}"
);
}
}
const ALL_STATUSES: [AgentStatus; 7] = [
AgentStatus::Success,
AgentStatus::Failed,
AgentStatus::Unknown,
AgentStatus::RateLimited,
AgentStatus::ResourceKilled,
AgentStatus::AgentUnavailable,
AgentStatus::IdleTimeout,
];
const ALL_VERDICTS: [Option<Verdict>; 3] = [Some(Verdict::Pass), Some(Verdict::Gaps), None];
fn classifier_fixture(
layer0: bool,
status: AgentStatus,
verdict: Option<Verdict>,
) -> agent_result::AgentResult {
agent_result::AgentResult {
status,
exit_code: None,
reason: None,
commits: None,
summary: None,
verdict,
decided_by_layer: if layer0 { Some(0) } else { Some(1) },
}
}
#[test]
fn classify_validate_outcome_sweeps_all_forty_two_cells() {
let mut visited = 0_usize;
for layer0 in [true, false] {
for status in ALL_STATUSES {
for verdict in ALL_VERDICTS {
let expected = match (layer0, status, verdict) {
(_, AgentStatus::Success, Some(Verdict::Pass)) => ValidateOutcome::Passed,
(true, AgentStatus::Success, Some(Verdict::Gaps)) => {
ValidateOutcome::Ambiguous(
"external verification passed but the agent reported gaps"
.to_string(),
)
}
(true, AgentStatus::Success, None) => ValidateOutcome::Ambiguous(
"external verification passed but no agent verdict arrived".to_string(),
),
_ => ValidateOutcome::Failed,
};
let actual =
classify_validate_outcome(&classifier_fixture(layer0, status, verdict));
assert_eq!(
actual, expected,
"cell (layer0={layer0}, status={status:?}, verdict={verdict:?}) \
classified as {actual:?}, expected {expected:?}"
);
visited += 1;
}
}
}
assert_eq!(
visited, 42,
"the sweep must visit every cell of the 2 x 7 x 3 matrix; a truncated \
iterator or a stale ALL_STATUSES/ALL_VERDICTS array shows up here"
);
}
#[test]
fn verdict_pass_classifies_as_passed_regardless_of_layer() {
for layer0 in [true, false] {
assert_eq!(
classify_validate_outcome(&classifier_fixture(
layer0,
AgentStatus::Success,
Some(Verdict::Pass),
)),
ValidateOutcome::Passed,
"a passing verdict on a successful stage advances regardless of which \
layer decided it (layer0={layer0}); this arm is deliberately \
layer-independent"
);
}
}
#[test]
fn external_verify_gaps_is_ambiguous_only_when_layer0_decided() {
assert!(
matches!(
classify_validate_outcome(&classifier_fixture(
true,
AgentStatus::Success,
Some(Verdict::Gaps),
)),
ValidateOutcome::Ambiguous(_)
),
"a Layer-0 probe pass against a gaps verdict is two signals disagreeing \
and must gate immediately"
);
assert_eq!(
classify_validate_outcome(&classifier_fixture(
false,
AgentStatus::Success,
Some(Verdict::Gaps),
)),
ValidateOutcome::Failed,
"with no Layer-0 probe there is no second signal to disagree with; this \
must stay the ordinary auto-loop, not become an immediate gate. If this \
half matched the layer0=true half, the layer0 dimension would be \
decorative rather than load-bearing"
);
}
#[test]
fn external_verify_absent_verdict_is_ambiguous_only_when_layer0_decided() {
assert!(
matches!(
classify_validate_outcome(&classifier_fixture(true, AgentStatus::Success, None)),
ValidateOutcome::Ambiguous(_)
),
"a Layer-0 probe pass with no agent verdict at all must gate immediately"
);
assert_eq!(
classify_validate_outcome(&classifier_fixture(false, AgentStatus::Success, None)),
ValidateOutcome::Failed,
"with no Layer-0 probe, a missing verdict is the ordinary fail-safe and \
must stay on the auto-loop. If this half matched the layer0=true half, \
the layer0 dimension would be decorative rather than load-bearing"
);
}
#[test]
fn grafted_failure_shape_gates_instead_of_shipping() {
let post_fix =
classify_validate_outcome(&classifier_fixture(true, AgentStatus::Success, None));
match &post_fix {
ValidateOutcome::Ambiguous(detail) => assert!(
detail.contains("no agent verdict"),
"the ambiguous payload must name the missing verdict so the \
[never-silent] gate context says which signal was absent: {detail}"
),
other => panic!("the post-fix Validate shape must gate, not ship — got {other:?}"),
}
assert_eq!(
classify_validate_outcome(&classifier_fixture(
true,
AgentStatus::Success,
Some(Verdict::Pass),
)),
ValidateOutcome::Passed,
"the laundered shape classifies as Passed — this is the downstream \
half of 999.74's exploit, closed upstream by 34-01's graft fix, not here"
);
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::Validate;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::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();
handle_validate_outcome(root, &mut state, post_fix).unwrap();
assert_ne!(
state.stage,
Stage::Ship,
"the post-fix shape must never advance to Ship — that advance is the \
whole of 999.74"
);
assert_eq!(
state.consecutive_failures, 0,
"an ambiguous outcome gates on cycle one without touching the counter, \
so the operator sees an immediate gate rather than a delayed retry"
);
}
#[test]
fn resource_killed_on_code_bumps_infra_failures_not_consecutive_failures() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(73);
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;
state.consecutive_failures = 1;
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 err = workflow::load_state(root, phase).unwrap_err();
assert!(matches!(err, workflow::WorkflowError::MissingState(_)));
assert!(!Gates::gate_path(root, phase, Stage::Validate).exists());
}
#[test]
fn resource_killed_on_validate_bumps_infra_not_consecutive_failures() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(74);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
state.consecutive_failures = 2;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::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();
handle_infra_outcome(
root,
&mut state,
Stage::Validate,
Some("agent process was killed (exit code 137, likely OOM)".into()),
)
.unwrap();
assert_eq!(state.infra_failures, 1);
assert_eq!(
state.consecutive_failures, 2,
"consecutive_failures must be untouched by the infra path"
);
}
#[test]
fn infra_ceiling_aborts_instead_of_gating() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(75);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.infra_failures = mode::MAX_INFRA_FAILURES - 1;
workflow::save_state(&state).unwrap();
handle_infra_outcome(root, &mut state, Stage::Code, Some("killed".into())).unwrap();
assert_eq!(state.infra_failures, mode::MAX_INFRA_FAILURES);
assert!(
!Gates::gate_path(root, phase, Stage::Code).exists(),
"at the ceiling, the run must abort rather than gate again"
);
let err = workflow::load_state(root, phase).unwrap_err();
assert!(matches!(err, workflow::WorkflowError::MissingState(_)));
}
#[test]
fn consecutive_failures_reaches_ceiling_across_cycles() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(81);
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::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).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());
}
for _ in 0..mode::MAX_CONSECUTIVE_FAILURES {
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
state.stage = Stage::Code;
let _ = transition(root, &mut state, Stage::Validate);
}
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert_eq!(state.consecutive_failures, mode::MAX_CONSECUTIVE_FAILURES);
assert!(
state.mode.should_gate(
Stage::Validate,
state.consecutive_failures,
state.phase_validate_failures
),
"reaching the ceiling must force the Auto-mode Validate gate"
);
assert_eq!(
state.infra_failures, 0,
"infra_failures must still reset unconditionally on the same hop the consecutive reset now skips"
);
}
#[test]
fn healthy_multi_wave_progress_does_not_reach_the_ceiling() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(87);
init_repo(root);
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::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).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());
}
for i in 0..(mode::MAX_CONSECUTIVE_FAILURES + 1) {
commit_on_feature_branch(root, phase, &format!("wave-{i}"));
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
state.stage = Stage::Code;
let _ = transition(root, &mut state, Stage::Validate);
}
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert_eq!(
state.consecutive_failures, 1,
"a new commit before every failure must restart the streak at 1, not accumulate it"
);
assert!(
!state.mode.should_gate(
Stage::Validate,
state.consecutive_failures,
state.phase_validate_failures
),
"genuine forward progress must never force the Auto-mode Validate gate"
);
}
#[test]
fn repeated_failure_without_new_commits_still_reaches_the_ceiling() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(88);
init_repo(root);
commit_on_feature_branch(root, phase, "seed");
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::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).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());
}
for _ in 0..mode::MAX_CONSECUTIVE_FAILURES {
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
state.stage = Stage::Code;
let _ = transition(root, &mut state, Stage::Validate);
}
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert_eq!(state.consecutive_failures, mode::MAX_CONSECUTIVE_FAILURES);
assert!(
state.mode.should_gate(
Stage::Validate,
state.consecutive_failures,
state.phase_validate_failures
),
"a genuinely stuck loop with no new commits must still reach the reachable ceiling"
);
}
#[test]
fn validate_failure_with_unmeasurable_count_accumulates_the_streak() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(89);
init_repo(root);
commit_on_feature_branch(root, phase, "seed");
std::fs::create_dir_all(root.join(".devflow")).unwrap();
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
state.last_validate_failure_commit_count = Some(1);
state.consecutive_failures = 1;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
let seed_gate_response = || {
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
};
seed_gate_response();
{
let _no_git = NoGitPath::install();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
}
assert_eq!(
state.last_validate_failure_commit_count,
Some(1),
"a cycle whose commit count could NOT be measured must leave the baseline \
byte-identical to the last real observation — overwriting it with a forged \
zero is 999.77 itself"
);
assert_eq!(
state.consecutive_failures, 2,
"an unmeasurable count is not evidence of forward progress, so the streak \
must continue rather than restart"
);
state.stage = Stage::Validate;
seed_gate_response();
{
let _neutral = NeutralPath::install();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
}
assert_ne!(
state.consecutive_failures, 1,
"the streak must never be reset to 1 by this sequence — that reset is the \
one free ceiling reset a single transient git fault used to buy"
);
assert_eq!(
state.consecutive_failures, 3,
"failure-with-an-unmeasurable-count followed by \
failure-with-an-unchanged-real-count must accumulate"
);
assert!(
state.mode.should_gate(
Stage::Validate,
state.consecutive_failures,
state.phase_validate_failures
),
"the human gate must stay reachable across a transient git fault"
);
}
const LOOP_BACK_RESPONSE: &str =
r#"{"approved":false,"note":"loop back for another pass","responded_by":"test"}"#;
fn last_gate_context(root: &Path, phase: PhaseId) -> Option<String> {
devflow_core::events::last_event_of_kind_for_phase(root, phase, "gate_fired")
.and_then(|event| event["context"].as_str().map(str::to_string))
}
#[test]
fn phase_validate_failure_ceiling_gates_despite_trivial_commit_progress() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(90);
init_repo(root);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).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());
}
for cycle in 1..=mode::MAX_PHASE_VALIDATE_FAILURES {
commit_on_feature_branch(root, phase, &format!("trivial-{cycle}"));
std::fs::write(&response_path, LOOP_BACK_RESPONSE).unwrap();
state.stage = Stage::Validate;
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
assert_eq!(
state.consecutive_failures, 1,
"cycle {cycle}: a new commit before every failure resets the streak, so the \
streak ceiling is unreachable here — if this is not 1 the test is no longer \
exercising the case 999.78 exists for"
);
if cycle < mode::MAX_PHASE_VALIDATE_FAILURES {
assert_eq!(
state.phase_validate_failures, cycle,
"cycle {cycle}: the per-phase total must accumulate once per recorded failure"
);
assert!(
last_gate_context(root, phase).is_none(),
"cycle {cycle}: no gate may fire below the per-phase ceiling"
);
}
}
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let context = last_gate_context(root, phase)
.expect("reaching the per-phase ceiling must fire a Validate gate");
assert!(
context.contains(&format!(
"The per-phase ceiling of {} is reached",
mode::MAX_PHASE_VALIDATE_FAILURES
)),
"the gate that fires at the ceiling must say so: {context}"
);
assert!(
devflow_core::workflow::state_path(root, phase).exists(),
"D-07: the ceiling fires a gate and the run STAYS ALIVE — persisted state for the \
phase must survive. A test asserting only that a gate fired cannot tell gating \
from aborting"
);
}
#[test]
fn validate_gate_message_leads_with_the_per_phase_total() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(91);
init_repo(root);
let mut state = State::new(
phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
state.stage = Stage::Validate;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).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 mut first_gate_context = String::new();
for cycle in 1..=5 {
commit_on_feature_branch(root, phase, &format!("trivial-{cycle}"));
std::fs::write(&response_path, LOOP_BACK_RESPONSE).unwrap();
state.stage = Stage::Validate;
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
if cycle == 1 {
first_gate_context = last_gate_context(root, phase)
.expect("Supervise gates on every Validate failure");
}
}
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let fifth_gate_context =
last_gate_context(root, phase).expect("the fifth failure must also gate in Supervise");
assert_ne!(
first_gate_context, fifth_gate_context,
"WR-04: the 1st and 5th Supervise gate must not read identically — that identity \
IS the defect"
);
let total_clause = "5 time(s) for this phase";
let streak_clause = "(1 in the current consecutive streak)";
let total_at = fifth_gate_context.find(total_clause).unwrap_or_else(|| {
panic!(
"the total must be reported and named as a per-phase quantity: {fifth_gate_context}"
)
});
let streak_at = fifth_gate_context.find(streak_clause).unwrap_or_else(|| {
panic!("the streak must still appear, as a subordinate clause: {fifth_gate_context}")
});
assert!(
total_at < streak_at,
"the cumulative total must LEAD the message, ahead of the streak: {fifth_gate_context}"
);
assert_eq!(
state.consecutive_failures, 1,
"the streak must genuinely differ from the total here, or the ordering assertion \
above is comparing a number against itself"
);
}
#[test]
fn ceiling_clause_appears_only_at_the_ceiling_even_in_supervise_mode() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(92);
init_repo(root);
let mut state = State::new(
phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
state.stage = Stage::Validate;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
let ceiling_clause = format!(
"The per-phase ceiling of {} is reached",
mode::MAX_PHASE_VALIDATE_FAILURES
);
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());
}
std::fs::write(&response_path, LOOP_BACK_RESPONSE).unwrap();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
let below = last_gate_context(root, phase).expect("Supervise gates on every Validate");
state.phase_validate_failures = mode::MAX_PHASE_VALIDATE_FAILURES - 1;
state.stage = Stage::Validate;
std::fs::write(&response_path, LOOP_BACK_RESPONSE).unwrap();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
let at = last_gate_context(root, phase).expect("the ceiling failure must also gate");
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert!(
!below.contains(&ceiling_clause),
"a below-ceiling Supervise gate must NOT carry the ceiling clause — if it does, the \
clause is keyed on gating rather than on the predicate: {below}"
);
assert!(
at.contains(&ceiling_clause),
"the gate at the ceiling must carry the ceiling clause: {at}"
);
}
#[test]
fn loop_back_reason_is_distinct_when_no_commit_baseline_exists() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(93);
init_repo(root);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
assert_eq!(
state.last_validate_failure_commit_count, None,
"the first half's premise: no baseline recorded for this phase"
);
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 _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
let without_baseline =
devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("an ungated Auto failure loops back")["reason"]
.as_str()
.expect("the loop_back event must carry a reason")
.to_string();
assert!(
state.last_validate_failure_commit_count.is_some(),
"the second half's premise: the first failure recorded a baseline"
);
state.stage = Stage::Validate;
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
let with_baseline =
devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("the second failure also loops back")["reason"]
.as_str()
.expect("the loop_back event must carry a reason")
.to_string();
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert_ne!(
without_baseline, with_baseline,
"IN-02: the absent-baseline case must be distinguishable in events.jsonl"
);
assert_eq!(without_baseline, "validate_failure_no_commit_baseline");
assert_eq!(with_baseline, "validate_failure");
}
#[test]
fn phase_validate_failures_reset_on_operator_approval_at_the_ceiling_gate() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
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 at_ceiling_phase = PhaseId::new(95);
let mut at_ceiling = State::new(
at_ceiling_phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
at_ceiling.stage = Stage::Validate;
at_ceiling.phase_validate_failures = mode::MAX_PHASE_VALIDATE_FAILURES - 1;
workflow::save_state(&at_ceiling).unwrap();
let at_ceiling_response = Gates::response_path(root, at_ceiling_phase, Stage::Validate);
std::fs::create_dir_all(at_ceiling_response.parent().unwrap()).unwrap();
std::fs::write(&at_ceiling_response, LOOP_BACK_RESPONSE).unwrap();
let _ = handle_validate_outcome(root, &mut at_ceiling, ValidateOutcome::Failed);
let below_phase = PhaseId::new(96);
let mut below = State::new(
below_phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
below.stage = Stage::Validate;
below.phase_validate_failures = 2;
workflow::save_state(&below).unwrap();
let below_response = Gates::response_path(root, below_phase, Stage::Validate);
std::fs::create_dir_all(below_response.parent().unwrap()).unwrap();
std::fs::write(&below_response, LOOP_BACK_RESPONSE).unwrap();
let _ = handle_validate_outcome(root, &mut below, ValidateOutcome::Failed);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert!(
last_gate_context(root, below_phase).is_some(),
"premise for the second half: Supervise gates on a below-ceiling failure too. If \
no gate fired, the half proves nothing about the discrimination"
);
assert_eq!(
at_ceiling.phase_validate_failures, 0,
"a human answered the CEILING gate, so the per-phase budget starts again"
);
assert_eq!(
below.phase_validate_failures, 3,
"an ordinary below-ceiling Supervise gate must leave the total untouched — a reset \
on every gate would clear it at every failure and the bound would never accumulate"
);
assert_eq!(
workflow::load_state(root, at_ceiling_phase)
.expect("the ceiling gate must leave the run alive, not abort it")
.phase_validate_failures,
0,
"the reset must be persisted, not merely in memory — the next process reads the file"
);
}
#[test]
fn a_passing_validate_at_the_ceiling_explains_why_it_gated() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
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 auto_phase = PhaseId::new(88);
let mut auto = State::new(
auto_phase,
AgentKind::Claude,
Mode::Auto,
root.to_path_buf(),
);
auto.stage = Stage::Validate;
auto.phase_validate_failures = mode::MAX_PHASE_VALIDATE_FAILURES;
workflow::save_state(&auto).unwrap();
let auto_response = Gates::response_path(root, auto_phase, Stage::Validate);
std::fs::create_dir_all(auto_response.parent().unwrap()).unwrap();
std::fs::write(&auto_response, LOOP_BACK_RESPONSE).unwrap();
let _ = handle_validate_outcome(root, &mut auto, ValidateOutcome::Passed);
let supervise_phase = PhaseId::new(89);
let mut supervise = State::new(
supervise_phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
supervise.stage = Stage::Validate;
supervise.phase_validate_failures = 2;
workflow::save_state(&supervise).unwrap();
let supervise_response = Gates::response_path(root, supervise_phase, Stage::Validate);
std::fs::create_dir_all(supervise_response.parent().unwrap()).unwrap();
std::fs::write(&supervise_response, LOOP_BACK_RESPONSE).unwrap();
let _ = handle_validate_outcome(root, &mut supervise, ValidateOutcome::Passed);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let auto_context = last_gate_context(root, auto_phase)
.expect("premise: an exhausted budget gates even in Auto, even on a pass");
assert!(
auto_context.contains("per-phase ceiling"),
"an Auto-mode gate on a PASS is unexplained without the ceiling clause — the \
operator has no way to know why the run stopped: {auto_context:?}"
);
let supervise_context = last_gate_context(root, supervise_phase)
.expect("premise for the control: Supervise gates on every Validate");
assert!(
!supervise_context.contains("per-phase ceiling"),
"NEGATIVE CONTROL: a clause appended to every passing gate would carry no \
information at all: {supervise_context:?}"
);
}
#[test]
fn the_ceiling_reset_records_the_total_it_spent() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
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 at_ceiling_phase = PhaseId::new(91);
let mut at_ceiling = State::new(
at_ceiling_phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
at_ceiling.stage = Stage::Validate;
at_ceiling.phase_validate_failures = mode::MAX_PHASE_VALIDATE_FAILURES - 1;
workflow::save_state(&at_ceiling).unwrap();
let at_ceiling_response = Gates::response_path(root, at_ceiling_phase, Stage::Validate);
std::fs::create_dir_all(at_ceiling_response.parent().unwrap()).unwrap();
std::fs::write(&at_ceiling_response, LOOP_BACK_RESPONSE).unwrap();
let _ = handle_validate_outcome(root, &mut at_ceiling, ValidateOutcome::Failed);
let below_phase = PhaseId::new(92);
let mut below = State::new(
below_phase,
AgentKind::Claude,
Mode::Supervise,
root.to_path_buf(),
);
below.stage = Stage::Validate;
below.phase_validate_failures = 2;
workflow::save_state(&below).unwrap();
let below_response = Gates::response_path(root, below_phase, Stage::Validate);
std::fs::create_dir_all(below_response.parent().unwrap()).unwrap();
std::fs::write(&below_response, LOOP_BACK_RESPONSE).unwrap();
let _ = handle_validate_outcome(root, &mut below, ValidateOutcome::Failed);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert!(
last_gate_context(root, below_phase).is_some(),
"premise for the control: Supervise gates on a below-ceiling failure too"
);
let reset = devflow_core::events::last_event_of_kind_for_phase(
root,
at_ceiling_phase,
"phase_failure_budget_reset",
)
.expect("spending the whole per-phase budget must leave a record of it");
assert_eq!(
reset["phase_validate_failures_before"].as_u64(),
Some(u64::from(mode::MAX_PHASE_VALIDATE_FAILURES)),
"the record must carry the total that was SPENT, not the zero it was reset to"
);
assert!(
devflow_core::events::last_event_of_kind_for_phase(
root,
below_phase,
"phase_failure_budget_reset",
)
.is_none(),
"NEGATIVE CONTROL: an ordinary below-ceiling gate spends no budget and must \
emit nothing — an event on every gate would carry no information"
);
}
#[test]
fn phase_validate_failures_increment_saturates() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(94);
init_repo(root);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
state.phase_validate_failures = u32::MAX;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(&response_path, LOOP_BACK_RESPONSE).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 _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let context = last_gate_context(root, phase)
.expect("a total at u32::MAX is past the ceiling, so this must gate");
assert!(
context.contains(&format!("failed {} time(s) for this phase", u32::MAX)),
"the total must saturate at u32::MAX, not wrap: {context}"
);
assert!(
!context.contains("failed 0 time(s) for this phase"),
"a wrapped total would silently restore an exhausted budget: {context}"
);
}
#[test]
fn evaluate_layer2_unrunnable_git_falls_through_to_layer3() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(agent_result::exit_code_path(root, PhaseId::new(4)), "0").unwrap();
assert!(
agent_result::exit_code_path(root, PhaseId::new(4)).exists(),
"the exit file must be readable, or Layer 2 returns Ok(None) for the wrong reason"
);
let result = {
let _no_git = NoGitPath::install();
agent_result::evaluate_layer2(
root,
PhaseId::new(4),
&GitFlowConfig::default(),
Stage::Code,
)
.unwrap()
};
assert!(
result.is_none(),
"an unmeasurable commit count must fall through to Layer 3, got: {result:?}"
);
assert_ne!(
result.as_ref().map(|r| r.status),
Some(devflow_core::agent_result::AgentStatus::Failed),
"Layer 2 must never classify an unmeasurable count as absent work"
);
}
#[test]
fn evaluate_layer2_unrunnable_git_still_classifies_exit_137_as_resource_killed() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(agent_result::exit_code_path(root, PhaseId::new(4)), "137").unwrap();
let result = {
let _no_git = NoGitPath::install();
agent_result::evaluate_layer2(
root,
PhaseId::new(4),
&GitFlowConfig::default(),
Stage::Code,
)
.unwrap()
};
let result = result.expect(
"exit 137 is classified from the exit code alone — an unmeasurable commit \
count must not discard the verdict and fall to a layer that cannot produce it",
);
assert_eq!(
result.status,
AgentStatus::ResourceKilled,
"Layer 2 is the only classifier for 137; losing it here loses it everywhere"
);
assert_eq!(result.exit_code, Some(137));
assert_eq!(
result.commits, None,
"'could not tell' must not be recorded as a measured zero"
);
assert_eq!(
devflow_core::outcome_policy::decide_action(Stage::Code, result.status),
devflow_core::outcome_policy::Action::GateInfra,
"an OOM-killed agent must route to the infra gate, NOT into the Validate loop"
);
}
#[test]
fn evaluate_layer2_unrunnable_git_keeps_success_for_a_non_commit_gated_stage() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(agent_result::exit_code_path(root, PhaseId::new(4)), "0").unwrap();
let result = {
let _no_git = NoGitPath::install();
agent_result::evaluate_layer2(
root,
PhaseId::new(4),
&GitFlowConfig::default(),
Stage::Validate,
)
.unwrap()
};
let result = result.expect(
"a stage that is not commit-gated never read the count, so an unmeasurable \
count cannot change its answer",
);
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.commits, None);
assert_eq!(
devflow_core::outcome_policy::decide_action(Stage::Validate, result.status),
devflow_core::outcome_policy::Action::Advance
);
}
#[test]
fn evaluate_agent_result_with_unrunnable_git_does_not_report_failed() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(96);
init_repo(root);
commit_on_feature_branch(root, phase, "seed");
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(agent_result::exit_code_path(root, phase), "0").unwrap();
assert!(
!agent_result::stdout_path(root, phase).exists(),
"Layer 1 must decline, or this test never reaches the cascade under study"
);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
let result = {
let _no_git = NoGitPath::install();
agent_result::evaluate_agent_result(root, &state, &GitFlowConfig::default()).unwrap()
};
assert_ne!(
result.status,
devflow_core::agent_result::AgentStatus::Failed,
"END TO END: exit 0 + Stage::Code + an unrunnable git must not report Failed. \
This is the criterion-6 outcome, and a passing evaluate_layer2 unit test does \
not establish it"
);
assert_eq!(
result.status,
devflow_core::agent_result::AgentStatus::Unknown,
"asserted positively too, so a future non-Failed value still confronts this test"
);
assert_eq!(
result.decided_by_layer,
Some(3),
"the cascade must genuinely traverse Layer 2's fall-through into Layer 3 — \
any other layer means this passed for the wrong reason"
);
assert_eq!(
result.commits, None,
"'could not tell' must not be recorded as a measured zero"
);
}
#[test]
fn evaluate_agent_result_with_real_git_and_empty_branch_still_reports_failed() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(97);
init_repo(root);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
assert!(
devflow_core::test_support::git_command(root)
.args(["checkout", "-b", &branch])
.output()
.unwrap()
.status
.success(),
"fixture must create the empty feature branch"
);
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(agent_result::exit_code_path(root, phase), "0").unwrap();
assert!(
!agent_result::stdout_path(root, phase).exists(),
"Layer 1 must decline here too, matching the companion test's shape"
);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
let result = {
let _neutral = NeutralPath::install();
agent_result::evaluate_agent_result(root, &state, &GitFlowConfig::default()).unwrap()
};
assert_eq!(
result.status,
devflow_core::agent_result::AgentStatus::Failed,
"a MEASURED zero on a commit-gated stage is still absent work — the fix must \
not have widened into 'never report Failed'"
);
assert_eq!(result.commits, Some(0));
assert_eq!(result.decided_by_layer, Some(2));
}
#[test]
fn mid_arc_loop_back_issues_plain_execute_command() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(82);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
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 _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let last = devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded");
assert_eq!(
last["fix"], "FullExecute",
"a mid-arc phase (no {{N}}-VERIFICATION.md) must dispatch FullExecute, not GapsOnly"
);
}
#[test]
fn genuine_gaps_loop_back_still_issues_gaps_only() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(83);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.verification_baseline_captured = true;
state.verification_run_nonce = Some(1);
workflow::save_state(&state).unwrap();
let phase_dir = root
.join(".planning/phases")
.join(format!("{padded}-test", padded = phase.padded()));
std::fs::create_dir_all(&phase_dir).unwrap();
std::fs::write(
phase_dir.join(format!("{padded}-VERIFICATION.md", padded = phase.padded())),
"verified\n",
)
.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 _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let last = devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded");
assert_eq!(
last["fix"], "GapsOnly",
"a phase with an existing {{N}}-VERIFICATION.md must still dispatch GapsOnly"
);
}
#[test]
fn worktree_mode_genuine_gaps_loop_back_issues_gaps_only() {
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::Validate;
let worktree = root.join(format!(".worktrees/phase-{phase}"));
std::fs::create_dir_all(&worktree).unwrap();
state.worktree_path = Some(worktree.clone());
state.verification_baseline_captured = true;
state.verification_run_nonce = Some(1);
workflow::save_state(&state).unwrap();
let phase_dir = worktree
.join(".planning/phases")
.join(format!("{padded}-test", padded = phase.padded()));
std::fs::create_dir_all(&phase_dir).unwrap();
std::fs::write(
phase_dir.join(format!("{padded}-VERIFICATION.md", padded = phase.padded())),
"verified\n",
)
.unwrap();
{
let _path_guard = NeutralPath::install();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
}
let last = devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded");
assert_eq!(
last["fix"], "GapsOnly",
"a {{N}}-VERIFICATION.md existing only in the phase's worktree must still dispatch GapsOnly"
);
}
#[test]
fn worktree_mode_mid_arc_loop_back_issues_plain_execute() {
let _guard = env_lock();
let dir_a = tempfile::tempdir().unwrap();
let root_a = dir_a.path();
let phase_a = PhaseId::new(94);
let mut state_a = State::new(phase_a, AgentKind::Claude, Mode::Auto, root_a.to_path_buf());
state_a.stage = Stage::Validate;
let worktree_a = root_a.join(format!(".worktrees/phase-{phase_a}"));
std::fs::create_dir_all(&worktree_a).unwrap();
state_a.worktree_path = Some(worktree_a.clone());
workflow::save_state(&state_a).unwrap();
{
let _path_guard = NeutralPath::install();
let _ = handle_validate_outcome(root_a, &mut state_a, ValidateOutcome::Failed);
}
let last_a =
devflow_core::events::last_event_of_kind_for_phase(root_a, phase_a, "loop_back")
.expect("scenario A loop_back event must be recorded");
assert_eq!(
last_a["fix"], "FullExecute",
"no {{N}}-VERIFICATION.md in the worktree (nor anywhere else) must dispatch FullExecute"
);
}
#[test]
fn worktree_mode_main_checkout_only_artifact_is_the_or_both_roots_discriminator() {
let _guard = env_lock();
let dir_b = tempfile::tempdir().unwrap();
let root_b = dir_b.path();
let phase_b = PhaseId::new(95);
let mut state_b = State::new(phase_b, AgentKind::Claude, Mode::Auto, root_b.to_path_buf());
state_b.stage = Stage::Validate;
let worktree_b = root_b.join(format!(".worktrees/phase-{phase_b}"));
std::fs::create_dir_all(&worktree_b).unwrap();
state_b.worktree_path = Some(worktree_b.clone());
workflow::save_state(&state_b).unwrap();
let stale_dir = root_b
.join(".planning/phases")
.join(format!("{phase_b:02}-test"));
std::fs::create_dir_all(&stale_dir).unwrap();
std::fs::write(
stale_dir.join(format!("{phase_b:02}-VERIFICATION.md")),
"stale artifact belonging to a different run\n",
)
.unwrap();
{
let _path_guard = NeutralPath::install();
let _ = handle_validate_outcome(root_b, &mut state_b, ValidateOutcome::Failed);
}
let last_b =
devflow_core::events::last_event_of_kind_for_phase(root_b, phase_b, "loop_back")
.expect("scenario B loop_back event must be recorded");
assert_eq!(
last_b["fix"], "FullExecute",
"a {{N}}-VERIFICATION.md visible only from the main checkout belongs to a different run and must NOT resurrect GapsOnly"
);
}
#[test]
fn stale_verification_artifact_dispatches_full_execute() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(86);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
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}-test", 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 -- authored by a PREVIOUS run\n",
)
.unwrap();
let baseline = devflow_core::agent_result::phase_verification_fingerprint(&worktree, phase);
assert!(
baseline.is_some(),
"premise: the inherited artifact must be visible from the evidence root, otherwise \
this test would assert FullExecute for the mid-arc reason instead of the stale one"
);
state.last_verification_fingerprint = baseline;
workflow::save_state(&state).unwrap();
{
let _path_guard = NeutralPath::install();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
}
let last = devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded");
assert_eq!(
last["fix"], "FullExecute",
"an artifact unchanged since this run started was inherited from a previous run; \
its verdict must NOT be reused, so the loop-back must dispatch FullExecute"
);
}
#[test]
fn verification_written_this_run_dispatches_gaps_only() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(87);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
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}-test", padded = phase.padded()));
std::fs::create_dir_all(&phase_dir).unwrap();
let artifact = phase_dir.join(format!("{padded}-VERIFICATION.md", padded = phase.padded()));
std::fs::write(&artifact, "verdict: pass -- from a PREVIOUS run\n").unwrap();
let run_start =
devflow_core::agent_result::phase_verification_fingerprint(&worktree, phase);
state.last_verification_fingerprint = run_start;
state.verification_run_nonce = Some(1);
workflow::save_state(&state).unwrap();
std::fs::write(&artifact, "verdict: gaps -- authored by THIS run\n").unwrap();
let rewritten =
devflow_core::agent_result::phase_verification_fingerprint(&worktree, phase);
assert_ne!(
run_start, rewritten,
"premise: the rewrite must actually change the fingerprint, otherwise this test \
is silently exercising the stale case with a fresh label"
);
let mut reloaded = workflow::load_state(root, phase).expect("state must persist");
reloaded.verification_run_nonce = Some(1);
assert_eq!(
reloaded.last_verification_fingerprint, run_start,
"premise: the run-start baseline must survive the save/load round trip, or the \
comparison below is against an in-memory value the real pipeline never sees"
);
{
let _path_guard = NeutralPath::install();
let _ = handle_validate_outcome(root, &mut reloaded, ValidateOutcome::Failed);
}
let last = devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded");
assert_eq!(
last["fix"], "GapsOnly",
"an artifact whose content changed since this run started was authored by THIS \
run's Validate agent and must still reach the gaps-only path — a rule that marks \
everything stale would pass the stale test and fail here, which is the whole point \
of the pair"
);
let persisted = workflow::load_state(root, phase)
.expect("state must still exist after the loop-back completed");
assert_eq!(
persisted.last_verification_fingerprint, rewritten,
"the baseline the selector recorded must reach DISK — a selector that updates a \
value nothing ever writes out would leave a later same-run check comparing against \
the old baseline and reading an unchanged artifact as fresh (F-11's fail-open \
direction)"
);
let dir_b = tempfile::tempdir().unwrap();
let root_b = dir_b.path();
let phase_b = PhaseId::new(88);
let mut state_b = State::new(phase_b, AgentKind::Claude, Mode::Auto, root_b.to_path_buf());
state_b.stage = Stage::Validate;
let worktree_b = root_b.join(format!(".worktrees/phase-{phase_b}"));
std::fs::create_dir_all(&worktree_b).unwrap();
state_b.worktree_path = Some(worktree_b.clone());
assert_eq!(
state_b.last_verification_fingerprint, None,
"premise: sub-case 2 requires an absent run-start baseline"
);
state_b.verification_baseline_captured = true;
workflow::save_state(&state_b).unwrap();
let phase_dir_b = worktree_b
.join(".planning/phases")
.join(format!("{phase_b:02}-test"));
std::fs::create_dir_all(&phase_dir_b).unwrap();
std::fs::write(
phase_dir_b.join(format!("{phase_b:02}-VERIFICATION.md")),
"verdict: gaps -- first verification of this phase\n",
)
.unwrap();
{
let _path_guard = NeutralPath::install();
let _ = handle_validate_outcome(root_b, &mut state_b, ValidateOutcome::Failed);
}
let last_b =
devflow_core::events::last_event_of_kind_for_phase(root_b, phase_b, "loop_back")
.expect("sub-case 2 loop_back event must be recorded");
assert_eq!(
last_b["fix"], "GapsOnly",
"an artifact that exists where the run-start baseline recorded none was authored \
this run and must dispatch GapsOnly — this is the ordinary first-verification case \
Phase 33 built, and a too-strict freshness rule breaks it"
);
}
#[test]
fn verification_freshness_truth_table_is_exhaustive() {
assert!(
!verification_authored_this_run((None, None), (None, None), true, Some(1)),
"row 1: an absent artifact is never 'authored this run'"
);
assert!(
!verification_authored_this_run((None, None), (Some(7), Some(100)), true, Some(1)),
"row 1b: an artifact that is absent NOW is never 'authored this run', whatever the \
baseline recorded"
);
assert!(
verification_authored_this_run((Some(7), Some(100)), (None, None), true, Some(1)),
"row 2: an artifact existing where the baseline recorded none was authored this run"
);
assert!(
!verification_authored_this_run((Some(7), Some(100)), (None, None), false, Some(1)),
"row 2b: with no captured baseline the artifact's provenance is unknown, and \
reading it as this run's is the 999.79 stall reproduced across an upgrade"
);
assert!(
!verification_authored_this_run(
(Some(7), Some(100)),
(Some(7), Some(100)),
true,
Some(1)
),
"row 3: an artifact whose fingerprint AND mtime equal the run-start baseline is \
INHERITED, not authored this run"
);
assert!(
verification_authored_this_run(
(Some(7), Some(200)),
(Some(7), Some(100)),
true,
Some(1)
),
"row 3b: an IDEMPOTENT rewrite is still a rewrite — unchanged bytes with an \
advanced mtime were written by this run's agent"
);
assert!(
!verification_authored_this_run((Some(7), None), (Some(7), Some(100)), true, Some(1)),
"row 3c: an unavailable mtime must degrade to the content comparison, not \
manufacture a difference"
);
assert!(
verification_authored_this_run(
(Some(7), Some(200)),
(Some(8), Some(100)),
true,
Some(1)
),
"row 4: an artifact whose fingerprint differs from the run-start baseline was \
rewritten during this run"
);
}
#[test]
fn an_uncaptured_baseline_does_not_claim_an_inherited_artifact() {
let _guard = env_lock();
fn dispatch_with(root: &Path, phase: PhaseId, baseline_captured: bool) -> String {
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.verification_run_nonce = Some(1);
state.stage = Stage::Validate;
state.verification_baseline_captured = baseline_captured;
workflow::save_state(&state).unwrap();
let phase_dir = root
.join(".planning/phases")
.join(format!("{padded}-test", 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 -- committed by a PREVIOUS run\n",
)
.unwrap();
{
let _path_guard = NeutralPath::install();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
}
devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded")["fix"]
.as_str()
.expect("the fix must be recorded as a string")
.to_string()
}
let upgraded_dir = tempfile::tempdir().unwrap();
let upgraded_root = upgraded_dir.path();
assert_eq!(
dispatch_with(upgraded_root, PhaseId::new(78), false),
"FullExecute",
"with no captured baseline the artifact's provenance is unknown; --gaps-only \
would match zero plans and gate unresolvably"
);
assert!(
devflow_core::events::last_event_of_kind_for_phase(
upgraded_root,
PhaseId::new(78),
"verification_baseline_absent",
)
.is_some(),
"the operator must be able to see WHY a gaps-only pass became a full execute"
);
let captured_dir = tempfile::tempdir().unwrap();
let captured_root = captured_dir.path();
assert_eq!(
dispatch_with(captured_root, PhaseId::new(79), true),
"GapsOnly",
"the ordinary first-verification case must be untouched — an always-stale rule \
re-runs every plan in the phase forever, which the selector's own comment calls \
the worse over-correction"
);
assert!(
devflow_core::events::last_event_of_kind_for_phase(
captured_root,
PhaseId::new(79),
"verification_baseline_absent",
)
.is_none(),
"a signal emitted on the ordinary case too would carry no information"
);
}
#[test]
fn an_idempotent_rewrite_is_authored_not_inherited() {
let _guard = env_lock();
fn dispatch_with(root: &Path, phase: PhaseId, rewrite_identically: bool) -> String {
let phase_dir = root
.join(".planning/phases")
.join(format!("{padded}-test", padded = phase.padded()));
std::fs::create_dir_all(&phase_dir).unwrap();
let artifact =
phase_dir.join(format!("{padded}-VERIFICATION.md", padded = phase.padded()));
let contents = "verdict: gaps -- G is still open\n";
std::fs::write(&artifact, contents).unwrap();
let baseline_hash =
devflow_core::agent_result::phase_verification_fingerprint(root, phase);
let baseline_mtime =
devflow_core::agent_result::phase_verification_mtime_nanos(root, phase)
.expect("the fixture needs a readable mtime");
if rewrite_identically {
std::fs::write(&artifact, contents).unwrap();
let advanced = std::time::UNIX_EPOCH
+ std::time::Duration::from_nanos(baseline_mtime)
+ std::time::Duration::from_secs(1);
std::fs::File::options()
.write(true)
.open(&artifact)
.unwrap()
.set_modified(advanced)
.unwrap();
assert_eq!(
devflow_core::agent_result::phase_verification_fingerprint(root, phase),
baseline_hash,
"premise: the rewrite must be byte-IDENTICAL, or this exercises row 4"
);
assert_ne!(
devflow_core::agent_result::phase_verification_mtime_nanos(root, phase),
Some(baseline_mtime),
"premise: the rewrite must advance the mtime, or there is nothing to see"
);
}
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
state.verification_baseline_captured = true;
state.last_verification_fingerprint = baseline_hash;
state.last_verification_mtime_nanos = Some(baseline_mtime);
state.verification_run_nonce = Some(1);
workflow::save_state(&state).unwrap();
{
let _path_guard = NeutralPath::install();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
}
devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded")["fix"]
.as_str()
.expect("the fix must be recorded as a string")
.to_string()
}
let rewritten_dir = tempfile::tempdir().unwrap();
assert_eq!(
dispatch_with(rewritten_dir.path(), PhaseId::new(76), true),
"GapsOnly",
"an idempotent rewrite is still a rewrite; reading it as inherited re-runs every \
plan in the phase on every later cycle"
);
let untouched_dir = tempfile::tempdir().unwrap();
assert_eq!(
dispatch_with(untouched_dir.path(), PhaseId::new(77), false),
"FullExecute",
"an artifact nobody touched since the baseline is INHERITED — a rule that always \
answered 'authored' would be the 999.79 stall"
);
}
#[test]
fn ambiguous_gate_loop_back_respects_the_mid_arc_check() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(84);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"loop back for another pass","responded_by":"test"}"#,
)
.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 _ = handle_validate_outcome(
root,
&mut state,
ValidateOutcome::Ambiguous("test disagreement".to_string()),
);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let last = devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded");
assert_eq!(
last["fix"], "FullExecute",
"the Ambiguous gate's loop-back must respect the mid-arc check, same as the plain tail arm"
);
}
#[test]
fn failure_gate_loop_back_respects_the_mid_arc_check() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(85);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
state.consecutive_failures = mode::MAX_CONSECUTIVE_FAILURES;
state.last_validate_failure_commit_count = Some(0);
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"loop back for another pass","responded_by":"test"}"#,
)
.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 _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let last = devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded");
assert!(
last["consecutive_failures"]
.as_u64()
.expect("loop_back event must carry consecutive_failures")
>= u64::from(mode::MAX_CONSECUTIVE_FAILURES),
"must be the consecutive-failure-GATED loop-back arm (counter {}) — a value below the threshold means this ran the ungated tail arm instead",
last["consecutive_failures"]
);
assert_eq!(
last["fix"], "FullExecute",
"the consecutive-failure-gated loop-back must respect the mid-arc check, same as the ungated tail arm"
);
}
#[test]
fn ship_loop_back_still_issues_gaps_only_when_verification_absent() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(86);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
assert!(
!state.yes_ship,
"no pre-authorization must short-circuit the gate"
);
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Ship);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"loop back for another pass","responded_by":"test"}"#,
)
.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 _ = handle_ship_outcome(root, &mut state);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let last = devflow_core::events::last_event_of_kind_for_phase(root, phase, "loop_back")
.expect("loop_back event must be recorded");
assert_eq!(
last["fix"], "GapsOnly",
"handle_ship_outcome must remain unaffected by the D-01 mid-arc check (D-02)"
);
}
#[test]
fn external_verify_cycles_reach_ceiling_without_unbounded_loop() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
arm_a_ambiguous_outcome_gates_on_cycle_one(root, PhaseId::new(93));
arm_b_genuine_failures_reach_the_ceiling(root, PhaseId::new(94));
}
fn arm_a_ambiguous_outcome_gates_on_cycle_one(root: &Path, phase: PhaseId) {
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
workflow::save_state(&state).unwrap();
let result = agent_result::AgentResult {
status: AgentStatus::Success,
exit_code: None,
reason: None,
commits: None,
summary: None,
verdict: Some(Verdict::Gaps),
decided_by_layer: Some(0),
};
let outcome = classify_validate_outcome(&result);
assert!(matches!(outcome, ValidateOutcome::Ambiguous(_)));
let response_path = Gates::response_path(root, phase, Stage::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();
handle_validate_outcome(root, &mut state, outcome).unwrap();
assert_eq!(
state.consecutive_failures, 0,
"18e's ambiguous gate must fire on cycle one, never touching 18d's counter"
);
}
fn arm_b_genuine_failures_reach_the_ceiling(root: &Path, phase: PhaseId) {
let _guard = env_lock();
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::Validate);
std::fs::create_dir_all(response_path.parent().unwrap()).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());
}
for _ in 0..mode::MAX_CONSECUTIVE_FAILURES {
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
let _ = handle_validate_outcome(root, &mut state, ValidateOutcome::Failed);
state.stage = Stage::Code;
let _ = transition(root, &mut state, Stage::Validate);
}
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert_eq!(state.consecutive_failures, mode::MAX_CONSECUTIVE_FAILURES);
assert!(
state.mode.should_gate(
Stage::Validate,
state.consecutive_failures,
state.phase_validate_failures
),
"a genuine repeated failure must still reach the reachable ceiling (18d)"
);
}
#[test]
fn consecutive_failures_increment_saturates() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(82);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
state.consecutive_failures = u32::MAX;
state.last_validate_failure_commit_count = Some(0);
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::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();
handle_validate_outcome(root, &mut state, ValidateOutcome::Failed).unwrap();
assert_eq!(state.consecutive_failures, u32::MAX);
}
#[test]
fn primary_loop_rate_limited_writes_single_agent_cron_instructions() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(76);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
workflow::save_state(&state).unwrap();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(
agent_result::stdout_path(root, phase),
r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
)
.unwrap();
advance(root, Some(phase)).unwrap();
let instructions = devflow_core::ship::load_cron_instructions(root, phase).unwrap();
assert_eq!(instructions.resume.command, "devflow");
assert_eq!(
instructions.resume.args,
["resume", "--phase", &phase.to_string()]
);
assert!(
instructions
.hermes_cron
.command
.contains(&format!("devflow resume --phase {phase}"))
);
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(reloaded.stage, Stage::Code);
assert!(!reloaded.gate_pending);
assert_eq!(reloaded.infra_failures, 1);
assert_eq!(reloaded.consecutive_failures, 0);
assert!(!Gates::gate_path(root, phase, Stage::Code).exists());
}
#[test]
fn rate_limited_at_infra_ceiling_stops_resuming_and_aborts() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(77);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state.infra_failures = mode::MAX_INFRA_FAILURES - 1;
workflow::save_state(&state).unwrap();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(
agent_result::stdout_path(root, phase),
r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
)
.unwrap();
advance(root, Some(phase)).unwrap();
let err = workflow::load_state(root, phase).unwrap_err();
assert!(
matches!(err, workflow::WorkflowError::MissingState(_)),
"the infra ceiling must abort, clearing state"
);
assert!(
devflow_core::ship::load_cron_instructions(root, phase).is_err(),
"must not schedule an auto-resume once the infra ceiling stops resumption"
);
}
#[test]
fn rate_limited_with_unparseable_retry_hint_gates_instead_of_stalling_silently() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(81);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
workflow::save_state(&state).unwrap();
std::fs::create_dir_all(root.join(".devflow")).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();
handle_rate_limited_outcome(
root,
&mut state,
phase,
Stage::Code,
Some("rate limited until usage limit".into()),
)
.unwrap();
let events =
std::fs::read_to_string(devflow_core::events::events_path(root)).unwrap_or_default();
assert!(
events.contains("gate_fired"),
"an unparseable retry hint must raise a gate, not stall the phase silently: {events}"
);
assert!(
events.contains("notify_fired"),
"the operator must be notified that a manual resume is needed: {events}"
);
assert!(
!events.contains("rate_limit_resume_scheduled"),
"nothing was scheduled — emitting a resume-scheduled event would be a false signal: {events}"
);
let instructions = devflow_core::ship::load_cron_instructions(root, phase).unwrap();
assert!(instructions.hermes_cron.schedule.is_empty());
}
#[test]
fn content_hooks_target_the_worktree_while_terminal_hooks_stay_on_project_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let worktree = root.join(".worktrees/phase-70");
std::fs::create_dir_all(&worktree).unwrap();
let mut state = State::new(
PhaseId::new(70),
AgentKind::Claude,
Mode::Auto,
root.to_path_buf(),
);
state.worktree_path = Some(worktree.clone());
assert_eq!(
hook_context_root(root, &state, false),
worktree,
"content hooks must write into the phase's worktree"
);
assert_eq!(
hook_context_root(root, &state, true),
root.to_path_buf(),
"terminal hooks merge/tag/delete against the primary checkout"
);
let mut no_worktree = state.clone();
no_worktree.worktree_path = None;
assert_eq!(hook_context_root(root, &no_worktree, false), root);
let mut missing = state.clone();
missing.worktree_path = Some(root.join(".worktrees/gone"));
assert_eq!(hook_context_root(root, &missing, false), root);
}
#[test]
fn truncate_reason_caps_long_reasons_and_keeps_short_ones() {
assert_eq!(truncate_reason("short reason"), "short reason");
let long = "x".repeat(5000);
let capped = truncate_reason(&long);
assert!(capped.chars().count() <= 300);
assert!(capped.ends_with("[truncated; full output in .devflow/]"));
}
#[test]
fn gate_context_rendering_neutralizes_all_controls_and_obeys_limit() {
let rendered = render_gate_context("line 1\n\u{1b}[2J\tline 2\u{7}", 100);
assert!(!rendered.chars().any(char::is_control));
assert_eq!(rendered, "line 1 [2J line 2 ");
let bounded = render_gate_context(&"x".repeat(500), 100);
assert_eq!(bounded.chars().count(), 100);
assert!(bounded.ends_with("[truncated; full output in .devflow/]"));
}
#[test]
fn ship_agent_failed_fires_gate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(40);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
workflow::save_state(&state).unwrap();
let gate_path = Gates::gate_path(root, phase, Stage::Ship);
let response_path = Gates::response_path(root, phase, Stage::Ship);
std::thread::scope(|scope| {
scope.spawn(|| {
handle_ship_failure(root, &mut state, Some("agent crashed".into())).unwrap();
});
let mut seen = false;
for _ in 0..150 {
if gate_path.exists() {
seen = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(
seen,
"handle_ship_failure must write a gate file, not silently return an Err"
);
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 ship_review_failed_loops_to_code() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(41);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
workflow::save_state(&state).unwrap();
let reason = Some("review: please fix naming".to_string());
assert!(is_ship_review_failure(&reason));
prepare_loop_back_to_code(
root,
&mut state,
FixType::AuditFix,
LoopBackReason::GateResponse,
)
.unwrap();
assert_eq!(state.stage, Stage::Code);
assert!(!Gates::gate_path(root, phase, Stage::Ship).exists());
assert!(workflow::load_state(root, phase).is_ok());
}
#[test]
fn ship_review_failed_uses_audit_fix() {
assert!(is_ship_review_failure(&Some(
"review: needs changes".into()
)));
assert!(is_ship_review_failure(&Some(" Review: nitpick".into())));
assert!(!is_ship_review_failure(&Some("agent crashed".into())));
assert!(!is_ship_review_failure(&None));
let prompt = prompt::fix_prompt(FixType::AuditFix, PhaseId::new(11));
assert!(prompt.contains("/gsd-audit-fix"));
assert!(!prompt.contains("--gaps-only"));
}
#[test]
fn state_new_alone_never_derives_yes_ship_from_config() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("devflow.toml"), "yes_ship = true\n").unwrap();
let _config = devflow_core::config::load_config(root);
let state = State::new(
PhaseId::new(1),
AgentKind::Claude,
Mode::Auto,
root.to_path_buf(),
);
assert!(
!state.yes_ship,
"State::new alone must never derive the Ship pre-authorization from \
devflow.toml (D-05, narrowed post-D-12: only commands::start's explicit \
OR-combine may do that — see yes_ship_config.rs)"
);
}
#[test]
fn handle_ship_outcome_with_yes_ship_auto_approves_exactly_once_with_attribution() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = PhaseId::new(50);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
let branch_created = devflow_core::test_support::git_command(root)
.args(["branch", &branch, "develop"])
.status()
.unwrap()
.success();
assert!(branch_created);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
state.yes_ship = true;
workflow::save_state(&state).unwrap();
handle_ship_outcome(root, &mut state).unwrap();
assert!(
matches!(
workflow::load_state(root, phase),
Err(workflow::WorkflowError::MissingState(_))
),
"the pre-authorized gate must let the run reach a completed Ship without a human"
);
assert!(!Gates::gate_path(root, phase, Stage::Ship).exists());
assert!(!Gates::response_path(root, phase, Stage::Ship).exists());
assert!(!Gates::ack_path(root, phase, Stage::Ship).exists());
let contents =
std::fs::read_to_string(devflow_core::events::events_path(root)).unwrap_or_default();
let gate_fired_count = contents
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|event| {
event["event"] == "gate_fired"
&& phase.matches_json(event.get("phase"))
&& event["stage"] == "ship"
})
.count();
assert_eq!(
gate_fired_count, 1,
"the Ship gate must be written exactly once, not reopened"
);
let resolved =
devflow_core::events::last_event_of_kind_for_phase(root, phase, "gate_resolved")
.expect("a gate_resolved event must be recorded");
assert_eq!(resolved["stage"], "ship");
assert_eq!(resolved["approved"], true);
assert_eq!(resolved["action"], "advance");
assert_eq!(
resolved["responded_by"], "--yes-ship",
"the gate ledger must carry the pre-authorization's literal attribution"
);
}
#[test]
fn handle_ship_outcome_without_yes_ship_writes_gate_but_no_response() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(51);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
assert!(!state.yes_ship, "yes_ship must default to false");
workflow::save_state(&state).unwrap();
let gate_path = Gates::gate_path(root, phase, Stage::Ship);
let response_path = Gates::response_path(root, phase, Stage::Ship);
std::thread::scope(|scope| {
scope.spawn(|| {
handle_ship_outcome(root, &mut state).unwrap();
});
let mut seen = false;
for _ in 0..150 {
if gate_path.exists() {
seen = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(seen, "handle_ship_outcome must write a gate request");
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
!response_path.exists(),
"with yes_ship unset, no response may ever be auto-written — the run must wait for a human"
);
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 non_validate_failure_fires_gate_and_hook() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sentinel = root.join("notify-sentinel");
unsafe {
std::env::set_var(
"DEVFLOW_GATE_NOTIFY_CMD",
format!("touch {}", sentinel.display()),
);
}
let phase = PhaseId::new(42);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
workflow::save_state(&state).unwrap();
assert!(!state.mode.should_gate(
Stage::Code,
state.consecutive_failures,
state.phase_validate_failures
));
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();
let result =
handle_stage_failure(root, &mut state, Stage::Code, Some("build failed".into()));
unsafe {
std::env::remove_var("DEVFLOW_GATE_NOTIFY_CMD");
}
result.unwrap();
assert!(
sentinel.exists(),
"handle_stage_failure must fire the configured notify hook, not silently skip it"
);
}
#[test]
fn stage_failure_retry_cleans_stale_response() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(43);
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();
handle_stage_failure(root, &mut state, Stage::Code, Some("first failure".into())).unwrap();
assert!(!Gates::gate_path(root, phase, Stage::Code).exists());
assert!(!Gates::response_path(root, phase, Stage::Code).exists());
assert!(!Gates::ack_path(root, phase, Stage::Code).exists());
Gates::write_gate(root, phase, Stage::Code, "re-fired gate").unwrap();
let started = std::time::Instant::now();
let got = Gates::poll_response(root, phase, Stage::Code, 1);
assert!(
got.is_none(),
"poll_response must not instantly resolve from a stale response after cleanup"
);
assert!(started.elapsed() >= std::time::Duration::from_secs(1));
}
#[test]
fn a_checkout_between_dispatches_does_not_read_as_authored_this_run() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(87);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
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}-test", padded = phase.padded()));
std::fs::create_dir_all(&phase_dir).unwrap();
let artifact_path =
phase_dir.join(format!("{padded}-VERIFICATION.md", padded = phase.padded()));
std::fs::write(&artifact_path, "verdict: pass — from a PREVIOUS run\n").unwrap();
state.last_verification_fingerprint =
devflow_core::agent_result::phase_verification_fingerprint(&worktree, phase);
state.last_verification_mtime_nanos =
devflow_core::agent_result::phase_verification_mtime_nanos(&worktree, phase);
state.verification_baseline_captured = true;
assert!(state.verification_run_nonce.is_none());
std::fs::write(&artifact_path, "verdict: pass — BRANCH CHECKOUT\n").unwrap();
assert_ne!(
devflow_core::agent_result::phase_verification_fingerprint(&worktree, phase),
state.last_verification_fingerprint,
"premise: replacement must change fingerprint"
);
workflow::save_state(&state).unwrap();
let fix = select_loop_back_fix(&worktree, phase, &mut state);
assert_eq!(fix, FixType::FullExecute);
}
#[test]
fn both_dispatch_directions_are_demonstrated_in_one_run() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = PhaseId::new(88);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
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}-test", padded = phase.padded()));
std::fs::create_dir_all(&phase_dir).unwrap();
let artifact = phase_dir.join(format!("{padded}-VERIFICATION.md", padded = phase.padded()));
std::fs::write(&artifact, "verdict: pass — authored this run\n").unwrap();
state.last_verification_fingerprint =
devflow_core::agent_result::phase_verification_fingerprint(&worktree, phase);
state.verification_baseline_captured = true;
state.verification_run_nonce = Some(1);
std::fs::write(&artifact, "verdict: gaps — rewritten by agent\n").unwrap();
assert_ne!(
devflow_core::agent_result::phase_verification_fingerprint(&worktree, phase),
state.last_verification_fingerprint,
"premise: agent rewrite must change fingerprint"
);
workflow::save_state(&state).unwrap();
assert_eq!(
select_loop_back_fix(&worktree, phase, &mut state),
FixType::GapsOnly,
"Direction A: nonce present, agent rewrote → GapsOnly"
);
state.verification_run_nonce = None;
assert_eq!(
select_loop_back_fix(&worktree, phase, &mut state),
FixType::FullExecute,
"Direction B: nonce absent → FullExecute"
);
}
}