Validation dead ends: custody recovery refusal and interrupted review exit 0

Author: VirtualRoboticHandsCreated Aug 9, 2026Updated Sep 21, 2026
Labelsbugready-for-pr

Two fail-closed validation paths have no supported exit

Summary

We observed two distinct failures with the same operational consequence: the validation lane correctly stopped instead of silently discarding or approving work, but its own reported state or runner result left no trustworthy supported path forward.

These observations were made on 2026-08-08 and 2026-08-09 with no-mistakes v1.41.2 in a Linux fleet that uses the tool daily.

The custody case overlaps the recovery shape already reported in #642 and #551, but adds a second confirmed lane and a structured-status contradiction: next_action.code=recover_custody recommends the guarded recovery that the same guard then refuses.

The Category 2 case is included because it has the same fail-closed-without-a-completion-path impact, but its attribution is narrower: the commands were direct Claude CLI invocations from the validation lane's outer command runner, not a reproduced invocation through no-mistakes' internal Claude adapter.

1. Structured status offers custody recovery that guarded sync refuses

Observed behavior

After a run failed with pipeline custody retained, structured status reported:

status=failed
branch_sync.state=pipeline_owned
safety=blocked_pipeline_owned_recoverable
next_action.code=recover_custody

Following that advertised next action caused guarded sync to refuse recovery because the gate worktree head was not a descendant of the pipeline's recorded head.

The fullest second-lane diagnostic was:

run=01KZJJSHH5WM3BY8Y5AA9NFHW9
gate/worktree head=8aedaf11f3f36382f3da20835c2b208412070dda
pipeline recorded head=3708fa8493e3f9f17ca0af6078c3e59e6287b5ec
local head=c01531210ff309fc7805ce873eea7e72231b27e7
abort: aborted=false, detail="no active run with that id (no-op)"
error="worktree HEAD 8aedaf11... is not a descendant of recorded head 3708fa84..."

The guarded recovery was attempted twice and refused identically while structured status continued to advertise recover_custody.

The underlying review log and daemon log both contained the more explicit reason:

refusing to commit review changes: worktree HEAD 8aedaf11... is not a descendant of the pipeline's recorded head 3708fa84...; the reviewed change was rewritten out-of-band and would be lost - aborting to protect it

That refusal is defensible and desirable in isolation.

Moving the gate head automatically would have discarded reviewed work.

The defect is the state-machine dead end: status calls the state recoverable and tells the operator to invoke a recovery action that cannot pass its own precondition, while abort is a no-op because the run is already terminal.

Earlier occurrence

An earlier lane reached the same shape after cancellation preserved pipeline head 71b6b7ae, which was never published to the remote, while the gate branch was at f2fa44a5.

Both axi sync --recover and axi sync --recover --keep-local refused because the gate head differed from the preserved unpublished head.

That occurrence is close to #551's unpublished-head case and #642's diverged-head case.

The 2026-08-09 occurrence confirms the problem is not limited to one branch or one interrupted fix round.

Reproduction conditions

  1. Start a validation run and let it create or record a pipeline head.
  2. Reach a failed or cancelled terminal state while pipeline custody remains recorded.
  3. Cause the gate worktree branch to contain work that is not a descendant of the recorded pipeline head, for example through an out-of-band rewrite or a preserved-but-unpublished pipeline commit.
  4. Run structured status and observe safety=blocked_pipeline_owned_recoverable with next_action.code=recover_custody.
  5. Invoke the advertised guarded recovery.
  6. Observe the descendant check refuse without changing custody, then observe abort is unavailable or a no-op on the terminal run.

What we ruled out

  • The refusal was not an arbitrary lock or stale-process error.
  • The head relationship genuinely failed the descendant test.
  • Automatically choosing either head would have risked losing work.
  • Retrying the same guarded action did not change the result.
  • In the earlier occurrence, the recorded preserved head was not present on the live remote, so remote equality could not satisfy the guard.

Our contribution to the state

Out-of-band history changes and cancellation during pipeline-owned work contributed to reaching these states.

We are not asking the tool to guess which history is correct or to weaken the data-loss guard.

We are asking for the structured state and supported actions to remain complete when the guard correctly refuses its first-choice recovery.

Expected behavior

When recover_custody cannot satisfy the descendant guard, status should stop describing the state as recoverable through that same command and should offer a supported explicit reconciliation path.

One safe shape would require the operator to name the head to keep, verify that exact head against the live gate/remote state, anchor both histories before moving any ref, and then return custody.

Alternatively, status could emit a distinct inspect_and_reconcile action with machine-readable head identities and a documented command that preserves both sides.

Workarounds used - not fixes

  • For an active run, use the supported abort, verify through structured status that ownership is returned, preserve both histories, rebuild from the correct pre-divergence base, and start exactly one fresh run.
  • For the second lane, the operator preserved both heads, created a custody bridge that fast-forwarded the gate safely, and then started one fresh run.
  • For the earlier lane, the completed local commits were exported and applied in a clean worktree/branch before a fresh validation run.
  • Restarting the shared daemon can clear some stuck state, but it terminates every in-flight fleet run and is not an acceptable routine recovery.

2. Interrupted Category 2 review reported exit 0 with only Execution error

Observed behavior

A Category 2 lane required an opposite-family Claude Opus review.

Two direct claude -p tool sessions remained active and produced internal Claude session records, but print mode emitted no terminal review text while the tool-driven reviews were still in progress.

