use std::path::{Path, PathBuf};
use std::sync::Arc;
use tempfile::TempDir;
use tokio::process::Command;
use tokio::sync::Mutex;
use super::support::{create_test_config_with, init_git_repo, TestAssertionExt};
use crate::agent::AgentRunner;
use crate::ai_command_runner::AiCommandRunner;
use crate::command_queue::CommandQueueConfig;
use crate::config::defaults::{acceptance_store_path_in, default_retry_patterns};
use crate::config::OrchestratorConfig;
use crate::orchestration::acceptance::evidence_location::scoped_acceptance_root_for_test;
use crate::orchestration::acceptance::execution_manifest::{
AcceptanceAdmission, AcceptanceHoldCategory, LiveAcceptanceHold, LiveAcceptanceHolds,
ManifestStore, WorkspaceAcceptanceAdmission,
};
use crate::orchestration::acceptance::manifest_builder::repository_project_root;
use crate::orchestration::acceptance::verification_evidence::{EvidenceStore, LEGACY_EVIDENCE_DIR};
use crate::orchestration::AcceptanceResult;
use crate::parallel::executor::{
execute_acceptance_in_workspace, scoped_review_budget_millis_for_test,
};
const REVIEWER_MARKER: &str = "reviewer-started.txt";
const GATE: &str = "focused-gate";
fn proposal_with_gate(change: &str, command: &str) -> String {
format!(
"---\n\
verifications:\n\
\x20 - id: {GATE}\n\
\x20 requirement: the focused gate passes\n\
\x20 phase: pre-integration\n\
\x20 owner: conflux\n\
\x20 trigger: pull-request-validation\n\
\x20 automation: openspec/changes/{change}/proposal.md\n\
\x20 evidence: {command}\n\
\x20 rerun: {command}\n\
\x20 prerequisites: []\n\
\x20 execution_class: repository-local\n\
\x20 completion_role: change-blocking\n\
---\n\
# Change\n"
)
}
const PROPOSAL_WITHOUT_GATES: &str = "---\nchange_type: implementation\n---\n# Change\n";
struct Fixture {
repo: TempDir,
external: TempDir,
change: String,
}
impl Fixture {
async fn new(change: &str, proposal: &str) -> Self {
let repo = TempDir::new().or_fail("unexpected error");
init_git_repo(repo.path()).await;
let change_dir = repo.path().join("openspec/changes").join(change);
std::fs::create_dir_all(&change_dir).or_fail("unexpected error");
std::fs::write(change_dir.join("proposal.md"), proposal).or_fail("unexpected error");
std::fs::write(
change_dir.join("tasks.md"),
"## Implementation Tasks\n\n- [x] 1. done\n",
)
.or_fail("unexpected error");
commit_all(repo.path(), &format!("Apply: {change}")).await;
Self {
repo,
external: TempDir::new().or_fail("unexpected error"),
change: change.to_string(),
}
}
fn path(&self) -> &Path {
self.repo.path()
}
fn change(&self) -> &str {
&self.change
}
async fn store_root(&self) -> PathBuf {
self.store_root_under(self.external.path()).await
}
async fn store_root_under(&self, external: &Path) -> PathBuf {
let project = repository_project_root(self.path())
.await
.unwrap_or_else(|| self.path().to_path_buf());
acceptance_store_path_in(external, &project, self.path(), &self.change)
.or_fail("the external store path must resolve")
}
async fn evidence(&self) -> EvidenceStore {
EvidenceStore::new(self.store_root().await)
}
async fn manifests(&self) -> ManifestStore {
ManifestStore::new(self.store_root().await)
}
fn reviewer_started(&self) -> bool {
self.path().join(REVIEWER_MARKER).exists()
}
fn clear_reviewer_marker(&self) {
let _ = std::fs::remove_file(self.path().join(REVIEWER_MARKER));
}
async fn accept(
&self,
config: &OrchestratorConfig,
ai_runner: &AiCommandRunner,
) -> (AcceptanceResult, u32) {
self.accept_under(self.external.path().to_path_buf(), config, ai_runner)
.await
}
async fn accept_under(
&self,
external: PathBuf,
config: &OrchestratorConfig,
ai_runner: &AiCommandRunner,
) -> (AcceptanceResult, u32) {
scoped_acceptance_root_for_test(
external,
run_acceptance(&self.change, self.path(), config, ai_runner),
)
.await
}
async fn porcelain_status(&self) -> String {
let output = Command::new("git")
.args(["status", "--porcelain=v1", "--untracked-files=all"])
.current_dir(self.path())
.output()
.await
.or_fail("unexpected error");
String::from_utf8_lossy(&output.stdout).to_string()
}
}
async fn commit_all(repo_root: &std::path::Path, message: &str) {
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.await
.or_fail("unexpected error");
Command::new("git")
.args(["commit", "-m", message])
.current_dir(repo_root)
.output()
.await
.or_fail("unexpected error");
}
fn canonical_reviewer() -> String {
format!("sh -c 'touch {REVIEWER_MARKER}; printf \"ACCEPTANCE: PASS\\n\"'")
}
fn non_terminating_reviewer() -> String {
format!("sh -c 'touch {REVIEWER_MARKER}; while true; do sleep 1; done'")
}
fn config_with_reviewer(command: String) -> OrchestratorConfig {
create_test_config_with(OrchestratorConfig {
acceptance_command: Some(command),
..Default::default()
})
}
fn runner(acceptance_max_runtime_secs: u64) -> AiCommandRunner {
AiCommandRunner::new(
CommandQueueConfig {
acceptance_max_runtime_secs,
stagger_delay_ms: 0,
max_retries: 0,
retry_delay_ms: 0,
retry_error_patterns: default_retry_patterns(),
retry_if_duration_under_secs: 0,
inactivity_timeout_secs: 0,
inactivity_kill_grace_secs: 10,
inactivity_timeout_max_retries: 0,
strict_process_cleanup: true,
max_runtime_secs: 0,
},
Arc::new(Mutex::new(None)),
)
}
async fn run_acceptance(
change: &str,
repo_root: &std::path::Path,
config: &OrchestratorConfig,
ai_runner: &AiCommandRunner,
) -> (AcceptanceResult, u32) {
let mut agent = AgentRunner::new(config.clone());
let acceptance_tail_injected = Arc::new(Mutex::new(std::collections::HashMap::new()));
let acceptance_history = Arc::new(Mutex::new(crate::history::AcceptanceHistory::new()));
execute_acceptance_in_workspace(
change,
repo_root,
&mut agent,
None,
None,
ai_runner,
config,
&acceptance_tail_injected,
&acceptance_history,
Some("main"),
None,
crate::orchestration::acceptance::AcceptanceCommandMode::Normal,
)
.await
.or_fail("acceptance must return a typed result")
}
fn hold_of(
result: AcceptanceResult,
) -> crate::orchestration::acceptance::execution_manifest::AcceptanceExecutionHold {
match result {
AcceptanceResult::ExecutionHold { hold } => hold,
other => panic!("expected a typed execution hold, got {other:?}"),
}
}
#[tokio::test]
async fn acceptance_execution_boundary_runtime_executes_declared_gates_outside_the_target() {
let fixture = Fixture::new(
"gates-outside-target",
&proposal_with_gate("gates-outside-target", "sh -c true"),
)
.await;
let config = config_with_reviewer(canonical_reviewer());
let (result, _) = fixture.accept(&config, &runner(3600)).await;
assert!(
matches!(result, AcceptanceResult::Pass),
"a passing declared gate plus a canonical verdict is a PASS, got {result:?}"
);
assert!(
fixture.reviewer_started(),
"the semantic reviewer must still run"
);
let store_root = fixture.store_root().await;
assert!(
store_root.starts_with(fixture.external.path()),
"the store must resolve under the configured external root: {store_root:?}"
);
assert!(
!store_root.starts_with(fixture.path()),
"the store must never resolve inside the target: {store_root:?}"
);
assert!(
store_root.join(format!("gates/{GATE}.json")).is_file(),
"the runtime must write bound evidence for the gate it supervised"
);
assert!(store_root.join(format!("gates/{GATE}.log")).is_file());
let manifest = fixture
.manifests()
.await
.load()
.expect("the manifest must be persisted to the external store");
assert_eq!(
manifest
.gates
.iter()
.map(|gate| gate.verification_id.as_str())
.collect::<Vec<_>>(),
vec![GATE],
"the manifest is the complete blocking allowlist"
);
assert_eq!(manifest.candidate_commit_oid.len(), 40);
assert!(manifest.review_range.contains(".."));
assert_eq!(
manifest.gates[0].artifact_path,
EvidenceStore::artifact_relative_path(GATE),
"gate artifacts are bound store-relative, never target-relative"
);
let status = fixture.porcelain_status().await;
let unexpected: Vec<&str> = status
.lines()
.filter(|line| !line.ends_with(REVIEWER_MARKER))
.collect();
assert!(
unexpected.is_empty(),
"Acceptance must create no file or directory in the target: {unexpected:?}"
);
assert!(
!fixture.path().join(".cflx").exists(),
"no target-local Conflux directory may be created"
);
assert!(
!fixture.path().join(LEGACY_EVIDENCE_DIR).exists(),
"the legacy evidence path must never be recreated"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_failing_gate_fails_without_starting_a_reviewer() {
let fixture = Fixture::new(
"failing-gate",
&proposal_with_gate("failing-gate", "sh -c false"),
)
.await;
let config = config_with_reviewer(canonical_reviewer());
let (result, iteration) = fixture.accept(&config, &runner(3600)).await;
match result {
AcceptanceResult::Fail { findings } => {
assert!(
findings.iter().any(|finding| finding.text().contains(GATE)),
"the failing gate must be the finding's evidence: {findings:?}"
);
}
other => panic!("a failing declared gate must FAIL the change, got {other:?}"),
}
assert!(iteration > 0, "a gate-decided FAIL records a real attempt");
assert!(
!fixture.reviewer_started(),
"the reviewer must not be started once the verdict is already determined"
);
assert!(
fixture.porcelain_status().await.is_empty(),
"even a failing gate must leave the target untouched"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_empty_allowlist_still_reaches_a_verdict() {
let fixture = Fixture::new("empty-allowlist", PROPOSAL_WITHOUT_GATES).await;
let config = config_with_reviewer(canonical_reviewer());
let (result, _) = fixture.accept(&config, &runner(3600)).await;
assert!(matches!(result, AcceptanceResult::Pass), "{result:?}");
assert!(fixture.reviewer_started());
let manifest = fixture
.manifests()
.await
.load()
.expect("even a gateless change gets a manifest");
assert!(!manifest.has_gates());
}
#[tokio::test]
async fn acceptance_execution_boundary_non_repository_workspace_gets_no_manifest() {
let workspace = TempDir::new().or_fail("unexpected error");
let external = TempDir::new().or_fail("unexpected error");
let change = "non-repository";
let change_dir = workspace.path().join("openspec/changes").join(change);
std::fs::create_dir_all(&change_dir).or_fail("unexpected error");
std::fs::write(
change_dir.join("proposal.md"),
proposal_with_gate(change, "sh -c true"),
)
.or_fail("unexpected error");
let config = config_with_reviewer(canonical_reviewer());
let (result, _) = scoped_acceptance_root_for_test(
external.path().to_path_buf(),
run_acceptance(change, workspace.path(), &config, &runner(3600)),
)
.await;
assert!(
matches!(result, AcceptanceResult::Pass),
"a non-repository workspace must keep its pre-boundary behaviour, got {result:?}"
);
assert!(workspace.path().join(REVIEWER_MARKER).exists());
assert_eq!(
std::fs::read_dir(external.path())
.or_fail("unexpected error")
.filter_map(Result::ok)
.count(),
0,
"no store may be created for a workspace with no candidate revision"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_unusable_state_path_holds_before_the_gate() {
let fixture = Fixture::new(
"unusable-state-path",
&proposal_with_gate("unusable-state-path", "sh -c true"),
)
.await;
let blocker = TempDir::new().or_fail("unexpected error");
let occupied = blocker.path().join("occupied");
std::fs::write(&occupied, b"not a directory").or_fail("unexpected error");
let config = config_with_reviewer(canonical_reviewer());
let (result, iteration) = fixture
.accept_under(occupied.clone(), &config, &runner(3600))
.await;
let hold = hold_of(result);
assert_eq!(hold.category, AcceptanceHoldCategory::StatePathUnavailable);
assert!(
hold.evidence
.iter()
.any(|line| line.contains("acceptance_store_")),
"the hold must carry the resolver's own typed code: {:?}",
hold.evidence
);
assert_eq!(iteration, 0, "a bounded hold consumes no attempt number");
assert!(
!fixture.reviewer_started(),
"no reviewer may start once the store is known to be unusable"
);
assert!(
fixture.porcelain_status().await.is_empty(),
"a state-path failure must never fall back into the target"
);
assert!(!fixture.path().join(".cflx").exists());
assert!(
!hold.category.refuses_unchanged_retry(),
"an unusable state path must not harden into `unchanged_acceptance_input`"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_store_inside_the_target_is_refused() {
let fixture = Fixture::new(
"store-inside-target",
&proposal_with_gate("store-inside-target", "sh -c true"),
)
.await;
let config = config_with_reviewer(canonical_reviewer());
let (result, _) = fixture
.accept_under(
fixture.path().join(".cflx/acceptance"),
&config,
&runner(3600),
)
.await;
let hold = hold_of(result);
assert_eq!(hold.category, AcceptanceHoldCategory::StatePathUnavailable);
assert!(
hold.evidence
.iter()
.any(|line| line.contains("acceptance_store_inside_target")),
"the refusal must name containment specifically: {:?}",
hold.evidence
);
assert!(
!fixture.path().join(".cflx").exists(),
"containment must be refused before the directory is created"
);
assert!(!fixture.reviewer_started());
assert!(fixture.porcelain_status().await.is_empty());
}
#[tokio::test]
async fn acceptance_execution_boundary_legacy_target_evidence_holds_without_touching_it() {
let fixture = Fixture::new(
"legacy-evidence",
&proposal_with_gate("legacy-evidence", "sh -c true"),
)
.await;
let legacy = fixture.path().join(LEGACY_EVIDENCE_DIR);
std::fs::create_dir_all(&legacy).or_fail("unexpected error");
std::fs::write(legacy.join(".gitignore"), "*\n").or_fail("unexpected error");
let record = legacy.join(format!("{GATE}.json"));
std::fs::write(
&record,
b"{\"schema\":\"conflux-verification-evidence-v1\"}",
)
.or_fail("unexpected error");
let config = config_with_reviewer(canonical_reviewer());
assert!(
!fixture.porcelain_status().await.contains(".cflx"),
"the legacy directory is invisible to tracked/untracked porcelain status"
);
let (result, iteration) = fixture.accept(&config, &runner(3600)).await;
let hold = hold_of(result);
assert_eq!(hold.category, AcceptanceHoldCategory::LegacyTargetEvidence);
assert!(
hold.evidence
.iter()
.any(|line| line.contains(LEGACY_EVIDENCE_DIR)),
"the hold must name the path a repository owner has to remove: {:?}",
hold.evidence
);
assert!(
hold.summary(fixture.change()).contains("Remove the legacy"),
"the operator-facing summary must state whose job the cleanup is: {}",
hold.summary(fixture.change())
);
assert_eq!(iteration, 0);
assert!(!fixture.reviewer_started(), "no reviewer may start");
assert_eq!(
std::fs::read(&record).or_fail("unexpected error"),
b"{\"schema\":\"conflux-verification-evidence-v1\"}",
"legacy content must be left exactly as it was found"
);
assert!(
legacy.join(".gitignore").is_file(),
"the legacy ignore file must not be removed either"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_unrelated_ignored_paths_are_not_evaluated() {
let fixture = Fixture::new(
"unrelated-ignored",
&proposal_with_gate("unrelated-ignored", "sh -c true"),
)
.await;
std::fs::write(fixture.path().join(".gitignore"), "build-output\n").or_fail("unexpected error");
commit_all(fixture.path(), "chore: ignore build output").await;
std::fs::create_dir_all(fixture.path().join("build-output")).or_fail("unexpected error");
std::fs::write(fixture.path().join("build-output/artifact.bin"), b"junk")
.or_fail("unexpected error");
let config = config_with_reviewer(canonical_reviewer());
let (result, _) = fixture.accept(&config, &runner(3600)).await;
assert!(
matches!(result, AcceptanceResult::Pass),
"an unrelated ignored path must not hold anything, got {result:?}"
);
assert!(fixture.path().join("build-output/artifact.bin").is_file());
}
#[tokio::test]
async fn acceptance_execution_boundary_untracked_target_content_refuses_the_gate() {
let fixture = Fixture::new(
"dirty-target",
&proposal_with_gate("dirty-target", "sh -c true"),
)
.await;
std::fs::create_dir_all(fixture.path().join(".cflx")).or_fail("unexpected error");
std::fs::write(fixture.path().join(".cflx/scratch.json"), b"{}").or_fail("unexpected error");
let config = config_with_reviewer(canonical_reviewer());
let (result, _) = fixture.accept(&config, &runner(3600)).await;
let hold = hold_of(result);
assert_eq!(
hold.category,
AcceptanceHoldCategory::RuntimeDefect,
"a dirty target is an environment fact, never a verdict about the change"
);
assert!(
hold.evidence
.iter()
.any(|line| line.contains("worktree_dirty")),
"the hold must say the target was dirty: {:?}",
hold.evidence
);
assert!(
!hold.category.refuses_unchanged_retry(),
"cleaning the worktree changes no fingerprint component, so this must stay retryable"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_cache_loss_reruns_the_gate_without_proving_pass() {
use crate::orchestration::acceptance::gate_execution::plan_manifest_reuse;
use crate::orchestration::acceptance::verification_evidence::{
GitRepositoryFacts, ReuseDecision, ReusePolicy,
};
let fixture = Fixture::new(
"cache-loss",
&proposal_with_gate("cache-loss", "sh -c true"),
)
.await;
let config = config_with_reviewer(canonical_reviewer());
let (first, _) = fixture.accept(&config, &runner(3600)).await;
assert!(matches!(first, AcceptanceResult::Pass), "{first:?}");
let store_root = fixture.store_root().await;
let envelope = store_root.join(format!("gates/{GATE}.json"));
assert!(envelope.is_file());
let manifest = fixture.manifests().await.load().expect("manifest persists");
std::fs::remove_dir_all(&store_root).or_fail("unexpected error");
let decisions = plan_manifest_reuse(
&GitRepositoryFacts,
fixture.path(),
&EvidenceStore::new(store_root.clone()),
&manifest,
ReusePolicy::default(),
)
.await;
match decisions.as_slice() {
[ReuseDecision::Rerun { reason, .. }] => assert_eq!(
reason.code(),
"evidence_missing",
"a deleted cache is a rerun reason: {}",
reason.detail()
),
other => panic!("cache loss must force a rerun, got {other:?}"),
}
fixture.clear_reviewer_marker();
let (second, _) = fixture.accept(&config, &runner(3600)).await;
assert!(matches!(second, AcceptanceResult::Pass), "{second:?}");
assert!(
envelope.is_file(),
"the rerun must write fresh bound evidence"
);
assert!(
fixture.reviewer_started(),
"cache loss costs work; it does not suppress the review"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_settled_fingerprint_survives_gate_execution() {
use crate::orchestration::acceptance::manifest_builder::build_manifest_for_workspace;
use crate::orchestration::acceptance::verification_evidence::GitRepositoryFacts;
let fixture = Fixture::new(
"settled-fingerprint",
&proposal_with_gate("settled-fingerprint", "sh -c true"),
)
.await;
let config = config_with_reviewer(canonical_reviewer());
let external = fixture.external.path().to_path_buf();
let change = fixture.change().to_string();
let boundary = scoped_acceptance_root_for_test(external, async {
crate::parallel::executor::run_acceptance_boundary(
&change,
fixture.path(),
config.get_accept_skill(),
Some("main"),
3600,
None,
)
.await
})
.await
.expect("a readable repository must produce a boundary")
.expect("a Git working tree must produce a manifest");
assert!(
boundary.gates.outcomes.iter().any(|outcome| matches!(
outcome,
crate::orchestration::acceptance::execution_manifest::GateOutcome::Executed { .. }
)),
"the declared gate must really have executed: {:?}",
boundary.gates.outcomes
);
let next_attempt = build_manifest_for_workspace(
&GitRepositoryFacts,
fixture.path(),
&fixture.evidence().await,
fixture.change(),
config.get_accept_skill(),
Some("main"),
3600,
)
.await
.expect("the manifest must be derivable from the workspace");
assert_eq!(
boundary.manifest.fingerprint(),
next_attempt.fingerprint(),
"the settled fingerprint must equal what the next attempt recomputes for the same \
commit, tree, inputs, declarations, and tool identity"
);
assert!(
boundary
.manifest
.gates
.iter()
.all(|gate| gate.artifact_digest.is_some()),
"the settled manifest must bind the artifacts the gates actually wrote"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_non_terminating_reviewer_settles_as_stalled() {
let fixture = Fixture::new("verdictless-reviewer", PROPOSAL_WITHOUT_GATES).await;
let config = config_with_reviewer(non_terminating_reviewer());
let ai_runner = runner(3600);
let (result, iteration) =
scoped_review_budget_millis_for_test(150, fixture.accept(&config, &ai_runner)).await;
let hold = hold_of(result);
assert_eq!(
hold.category,
AcceptanceHoldCategory::ReviewDeadlineExhausted,
"the reviewer's owned process group must be proven quiescent: {}",
hold.cleanup_diagnostics
);
assert!(
hold.cleanup_confirmed,
"cleanup evidence must be confirmed: {}",
hold.cleanup_diagnostics
);
assert!(fixture.reviewer_started());
assert_eq!(
iteration, 0,
"a bounded hold is not a verdict and consumes no attempt number"
);
let blocker = hold.to_stalled_blocker(fixture.change());
assert_eq!(blocker.phase, "acceptance");
assert!(!blocker.resumable);
assert!(blocker.unblock_condition.is_none());
assert!(blocker.worktree_preserved);
assert!(
blocker
.evidence
.iter()
.any(|line| line == "process_group_cleanup=confirmed"),
"the published blocker must carry the proof, not just the claim: {:?}",
blocker.evidence
);
assert!(
fixture.manifests().await.diagnostics_path().is_file(),
"the invocation's diagnostics belong in the external store"
);
let status = fixture.porcelain_status().await;
let unexpected: Vec<&str> = status
.lines()
.filter(|line| !line.ends_with(REVIEWER_MARKER))
.collect();
assert!(
unexpected.is_empty(),
"a hold must leave the target untouched: {unexpected:?}"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_hold_is_not_a_runtime_limit_or_a_verdict() {
let fixture = Fixture::new("hold-classification", PROPOSAL_WITHOUT_GATES).await;
let config = config_with_reviewer(non_terminating_reviewer());
let ai_runner = runner(3600);
let (result, _) =
scoped_review_budget_millis_for_test(150, fixture.accept(&config, &ai_runner)).await;
assert!(result.is_execution_hold());
assert!(!result.is_runtime_limit(), "the outer limit must not fire");
assert!(!result.is_canonical_verdict());
assert!(
!result.permits_acceptance_retry(),
"a bounded hold must not be re-dispatched automatically"
);
assert!(
crate::orchestration::acceptance::classify_invalid_acceptance_result(&result).is_none(),
"a boundary decision is not an invalid reviewer response"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_verdict_before_the_deadline_is_kept() {
let fixture = Fixture::new("verdict-before-deadline", PROPOSAL_WITHOUT_GATES).await;
let config = config_with_reviewer(format!(
"sh -c 'touch {REVIEWER_MARKER}; printf \"ACCEPTANCE: PASS\\n\"; while true; do sleep 1; done'"
));
let ai_runner = runner(3600);
let (result, iteration) = scoped_review_budget_millis_for_test(
2_000,
crate::parallel::executor::scoped_verdict_grace_secs_for_test(
1,
fixture.accept(&config, &ai_runner),
),
)
.await;
assert!(
matches!(result, AcceptanceResult::Pass),
"a verdict emitted before the deadline must stand, got {result:?}"
);
assert!(iteration > 0);
}
#[tokio::test]
async fn acceptance_execution_boundary_live_owner_refuses_unchanged_input() {
let fixture = Fixture::new("live-owner-refusal", PROPOSAL_WITHOUT_GATES).await;
let ai_runner = runner(3600);
let (first, _) = scoped_review_budget_millis_for_test(
150,
fixture.accept(
&config_with_reviewer(non_terminating_reviewer()),
&ai_runner,
),
)
.await;
let first_hold = hold_of(first);
assert_eq!(
first_hold.category,
AcceptanceHoldCategory::ReviewDeadlineExhausted
);
fixture.clear_reviewer_marker();
let (second, iteration) = fixture
.accept(&config_with_reviewer(canonical_reviewer()), &ai_runner)
.await;
let hold = hold_of(second);
assert_eq!(hold.fingerprint, first_hold.fingerprint);
assert_eq!(
hold.category,
AcceptanceHoldCategory::ReviewDeadlineExhausted
);
assert!(hold
.summary(fixture.change())
.contains("unchanged_acceptance_input"));
assert_eq!(iteration, 0);
assert!(
!fixture.reviewer_started(),
"no reviewer may be dispatched for an unchanged input in the same owner"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_restart_admits_one_fresh_attempt() {
let fixture = Fixture::new("restart-admits", PROPOSAL_WITHOUT_GATES).await;
let ai_runner = runner(3600);
let (first, _) = scoped_review_budget_millis_for_test(
150,
fixture.accept(
&config_with_reviewer(non_terminating_reviewer()),
&ai_runner,
),
)
.await;
assert!(first.is_execution_hold(), "{first:?}");
fixture.clear_reviewer_marker();
crate::orchestration::acceptance::execution_manifest::live_acceptance_holds()
.clear(fixture.change());
let (second, iteration) = fixture
.accept(&config_with_reviewer(canonical_reviewer()), &ai_runner)
.await;
assert!(
matches!(second, AcceptanceResult::Pass),
"a restarted owner must admit one fresh bounded attempt, got {second:?}"
);
assert!(
fixture.reviewer_started(),
"the fresh attempt really dispatches a reviewer"
);
assert!(iteration > 0, "the fresh attempt records a real verdict");
assert!(
fixture
.porcelain_status()
.await
.lines()
.all(|line| line.ends_with(REVIEWER_MARKER)),
"admission after restart must require no repository mutation"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_shared_admission_is_live_owner_scoped() {
use crate::orchestration::acceptance::manifest_builder::build_manifest_for_workspace;
use crate::orchestration::acceptance::verification_evidence::GitRepositoryFacts;
use crate::orchestration::operator_command::AcceptanceAdmissionPort;
let fixture = Fixture::new(
"shared-admission",
&proposal_with_gate("shared-admission", "sh -c true"),
)
.await;
let config = config_with_reviewer(canonical_reviewer());
let workspace = fixture.path().to_path_buf();
let resolve = move |_change: &str| Some(workspace.clone());
let manifest = build_manifest_for_workspace(
&GitRepositoryFacts,
fixture.path(),
&fixture.evidence().await,
fixture.change(),
config.get_accept_skill(),
Some("main"),
0,
)
.await
.expect("the manifest must be derivable");
let live = Arc::new(LiveAcceptanceHolds::new());
live.record(
fixture.change(),
LiveAcceptanceHold {
category: AcceptanceHoldCategory::ReviewDeadlineExhausted,
fingerprint: manifest.fingerprint(),
review_base_ref: Some("main".to_string()),
},
);
let admission = WorkspaceAcceptanceAdmission::with_holds(
resolve.clone(),
config.get_accept_skill(),
None,
live.clone(),
);
let refusal = scoped_acceptance_root_for_test(
fixture.external.path().to_path_buf(),
admission.classify(fixture.change()),
)
.await;
match &refusal {
AcceptanceAdmission::Refuse {
outcome,
category,
components,
..
} => {
assert_eq!(*outcome, "unchanged_acceptance_input");
assert_eq!(*category, AcceptanceHoldCategory::ReviewDeadlineExhausted);
assert!(!components.is_empty());
}
other => panic!("a live owner must refuse the unchanged input, got {other:?}"),
}
let restarted = WorkspaceAcceptanceAdmission::with_holds(
resolve.clone(),
config.get_accept_skill(),
None,
Arc::new(LiveAcceptanceHolds::new()),
);
assert_eq!(
scoped_acceptance_root_for_test(
fixture.external.path().to_path_buf(),
restarted.classify(fixture.change()),
)
.await,
AcceptanceAdmission::Admit,
"external cache must not recreate retry refusal after owner restart"
);
std::fs::write(fixture.path().join("repair.rs"), "fn repaired() {}\n")
.or_fail("unexpected error");
commit_all(fixture.path(), "Apply: repair").await;
assert_eq!(
scoped_acceptance_root_for_test(
fixture.external.path().to_path_buf(),
admission.classify(fixture.change()),
)
.await,
AcceptanceAdmission::Admit,
"a repository-visible change must restore live-owner retry eligibility"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_changed_input_permits_a_new_attempt() {
let fixture = Fixture::new("changed-input", PROPOSAL_WITHOUT_GATES).await;
let ai_runner = runner(3600);
let (first, _) = scoped_review_budget_millis_for_test(
150,
fixture.accept(
&config_with_reviewer(non_terminating_reviewer()),
&ai_runner,
),
)
.await;
let refused_fingerprint = hold_of(first).fingerprint;
fixture.clear_reviewer_marker();
std::fs::write(fixture.path().join("repair.rs"), "fn repaired() {}\n")
.or_fail("unexpected error");
commit_all(fixture.path(), "Apply: repair").await;
let (result, _) = fixture
.accept(&config_with_reviewer(canonical_reviewer()), &ai_runner)
.await;
assert!(
matches!(result, AcceptanceResult::Pass),
"a changed candidate must permit a new bounded attempt, got {result:?}"
);
assert!(fixture.reviewer_started());
let stored = fixture
.manifests()
.await
.load()
.expect("the new attempt rewrites the manifest");
assert_ne!(stored.fingerprint(), refused_fingerprint);
}
#[tokio::test]
async fn acceptance_execution_boundary_settlement_clears_only_external_state() {
let fixture = Fixture::new(
"settlement-cleanup",
&proposal_with_gate("settlement-cleanup", "sh -c true"),
)
.await;
let ai_runner = runner(3600);
let (first, _) = scoped_review_budget_millis_for_test(
150,
fixture.accept(
&config_with_reviewer(non_terminating_reviewer()),
&ai_runner,
),
)
.await;
assert!(first.is_execution_hold(), "{first:?}");
let store_root = fixture.store_root().await;
assert!(store_root.join(format!("gates/{GATE}.json")).is_file());
assert!(
crate::orchestration::acceptance::execution_manifest::live_acceptance_holds()
.get(fixture.change())
.is_some(),
"the live owner must be holding this change before settlement"
);
let config = config_with_reviewer(canonical_reviewer());
scoped_acceptance_root_for_test(fixture.external.path().to_path_buf(), async {
crate::parallel::executor::clear_acceptance_state(fixture.change(), fixture.path(), &config)
.await
})
.await;
assert!(
!store_root.exists(),
"settlement must discard the external Acceptance cache"
);
assert!(
crate::orchestration::acceptance::execution_manifest::live_acceptance_holds()
.get(fixture.change())
.is_none(),
"settlement must discard the live refusal too"
);
let status = fixture.porcelain_status().await;
let unexpected: Vec<&str> = status
.lines()
.filter(|line| !line.ends_with(REVIEWER_MARKER))
.collect();
assert!(
unexpected.is_empty(),
"clearing out-of-worktree state must not modify the target worktree: {unexpected:?}"
);
}
#[tokio::test]
async fn acceptance_execution_boundary_changed_change_inputs_permit_a_new_attempt() {
let fixture = Fixture::new("changed-inputs", PROPOSAL_WITHOUT_GATES).await;
let ai_runner = runner(3600);
let (first, _) = scoped_review_budget_millis_for_test(
150,
fixture.accept(
&config_with_reviewer(non_terminating_reviewer()),
&ai_runner,
),
)
.await;
assert!(first.is_execution_hold(), "{first:?}");
fixture.clear_reviewer_marker();
std::fs::write(
fixture
.path()
.join("openspec/changes")
.join(fixture.change())
.join("tasks.md"),
"## Implementation Tasks\n\n- [x] 1. done\n- [x] 2. also done\n",
)
.or_fail("unexpected error");
let (result, _) = fixture
.accept(&config_with_reviewer(canonical_reviewer()), &ai_runner)
.await;
assert!(
matches!(result, AcceptanceResult::Pass),
"changed change inputs must restore eligibility, got {result:?}"
);
}