use std::{
fs,
io::{self, IsTerminal, Write},
path::Path,
process::ExitCode,
sync::atomic::{AtomicI32, Ordering},
time::{Duration, Instant},
};
use anyhow::Result;
use crate::{
cli::ResolveArgs,
config::TruthMirrorConfig,
ledger::{LedgerStore, ResolutionKind, Verdict},
};
pub const MAX_PETITION_ATTEMPTS: u32 = 2;
pub fn run(args: ResolveArgs, state_dir: &Path, _config: &TruthMirrorConfig) -> Result<ExitCode> {
let store = LedgerStore::new(state_dir);
if args.waive {
return run_waive(args, &store);
}
if let Some(fix_sha) = args.fixed_by {
return run_petition(args.rejection_sha, fix_sha, &store, state_dir);
}
anyhow::bail!(
"resolve requires either --fixed-by <fix-sha> (agent petition) or --waive --reason <text> (human-only)"
)
}
fn run_waive(args: ResolveArgs, store: &LedgerStore) -> Result<ExitCode> {
let reason = args
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("--waive requires a non-empty --reason"))?;
let entry = waive_human(store, &args.rejection_sha, reason)?;
println!(
"truth-mirror: waived {} via human lane ({:?})",
entry.commit_sha,
ResolutionKind::Waived
);
Ok(ExitCode::SUCCESS)
}
pub fn waive_human(
store: &LedgerStore,
rejection_sha: &str,
reason: &str,
) -> Result<crate::ledger::LedgerEntry> {
let reason = reason.trim();
if reason.is_empty() {
anyhow::bail!("waive requires a non-empty reason");
}
if !stdin_is_tty() {
anyhow::bail!(
"--waive requires an interactive terminal (TTY); refuse when stdin is not a tty so agents structurally cannot invoke it"
);
}
store
.waive(rejection_sha, reason)
.map_err(|error| anyhow::anyhow!(error.to_string()))
}
fn run_petition(
rejection_sha: String,
fix_sha: String,
store: &LedgerStore,
state_dir: &Path,
) -> Result<ExitCode> {
let message = petition_fix(store, state_dir, &rejection_sha, &fix_sha)?;
println!("truth-mirror: {message}");
Ok(ExitCode::SUCCESS)
}
pub fn petition_fix(
store: &LedgerStore,
state_dir: &Path,
rejection_sha: &str,
fix_sha: &str,
) -> Result<String> {
let original = store.show(rejection_sha).map_err(|error| {
anyhow::anyhow!("rejection {rejection_sha} not found in ledger: {error}")
})?;
if !original.is_unresolved_rejection() {
let disposition = original.disposition;
let verdict = original.verdict;
let petition_attempts = store.petition_attempts_for(rejection_sha).unwrap_or(0);
anyhow::bail!(
"rejection {rejection_sha} is not open (verdict={verdict}, disposition={disposition}, petition_attempts={petition_attempts}); no petition review is needed"
);
}
let fix_sha = resolve_fix_ref_to_full_sha(fix_sha)?;
let admission_guard = PetitionAdmissionLock::acquire(state_dir)?;
let prior_attempts = store.petition_attempts_for(rejection_sha).unwrap_or(0);
if prior_attempts >= MAX_PETITION_ATTEMPTS {
let reason = format!(
"petition refused: {MAX_PETITION_ATTEMPTS} attempts already recorded for {rejection_sha}; escalating to needs-human"
);
store
.escalate_to_needs_human(rejection_sha, &reason)
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
return Ok(format!(
"refused 3rd petition attempt for {rejection_sha}; escalation to needs-human recorded"
));
}
let prior_for_error = prior_attempts;
let (queued, next_attempts) = match crate::reviewer::commit_petition_resolve(
state_dir,
store,
rejection_sha,
&fix_sha,
) {
Ok(ok) => ok,
Err(crate::reviewer::ReviewerError::PetitionInFlight { rejection_sha }) => {
anyhow::bail!(
"refusing petition without spending an attempt: a petition for {rejection_sha} is already queued; let the watcher drain it first"
);
}
Err(crate::reviewer::ReviewerError::PetitionExhausted { rejection_sha }) => {
anyhow::bail!(
"petition refused: {MAX_PETITION_ATTEMPTS} attempts already recorded for {rejection_sha}"
);
}
Err(enqueue_error) => {
let recorded = store.petition_attempts_for(rejection_sha).unwrap_or(0);
if recorded <= prior_for_error {
anyhow::bail!("{enqueue_error}");
}
let next_attempts = recorded;
if next_attempts >= MAX_PETITION_ATTEMPTS {
let reason = format!(
"petition enqueue failed after the attempt was recorded ({enqueue_error}); {MAX_PETITION_ATTEMPTS} attempts now spent for {rejection_sha}; escalating to needs-human"
);
store
.escalate_to_needs_human_with_attempts(
rejection_sha,
&reason,
next_attempts,
None,
)
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
anyhow::bail!(
"failed to enqueue petition review for {rejection_sha}: {enqueue_error}; the spent attempt exhausted the bound and the rejection was escalated to needs-human"
);
}
anyhow::bail!(
"failed to enqueue petition review for {rejection_sha}: {enqueue_error}; the attempt was already recorded (attempts={next_attempts}/{MAX_PETITION_ATTEMPTS}) with nothing queued — retry resolve --fixed-by if attempts remain"
);
}
};
let _queued = queued;
drop(admission_guard);
if let Err(error) = crate::watcher::ensure_watcher(state_dir) {
eprintln!("truth-mirror: warning: failed to ensure a review watcher: {error}");
}
Ok(format!(
"enqueued petition review for fix={fix_sha} against rejection={rejection_sha} (attempt {next_attempts}/{MAX_PETITION_ATTEMPTS}); the watcher will drain and transition the rejection based on the verdict"
))
}
const PETITION_ADMISSION_LOCK_FILE: &str = "resolve-petition-admission.lock";
const PETITION_ADMISSION_LOCK_WAIT: Duration = Duration::from_secs(10);
const PETITION_ADMISSION_LOCK_POLL: Duration = Duration::from_millis(25);
struct PetitionAdmissionLock {
#[allow(dead_code)]
file: fs::File,
}
impl PetitionAdmissionLock {
fn acquire(state_dir: &Path) -> Result<Self> {
fs::create_dir_all(state_dir)?;
let path = state_dir.join(PETITION_ADMISSION_LOCK_FILE);
let file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
let started = Instant::now();
loop {
match file.try_lock() {
Ok(()) => return Ok(Self { file }),
Err(fs::TryLockError::WouldBlock) => {
if started.elapsed() >= PETITION_ADMISSION_LOCK_WAIT {
anyhow::bail!(
"timed out after {}s waiting for the petition admission lock at {}",
PETITION_ADMISSION_LOCK_WAIT.as_secs(),
path.display()
);
}
std::thread::sleep(PETITION_ADMISSION_LOCK_POLL);
}
Err(fs::TryLockError::Error(error)) => return Err(error.into()),
}
}
}
}
fn resolve_fix_ref_to_full_sha(fix_ref: &str) -> Result<String> {
let in_repo = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if !in_repo {
return Ok(fix_ref.to_owned());
}
let output = std::process::Command::new("git")
.args(["rev-parse", "--verify", &format!("{fix_ref}^{{commit}}")])
.output()
.map_err(|error| {
anyhow::anyhow!("failed to run git rev-parse for --fixed-by {fix_ref:?}: {error}")
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"refusing petition without spending an attempt: cannot resolve --fixed-by {fix_ref:?} to a commit in this repository ({})",
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub fn stdin_is_tty() -> bool {
if let Some(value) = stdin_tty_override() {
return value;
}
io::stdin().is_terminal()
}
const STDIN_TTY_OVERRIDE_NONE: i32 = i32::MIN;
static STDIN_TTY_OVERRIDE: AtomicI32 = AtomicI32::new(STDIN_TTY_OVERRIDE_NONE);
fn stdin_tty_override() -> Option<bool> {
let value = STDIN_TTY_OVERRIDE.load(Ordering::SeqCst);
if value == STDIN_TTY_OVERRIDE_NONE {
None
} else {
Some(value != 0)
}
}
#[cfg(any(test, debug_assertions))]
#[doc(hidden)]
pub fn set_stdin_tty_override_for_testing(value: Option<bool>) {
match value {
Some(flag) => {
STDIN_TTY_OVERRIDE.store(flag as i32, Ordering::SeqCst);
}
None => {
STDIN_TTY_OVERRIDE.store(STDIN_TTY_OVERRIDE_NONE, Ordering::SeqCst);
}
}
}
pub fn petition_accepts(verdict: Verdict) -> bool {
matches!(verdict, Verdict::Pass)
}
#[doc(hidden)]
pub fn prompt_waiver_reason_to_writer(writer: &mut dyn Write, default: &str) -> io::Result<()> {
write!(writer, "waiver reason [{default}]: ")?;
writer.flush()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Barrier, atomic::AtomicUsize};
#[test]
fn concurrent_petitions_for_the_same_rejection_never_double_enqueue() {
const THREADS: usize = 8;
let temp = tempfile::tempdir().unwrap();
let state_dir = Arc::new(temp.path().to_path_buf());
let seed_store = LedgerStore::new(state_dir.as_ref());
seed_store
.append_entry(&crate::ledger::LedgerEntry::new_at(
"deadbeef",
Verdict::Reject,
"CLAIM: rejected | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
crate::ledger::ReviewerConfig::new("claude", "claude-opus-4-1", false),
vec!["unsupported claim".to_owned()],
100,
))
.unwrap();
let barrier = Arc::new(Barrier::new(THREADS));
let succeeded = Arc::new(AtomicUsize::new(0));
let mut threads = Vec::with_capacity(THREADS);
for _ in 0..THREADS {
let state_dir = Arc::clone(&state_dir);
let barrier = Arc::clone(&barrier);
let succeeded = Arc::clone(&succeeded);
threads.push(std::thread::spawn(move || {
let store = LedgerStore::new(state_dir.as_ref());
barrier.wait();
if petition_fix(&store, state_dir.as_ref(), "deadbeef", "HEAD").is_ok() {
succeeded.fetch_add(1, Ordering::SeqCst);
}
}));
}
for thread in threads {
thread.join().unwrap();
}
assert_eq!(
succeeded.load(Ordering::SeqCst),
1,
"exactly one concurrent petition for the same rejection must succeed"
);
let queue = crate::reviewer::ReviewQueue::new(state_dir.as_ref())
.pending()
.unwrap();
let petitions_for_rejection = queue
.iter()
.filter(|item| item.petition_for.as_deref() == Some("deadbeef"))
.count();
assert_eq!(
petitions_for_rejection, 1,
"exactly one petition must be enqueued for the rejection, never a duplicate"
);
let attempts = seed_store.petition_attempts_for("deadbeef").unwrap();
assert_eq!(
attempts, 1,
"exactly one attempt must be spent, not one per racing caller"
);
}
fn block_run_store_directory(state_dir: &Path) {
fs::write(
state_dir.join(crate::reviewer::REVIEW_RUNS_DIR),
b"not a directory",
)
.unwrap();
}
#[test]
fn petition_enqueue_failure_records_the_spent_attempt_honestly() {
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path();
let store = LedgerStore::new(state_dir);
store
.append_entry(&crate::ledger::LedgerEntry::new_at(
"deadbeef",
Verdict::Reject,
"CLAIM: rejected | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
crate::ledger::ReviewerConfig::new("claude", "claude-opus-4-1", false),
vec!["unsupported claim".to_owned()],
100,
))
.unwrap();
block_run_store_directory(state_dir);
let error = petition_fix(&store, state_dir, "deadbeef", "HEAD").unwrap_err();
assert!(
error
.to_string()
.contains("failed to enqueue petition review"),
"expected an enqueue-failure error, got: {error}"
);
let attempts = store.petition_attempts_for("deadbeef").unwrap();
assert_eq!(
attempts, 1,
"the spent attempt must be honestly recorded, not silently rolled back to 0"
);
let entry = store.show("deadbeef").unwrap();
assert!(
entry.is_unresolved_rejection(),
"with attempts remaining, the rejection must stay open for a retry"
);
}
#[test]
fn petition_enqueue_failure_escalates_when_it_exhausts_the_bound() {
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path();
let store = LedgerStore::new(state_dir);
store
.append_entry(&crate::ledger::LedgerEntry::new_at(
"deadbeef",
Verdict::Reject,
"CLAIM: rejected | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
crate::ledger::ReviewerConfig::new("claude", "claude-opus-4-1", false),
vec!["unsupported claim".to_owned()],
100,
))
.unwrap();
store
.append_petition_transition(
"deadbeef",
crate::ledger::Disposition::Open,
ResolutionKind::Resolved,
"seeded prior attempt",
MAX_PETITION_ATTEMPTS - 1,
)
.unwrap();
block_run_store_directory(state_dir);
let error = petition_fix(&store, state_dir, "deadbeef", "HEAD").unwrap_err();
assert!(
error.to_string().contains("escalated to needs-human"),
"expected an escalation error, got: {error}"
);
let entry = store.show("deadbeef").unwrap();
assert_eq!(entry.disposition, crate::ledger::Disposition::NeedsHuman);
assert_eq!(
store.petition_attempts_for("deadbeef").unwrap(),
MAX_PETITION_ATTEMPTS
);
}
#[test]
fn stdin_tty_override_round_trip() {
set_stdin_tty_override_for_testing(Some(true));
assert!(stdin_is_tty());
set_stdin_tty_override_for_testing(Some(false));
assert!(!stdin_is_tty());
set_stdin_tty_override_for_testing(None);
}
#[test]
fn petition_accepts_only_pass_not_flag_or_reject() {
assert!(petition_accepts(Verdict::Pass));
assert!(!petition_accepts(Verdict::Flag));
assert!(!petition_accepts(Verdict::Reject));
}
#[test]
fn max_petition_attempts_is_two() {
assert_eq!(MAX_PETITION_ATTEMPTS, 2);
}
#[test]
fn prompt_helper_writes_default_text() {
let mut buffer = Vec::new();
prompt_waiver_reason_to_writer(&mut buffer, "approved exception").unwrap();
let text = String::from_utf8(buffer).unwrap();
assert_eq!(text, "waiver reason [approved exception]: ");
}
}