The worker incorrectly interpreted the buffered silence as a hang and manually interrupted both sessions before either emitted a final result record.

For each interruption, the outer command runner reported:

exit_code=0
output="Execution error"

The runner exposed one combined output field, so no separate stderr was captured.

If a validation orchestrator trusts only the zero exit code, this is a review that never completed being represented as command success.

This fleet's surrounding lane separately treated empty review output as a hard block, which prevented a false approval.

Exact active invocations

The first active review placed the prompt before the variadic allowlist option:

bash
claude -p "Act as a fresh read-only adversarial reviewer for this Category 2 reliability patch. Inspect the complete git diff and relevant surrounding code/tests. Do not edit files. Focus on: correctness of two-browser save/approval/restore race evidence and negative controls; whether issued state is truly protected; canonical drawing id versus editor snapshot id merge semantics; REST conflict mapping; issued-revision validation against ADR 0144; truthful user recovery copy; accuracy of the documented issue-action and interactive-sign-in limitations; test false-green risks and regressions. Return findings ordered by severity with file/line references, or state no findings, and state what you inspected." --model opus --effort max --allowedTools 'Read,Grep,Glob,Bash(git diff *),Bash(git status *)'

It ran from 05:03:39Z to 05:13:12Z in Claude session 80ce62a7-897a-4a5a-bc47-50bf28ca0307, produced 202 internal records and authenticated claude-opus-5 responses, and had no final result record when interrupted.

The Bash pattern did not permit the intended git diff, but Read/Grep/Glob remained available and the review continued through those tools.

The second review streamed the diff on stdin and attempted to disable tools with an empty allowlist:

bash
git diff -- projects/pid-product projects/spine/platform-core projects/spine/platform-gateway projects/spine/platform-runtime | claude -p --model opus --effort max --append-system-prompt "You are a fresh read-only adversarial reviewer for a Category 2 reliability patch. The user input is the complete git diff. Do not suggest edits unless tied to a concrete correctness, security, false-green, or regression risk. Focus on two-browser save/approval/restore evidence and negative controls, issued-state immutability, canonical target id versus editor snapshot id merge semantics, REST conflict mapping, ADR 0144 issue validation, recovery copy, documented issue-action and interactive-sign-in limitations. Return findings ordered by severity with file references, or state no findings, and list what you inspected." --allowedTools ''

It ran from 05:13:22Z to 05:21:54Z in Claude session 620e100e-5d68-4eba-a171-3f2ba081db68, produced 147 internal records, continued issuing Read calls, and had no final result record when interrupted.

What we ruled out

  • Claude Code was installed and callable at both /usr/bin/claude and the PATH-selected NVM location.
  • Both resolved binaries reported Claude Code 2.1.226.
  • Authentication was healthy, authenticated claude-opus-5 responses were present in both session logs, and 53 percent of the weekly window remained.
  • The sessions were not idle processes with no model activity.
  • No completed review result existed to recover from either session.
  • A preceding malformed command exited 1 because --allowedTools consumed a trailing prompt; that setup error was corrected before the two active observations and is not counted as one of them.

Our contribution to the failure

The worker interrupted both active sessions and therefore caused the reviews not to finish.

The worker also assumed --allowedTools '' would mean no tools, but the observed CLI semantics did not enforce that assumption.

Those usage errors should remain visible in the diagnosis.

They do not make exit_code=0 plus Execution error a safe completion signal for a required review.

Attribution boundary

These commands were launched directly from the validation lane's outer runner.

We have not reproduced this exact exit-0 behavior through no-mistakes' internal Claude adapter, and this report should not be read as proof that internal/agent/claude.go emits the same result.

The actionable validation-tool requirement is to fail closed on this outer-runner result shape, retain stream/error attribution, and never infer review approval without a terminal review result.

Expected behavior

  • An interrupted or execution-error command must be reported nonzero regardless of a PTY wrapper's process-exit observation.
  • A required review must succeed only after a terminal result is parsed and validated, not from exit code alone.
  • Empty final review output must be a hard failure.
  • Long tool-driven print-mode work should expose progress through stream JSON or a bounded monitor so buffered silence is not mistaken for inactivity.
  • Stdout and stderr should remain separately attributable, or the combined field should be explicitly labeled and accompanied by a structured interruption/error flag.
  • Tool restrictions should use a CLI form whose semantics are verified; an empty --allowedTools value should not be assumed to disable tools.

Workaround used - not a fix

The lane treated empty review output as a hard block and did not infer approval from exit 0.

After escalation, an independent Codex gpt-5.6-sol reviewer at xhigh completed the Category 2 review against the full diff.

The reviewer substitution and this defect were disclosed in the product PR.

That substitution restored one delivery lane but does not repair the review invocation or runner contract.

Requested maintainer action

  1. Extend the custody state machine so every emitted next_action is executable from the state that emits it, including a guarded preserve-both-heads reconciliation path when descendant checks correctly refuse.
  2. Add regression coverage for a terminal pipeline-owned state whose gate head is not a descendant of the recorded head and for a preserved recorded head that was never published.
  3. Ensure required review completion depends on a parsed terminal result and a clean execution state, not exit code alone.
  4. Preserve or stream enough process state to distinguish an active buffered review, a manual interruption, an execution error, and a completed empty result.
  5. If the direct Claude invocation is outside no-mistakes' ownership, document that boundary and keep this second incident as an integration requirement rather than attributing it to the internal adapter.