From ff4af29a625ecfb8433d2142d28d1195953d6a30 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:58:44 -0400 Subject: [PATCH 01/31] fix(branchsync): settle wedged custody records via --keep-local A terminal run whose recorded pipeline head can no longer be verified - missing from every reachable object store, or contradicted by the run's own recovery ref - left the branch pipeline_owned with no command that could settle it: every guarded recovery refused on the same unverifiable head, and abort of the already-terminal run was an idempotent no-op (kunchenguid/no-mistakes#824). --recover --keep-local now settles that self-inconsistent record. It stays fail-closed: every reachable copy of the recorded head is pinned at refs/no-mistakes/recover-stranded/ first, and a head that still exists but cannot be pinned refuses rather than being stranded. The gate branch moves by the existing compare-and-swap, never a force, so a concurrent gate push still wins. Inspection names that exit as next_action.code: return_custody_keep_local instead of the dead-end manual-reconciliation pointer, and terminal-run abort responses carry the same command. The #814 polarity is preserved: an unverifiable record never advertises recover_custody. Co-Authored-By: Claude Opus 5 --- docs/src/content/docs/guides/agents.md | 1 + docs/src/content/docs/reference/cli.md | 11 +- internal/branchsync/recover_test.go | 241 ++++++++++++++++++++++++- internal/branchsync/sync.go | 124 ++++++++++++- internal/cli/axi_abort_custody_test.go | 193 ++++++++++++++++++++ internal/cli/axi_drive.go | 96 ++++++++-- internal/cli/axi_guidance.go | 2 +- internal/cli/axi_guidance_test.go | 6 + internal/cli/sync.go | 12 +- internal/custody/refs.go | 10 + internal/skill/skill.go | 1 + skills/no-mistakes/SKILL.md | 1 + 12 files changed, 670 insertions(+), 28 deletions(-) create mode 100644 internal/cli/axi_abort_custody_test.go diff --git a/docs/src/content/docs/guides/agents.md b/docs/src/content/docs/guides/agents.md index d8d6d98ac..1d54e2eb1 100644 --- a/docs/src/content/docs/guides/agents.md +++ b/docs/src/content/docs/guides/agents.md @@ -178,6 +178,7 @@ no-mistakes axi abort --run Before any post-pipeline local commit or fresh run, read `branch_sync`. Only when its structured `next_action.code` is `sync`, run `no-mistakes axi sync` first. When `next_action.code` is `recover_custody` - a terminal run left unpublished pipeline commits preserved in the local gate - run `no-mistakes axi sync --recover` to return custody, or `no-mistakes rerun` to resume validating the preserved head. +When `next_action.code` is `return_custody_keep_local` - a terminal run whose recorded pipeline head can no longer be verified - run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: it is immediately usable and needs no sync action. When `next_action.code` is `continue_active_run`, run the reported command and keep driving the active run. If synchronization is blocked, process that state instead of improvising reset, stash, merge, rebase, force, or branch replacement. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index f7621dc17..fbc91cd09 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -205,7 +205,7 @@ no-mistakes axi sync --recover --keep-local | -------------- | ------ | ------- | ---------------------------------------------------------------------------- | | `--check` | `bool` | `false` | Verify the live target and exact plan without changing `HEAD` | | `--recover` | `bool` | `false` | Return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch) | -| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree | +| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and points the gate branch at the kept head | The default command is an explicit non-interactive apply request and never prompts. All modes return the complete `branch_sync` object as TOON. @@ -231,12 +231,17 @@ For behind or diverged worktrees, recovery verifies the preserved head at the ru A clean behind worktree fast-forwards. A diverged worktree is adopted only when the preserved head provably carries every local change, proven by an executable three-way merge whose result is exactly the preserved head's tree. This covers a pipeline rebase onto a newer base without requiring the gate branch to advance to the preserved head. -Terminalization pins a verified unpublished pipeline head under a run-specific recovery ref, so recovery does not require the gate branch itself to have advanced. If the recorded head is genuinely missing, status reports manual reconciliation instead of advertising `recover_custody`. +Terminalization pins a verified unpublished pipeline head under a run-specific recovery ref, so recovery does not require the gate branch itself to have advanced. If the recorded head can no longer be verified - it is missing from every reachable object store, or the run's own recovery ref names a different object - status reports `next_action.code: return_custody_keep_local` instead of advertising `recover_custody`, and `--recover --keep-local` settles that self-inconsistent record. That adoption anchors the pre-recovery local head under `refs/no-mistakes/recover-local/`, then moves the branch with Git operations that refuse on their own rather than after a preceding check: an atomic compare-and-swap on the branch ref, and a working-tree update that aborts instead of overwriting a modified or untracked file. The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. + +`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses with `safety: blocked_recover_preserved_head_missing` or `blocked_recover_anchor_mismatch` and nothing else could settle the branch. +Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. +The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses. +`no-mistakes axi abort` on an already-terminal run stays an idempotent no-op - there is nothing left to cancel - but its response names that settlement command when the invoking worktree's branch is still held by that run. `no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back. A recovered never-pushed run reports `state: custody_returned`; a recovered pushed run reports its ordinary classification against the last push binding, typically `local_ahead`. On a `user_owned` branch, `--recover` is an idempotent no-op success: nothing pipeline-created exists to recover, and no file, ref, or database row changes. @@ -363,7 +368,7 @@ no-mistakes sync --recover --keep-local | `--check` | `bool` | `false` | Verify and print the fresh plan without changing `HEAD` | | `-y`, `--yes` | `bool` | `false` | Apply an eligible guarded synchronization without an interactive prompt | | `--recover` | `bool` | `false` | Return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch) | -| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree | +| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and points the gate branch at the kept head | Without `--yes`, apply prints the exact full-SHA plan and requires TTY confirmation; `--recover` prompts the same way before returning custody. A non-TTY apply or recovery refuses with a direct `--yes` hint. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 99481251e..50a0ff753 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -842,6 +842,10 @@ func TestRecoverUsesTerminalAnchorWhenGateBranchLags(t *testing.T) { } } +// TestInspectDoesNotAdvertiseRecoveryWhenRecordedHeadIsMissing keeps the #814 +// polarity - an unverifiable record never advertises recover_custody - while +// #824 replaces the dead-end manual-reconciliation pointer with the keep-local +// settlement, which is a command that can actually complete. func TestInspectDoesNotAdvertiseRecoveryWhenRecordedHeadIsMissing(t *testing.T) { t.Parallel() @@ -855,7 +859,7 @@ func TestInspectDoesNotAdvertiseRecoveryWhenRecordedHeadIsMissing(t *testing.T) if state.Safety != "blocked_recover_preserved_head_missing" { t.Fatalf("missing-head safety = %q, want blocked_recover_preserved_head_missing: %#v", state.Safety, state) } - if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + if state.NextAction == nil || state.NextAction.Code != "return_custody_keep_local" { t.Fatalf("missing-head next action = %#v", state.NextAction) } if state.NextAction.Code == "recover_custody" { @@ -869,7 +873,8 @@ func TestInspectDoesNotAdvertiseRecoveryWhenTerminalAnchorConflicts(t *testing.T f := newRecoverFixture(t, types.RunCancelled) // The recorded preserved commit remains available in the gate, but the // run-specific evidence points elsewhere. Status must honor that conflict - // instead of advertising a recovery command that Recover will refuse. + // instead of advertising a recovery command that Recover will refuse, and + // must point at the keep-local settlement that can complete instead. mustRun(t, f.local, "fetch", f.gate, f.preserved) mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) @@ -877,7 +882,7 @@ func TestInspectDoesNotAdvertiseRecoveryWhenTerminalAnchorConflicts(t *testing.T if state.Safety != "blocked_recover_preserved_head_missing" { t.Fatalf("conflicting-anchor safety = %q, want blocked_recover_preserved_head_missing: %#v", state.Safety, state) } - if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + if state.NextAction == nil || state.NextAction.Code != "return_custody_keep_local" { t.Fatalf("conflicting-anchor next action = %#v", state.NextAction) } if state.NextAction.Code == "recover_custody" { @@ -896,7 +901,7 @@ func TestInspectDoesNotAdvertiseRecoveryWhenTerminalAnchorIsNotACommit(t *testin mustRun(t, f.gate, "update-ref", f.anchorRef(), blob) state := f.service.InspectCached(f.ctx) - if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + if state.NextAction == nil || state.NextAction.Code != "return_custody_keep_local" { t.Fatalf("non-commit-anchor next action = %#v", state.NextAction) } if state.NextAction.Code == "recover_custody" { @@ -1884,3 +1889,231 @@ func TestRecoverSquashedPreservedHeadStillEscalatesForDroppedLocalWork(t *testin t.Fatal("dropped-work escalation stamped custody") } } + +// wedgedCustodyFixture reproduces the exact self-inconsistent custody record +// reported in issue #824: the bound run is already TERMINAL with no push +// binding (pushed_head empty), its recorded pipeline head is not present in +// the invoking worktree or the local gate, and the gate branch sits at a +// different, later run's head. Nothing in that record can be verified, so +// every guarded recovery refused and abort of the terminal run was a no-op - +// the record had no supported settlement at all. +func wedgedCustodyFixture(t *testing.T, status types.RunStatus) (*recoverFixture, string, string) { + t.Helper() + f := newRecoverFixture(t, status) + + // A later run pushed its own head onto the gate branch and was then + // cancelled, so the gate branch no longer names this run's head. + writer := filepath.Join(t.TempDir(), "later-run") + mustRun(t, filepath.Dir(writer), "-c", "core.autocrlf=false", "clone", f.gate, writer) + configureIdentity(t, writer) + mustRun(t, writer, "checkout", "feature/recover") + mustWrite(t, filepath.Join(writer, "later.txt"), "later cancelled run\n") + mustRun(t, writer, "add", "later.txt") + mustRun(t, writer, "commit", "-m", "no-mistakes(review): later run fix") + mustRun(t, writer, "push", "origin", "HEAD:refs/heads/feature/recover") + staleGate := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover") + + // The recorded pipeline head is gone from every reachable object store. + missing := strings.Repeat("a", 40) + if err := f.db.UpdateRunStatusWithVerifiedHead(f.run.ID, status, missing); err != nil { + t.Fatal(err) + } + run, err := f.db.GetRun(f.run.ID) + if err != nil || run == nil { + t.Fatalf("reload run: %#v, %v", run, err) + } + f.run = run + return f, staleGate, missing +} + +// TestRecoverKeepLocalSettlesWedgedCustodyRecord is the issue #824 regression: +// a terminal run with an empty pushed head, a recorded pipeline head that no +// object store still has, and a gate branch parked at another run's head must +// be settleable by the explicit human choice `--recover --keep-local`. Custody +// returns at the kept local head and the gate branch compare-and-swaps onto +// it, so the record stops advertising a recovery that always refuses. +func TestRecoverKeepLocalSettlesWedgedCustodyRecord(t *testing.T) { + t.Parallel() + + f, staleGate, missing := wedgedCustodyFixture(t, types.RunFailed) + + state := f.service.Recover(f.ctx, true) + if !state.Recovered { + t.Fatalf("keep-local settlement did not return custody = %#v", state) + } + if state.Changed { + t.Fatalf("keep-local settlement moved the worktree = %#v", state) + } + if state.NextAction != nil && state.NextAction.Code == "recover_custody" { + t.Fatalf("settled record still advertises recover_custody = %#v", state.NextAction) + } + if !f.custodyReturned() { + t.Fatal("keep-local settlement did not stamp custody returned") + } + if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.submitted { + t.Fatalf("keep-local settlement moved local HEAD = %s, want %s", got, f.submitted) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.submitted { + t.Fatalf("gate branch = %s, want the kept local head %s", got, f.submitted) + } + if got := mustRun(t, f.gate, "rev-parse", custody.RecoveryGateRef(f.run.ID)); got != staleGate { + t.Fatalf("displaced gate head anchor = %s, want %s", got, staleGate) + } + if objectExists(f.ctx, f.gate, missing) { + t.Fatalf("fixture invariant broken: recorded head %s exists in the gate", missing) + } +} + +// TestRecoverMissingPreservedHeadStillRefusesWithoutKeepLocal keeps the +// fail-closed default: only the explicit human choice settles a record whose +// preserved head cannot be verified. +func TestRecoverMissingPreservedHeadStillRefusesWithoutKeepLocal(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + + state := f.service.Recover(f.ctx, false) + if state.Recovered || state.Changed { + t.Fatalf("default recovery settled an unverifiable record = %#v", state) + } + if state.Safety != "blocked_recover_preserved_head_missing" { + t.Fatalf("default recovery safety = %q, want blocked_recover_preserved_head_missing", state.Safety) + } + if f.custodyReturned() { + t.Fatal("default recovery stamped custody for an unverifiable record") + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != staleGate { + t.Fatalf("default recovery moved the gate branch = %s, want %s", got, staleGate) + } +} + +// TestRecoverKeepLocalSettlesConflictingGateAnchorAndPinsPreservedHead covers +// the second self-inconsistent shape: the recorded pipeline head still exists, +// but the run's own recovery evidence in the gate names a different commit, so +// nothing about the record can be verified. Keep-local settles it, and the +// still-present preserved head is pinned first so the settlement can never +// strand it. +func TestRecoverKeepLocalSettlesConflictingGateAnchorAndPinsPreservedHead(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) + + state := f.service.Recover(f.ctx, true) + if !state.Recovered || state.Changed { + t.Fatalf("keep-local settlement of a conflicting anchor = %#v", state) + } + if !f.custodyReturned() { + t.Fatal("keep-local settlement did not stamp custody returned") + } + if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.submitted { + t.Fatalf("keep-local settlement moved local HEAD = %s, want %s", got, f.submitted) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.submitted { + t.Fatalf("gate branch = %s, want the kept local head %s", got, f.submitted) + } + if got := mustRun(t, f.gate, "rev-parse", f.anchorRef()); got != f.submitted { + t.Fatalf("settlement overwrote the conflicting evidence = %s, want %s", got, f.submitted) + } + if got := mustRun(t, f.gate, "rev-parse", custody.RecoveryStrandedRef(f.run.ID)); got != f.preserved { + t.Fatalf("stranded preserved head anchor = %s, want %s", got, f.preserved) + } +} + +// TestRecoverKeepLocalRefusesWhenStillPresentPreservedHeadCannotBeAnchored is +// the fail-closed half of the settlement: unique unpublished pipeline commits +// that still exist but cannot be anchored keep refusing, with a safety code +// that names the reason. Here an earlier partial settlement left the stranded +// anchor at a different commit, so pinning the preserved head fails. +func TestRecoverKeepLocalRefusesWhenStillPresentPreservedHeadCannotBeAnchored(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) + mustRun(t, f.gate, "update-ref", custody.RecoveryStrandedRef(f.run.ID), f.submitted) + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Changed { + t.Fatalf("keep-local settled a record whose preserved head could not be anchored = %#v", state) + } + if state.Safety != "blocked_recover_preserve_failed" { + t.Fatalf("safety = %q, want blocked_recover_preserve_failed", state.Safety) + } + // A refusal must still name an exit that completes rather than one that + // loops back into the same refusal. + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("refused settlement next action = %#v", state.NextAction) + } + if !strings.Contains(state.Error, custody.RecoveryStrandedRef(f.run.ID)) { + t.Fatalf("refusal did not name the conflicting anchor: %q", state.Error) + } + if f.custodyReturned() { + t.Fatal("unanchorable preserved head stamped custody") + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.preserved { + t.Fatalf("refused settlement moved the gate branch = %s, want %s", got, f.preserved) + } + if got := mustRun(t, f.gate, "rev-parse", custody.RecoveryStrandedRef(f.run.ID)); got != f.submitted { + t.Fatalf("refused settlement overwrote existing evidence = %s, want %s", got, f.submitted) + } +} + +// TestRecoverKeepLocalSettlementLosesConcurrentGatePushCleanly pins the +// compare-and-swap: the settlement never force-moves the gate branch. A push +// landing after the stale head was observed wins, and the settlement refuses +// without stamping custody. +func TestRecoverKeepLocalSettlementLosesConcurrentGatePushCleanly(t *testing.T) { + t.Parallel() + + f, _, _ := wedgedCustodyFixture(t, types.RunFailed) + var raced string + f.service.beforeGateReset = func() { + if raced != "" { + return + } + writer := filepath.Join(t.TempDir(), "racer") + mustRun(t, filepath.Dir(writer), "-c", "core.autocrlf=false", "clone", f.gate, writer) + configureIdentity(t, writer) + mustRun(t, writer, "checkout", "feature/recover") + mustWrite(t, filepath.Join(writer, "raced.txt"), "raced\n") + mustRun(t, writer, "add", "raced.txt") + mustRun(t, writer, "commit", "-m", "concurrent gate push") + mustRun(t, writer, "push", "origin", "HEAD:refs/heads/feature/recover") + raced = mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover") + } + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != "blocked_recover_gate_race" { + t.Fatalf("racing settlement = %#v", state) + } + if f.custodyReturned() { + t.Fatal("lost compare-and-swap stamped custody") + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != raced { + t.Fatalf("gate branch = %s, want the concurrent push %s", got, raced) + } +} + +// TestInspectNamesKeepLocalSettlementForWedgedCustodyRecord is the R5 half of +// issue #824: a refused recovery must still name a supported exit. Inspection +// must not advertise recover_custody for a record it cannot verify, but the +// next action it does advertise has to be one that can actually complete. +func TestInspectNamesKeepLocalSettlementForWedgedCustodyRecord(t *testing.T) { + t.Parallel() + + f, _, _ := wedgedCustodyFixture(t, types.RunFailed) + + state := f.service.InspectCached(f.ctx) + if state.Safety != "blocked_recover_preserved_head_missing" { + t.Fatalf("wedged-record safety = %q, want blocked_recover_preserved_head_missing: %#v", state.Safety, state) + } + if state.NextAction == nil || state.NextAction.Code != "return_custody_keep_local" { + t.Fatalf("wedged-record next action = %#v", state.NextAction) + } + if state.NextAction.Code == "recover_custody" { + t.Fatal("unverifiable record advertised an impossible recovery") + } + if !strings.Contains(state.NextAction.Command, "--keep-local") { + t.Fatalf("next action command = %q, want the keep-local settlement", state.NextAction.Command) + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 52e040a13..a3603c655 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -522,7 +522,16 @@ func (s *Service) Apply(ctx context.Context) State { // work // diverged any refuse (anchor named, manual custody at local head; // reconcile / rerun offered) gate reset to it (CAS) -// P missing any refuse refuse +// P missing any refuse settle: custody at local +// or its head, gate reset to it +// evidence (CAS), any surviving +// conflicts copy of P pinned first +// +// The last row is the self-inconsistent record of issue #824: a record that +// contradicts itself has no verifiable head to protect, and refusing there +// left the branch pipeline_owned with no command that could ever settle it. +// recoverSettleInconsistent owns that reasoning and keeps it fail-closed for a +// surviving-but-unanchorable head. // // The containment row exists because a cancelled validation routinely leaves P // as a REBASE of the local branch onto a newer base: the same logical commits @@ -633,6 +642,9 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if gateAvailable { compatible, err := recoveryAnchorCompatible(ctx, gateDir, run.ID, preserved) if err != nil || !compatible { + if keepLocal { + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", "the run recovery ref in the local gate conflicts with the recorded pipeline head; inspect both objects before returning custody; no files or refs were changed") } } @@ -651,15 +663,24 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if gateAnchorExists { gateAnchored, err := git.Run(ctx, gateDir, "rev-parse", gateAnchor+"^{commit}") if err != nil { + if keepLocal { + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", fmt.Sprintf("the run recovery ref points at non-commit object %s instead of the recorded pipeline head %s; inspect both objects before returning custody; no files or refs were changed", gateAnchorTarget, preserved)) } if gateAnchored != preserved { + if keepLocal { + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", fmt.Sprintf("the run recovery ref points at %s instead of the recorded pipeline head %s; inspect both heads before returning custody; no files or refs were changed", gateAnchored, preserved)) } gateAnchorAvailable = true } if !gateAnchorAvailable { if !objectExists(ctx, gateDir, preserved) { + if keepLocal { + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + } return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserved_head_missing", fmt.Sprintf("the recorded pipeline head %s is missing from the local gate; inspect the recorded and live heads before returning custody; no files or refs were changed", preserved)) } if err := custody.PreserveRecoveryHead(ctx, gateDir, run.ID, preserved); err != nil { @@ -676,6 +697,9 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be fetched from the local gate; no files or refs were changed") } if preserveErr := custody.PreserveRecoveryAnchor(ctx, wd, anchorRef, preserved); preserveErr != nil { + if keepLocal { + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", "the invoking worktree recovery ref conflicts with the recorded pipeline head; inspect both objects before returning custody; no files or refs were changed") } } @@ -724,6 +748,55 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } } +// recoverSettleInconsistent is the explicit keep-local settlement of a +// SELF-INCONSISTENT custody record: a terminal run whose recorded pipeline +// head cannot be verified at all, because no reachable object store still has +// it or because the run's own recovery evidence names a different object. Such +// a record used to have no supported exit - every guarded recovery refused on +// the same unverifiable head, abort of the already-terminal run was an +// idempotent no-op, and the branch stayed pipeline_owned forever (issue #824). +// +// The settlement is deliberately narrow. It is reached only with --keep-local, +// which is the operator stating that the head they want is the one already in +// their worktree, and it is a shortcut for a record that contradicts itself, +// never for a record protecting unique unpublished content: +// +// - Every reachable copy of the recorded pipeline head is pinned first, at +// RecoveryStrandedRef, so a head that still exists survives the settlement +// as inspectable evidence. If such a head exists and CANNOT be pinned, the +// settlement refuses: that is exactly the "unique unpublished pipeline +// commits that cannot be anchored" case, and it stays fail-closed. +// - A head that no store still has cannot be pinned and cannot be lost. It +// is already unrecoverable, so refusing protects nothing and only strands +// the branch. +// - The gate branch then moves through the ordinary keep-local path, which +// anchors an independently moved gate head before an atomic +// compare-and-swap. The gate is never force-moved, so a concurrent gate +// push still wins and the settlement refuses. +func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, state State, gateDir, preserved string) State { + strandedRef := custody.RecoveryStrandedRef(run.ID) + for _, dir := range []string{s.workDir(), strings.TrimSpace(gateDir)} { + if dir == "" || !objectExists(ctx, dir, preserved) { + continue + } + if err := custody.PreserveRecoveryAnchor(ctx, dir, strandedRef, preserved); err != nil { + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", fmt.Sprintf("the recorded pipeline head %s still exists but could not be anchored at %s, so custody cannot be settled without stranding it; inspect that object before retrying; no files or refs were changed", preserved, strandedRef)) + blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} + return blocked + } + } + if strings.TrimSpace(gateDir) == "" { + return s.finishRecover(ctx, run, false) + } + gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+state.Local.Branch+"^{commit}") + if err != nil { + // The gate no longer carries this branch, so nothing there holds + // custody and there is no ref to compare-and-swap. + return s.finishRecover(ctx, run, false) + } + return s.recoverKeepLocal(ctx, run, state, gateHead) +} + // recoverKeepLocal performs the explicit keep-local custody return: the // worktree is never touched; the gate branch moves to the kept local head with // an atomic compare-and-swap so a concurrent gate push refuses instead of @@ -1436,6 +1509,16 @@ func (s *Service) classifyPipelineOwned(ctx context.Context, state *State, run * if terminalRunStatus(run.Status) { if !s.recoverySourceAvailable(ctx, state, run) { state.Safety = "blocked_recover_preserved_head_missing" + // A record that contradicts itself must still name an exit that + // can complete: `--keep-local` settles it at the head the operator + // already has (issue #824). Only a record that keeps its own + // evidence intact falls back to manual reconciliation, because + // there the refusal is protecting something real. + if s.selfInconsistentCustodyRecord(ctx, run) { + state.Error = "the run finished " + string(run.Status) + " but its recorded pipeline head cannot be verified in the invoking worktree or local gate; return custody at the current local head, which also points the gate branch at it" + state.NextAction = &NextAction{Code: "return_custody_keep_local", Command: "no-mistakes axi sync --recover --keep-local"} + return + } state.Error = "the run finished " + string(run.Status) + " but its recorded pipeline head is not available in the invoking worktree or local gate; inspect and reconcile the recorded and live heads manually" state.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} return @@ -1450,6 +1533,45 @@ func (s *Service) classifyPipelineOwned(ctx context.Context, state *State, run * state.NextAction = &NextAction{Code: "continue_active_run", Command: "no-mistakes axi status"} } +// selfInconsistentCustodyRecord reports the exact class of stranded record +// that `--recover --keep-local` can settle: the recorded pipeline head is not +// in any reachable object store, or the run's own recovery evidence names +// something other than that head. Both mean nothing about the record can be +// verified, so a refusal protects nothing and only strands the branch. +// +// It is deliberately conservative. A record whose evidence is intact but whose +// recovery is merely blocked (a genuinely diverged head, an absent gate that +// might just be unmounted) is NOT self-inconsistent: settlement there would +// abandon a preserved head that recovery can still import, so those keep +// pointing at manual reconciliation. +func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, run *db.Run) bool { + if run == nil { + return false + } + preserved := strings.TrimSpace(run.HeadSHA) + gateDir := strings.TrimSpace(s.GateDir) + if gateDir == "" { + return false + } + if _, err := os.Stat(gateDir); err != nil { + return false + } + if preserved == "" { + return true + } + wd := s.workDir() + if !objectExists(ctx, wd, preserved) && !objectExists(ctx, gateDir, preserved) { + return true + } + for _, dir := range []string{gateDir, wd} { + compatible, err := recoveryAnchorCompatible(ctx, dir, run.ID, preserved) + if err == nil && !compatible { + return true + } + } + return false +} + func (s *Service) recoverySourceAvailable(ctx context.Context, state *State, run *db.Run) bool { if state == nil || run == nil || strings.TrimSpace(run.HeadSHA) == "" { return false diff --git a/internal/cli/axi_abort_custody_test.go b/internal/cli/axi_abort_custody_test.go new file mode 100644 index 000000000..64b64ab42 --- /dev/null +++ b/internal/cli/axi_abort_custody_test.go @@ -0,0 +1,193 @@ +package cli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kunchenguid/no-mistakes/internal/db" + "github.com/kunchenguid/no-mistakes/internal/git" + "github.com/kunchenguid/no-mistakes/internal/ipc" + "github.com/kunchenguid/no-mistakes/internal/paths" + "github.com/kunchenguid/no-mistakes/internal/types" +) + +// wedgedCustodyAbortFixture builds the issue #824 record on real state: a +// registered operator worktree, a real local gate whose branch sits at a +// LATER run's head, and a terminal run row with no push binding whose recorded +// pipeline head is in no object store at all. Aborting that run cannot cancel +// anything, so its response is the only place left to name a settlement. +func wedgedCustodyAbortFixture(t *testing.T) (string, *paths.Paths, string) { + t.Helper() + nmHome := makeSocketSafeTempDir(t) + t.Setenv("NM_HOME", nmHome) + + root := t.TempDir() + local := filepath.Join(root, "operator") + cliGit(t, root, "init", "-b", "main", local) + cliGit(t, local, "config", "user.name", "Test") + cliGit(t, local, "config", "user.email", "test@example.com") + if err := os.WriteFile(filepath.Join(local, "file.txt"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, local, "add", "file.txt") + cliGit(t, local, "commit", "-m", "base") + cliGit(t, local, "checkout", "-b", "feature/wedged") + if err := os.WriteFile(filepath.Join(local, "file.txt"), []byte("feature\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, local, "commit", "-am", "feature") + submitted := cliGit(t, local, "rev-parse", "HEAD") + + p, err := paths.New() + if err != nil { + t.Fatal(err) + } + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + database, err := db.Open(p.DB()) + if err != nil { + t.Fatal(err) + } + registeredRoot, err := git.FindGitRoot(local) + if err != nil { + t.Fatal(err) + } + repo, err := database.InsertRepo(registeredRoot, filepath.Join(root, "remote.git"), "main") + if err != nil { + t.Fatal(err) + } + + gate := p.RepoDir(repo.ID) + cliGit(t, root, "init", "--bare", gate) + cliGit(t, local, "push", gate, "refs/heads/feature/wedged:refs/heads/feature/wedged") + // A later run pushed its own head onto the gate branch and was cancelled, + // so the gate no longer names this run's head either. + pipelineClone := filepath.Join(root, "later-run") + cliGit(t, root, "-c", "core.autocrlf=false", "clone", gate, pipelineClone) + cliGit(t, pipelineClone, "config", "user.name", "Test") + cliGit(t, pipelineClone, "config", "user.email", "test@example.com") + cliGit(t, pipelineClone, "checkout", "feature/wedged") + if err := os.WriteFile(filepath.Join(pipelineClone, "later.txt"), []byte("later run\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, pipelineClone, "add", "later.txt") + cliGit(t, pipelineClone, "commit", "-m", "no-mistakes(review): later run fix") + cliGit(t, pipelineClone, "push", "origin", "HEAD:refs/heads/feature/wedged") + + run, err := database.InsertRun(repo.ID, "feature/wedged", submitted, submitted) + if err != nil { + t.Fatal(err) + } + // The recorded pipeline head is gone from every reachable object store. + if err := database.UpdateRunStatusWithVerifiedHead(run.ID, types.RunFailed, strings.Repeat("a", 40)); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + + chdir(t, local) + return run.ID, p, local +} + +func assertNamesKeepLocalSettlement(t *testing.T, out string) { + t.Helper() + for _, want := range []string{"aborted: false", "run_status: failed", "already terminal"} { + if !strings.Contains(out, want) { + t.Errorf("terminal-run abort output missing %q:\n%s", want, out) + } + } + // The prescribed action is the FIRST help entry; the standing branch-sync + // guidance that follows it names `--recover` in general prose, so only the + // prescribed entry can be asserted. On this record the plain recovery is + // the command that always refuses, and it must never be prescribed here. + prescribed := "" + for _, line := range strings.Split(out, "\n") { + if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "help[") { + _, rest, _ := strings.Cut(trimmed, ": ") + prescribed, _, _ = strings.Cut(rest, ",") + break + } + } + if prescribed != "Run `no-mistakes axi sync --recover --keep-local`" { + t.Errorf("terminal-run abort prescribed %q, want the keep-local settlement:\n%s", prescribed, out) + } +} + +// TestAxiAbortOfTerminalRunNamesTheSupportedCustodySettlement is the issue #824 +// regression on the abort surface. Aborting an already-terminal run is an +// idempotent no-op by design - there is nothing left to cancel - but the +// reporter was left with that no-op plus a `sync --check` that kept offering a +// recovery which always refused. The no-op must therefore name the command +// that can actually settle the record. +func TestAxiAbortOfTerminalRunNamesTheSupportedCustodySettlement(t *testing.T) { + t.Run("daemon unavailable", func(t *testing.T) { + runID, _, _ := wedgedCustodyAbortFixture(t) + + out, err := executeCmd("axi", "abort", "--run", runID) + t.Logf("daemon-down terminal abort output:\n%s", out) + if err != nil { + t.Fatalf("terminal run must resolve idempotently: %v\n%s", err, out) + } + assertNamesKeepLocalSettlement(t, out) + }) + + t.Run("daemon reports no active run", func(t *testing.T) { + runID, p, _ := wedgedCustodyAbortFixture(t) + startInactiveAbortDaemon(t, p, runID) + + out, err := executeCmd("axi", "abort", "--run", runID) + t.Logf("daemon-up terminal abort output:\n%s", out) + if err != nil { + t.Fatalf("terminal run must resolve idempotently: %v\n%s", err, out) + } + assertNamesKeepLocalSettlement(t, out) + }) +} + +// startInactiveAbortDaemon serves the exact daemon responses a terminal run +// produces: cancel_run has nothing active to cancel, and get_run reports the +// durable terminal record. +func startInactiveAbortDaemon(t *testing.T, p *paths.Paths, runID string) { + t.Helper() + srv := ipc.NewServer() + srv.Handle(ipc.MethodHealth, func(context.Context, json.RawMessage) (interface{}, error) { + return &ipc.HealthResult{Status: "ok"}, nil + }) + srv.Handle(ipc.MethodGateContext, func(context.Context, json.RawMessage) (interface{}, error) { + return &ipc.GateContextResult{Nested: false}, nil + }) + srv.Handle(ipc.MethodCancelRun, func(context.Context, json.RawMessage) (interface{}, error) { + return nil, noActiveRunErr(runID) + }) + srv.Handle(ipc.MethodGetRun, func(context.Context, json.RawMessage) (interface{}, error) { + return &ipc.GetRunResult{Run: &ipc.RunInfo{ + ID: runID, Branch: "feature/wedged", Status: types.RunFailed, + }}, nil + }) + errCh := make(chan error, 1) + go func() { errCh <- srv.Serve(p.Socket()) }() + t.Cleanup(func() { + srv.Close() + select { + case <-errCh: + case <-time.After(time.Second): + t.Error("fake daemon did not stop") + } + }) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if client, dialErr := ipc.Dial(p.Socket()); dialErr == nil { + client.Close() + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("fake daemon did not become reachable") +} diff --git a/internal/cli/axi_drive.go b/internal/cli/axi_drive.go index 0805b0708..89163fdf8 100644 --- a/internal/cli/axi_drive.go +++ b/internal/cli/axi_drive.go @@ -264,6 +264,55 @@ func inspectAxiBranchSync(ctx context.Context, env *axiEnv) branchsync.State { return service.InspectCached(ctx) } +// terminalRunCustodyHelp names the supported settlement for a branch that an +// already-terminal run still holds. Cancelling a terminal run is necessarily +// an idempotent no-op - there is nothing left to cancel - but that no-op used +// to be the whole answer, leaving the operator with a branch whose only +// advertised next action was a recovery that always refused (issue #824). The +// abort response therefore carries whatever settlement the branch's own +// ownership state names, so every terminal-run abort hands back a command that +// can complete. +// +// Best effort by construction: an `--run ` abort may be issued from +// outside the run's worktree entirely, so any failure to resolve the invoking +// repo, branch, or ownership simply adds no guidance. +func terminalRunCustodyHelp(ctx context.Context, p *paths.Paths, runID string) []string { + if p == nil { + return nil + } + d, err := db.Open(p.DB()) + if err != nil { + return nil + } + defer d.Close() + return terminalRunCustodyHelpWithDB(ctx, p, d, runID) +} + +func terminalRunCustodyHelpWithDB(ctx context.Context, p *paths.Paths, d *db.DB, runID string) []string { + if p == nil || d == nil || strings.TrimSpace(runID) == "" { + return nil + } + repo, err := findRepo(d) + if err != nil || repo == nil { + return nil + } + service := &branchsync.Service{ + DB: d, + Repo: repo, + WorkDir: ".", + GateDir: p.RepoDir(repo.ID), + Paths: p, + } + state := service.InspectCached(ctx) + if state.Pipeline.RunID != runID || state.NextAction == nil { + return nil + } + return []string{ + "Run `" + state.NextAction.Command + "`", + branchSyncAgentGuidance, + } +} + func freshRunBranchOwnershipState(ctx context.Context, env *axiEnv) *branchsync.State { state := inspectAxiBranchSync(ctx, env) switch state.State { @@ -893,9 +942,18 @@ func runAxiAbort(cmd *cobra.Command, runID string) error { {Key: "aborted", Value: false}, {Key: "detail", Value: "no active run (no-op)"}, } - if state := inspectAxiBranchSync(ctx, env); relevantCachedSyncState(state) { + state := inspectAxiBranchSync(ctx, env) + if relevantCachedSyncState(state) { fields = append(fields, branchSyncField(state)) } + // The branch a terminal run still holds must leave with a command that + // can settle it, not just the no-op (issue #824). + if state.NextAction != nil { + fields = append(fields, toon.Field{Key: "help", Value: []string{ + "Run `" + state.NextAction.Command + "`", + branchSyncAgentGuidance, + }}) + } emitDoc(cmd, fields...) return nil } @@ -1070,7 +1128,7 @@ func runAxiAbortByRunID(cmd *cobra.Command, runID string) error { // terminal no-op, the documented unknown-id no-op, and the nonzero // terminal-unconfirmed contract. if strings.Contains(err.Error(), "no active run") { - return resolveInactiveAbortTruth(cmd, client, runID) + return resolveInactiveAbortTruth(cmd, p, client, runID) } return emitError(cmd, 1, fmt.Sprintf("abort run: %v", err)) } @@ -1106,7 +1164,7 @@ func runViewPtrFromIPC(run *ipc.RunInfo) *runView { // fabricated), a positively proven unknown id keeps the documented no-op, and // a still-nonterminal or unreadable run is the nonzero terminal-unconfirmed // contract. -func resolveInactiveAbortTruth(cmd *cobra.Command, client *ipc.Client, runID string) error { +func resolveInactiveAbortTruth(cmd *cobra.Command, p *paths.Paths, client *ipc.Client, runID string) error { ctx, cancel := context.WithTimeout(cmd.Context(), abortStateWaitTimeout) defer cancel() var result ipc.GetRunResult @@ -1132,12 +1190,16 @@ func resolveInactiveAbortTruth(cmd *cobra.Command, client *ipc.Client, runID str return emitUnconfirmedAbort(cmd, runID, "", fmt.Sprintf("the daemon returned durable state for run %s instead of the requested run %s", run.ID, runID), nil, true) } if terminalStatus(string(run.Status)) { - emitDoc(cmd, - toon.Field{Key: "aborted", Value: false}, - toon.Field{Key: "run", Value: runID}, - toon.Field{Key: "run_status", Value: string(run.Status)}, - toon.Field{Key: "detail", Value: "run is already terminal (idempotent no-op)"}, - ) + fields := []toon.Field{ + {Key: "aborted", Value: false}, + {Key: "run", Value: runID}, + {Key: "run_status", Value: string(run.Status)}, + {Key: "detail", Value: "run is already terminal (idempotent no-op)"}, + } + if help := terminalRunCustodyHelp(ctx, p, runID); len(help) > 0 { + fields = append(fields, toon.Field{Key: "help", Value: help}) + } + emitDoc(cmd, fields...) return nil } return emitUnconfirmedAbort(cmd, runID, run.Branch, fmt.Sprintf("the daemon reported no active run, but the exact run's durable state is still %s", run.Status), runViewPtrFromIPC(run), true) @@ -1171,12 +1233,16 @@ func resolveDaemonDownAbortTruth(cmd *cobra.Command, p *paths.Paths, runID strin return emitUnconfirmedAbort(cmd, runID, "", fmt.Sprintf("the durable record identified run %s instead of the requested run %s", run.ID, runID), nil, false) } if terminalStatus(string(run.Status)) { - emitDoc(cmd, - toon.Field{Key: "aborted", Value: false}, - toon.Field{Key: "run", Value: runID}, - toon.Field{Key: "run_status", Value: string(run.Status)}, - toon.Field{Key: "detail", Value: "daemon not running; run is already terminal (idempotent no-op)"}, - ) + fields := []toon.Field{ + {Key: "aborted", Value: false}, + {Key: "run", Value: runID}, + {Key: "run_status", Value: string(run.Status)}, + {Key: "detail", Value: "daemon not running; run is already terminal (idempotent no-op)"}, + } + if help := terminalRunCustodyHelpWithDB(cmd.Context(), p, database, runID); len(help) > 0 { + fields = append(fields, toon.Field{Key: "help", Value: help}) + } + emitDoc(cmd, fields...) return nil } return emitUnconfirmedAbort(cmd, runID, run.Branch, fmt.Sprintf("the daemon is not running, so cancellation cannot be requested, and the durable run record is still %s", run.Status), nil, false) diff --git a/internal/cli/axi_guidance.go b/internal/cli/axi_guidance.go index 178b913ba..b361894ef 100644 --- a/internal/cli/axi_guidance.go +++ b/internal/cli/axi_guidance.go @@ -26,4 +26,4 @@ const preserveGateFixCommitsGuidance = "Commit post-pipeline follow-up work on t // branchSyncAgentGuidance is emitted only when a relevant branch_sync object // is present. Keeping it conditional avoids flooding ordinary runs whose local // and pipeline heads never differed. -const branchSyncAgentGuidance = "Before a post-pipeline local commit or fresh run, follow the structured `branch_sync.next_action`. Run `no-mistakes axi sync` only when its code is `sync`; that guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics. Run `no-mistakes axi sync --recover` only when its code is `recover_custody` (a terminal run left unpublished pipeline commits preserved in the local gate). A `user_owned` state means cancellation released the branch before changing the submitted head: the exact branch and head are yours, immediately usable, and no sync action is needed. Process blocked or pipeline-owned states instead of improvising reset, stash, merge, rebase, force, or branch replacement." +const branchSyncAgentGuidance = "Before a post-pipeline local commit or fresh run, follow the structured `branch_sync.next_action`. Run `no-mistakes axi sync` only when its code is `sync`; that guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics. Run `no-mistakes axi sync --recover` only when its code is `recover_custody` (a terminal run left unpublished pipeline commits preserved in the local gate). Run `no-mistakes axi sync --recover --keep-local` only when its code is `return_custody_keep_local` (a terminal run whose recorded pipeline head can no longer be verified): it returns custody at your current head and points the gate branch at it. A `user_owned` state means cancellation released the branch before changing the submitted head: the exact branch and head are yours, immediately usable, and no sync action is needed. Process blocked or pipeline-owned states instead of improvising reset, stash, merge, rebase, force, or branch replacement." diff --git a/internal/cli/axi_guidance_test.go b/internal/cli/axi_guidance_test.go index 212e3be58..696087a58 100644 --- a/internal/cli/axi_guidance_test.go +++ b/internal/cli/axi_guidance_test.go @@ -49,6 +49,12 @@ var canonicalBranchSyncPhrases = []string{ // that it needs no recovery. "user_owned", "before changing the submitted head", + // Settlement of a self-inconsistent custody record (issue #824): a record + // whose recorded pipeline head can no longer be verified has no preserved + // head to take, so every surface must name the keep-local settlement that + // can actually complete instead of a recovery that always refuses. + "return_custody_keep_local", + "no-mistakes axi sync --recover --keep-local", } const canonicalPipelineAgentPrerequisite = "a supported native agent binary, the `agent: cursor` ACP alias, or an explicit `acp:` through `acpx`" diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 2d4d1bc68..03820ddcc 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -34,7 +34,9 @@ func newSyncCmd() *cobra.Command { "carry every local change. Unproven divergence refuses. A run cancelled before\n" + "the pipeline changed anything releases the branch by itself (user_owned) and\n" + "makes --recover a no-op. --recover --keep-local keeps the current local head\n" + - "instead and never touches the worktree.", + "instead, never touches the worktree, and points the gate branch at the kept\n" + + "head with a compare-and-swap; that is also the settlement for a record whose\n" + + "preserved head can no longer be verified.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if check && yes { @@ -55,7 +57,7 @@ func newSyncCmd() *cobra.Command { cmd.Flags().BoolVar(&check, "check", false, "freshly verify and show the synchronization plan without changing HEAD") cmd.Flags().BoolVarP(&yes, "yes", "y", false, "apply an eligible guarded synchronization without prompting") cmd.Flags().BoolVar(&recover, "recover", false, "return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch)") - cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; the preserved commits stay anchored and the gate follows the kept head") + cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; surviving preserved commits stay anchored and the gate branch compare-and-swaps onto the kept head, which also settles a record whose preserved head can no longer be verified") return cmd } @@ -72,7 +74,9 @@ func newAxiSyncCmd() *cobra.Command { "verified pipeline head with reset semantics.\n" + "--check performs the same fresh read-only plan. Blocked states change nothing.\n" + "--recover performs the guarded custody return offered by\n" + - "next_action.code: recover_custody; --keep-local keeps the current local head.", + "next_action.code: recover_custody; --keep-local keeps the current local head\n" + + "and moves the gate branch to it, which is also the settlement offered by\n" + + "next_action.code: return_custody_keep_local.", Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, @@ -88,7 +92,7 @@ func newAxiSyncCmd() *cobra.Command { } cmd.Flags().BoolVar(&check, "check", false, "freshly verify and return the plan without changing HEAD") cmd.Flags().BoolVar(&recover, "recover", false, "return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch)") - cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; the preserved commits stay anchored and the gate follows the kept head") + cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; surviving preserved commits stay anchored and the gate branch compare-and-swaps onto the kept head, which also settles a record whose preserved head can no longer be verified") return cmd } diff --git a/internal/custody/refs.go b/internal/custody/refs.go index 90db8bea6..4820335a5 100644 --- a/internal/custody/refs.go +++ b/internal/custody/refs.go @@ -53,3 +53,13 @@ func RecoveryLocalRef(runID string) string { func RecoveryGateRef(runID string) string { return "refs/no-mistakes/recover-gate/" + runID } + +// RecoveryStrandedRef keeps a terminal run's recorded pipeline head reachable +// when custody is settled at the operator's kept local head instead of at that +// head. It is deliberately separate from RecoveryRef: the settlement exists +// precisely because the run's own recovery evidence is unusable (missing, or +// naming another commit), so the surviving object needs an anchor that cannot +// collide with the evidence being preserved for inspection. +func RecoveryStrandedRef(runID string) string { + return "refs/no-mistakes/recover-stranded/" + runID +} diff --git a/internal/skill/skill.go b/internal/skill/skill.go index f3fda1497..c1083dbe0 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -242,6 +242,7 @@ Recovery takes that head by fast-forward, or by adopting a diverged preserved he That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours. A ` + "`branch_sync.state`" + ` of ` + "`user_owned`" + ` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated ` + "`--recover`" + ` there is a harmless no-op. A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; ` + "`--keep-local`" + ` keeps your current head while the preserved commits stay anchored under ` + "`refs/no-mistakes/recover/`" + `. +When ` + "`next_action.code`" + ` is ` + "`return_custody_keep_local`" + `, the terminal run's recorded pipeline head can no longer be verified in your worktree or the local gate, so there is no preserved head to take: run ` + "`no-mistakes axi sync --recover --keep-local`" + ` to return custody at your current head and point the gate branch at it. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. After synchronization, commit the follow-up on top and re-run ` + "`no-mistakes axi run --intent \"...\"`" + ` with the original user intent. This preserves every prior gate-fix commit regardless of its configured subject. diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index 36f3b973f..4d82b9825 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -242,6 +242,7 @@ Recovery takes that head by fast-forward, or by adopting a diverged preserved he That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours. A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated `--recover` there is a harmless no-op. A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/`. +When `next_action.code` is `return_custody_keep_local`, the terminal run's recorded pipeline head can no longer be verified in your worktree or the local gate, so there is no preserved head to take: run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. After synchronization, commit the follow-up on top and re-run `no-mistakes axi run --intent "..."` with the original user intent. This preserves every prior gate-fix commit regardless of its configured subject. From ced6c2824ea2dced76e4fc87cb9c732f93cf41b0 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:23:38 -0400 Subject: [PATCH 02/31] fix(branchsync): close settlement edge cases found in review Four defects in the #824 keep-local settlement, all in edge sub-shapes rather than the primary flow: - selfInconsistentCustodyRecord advertised the settlement for a terminal run whose head was never verified, but Recover refuses that at its unverified-head guard before any keep-local interception - recreating the same "advertised action that always refuses" wedge. That shape is #707's scope, so the predicate now excludes it and status keeps its honest manual-reconciliation pointer. - A conflicting worktree recovery anchor on the locally reachable path refused without settlement while the predicate advertised it. It is now intercepted like the gate-side shapes; keeping a head that already contains the preserved commits can lose nothing. - The settlement treated any gate-branch rev-parse error as "branch missing" and stamped custody returned without the compare-and-swap. Only a branch proven absent via ExactRefTarget settles without one; an unreadable branch refuses like the sibling keep-local sites. - A refusal claimed "no files or refs were changed" after the pin loop may already have written the stranded anchor. It now names where that anchor exists. Each fix has a regression test that fails without it. Co-Authored-By: Claude Opus 5 --- docs/src/content/docs/reference/cli.md | 3 +- internal/branchsync/recover_test.go | 157 +++++++++++++++++++++++++ internal/branchsync/sync.go | 63 ++++++++-- 3 files changed, 213 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index fbc91cd09..15748e4a1 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -240,7 +240,8 @@ When you explicitly keep a behind or diverged local head instead of taking the p `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses with `safety: blocked_recover_preserved_head_missing` or `blocked_recover_anchor_mismatch` and nothing else could settle the branch. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. -The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses. +The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. +A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. `no-mistakes axi abort` on an already-terminal run stays an idempotent no-op - there is nothing left to cancel - but its response names that settlement command when the invoking worktree's branch is still held by that run. `no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back. A recovered never-pushed run reports `state: custody_returned`; a recovered pushed run reports its ordinary classification against the last push binding, typically `local_ahead`. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 50a0ff753..1afaabbde 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2117,3 +2117,160 @@ func TestInspectNamesKeepLocalSettlementForWedgedCustodyRecord(t *testing.T) { t.Fatalf("next action command = %q, want the keep-local settlement", state.NextAction.Command) } } + +// TestInspectDoesNotAdvertiseSettlementForUnverifiedTerminalHead is the review +// regression for the sibling shape that would have recreated the #824 wedge: +// a terminal run whose head was never verified (a daemon crash after the +// managed worktree was already gone) refuses at Recover's unverified-head +// guard, strictly before any keep-local interception. Inspection must not +// advertise a settlement that guard will always refuse. +func TestInspectDoesNotAdvertiseSettlementForUnverifiedTerminalHead(t *testing.T) { + t.Parallel() + + // A non-terminal fixture status records the run without a verified + // terminal head; the head is then moved off every reachable object and + // terminalized the way a crash-recovered run is. + f := newRecoverFixture(t, types.RunRunning) + if err := f.db.UpdateRunHeadSHA(f.run.ID, strings.Repeat("b", 40)); err != nil { + t.Fatal(err) + } + if err := f.db.UpdateRunStatus(f.run.ID, types.RunFailed); err != nil { + t.Fatal(err) + } + run, err := f.db.GetRun(f.run.ID) + if err != nil || run == nil { + t.Fatalf("reload run: %#v, %v", run, err) + } + f.run = run + if run.TerminalHeadVerifiedAt != nil { + t.Fatal("fixture invariant broken: the terminal head is verified") + } + + state := f.service.InspectCached(f.ctx) + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("unverified-head next action = %#v", state.NextAction) + } + + // The advertisement must match what recovery actually does. + recovered := f.service.Recover(f.ctx, true) + if recovered.Recovered || recovered.Safety != "blocked_recover_unverified_head" { + t.Fatalf("keep-local on an unverified terminal head = %#v", recovered) + } + if f.custodyReturned() { + t.Fatal("an unverified terminal head stamped custody") + } +} + +// TestRecoverKeepLocalSettlesConflictingWorktreeAnchorOnReachableHead covers +// the review regression on the locally reachable path: when the preserved head +// is already reachable from the local branch but the invoking worktree's own +// recovery ref names something else, the record is just as self-inconsistent as +// the gate-side shapes - and keeping a head that already contains the preserved +// commits can lose nothing at all. +func TestRecoverKeepLocalSettlesConflictingWorktreeAnchorOnReachableHead(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + mustRun(t, f.local, "fetch", f.gate, f.preserved) + mustRun(t, f.local, "reset", "--hard", f.preserved) + mustRun(t, f.local, "update-ref", f.anchorRef(), f.submitted) + + // The default recovery stays fail-closed on the same record. + refused := f.service.Recover(f.ctx, false) + if refused.Recovered || refused.Safety != "blocked_recover_preserve_failed" { + t.Fatalf("default recovery with a conflicting worktree anchor = %#v", refused) + } + if f.custodyReturned() { + t.Fatal("default recovery stamped custody for a conflicting worktree anchor") + } + + state := f.service.Recover(f.ctx, true) + if !state.Recovered || state.Changed { + t.Fatalf("keep-local settlement of a conflicting worktree anchor = %#v", state) + } + if !f.custodyReturned() { + t.Fatal("keep-local settlement did not stamp custody returned") + } + if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.preserved { + t.Fatalf("settlement moved local HEAD = %s, want %s", got, f.preserved) + } + if got := mustRun(t, f.local, "rev-parse", f.anchorRef()); got != f.submitted { + t.Fatalf("settlement overwrote the conflicting worktree evidence = %s, want %s", got, f.submitted) + } + if got := mustRun(t, f.local, "rev-parse", custody.RecoveryStrandedRef(f.run.ID)); got != f.preserved { + t.Fatalf("stranded anchor in the worktree = %s, want %s", got, f.preserved) + } +} + +// TestRecoverKeepLocalSettlementTreatsAnUnreadableGateBranchAsUnknown is the +// review regression for the fail-open conflation: only a gate branch PROVEN +// absent settles without the compare-and-swap. A gate branch that exists but +// cannot be resolved to a commit is not evidence of absence, so the settlement +// refuses rather than stamping custody against a head it never observed. +func TestRecoverKeepLocalSettlementTreatsAnUnreadableGateBranchAsUnknown(t *testing.T) { + t.Parallel() + + t.Run("absent gate branch settles without a gate move", func(t *testing.T) { + f, _, _ := wedgedCustodyFixture(t, types.RunFailed) + mustRun(t, f.gate, "update-ref", "-d", "refs/heads/feature/recover") + + state := f.service.Recover(f.ctx, true) + if !state.Recovered || state.Changed { + t.Fatalf("settlement with an absent gate branch = %#v", state) + } + if !f.custodyReturned() { + t.Fatal("absent gate branch did not stamp custody returned") + } + }) + + t.Run("unreadable gate branch refuses", func(t *testing.T) { + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + // A branch ref that exists but does not name a commit: git refuses to + // write one through update-ref, so the loose ref is written directly. + mustRun(t, f.gate, "pack-refs", "--all") + blob := mustRun(t, f.gate, "hash-object", "-w", filepath.Join(f.local, "file.txt")) + mustWrite(t, filepath.Join(f.gate, "refs", "heads", "feature", "recover"), blob+"\n") + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != "blocked_recover_gate_unavailable" { + t.Fatalf("settlement with an unreadable gate branch = %#v", state) + } + if f.custodyReturned() { + t.Fatal("an unobserved gate head stamped custody") + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got == staleGate { + t.Fatalf("fixture invariant broken: the gate branch still resolves to %s", staleGate) + } + }) +} + +// TestRecoverKeepLocalRefusalNamesTheAnchorItAlreadyWrote is the review +// regression for the honesty claim: the stranded anchor is the one ref the +// settlement can write before failing, so a refusal must report where it now +// exists instead of claiming nothing changed. +func TestRecoverKeepLocalRefusalNamesTheAnchorItAlreadyWrote(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + mustRun(t, f.local, "fetch", f.gate, f.preserved) + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) + // The worktree pin succeeds; the gate pin then fails on prior evidence. + mustRun(t, f.gate, "update-ref", custody.RecoveryStrandedRef(f.run.ID), f.submitted) + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != "blocked_recover_preserve_failed" { + t.Fatalf("refused settlement = %#v", state) + } + if got := mustRun(t, f.local, "rev-parse", custody.RecoveryStrandedRef(f.run.ID)); got != f.preserved { + t.Fatalf("worktree stranded anchor = %s, want %s", got, f.preserved) + } + if strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("refusal claimed nothing changed after writing the worktree anchor: %q", state.Error) + } + if !strings.Contains(state.Error, "the invoking worktree") { + t.Fatalf("refusal did not name where the anchor now exists: %q", state.Error) + } + if f.custodyReturned() { + t.Fatal("refused settlement stamped custody") + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index a3603c655..35ea6ff7e 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -637,6 +637,13 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if objectExists(ctx, wd, preserved) && (local == preserved || isAncestor(ctx, wd, preserved, local)) { if blocked, ok := s.anchorReachablePreserved(ctx, state, run.ID, preserved); !ok { + // The recovery ref in the invoking worktree is unusable, which is + // the same self-inconsistency the gate-side sites settle - and here + // the preserved head is already reachable from the local branch, so + // keeping that head can lose nothing at all. + if keepLocal { + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + } return blocked } if gateAvailable { @@ -773,30 +780,61 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { // anchors an independently moved gate head before an atomic // compare-and-swap. The gate is never force-moved, so a concurrent gate // push still wins and the settlement refuses. +// +// Only a gate branch PROVEN absent settles without that compare-and-swap. An +// unreadable gate branch is not evidence of absence, so it refuses exactly like +// the sibling keep-local sites in Recover rather than stamping custody against +// a gate head that was never observed. func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, state State, gateDir, preserved string) State { strandedRef := custody.RecoveryStrandedRef(run.ID) - for _, dir := range []string{s.workDir(), strings.TrimSpace(gateDir)} { - if dir == "" || !objectExists(ctx, dir, preserved) { + gateDir = strings.TrimSpace(gateDir) + // The stranded anchor is the ONLY ref this function can write before the + // gate move, so a refusal reports precisely where it now exists rather + // than claiming nothing changed. + pinned := []string{} + for _, store := range []struct{ name, dir string }{ + {"the invoking worktree", s.workDir()}, + {"the local gate", gateDir}, + } { + if store.dir == "" || !objectExists(ctx, store.dir, preserved) { continue } - if err := custody.PreserveRecoveryAnchor(ctx, dir, strandedRef, preserved); err != nil { - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", fmt.Sprintf("the recorded pipeline head %s still exists but could not be anchored at %s, so custody cannot be settled without stranding it; inspect that object before retrying; no files or refs were changed", preserved, strandedRef)) + if err := custody.PreserveRecoveryAnchor(ctx, store.dir, strandedRef, preserved); err != nil { + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", fmt.Sprintf("the recorded pipeline head %s still exists but could not be anchored at %s in %s, so custody cannot be settled without stranding it; inspect that object before retrying; no branch, worktree, or file changes were made%s", preserved, strandedRef, store.name, anchoredElsewhere(pinned, strandedRef))) blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} return blocked } + pinned = append(pinned, store.name) } - if strings.TrimSpace(gateDir) == "" { + if gateDir == "" { return s.finishRecover(ctx, run, false) } - gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+state.Local.Branch+"^{commit}") + gateBranchRef := "refs/heads/" + state.Local.Branch + _, gateBranchExists, err := git.ExactRefTarget(ctx, gateDir, gateBranchRef) if err != nil { - // The gate no longer carries this branch, so nothing there holds - // custody and there is no ref to compare-and-swap. + return blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s could not be read, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, anchoredElsewhere(pinned, strandedRef))) + } + if !gateBranchExists { + // Proven absent: the gate holds no branch ref for this run, so nothing + // there holds custody and there is no ref to compare-and-swap. return s.finishRecover(ctx, run, false) } + gateHead, err := git.Run(ctx, gateDir, "rev-parse", gateBranchRef+"^{commit}") + if err != nil { + return blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s does not resolve to a commit, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, anchoredElsewhere(pinned, strandedRef))) + } return s.recoverKeepLocal(ctx, run, state, gateHead) } +// anchoredElsewhere keeps a settlement refusal honest about the one ref it can +// already have written before failing. +func anchoredElsewhere(pinned []string, ref string) string { + if len(pinned) == 0 { + return "" + } + return fmt.Sprintf("; the recorded head is now anchored at %s in %s", ref, strings.Join(pinned, " and ")) +} + // recoverKeepLocal performs the explicit keep-local custody return: the // worktree is never touched; the gate branch moves to the kept local head with // an atomic compare-and-swap so a concurrent gate push refuses instead of @@ -1544,8 +1582,15 @@ func (s *Service) classifyPipelineOwned(ctx context.Context, state *State, run * // might just be unmounted) is NOT self-inconsistent: settlement there would // abandon a preserved head that recovery can still import, so those keep // pointing at manual reconciliation. +// +// An UNVERIFIED terminal head is excluded for a different reason: Recover +// refuses it at the unverified-head guard, strictly before any keep-local +// interception, so advertising the settlement there would recreate the very +// "advertised action that always refuses" wedge this change exists to remove. +// Making that shape recoverable is issue #707's scope, not this one; until +// then the honest pointer is manual reconciliation. func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, run *db.Run) bool { - if run == nil { + if run == nil || run.TerminalHeadVerifiedAt == nil { return false } preserved := strings.TrimSpace(run.HeadSHA) From abc623ebcd695c099fb4b12aee1edbc496e1c758 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:11:12 -0400 Subject: [PATCH 03/31] no-mistakes(review): fail-close keep-local settlement advertisement; document preserve-failed refusal --- docs/src/content/docs/reference/cli.md | 2 +- internal/branchsync/recover_test.go | 61 ++++++++++++++++++++++++++ internal/branchsync/sync.go | 34 ++++++++++---- 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 15748e4a1..9ea4e0d4b 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -238,7 +238,7 @@ Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds A dirty worktree refuses with explicit choices. When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. -`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses with `safety: blocked_recover_preserved_head_missing` or `blocked_recover_anchor_mismatch` and nothing else could settle the branch. +`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch; every one of those refusals is still settleable with `--keep-local`. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 1afaabbde..37baaa402 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2161,6 +2161,67 @@ func TestInspectDoesNotAdvertiseSettlementForUnverifiedTerminalHead(t *testing.T } } +// TestInspectDoesNotAdvertiseSettlementWhenGateRecoveryRefIsUninspectable is +// the review regression for the fail-closed advertisement: a gate recovery ref +// that cannot be inspected at all (corrupt packed-refs while the gate +// directory itself is intact) is not evidence of inconsistency, and Recover +// refuses that probe error strictly before any keep-local interception, so +// inspection must keep the honest manual-reconciliation pointer instead of +// advertising a settlement that always refuses. +func TestInspectDoesNotAdvertiseSettlementWhenGateRecoveryRefIsUninspectable(t *testing.T) { + t.Parallel() + + f, _, _ := wedgedCustodyFixture(t, types.RunFailed) + mustWrite(t, filepath.Join(f.gate, "packed-refs"), "garbage line\n") + if _, _, refErr := gitpkg.ExactRefTarget(f.ctx, f.gate, f.anchorRef()); refErr == nil { + t.Fatal("fixture invariant broken: the gate recovery ref is still inspectable") + } + + state := f.service.InspectCached(f.ctx) + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("uninspectable-gate next action = %#v", state.NextAction) + } + + // The advertisement must match what recovery actually does. + recovered := f.service.Recover(f.ctx, true) + if recovered.Recovered || recovered.Safety != "blocked_recover_anchor_mismatch" { + t.Fatalf("keep-local with an uninspectable gate recovery ref = %#v", recovered) + } + if f.custodyReturned() { + t.Fatal("an uninspectable gate recovery ref stamped custody") + } +} + +// TestInspectDoesNotAdvertiseSettlementForIncompleteAdoption is the review +// regression for the sibling shape: a conflicting recovery anchor on a record +// whose branch already reached the preserved head with a stale mid-adoption +// worktree is refused by Recover's incomplete-adoption guard, strictly before +// any keep-local interception, so inspection must not advertise the +// settlement there either. +func TestInspectDoesNotAdvertiseSettlementForIncompleteAdoption(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + mustRun(t, f.local, "fetch", "--no-tags", f.gate, "+refs/heads/feature/recover:"+f.anchorRef()) + mustRun(t, f.local, "update-ref", f.localAnchorRef(), f.submitted, "") + mustRun(t, f.local, "update-ref", "refs/heads/feature/recover", f.preserved, f.submitted) + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) + + state := f.service.InspectCached(f.ctx) + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("incomplete-adoption next action = %#v", state.NextAction) + } + + // The advertisement must match what recovery actually does. + recovered := f.service.Recover(f.ctx, true) + if recovered.Recovered || recovered.Safety != "blocked_recover_incomplete_adoption" { + t.Fatalf("keep-local on an incomplete adoption = %#v", recovered) + } + if f.custodyReturned() { + t.Fatal("an incomplete adoption stamped custody") + } +} + // TestRecoverKeepLocalSettlesConflictingWorktreeAnchorOnReachableHead covers // the review regression on the locally reachable path: when the preserved head // is already reachable from the local branch but the invoking worktree's own diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 35ea6ff7e..2f6146368 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -1552,7 +1552,7 @@ func (s *Service) classifyPipelineOwned(ctx context.Context, state *State, run * // already has (issue #824). Only a record that keeps its own // evidence intact falls back to manual reconciliation, because // there the refusal is protecting something real. - if s.selfInconsistentCustodyRecord(ctx, run) { + if s.selfInconsistentCustodyRecord(ctx, state, run) { state.Error = "the run finished " + string(run.Status) + " but its recorded pipeline head cannot be verified in the invoking worktree or local gate; return custody at the current local head, which also points the gate branch at it" state.NextAction = &NextAction{Code: "return_custody_keep_local", Command: "no-mistakes axi sync --recover --keep-local"} return @@ -1583,13 +1583,22 @@ func (s *Service) classifyPipelineOwned(ctx context.Context, state *State, run * // abandon a preserved head that recovery can still import, so those keep // pointing at manual reconciliation. // +// The advertisement carries the same fail-closed polarity as +// recoverSettleInconsistent: it may name the settlement only when +// Recover(keepLocal) actually reaches it. A gate recovery ref that cannot be +// inspected at all is not evidence of inconsistency - Recover refuses that +// probe error strictly before any keep-local interception - and an incomplete +// adoption (the branch reached the preserved head while the worktree still +// differs) is refused by its own guard even earlier, so both disqualify the +// record and keep the honest manual-reconciliation pointer. +// // An UNVERIFIED terminal head is excluded for a different reason: Recover // refuses it at the unverified-head guard, strictly before any keep-local // interception, so advertising the settlement there would recreate the very // "advertised action that always refuses" wedge this change exists to remove. // Making that shape recoverable is issue #707's scope, not this one; until // then the honest pointer is manual reconciliation. -func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, run *db.Run) bool { +func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, state *State, run *db.Run) bool { if run == nil || run.TerminalHeadVerifiedAt == nil { return false } @@ -1601,18 +1610,27 @@ func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, run *db.Run if _, err := os.Stat(gateDir); err != nil { return false } + wd := s.workDir() + gateCompatible, err := recoveryAnchorCompatible(ctx, gateDir, run.ID, preserved) + if err != nil { + return false + } + if state != nil && state.Local.Head == preserved && !state.Local.Clean { + if anchoredLocal, err := git.Run(ctx, wd, "rev-parse", "--verify", custody.RecoveryLocalRef(run.ID)+"^{commit}"); err == nil && anchoredLocal != preserved { + return false + } + } if preserved == "" { return true } - wd := s.workDir() if !objectExists(ctx, wd, preserved) && !objectExists(ctx, gateDir, preserved) { return true } - for _, dir := range []string{gateDir, wd} { - compatible, err := recoveryAnchorCompatible(ctx, dir, run.ID, preserved) - if err == nil && !compatible { - return true - } + if !gateCompatible { + return true + } + if compatible, err := recoveryAnchorCompatible(ctx, wd, run.ID, preserved); err == nil && !compatible { + return true } return false } From 6d9d16a4bb093c88475b8339eb7e7b29ac905bd6 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:40:32 -0400 Subject: [PATCH 04/31] fix(branchsync): fail-close settlement advertisement and scope abort help Three findings from the review of 391b3d7: - selfInconsistentCustodyRecord still treated every incompatible gate recovery ref as settleable, but SYMBOLIC evidence is refused before any keep-local interception. A dangling symref is invisible to for-each-ref while symbolic-ref still succeeds, so the predicate said "incompatible" while Recover's own probe saw no ref and then failed inside PreserveRecoveryHead's symbolic check - an un-intercepted refusal. A resolving symref reaches the ordinary keep-local path and refuses there. Symbolic gate-anchor evidence now disqualifies the record outright, keeping the honest manual-reconciliation pointer. - The bare-abort no-op emitted the branch's next action unconditionally, so on a released branch it answered an abort by prescribing `axi run` - telling the operator to launch a pipeline. Abort help is now allowlisted to custody-settlement actions via custodySettlementHelp, applied to all three abort sites so the --run siblings cannot drift into the same overreach. The structured branch_sync object still reports the branch's own next_action, which is state rather than a prescription. - cli.md claimed every listed refusal was settleable with --keep-local; the shapes refused before an interception are not. The predicate's invariant is now stated once: it may name the settlement only where Recover(keepLocal) actually reaches it. Each fix has a regression test that fails without it. Co-Authored-By: Claude Opus 5 --- docs/src/content/docs/reference/cli.md | 2 +- internal/branchsync/recover_test.go | 56 ++++++++++++++++++ internal/branchsync/sync.go | 11 ++++ internal/cli/axi_abort_custody_test.go | 80 ++++++++++++++++++++++++++ internal/cli/axi_drive.go | 40 +++++++++---- 5 files changed, 177 insertions(+), 12 deletions(-) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 9ea4e0d4b..2003ba00f 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -238,7 +238,7 @@ Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds A dirty worktree refuses with explicit choices. When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. -`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch; every one of those refusals is still settleable with `--keep-local`. +`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, so status keeps reporting manual reconciliation there rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 37baaa402..44ac4ec8b 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2335,3 +2335,59 @@ func TestRecoverKeepLocalRefusalNamesTheAnchorItAlreadyWrote(t *testing.T) { t.Fatal("refused settlement stamped custody") } } + +// TestInspectDoesNotAdvertiseSettlementForSymbolicGateAnchor is the review +// regression for the residual advertise-then-refuse corner. A DANGLING +// symbolic gate recovery ref is invisible to `for-each-ref` while +// `symbolic-ref -q` still succeeds, so recoveryAnchorCompatible reports +// "incompatible" with no error while Recover's own ExactRefTarget probe sees +// no ref at all. Recovery then fails inside PreserveRecoveryHead's symbolic +// check, at a refusal site that deliberately has no keep-local interception. +// Symbolic gate-anchor evidence must therefore disqualify the record outright. +func TestInspectDoesNotAdvertiseSettlementForSymbolicGateAnchor(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + // Dangling: the target never exists, so for-each-ref omits the ref. + mustRun(t, f.gate, "symbolic-ref", f.anchorRef(), "refs/no-mistakes/evidence/"+f.run.ID) + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, f.anchorRef()); err != nil || exists { + t.Fatalf("fixture invariant broken: exact ref target exists=%v err=%v", exists, err) + } + + state := f.service.InspectCached(f.ctx) + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("symbolic-anchor next action = %#v", state.NextAction) + } + + // The advertisement must match what recovery actually does. + recovered := f.service.Recover(f.ctx, true) + if recovered.Recovered || recovered.Safety != "blocked_recover_preserve_failed" { + t.Fatalf("keep-local with a symbolic gate anchor = %#v", recovered) + } + if f.custodyReturned() { + t.Fatal("a symbolic gate anchor stamped custody") + } + if got := mustRun(t, f.gate, "symbolic-ref", f.anchorRef()); got != "refs/no-mistakes/evidence/"+f.run.ID { + t.Fatalf("symbolic evidence was rewritten = %s", got) + } +} + +// TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicGateAnchor covers +// the softer sibling named in the same finding: a symbolic gate anchor that +// DOES resolve (to the preserved head) is listed by for-each-ref, so recovery +// gets further, but the ordinary keep-local path still refuses when the gate +// branch is gone. Symbolic evidence disqualifies the record either way. +func TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicGateAnchor(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + mustRun(t, f.gate, "symbolic-ref", f.anchorRef(), "refs/heads/feature/recover") + + state := f.service.InspectCached(f.ctx) + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("resolving-symbolic-anchor next action = %#v", state.NextAction) + } + if f.custodyReturned() { + t.Fatal("inspection stamped custody") + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 2f6146368..6fe502ee0 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -1611,6 +1611,17 @@ func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, state *Stat return false } wd := s.workDir() + // Symbolic gate-anchor evidence disqualifies the record outright. A + // DANGLING symref is invisible to for-each-ref while symbolic-ref still + // succeeds, so recoveryAnchorCompatible reports "incompatible" with no + // error while Recover's own probe sees no ref at all and then fails inside + // PreserveRecoveryHead's symbolic check - a refusal site that deliberately + // carries no keep-local interception. A RESOLVING symref gets further and + // still refuses on the ordinary keep-local path. Either way the settlement + // is unreachable, so it must not be advertised. + if symbolic, symErr := git.Run(ctx, gateDir, "symbolic-ref", "-q", custody.RecoveryRef(run.ID)); symErr == nil && symbolic != "" { + return false + } gateCompatible, err := recoveryAnchorCompatible(ctx, gateDir, run.ID, preserved) if err != nil { return false diff --git a/internal/cli/axi_abort_custody_test.go b/internal/cli/axi_abort_custody_test.go index 64b64ab42..154fe7a72 100644 --- a/internal/cli/axi_abort_custody_test.go +++ b/internal/cli/axi_abort_custody_test.go @@ -191,3 +191,83 @@ func startInactiveAbortDaemon(t *testing.T, p *paths.Paths, runID string) { } t.Fatal("fake daemon did not become reachable") } + +// TestBareAbortNoOpNeverPrescribesLaunchingAPipeline is the review regression +// for the branch-scoped abort no-op. The `--run` sites already guard their +// help behind an exact run match, but the bare-abort site emitted the branch's +// next action unconditionally - so on a branch whose custody was already +// returned it answered an abort by prescribing `axi run`, telling the operator +// to LAUNCH a pipeline. Abort help exists to name a custody settlement (issue +// #824 constraint 2); it must never prescribe starting a run. +func TestBareAbortNoOpNeverPrescribesLaunchingAPipeline(t *testing.T) { + runID, p, _ := wedgedCustodyAbortFixture(t) + // Custody already returned: the branch's own next action becomes + // run_pipeline, which an abort response must not hand back. + database, err := db.Open(p.DB()) + if err != nil { + t.Fatal(err) + } + if err := database.SetRunCustodyReturned(runID); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + startNoActiveRunDaemon(t, p) + + out, err := executeCmd("axi", "abort") + t.Logf("bare abort on a released branch:\n%s", out) + if err != nil { + t.Fatalf("bare abort with no active run must be a no-op success: %v\n%s", err, out) + } + if !strings.Contains(out, "aborted: false") { + t.Errorf("bare abort no-op output missing %q:\n%s", "aborted: false", out) + } + // The structured branch_sync object still REPORTS the branch's own + // next_action (run_pipeline here); that is ownership state, not a + // prescription. Only the abort's own help prescribes a command, and a + // non-settlement action must yield no help at all. + if strings.Contains(out, "Run `no-mistakes axi run") { + t.Errorf("abort prescribed launching a pipeline:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "help[") { + t.Errorf("abort emitted help for a non-settlement next action:\n%s", line) + } + } +} + +// startNoActiveRunDaemon serves a daemon with no active run for the branch, so +// the bare abort takes its documented idempotent no-op path. +func startNoActiveRunDaemon(t *testing.T, p *paths.Paths) { + t.Helper() + srv := ipc.NewServer() + srv.Handle(ipc.MethodHealth, func(context.Context, json.RawMessage) (interface{}, error) { + return &ipc.HealthResult{Status: "ok"}, nil + }) + srv.Handle(ipc.MethodGateContext, func(context.Context, json.RawMessage) (interface{}, error) { + return &ipc.GateContextResult{Nested: false}, nil + }) + srv.Handle(ipc.MethodGetActiveRun, func(context.Context, json.RawMessage) (interface{}, error) { + return &ipc.GetActiveRunResult{Run: nil}, nil + }) + errCh := make(chan error, 1) + go func() { errCh <- srv.Serve(p.Socket()) }() + t.Cleanup(func() { + srv.Close() + select { + case <-errCh: + case <-time.After(time.Second): + t.Error("fake daemon did not stop") + } + }) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if client, dialErr := ipc.Dial(p.Socket()); dialErr == nil { + client.Close() + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("fake daemon did not become reachable") +} diff --git a/internal/cli/axi_drive.go b/internal/cli/axi_drive.go index 89163fdf8..e4794542e 100644 --- a/internal/cli/axi_drive.go +++ b/internal/cli/axi_drive.go @@ -288,6 +288,29 @@ func terminalRunCustodyHelp(ctx context.Context, p *paths.Paths, runID string) [ return terminalRunCustodyHelpWithDB(ctx, p, d, runID) } +// custodySettlementHelp renders abort help for a branch-ownership next action, +// but ONLY for the custody-settlement actions an abort response is entitled to +// prescribe. Abort help exists to name how to settle custody the pipeline +// still holds (issue #824); a released branch's ordinary next action is +// run_pipeline, and answering an abort by telling the operator to LAUNCH a +// fresh run exceeds that scope entirely. The allowlist is deliberate: an +// unrecognized or pipeline-launching action yields no help rather than being +// passed through. +func custodySettlementHelp(action *branchsync.NextAction) []string { + if action == nil { + return nil + } + switch action.Code { + case "recover_custody", "return_custody_keep_local", "inspect_and_reconcile_manually": + return []string{ + "Run `" + action.Command + "`", + branchSyncAgentGuidance, + } + default: + return nil + } +} + func terminalRunCustodyHelpWithDB(ctx context.Context, p *paths.Paths, d *db.DB, runID string) []string { if p == nil || d == nil || strings.TrimSpace(runID) == "" { return nil @@ -304,13 +327,10 @@ func terminalRunCustodyHelpWithDB(ctx context.Context, p *paths.Paths, d *db.DB, Paths: p, } state := service.InspectCached(ctx) - if state.Pipeline.RunID != runID || state.NextAction == nil { + if state.Pipeline.RunID != runID { return nil } - return []string{ - "Run `" + state.NextAction.Command + "`", - branchSyncAgentGuidance, - } + return custodySettlementHelp(state.NextAction) } func freshRunBranchOwnershipState(ctx context.Context, env *axiEnv) *branchsync.State { @@ -947,12 +967,10 @@ func runAxiAbort(cmd *cobra.Command, runID string) error { fields = append(fields, branchSyncField(state)) } // The branch a terminal run still holds must leave with a command that - // can settle it, not just the no-op (issue #824). - if state.NextAction != nil { - fields = append(fields, toon.Field{Key: "help", Value: []string{ - "Run `" + state.NextAction.Command + "`", - branchSyncAgentGuidance, - }}) + // can settle it, not just the no-op (issue #824) - but only a custody + // settlement, never an instruction to launch a fresh pipeline. + if help := custodySettlementHelp(state.NextAction); len(help) > 0 { + fields = append(fields, toon.Field{Key: "help", Value: help}) } emitDoc(cmd, fields...) return nil From b1a9a2270f4289b7a9e9667ea68319a860eb738b Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:29:18 -0400 Subject: [PATCH 05/31] no-mistakes(review): fail-close settlement advertisement on unreadable gate branch --- docs/src/content/docs/reference/cli.md | 5 ++- internal/branchsync/recover_test.go | 47 +++++++++++++++++-- internal/branchsync/sync.go | 62 ++++++++++++++++++++++---- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 2003ba00f..97c2609d6 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -223,7 +223,7 @@ Run `axi sync` only when structured output offers `next_action.code: sync`; proc A run that goes terminal (cancelled, failed, or completed without a push stage) after moving the pipeline head leaves the branch `pipeline_owned`. Status offers `next_action.code: recover_custody` only when recovery can establish the same eligibility it will enforce: an equal or ahead local head proves the source locally and can create the local anchor when the gate is unavailable, but any existing gate recovery ref must still match the recorded head; importing a missing preserved head requires an exact run-specific gate anchor (or legacy commit evidence that can be anchored), a clean worktree, and either local ancestry or the content-preservation proof described below. The eligible state reports `safety: blocked_pipeline_owned_recoverable`, the run's terminal `pipeline.status`, and the exact `submitted_head`/`current_head`/`relation` ownership facts. A run whose terminalization verifies that the managed worktree head never changed from the submitted head releases the branch instead: the terminal outcome, including cancellation, ends ownership; status reports `state: user_owned` with the same exact ownership facts and no `next_action`; the branch and head are immediately usable for any separately authorized delivery; and nothing blocks a direct push or PR. -Without positive evidence that the submitted head stayed unchanged, custody is not guessed away. Missing or conflicting evidence, and import cases with a dirty worktree or genuinely divergent history, require manual reconciliation instead of advertising a recovery that will refuse. +Without positive evidence that the submitted head stayed unchanged, custody is not guessed away. Missing or conflicting evidence, and import cases with a dirty worktree or genuinely divergent history, never advertise a recovery that will refuse: where the recorded head can no longer be verified at all, status names the `--keep-local` settlement described below, and every remaining shape reports manual reconciliation. While a run is still active, it reports `state: pipeline_owned`, the exact submitted/current heads and their relation, and `next_action.code: continue_active_run` with `no-mistakes axi status`, even when its head has not moved yet. `--recover` verifies the run is terminal, anchors the preserved head under `refs/no-mistakes/recover/` in the invoking repository, and stamps custody returned so a fresh run can start. For equal or ahead worktrees where the preserved head is already locally reachable, recovery writes that anchor locally without requiring gate access. If the gate is available, an existing symbolic, non-commit, or mismatched recovery ref is conflicting evidence and recovery refuses without overwriting it. @@ -238,9 +238,10 @@ Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds A dirty worktree refuses with explicit choices. When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. -`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, so status keeps reporting manual reconciliation there rather than advertising a settlement that would also refuse. +`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and a record whose local gate branch cannot be read or does not name a commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. +Every settlement refusal names `next_action.code: inspect_and_reconcile_manually`, so a refusal is never a dead end even for a shape status advertised in error. A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. `no-mistakes axi abort` on an already-terminal run stays an idempotent no-op - there is nothing left to cancel - but its response names that settlement command when the invoking worktree's branch is still held by that run. `no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 44ac4ec8b..de666785b 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2275,6 +2275,13 @@ func TestRecoverKeepLocalSettlementTreatsAnUnreadableGateBranchAsUnknown(t *test f, _, _ := wedgedCustodyFixture(t, types.RunFailed) mustRun(t, f.gate, "update-ref", "-d", "refs/heads/feature/recover") + // A gate branch proven absent keeps the advertisement, because the + // settlement completes there with no compare-and-swap at all. + inspected := f.service.InspectCached(f.ctx) + if inspected.NextAction == nil || inspected.NextAction.Code != "return_custody_keep_local" { + t.Fatalf("absent-gate-branch next action = %#v", inspected.NextAction) + } + state := f.service.Recover(f.ctx, true) if !state.Recovered || state.Changed { t.Fatalf("settlement with an absent gate branch = %#v", state) @@ -2292,10 +2299,21 @@ func TestRecoverKeepLocalSettlementTreatsAnUnreadableGateBranchAsUnknown(t *test blob := mustRun(t, f.gate, "hash-object", "-w", filepath.Join(f.local, "file.txt")) mustWrite(t, filepath.Join(f.gate, "refs", "heads", "feature", "recover"), blob+"\n") + // The settlement refuses this shape, so inspection must not prescribe + // it: an advertisement that always refuses is the exact wedge #824 + // removes. + inspected := f.service.InspectCached(f.ctx) + if inspected.NextAction == nil || inspected.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("unreadable-gate-branch next action = %#v", inspected.NextAction) + } + state := f.service.Recover(f.ctx, true) if state.Recovered || state.Safety != "blocked_recover_gate_unavailable" { t.Fatalf("settlement with an unreadable gate branch = %#v", state) } + if state.NextAction == nil { + t.Fatalf("refused settlement named no exit at all = %#v", state) + } if f.custodyReturned() { t.Fatal("an unobserved gate head stamped custody") } @@ -2374,9 +2392,15 @@ func TestInspectDoesNotAdvertiseSettlementForSymbolicGateAnchor(t *testing.T) { // TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicGateAnchor covers // the softer sibling named in the same finding: a symbolic gate anchor that -// DOES resolve (to the preserved head) is listed by for-each-ref, so recovery -// gets further, but the ordinary keep-local path still refuses when the gate -// branch is gone. Symbolic evidence disqualifies the record either way. +// DOES resolve is listed by for-each-ref, so recovery gets all the way through +// and keep-local actually completes. The predicate disqualifies symbolic +// evidence CONSERVATIVELY, without separating the dangling variant (genuinely +// unreachable) from this resolving one, so this is a deliberate +// UNDER-advertisement: status names a manual exit that can complete instead of +// promising a settlement whose reachability it did not prove. Under-advertising +// is fail-safe; over-advertising is the #824 wedge. The Recover half pins what +// the command really does here, so a later change that starts advertising this +// shape is a deliberate choice rather than an accident. func TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicGateAnchor(t *testing.T) { t.Parallel() @@ -2390,4 +2414,21 @@ func TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicGateAnchor(t *test if f.custodyReturned() { t.Fatal("inspection stamped custody") } + + recovered := f.service.Recover(f.ctx, true) + if !recovered.Recovered { + t.Fatalf("keep-local with a resolving symbolic gate anchor = %#v", recovered) + } + if recovered.Changed { + t.Fatalf("keep-local moved the worktree = %#v", recovered) + } + if !f.custodyReturned() { + t.Fatal("completed settlement did not stamp custody returned") + } + if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.submitted { + t.Fatalf("local HEAD = %s, want the kept head %s", got, f.submitted) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.submitted { + t.Fatalf("gate branch = %s, want the kept local head %s", got, f.submitted) + } } diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 6fe502ee0..5e5916fab 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -785,6 +785,11 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { // unreadable gate branch is not evidence of absence, so it refuses exactly like // the sibling keep-local sites in Recover rather than stamping custody against // a gate head that was never observed. +// +// Every refusal here names a completable exit. blockedPlan clears NextAction by +// default, and this function is the terminal settlement, so a refusal with no +// next action would be the dead end the whole issue exists to remove - even for +// a shape selfInconsistentCustodyRecord failed to disqualify. func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, state State, gateDir, preserved string) State { strandedRef := custody.RecoveryStrandedRef(run.ID) gateDir = strings.TrimSpace(gateDir) @@ -812,7 +817,9 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st gateBranchRef := "refs/heads/" + state.Local.Branch _, gateBranchExists, err := git.ExactRefTarget(ctx, gateDir, gateBranchRef) if err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s could not be read, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, anchoredElsewhere(pinned, strandedRef))) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s could not be read, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, anchoredElsewhere(pinned, strandedRef))) + blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} + return blocked } if !gateBranchExists { // Proven absent: the gate holds no branch ref for this run, so nothing @@ -821,7 +828,9 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st } gateHead, err := git.Run(ctx, gateDir, "rev-parse", gateBranchRef+"^{commit}") if err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s does not resolve to a commit, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, anchoredElsewhere(pinned, strandedRef))) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s does not resolve to a commit, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, anchoredElsewhere(pinned, strandedRef))) + blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} + return blocked } return s.recoverKeepLocal(ctx, run, state, gateHead) } @@ -1585,12 +1594,21 @@ func (s *Service) classifyPipelineOwned(ctx context.Context, state *State, run * // // The advertisement carries the same fail-closed polarity as // recoverSettleInconsistent: it may name the settlement only when -// Recover(keepLocal) actually reaches it. A gate recovery ref that cannot be -// inspected at all is not evidence of inconsistency - Recover refuses that -// probe error strictly before any keep-local interception - and an incomplete -// adoption (the branch reached the preserved head while the worktree still -// differs) is refused by its own guard even earlier, so both disqualify the -// record and keep the honest manual-reconciliation pointer. +// Recover(keepLocal) actually reaches it AND the settlement can complete +// there. A gate recovery ref that cannot be inspected at all is not evidence +// of inconsistency - Recover refuses that probe error strictly before any +// keep-local interception - and an incomplete adoption (the branch reached the +// preserved head while the worktree still differs) is refused by its own guard +// even earlier, so both disqualify the record and keep the honest +// manual-reconciliation pointer. +// +// The gate BRANCH is probed for the same reason, exactly the way the +// settlement probes it: recoverSettleInconsistent compare-and-swaps +// refs/heads/, so a branch ref that cannot be read or does not name a +// commit refuses inside the settlement instead of completing. A gate branch +// PROVEN absent is the deliberate exception - the settlement legitimately +// completes there with no swap at all, because nothing in the gate holds +// custody - so absent stays advertisable while unreadable does not. // // An UNVERIFIED terminal head is excluded for a different reason: Recover // refuses it at the unverified-head guard, strictly before any keep-local @@ -1622,6 +1640,9 @@ func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, state *Stat if symbolic, symErr := git.Run(ctx, gateDir, "symbolic-ref", "-q", custody.RecoveryRef(run.ID)); symErr == nil && symbolic != "" { return false } + if !s.settlementGateBranchUsable(ctx, state, gateDir) { + return false + } gateCompatible, err := recoveryAnchorCompatible(ctx, gateDir, run.ID, preserved) if err != nil { return false @@ -1646,6 +1667,31 @@ func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, state *Stat return false } +// settlementGateBranchUsable answers the one question recoverSettleInconsistent +// asks of the gate branch before its compare-and-swap: can the kept local head +// be swapped onto it, or is the branch proven absent so no swap is needed. An +// unreadable ref and a ref that does not name a commit are neither, so the +// settlement refuses there and must not be advertised. +func (s *Service) settlementGateBranchUsable(ctx context.Context, state *State, gateDir string) bool { + if state == nil { + return false + } + branch := strings.TrimSpace(state.Local.Branch) + if branch == "" { + return false + } + ref := "refs/heads/" + branch + _, exists, err := git.ExactRefTarget(ctx, gateDir, ref) + if err != nil { + return false + } + if !exists { + return true + } + _, err = git.Run(ctx, gateDir, "rev-parse", ref+"^{commit}") + return err == nil +} + func (s *Service) recoverySourceAvailable(ctx context.Context, state *State, run *db.Run) bool { if state == nil || run == nil || strings.TrimSpace(run.HeadSHA) == "" { return false From 3db517c2c64d303ef3b48ff257950fbb298c000f Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:47:05 -0400 Subject: [PATCH 06/31] no-mistakes(review): close settlement dead end after lost gate compare-and-swap --- docs/src/content/docs/reference/cli.md | 4 +- internal/branchsync/recover_test.go | 28 ++++++++++ internal/branchsync/sync.go | 72 +++++++++++++++++++++++--- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 97c2609d6..f58d18f78 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -238,10 +238,10 @@ Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds A dirty worktree refuses with explicit choices. When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. -`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and a record whose local gate branch cannot be read or does not name a commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. +`--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. -Every settlement refusal names `next_action.code: inspect_and_reconcile_manually`, so a refusal is never a dead end even for a shape status advertised in error. +Every refusal on the keep-local path names `next_action.code: inspect_and_reconcile_manually`, including the conflicting displaced-gate-head anchor a lost compare-and-swap leaves behind, so a refusal is never a dead end even for a shape status advertised in error. A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. `no-mistakes axi abort` on an already-terminal run stays an idempotent no-op - there is nothing left to cancel - but its response names that settlement command when the invoking worktree's branch is still held by that run. `no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index de666785b..e6d0b0020 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2032,6 +2032,12 @@ func TestRecoverKeepLocalRefusesWhenStillPresentPreservedHeadCannotBeAnchored(t mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) mustRun(t, f.gate, "update-ref", custody.RecoveryStrandedRef(f.run.ID), f.submitted) + // The settlement can only refuse here, so inspection must not prescribe it. + inspected := f.service.InspectCached(f.ctx) + if inspected.NextAction == nil || inspected.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("occupied-stranded-anchor next action = %#v", inspected.NextAction) + } + state := f.service.Recover(f.ctx, true) if state.Recovered || state.Changed { t.Fatalf("keep-local settled a record whose preserved head could not be anchored = %#v", state) @@ -2092,6 +2098,28 @@ func TestRecoverKeepLocalSettlementLosesConcurrentGatePushCleanly(t *testing.T) if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != raced { t.Fatalf("gate branch = %s, want the concurrent push %s", got, raced) } + + // The lost swap leaves refs/no-mistakes/recover-gate/ pinned at the + // head observed before the race, and nothing retires it. The retry the + // refusal invites therefore hits the anchor conflict permanently, so it + // must name a completable exit and status must stop prescribing it. + retry := f.service.Recover(f.ctx, true) + if retry.Recovered { + t.Fatalf("retry after a lost compare-and-swap settled the record = %#v", retry) + } + if retry.NextAction == nil { + t.Fatalf("permanently refusing retry named no exit at all = %#v", retry) + } + if retry.NextAction.Code == "return_custody_keep_local" { + t.Fatalf("retry prescribed the settlement that just refused = %#v", retry.NextAction) + } + if f.custodyReturned() { + t.Fatal("refused retry stamped custody") + } + inspected := f.service.InspectCached(f.ctx) + if inspected.NextAction == nil || inspected.NextAction.Code == "return_custody_keep_local" { + t.Fatalf("wedged-anchor next action = %#v", inspected.NextAction) + } } // TestInspectNamesKeepLocalSettlementForWedgedCustodyRecord is the R5 half of diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 5e5916fab..a67378056 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -859,7 +859,9 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State gateAnchor := custody.RecoveryGateRef(run.ID) existing, exists, err := git.ExactRefTarget(ctx, s.GateDir, gateAnchor) if err != nil || (exists && existing != gateHead) { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the independently moved gate head conflicts with the existing run recovery anchor; inspect both refs before returning custody; no files or branch refs were changed") + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the independently moved gate head conflicts with the existing run recovery anchor; inspect both refs before returning custody; no files or branch refs were changed") + blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} + return blocked } if !exists { err = custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead) @@ -1602,13 +1604,13 @@ func (s *Service) classifyPipelineOwned(ctx context.Context, state *State, run * // even earlier, so both disqualify the record and keep the honest // manual-reconciliation pointer. // -// The gate BRANCH is probed for the same reason, exactly the way the -// settlement probes it: recoverSettleInconsistent compare-and-swaps -// refs/heads/, so a branch ref that cannot be read or does not name a -// commit refuses inside the settlement instead of completing. A gate branch -// PROVEN absent is the deliberate exception - the settlement legitimately -// completes there with no swap at all, because nothing in the gate holds -// custody - so absent stays advertisable while unreadable does not. +// The three refs the settlement itself writes or swaps are probed for the same +// reason, exactly the way it probes them, because these refuse INSIDE the +// settlement rather than at a guard Recover reaches first: the gate BRANCH it +// compare-and-swaps (a ref that cannot be read or does not name a commit +// refuses, while one PROVEN absent is the deliberate exception, since the +// settlement completes there with no swap at all), and the stranded and gate +// anchor refs of settlementAnchorsFree. // // An UNVERIFIED terminal head is excluded for a different reason: Recover // refuses it at the unverified-head guard, strictly before any keep-local @@ -1643,6 +1645,9 @@ func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, state *Stat if !s.settlementGateBranchUsable(ctx, state, gateDir) { return false } + if !s.settlementAnchorsFree(ctx, state, run, gateDir, preserved) { + return false + } gateCompatible, err := recoveryAnchorCompatible(ctx, gateDir, run.ID, preserved) if err != nil { return false @@ -1692,6 +1697,57 @@ func (s *Service) settlementGateBranchUsable(ctx context.Context, state *State, return err == nil } +// settlementAnchorsFree answers the other two questions the settlement asks +// before it can complete, both of which refuse INSIDE it rather than at a +// guard Recover reaches first. +// +// The stranded ref is where the pin loop puts every still-reachable copy of +// the recorded head; PreserveRecoveryAnchor refuses to overwrite a ref that +// already names something else, so a leftover anchor at another commit makes +// the settlement refuse permanently. +// +// The gate ref is where recoverKeepLocal pins an independently moved gate head +// before its compare-and-swap. It is written BEFORE the swap, so a settlement +// that lost one race leaves it pinned at the head observed then; once the gate +// has moved on, that stale pin conflicts with every later attempt and nothing +// retires it. Probing both keeps the advertisement honest: a record that can +// only refuse falls back to manual reconciliation. +func (s *Service) settlementAnchorsFree(ctx context.Context, state *State, run *db.Run, gateDir, preserved string) bool { + anchorFreeAt := func(dir, ref, want string) bool { + target, exists, err := git.ExactRefTarget(ctx, dir, ref) + if err != nil { + return false + } + if !exists { + return true + } + if target == want { + return true + } + resolved, err := git.Run(ctx, dir, "rev-parse", ref+"^{commit}") + return err == nil && resolved == want + } + stranded := custody.RecoveryStrandedRef(run.ID) + if preserved != "" { + for _, dir := range []string{s.workDir(), gateDir} { + if dir == "" || !objectExists(ctx, dir, preserved) { + continue + } + if !anchorFreeAt(dir, stranded, preserved) { + return false + } + } + } + if state == nil || strings.TrimSpace(state.Local.Branch) == "" { + return false + } + gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+state.Local.Branch+"^{commit}") + if err != nil || gateHead == state.Local.Head || gateHead == preserved { + return true + } + return anchorFreeAt(gateDir, custody.RecoveryGateRef(run.ID), gateHead) +} + func (s *Service) recoverySourceAvailable(ctx context.Context, state *State, run *db.Run) bool { if state == nil || run == nil || strings.TrimSpace(run.HeadSHA) == "" { return false From 684a4aa845d549efecff12429cae3e4ec0fe5595 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:02:06 -0400 Subject: [PATCH 07/31] no-mistakes(review): name an exit on every keep-local recovery refusal --- internal/branchsync/recover_test.go | 12 +++++++++++ internal/branchsync/sync.go | 32 +++++++++++++++++++---------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index e6d0b0020..847f24472 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2098,6 +2098,18 @@ func TestRecoverKeepLocalSettlementLosesConcurrentGatePushCleanly(t *testing.T) if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != raced { t.Fatalf("gate branch = %s, want the concurrent push %s", got, raced) } + // The race refusal itself has to name an exit, not just the retry after it. + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("lost compare-and-swap next action = %#v", state.NextAction) + } + // The pre-CAS pin makes the retry this message used to prescribe refuse + // forever, so the refusal must point at the anchor instead of a re-run. + if strings.Contains(state.Error, "re-run the recovery") { + t.Fatalf("refusal still prescribes a retry that cannot succeed: %q", state.Error) + } + if !strings.Contains(state.Error, custody.RecoveryGateRef(f.run.ID)) { + t.Fatalf("refusal did not name the anchor to reconcile: %q", state.Error) + } // The lost swap leaves refs/no-mistakes/recover-gate/ pinned at the // head observed before the race, and nothing retires it. The retry the diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index a67378056..e23dbaaed 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -720,7 +720,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if keepLocal { gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") if err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) + return keepLocalBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) } return s.recoverKeepLocal(ctx, run, state, gateHead) } @@ -735,7 +735,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if keepLocal { gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") if err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) + return keepLocalBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) } return s.recoverKeepLocal(ctx, run, state, gateHead) } @@ -850,6 +850,18 @@ func anchoredElsewhere(pinned []string, ref string) string { // being clobbered. The kept head's objects reach the gate through a gate-side // fetch - never a push, which would fire the gate's receive hooks and start a // pipeline run. The preserved head stays reachable through the anchor ref. +// keepLocalBlocked is the one constructor for a keep-local refusal, because +// blockedPlan clears NextAction and every refusal on this path must still name +// an exit that completes. Manual reconciliation is that exit: the settlement +// is already the last resort, so a refusal here has nothing further to +// prescribe. Building it in one place is what keeps the guarantee true as new +// refusal sites appear. +func keepLocalBlocked(state State, safety, message string) State { + blocked := blockedPlan(state, StatePipelineOwned, safety, message) + blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} + return blocked +} + func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State, gateHead string) State { if s.beforeGateReset != nil { s.beforeGateReset() @@ -859,41 +871,39 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State gateAnchor := custody.RecoveryGateRef(run.ID) existing, exists, err := git.ExactRefTarget(ctx, s.GateDir, gateAnchor) if err != nil || (exists && existing != gateHead) { - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the independently moved gate head conflicts with the existing run recovery anchor; inspect both refs before returning custody; no files or branch refs were changed") - blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} - return blocked + return keepLocalBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head conflicts with the existing run recovery anchor; inspect both refs before returning custody; no files or branch refs were changed") } if !exists { err = custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead) } if err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; no files or branch refs were changed") + return keepLocalBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; no files or branch refs were changed") } } head, err := git.HeadSHA(ctx, s.workDir()) if err != nil || head != state.Local.Head { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") + return keepLocalBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") } // The fetch source must be absolute: the command runs inside the gate // directory, where a relative invoking-worktree path would resolve to // the gate itself. source, err := filepath.Abs(s.workDir()) if err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; no files or refs were changed") + return keepLocalBlocked(state, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; no files or refs were changed") } stagingRef := "refs/no-mistakes/custody-return/" + run.ID if _, err := git.Run(ctx, s.GateDir, "fetch", "--no-tags", "--no-write-fetch-head", source, "+refs/heads/"+state.Local.Branch+":"+stagingRef); err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; no files or refs were changed") + return keepLocalBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; no files or refs were changed") } staged, err := git.Run(ctx, s.GateDir, "rev-parse", stagingRef+"^{commit}") if err != nil || staged != state.Local.Head { _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") + return keepLocalBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") } _, casErr := git.Run(ctx, s.GateDir, "update-ref", "refs/heads/"+state.Local.Branch, state.Local.Head, gateHead) _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) if casErr != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_race", "the gate branch changed while custody was being returned; re-run the recovery; no local files or refs were changed") + return keepLocalBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; while the run recovery anchor %s still names the gate head this attempt observed, a further attempt refuses on that conflict, so reconcile that anchor against the live gate head before returning custody; no local files or refs were changed", custody.RecoveryGateRef(run.ID))) } } return s.finishRecover(ctx, run, false) From e59c7c19456d7e4dca408633b0874e94e651754b Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:18:08 -0400 Subject: [PATCH 08/31] no-mistakes(review): name a completable exit on every recovery refusal --- docs/src/content/docs/reference/cli.md | 2 +- internal/branchsync/recover_test.go | 121 +++++++++++++++++++++++++ internal/branchsync/sync.go | 74 +++++++++------ 3 files changed, 166 insertions(+), 31 deletions(-) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index f58d18f78..0d242d5e3 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -241,7 +241,7 @@ When you explicitly keep a behind or diverged local head instead of taking the p `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. -Every refusal on the keep-local path names `next_action.code: inspect_and_reconcile_manually`, including the conflicting displaced-gate-head anchor a lost compare-and-swap leaves behind, so a refusal is never a dead end even for a shape status advertised in error. +Every `--recover` refusal that leaves the branch held names a next action that can complete, so a refusal is never a dead end even for a shape status advertised in error: a still-active run points at `continue_active_run`, a dirty worktree at `inspect_worktree`, and every other refusal - including an unverifiable recorded head and the conflicting displaced-gate-head anchor a lost compare-and-swap leaves behind - at `inspect_and_reconcile_manually`. A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. `no-mistakes axi abort` on an already-terminal run stays an idempotent no-op - there is nothing left to cancel - but its response names that settlement command when the invoking worktree's branch is still held by that run. `no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 847f24472..e9854a1f1 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -1346,7 +1346,12 @@ func TestRecoverConcurrentGatePushLosesCleanly(t *testing.T) { mustWrite(t, filepath.Join(f.local, "rescope.txt"), "rescope\n") mustRun(t, f.local, "add", "rescope.txt") mustRun(t, f.local, "commit", "-m", "diverging rescope") + raced := false f.service.beforeGateReset = func() { + if raced { + return + } + raced = true writer := filepath.Join(t.TempDir(), "racer") mustRun(t, filepath.Dir(writer), "-c", "core.autocrlf=false", "clone", f.gate, writer) configureIdentity(t, writer) @@ -1363,6 +1368,25 @@ func TestRecoverConcurrentGatePushLosesCleanly(t *testing.T) { if f.custodyReturned() { t.Fatal("racing recover stamped custody") } + // The gate sat exactly at the recorded head, so no displaced-gate-head + // anchor was written and a retry genuinely succeeds. The refusal must say + // so rather than sending the operator to reconcile a ref that is absent. + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, custody.RecoveryGateRef(f.run.ID)); err != nil || exists { + t.Fatalf("fixture invariant broken: gate anchor exists=%v err=%v", exists, err) + } + if strings.Contains(state.Error, custody.RecoveryGateRef(f.run.ID)) { + t.Fatalf("refusal pointed at an anchor that was never written: %q", state.Error) + } + if !strings.Contains(state.Error, "re-run the recovery") { + t.Fatalf("refusal withheld the retry guidance that is correct here: %q", state.Error) + } + if state.NextAction == nil { + t.Fatalf("racing keep-local recover named no exit = %#v", state) + } + retry := f.service.Recover(f.ctx, true) + if !retry.Recovered { + t.Fatalf("the retry the refusal prescribes did not succeed = %#v", retry) + } } func TestRecoverRetryDoesNotOverwriteIndependentGateAnchor(t *testing.T) { @@ -2422,6 +2446,12 @@ func TestInspectDoesNotAdvertiseSettlementForSymbolicGateAnchor(t *testing.T) { if recovered.Recovered || recovered.Safety != "blocked_recover_preserve_failed" { t.Fatalf("keep-local with a symbolic gate anchor = %#v", recovered) } + // This is the acceptance criterion's own shape: the preserved commits + // still exist in the gate and cannot be anchored, so the refusal has to + // name an exit rather than strand the operator. + if recovered.NextAction == nil || recovered.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("unanchorable preserved head named no completable exit = %#v", recovered) + } if f.custodyReturned() { t.Fatal("a symbolic gate anchor stamped custody") } @@ -2472,3 +2502,94 @@ func TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicGateAnchor(t *test t.Fatalf("gate branch = %s, want the kept local head %s", got, f.submitted) } } + +// TestRecoverRefusalAlwaysNamesAnExit is the durable pin for the R1/R5 +// invariant the CLI reference states: a `--recover` that refuses while the +// branch stays held must name a next action the operator can actually run. +// blockedPlan nils NextAction, so every new refusal site starts out violating +// this; enumerating the sites one review round at a time is exactly how the +// documented claim kept outrunning the code, hence one table over real git +// states rather than four isolated assertions. +func TestRecoverRefusalAlwaysNamesAnExit(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + wantSafety string + setup func(t *testing.T) *recoverFixture + }{ + { + name: "no gate configured", + wantSafety: "blocked_recover_gate_unavailable", + setup: func(t *testing.T) *recoverFixture { + f := newRecoverFixture(t, types.RunCancelled) + f.service.GateDir = "" + return f + }, + }, + { + name: "unverified head with no gate", + wantSafety: "blocked_recover_unverified_head", + setup: func(t *testing.T) *recoverFixture { + f := newRecoverFixture(t, types.RunCancelled) + if err := f.db.UpdateRunStatus(f.run.ID, types.RunCancelled); err != nil { + t.Fatal(err) + } + run, err := f.db.GetRun(f.run.ID) + if err != nil || run == nil { + t.Fatalf("reload run: %#v, %v", run, err) + } + f.run = run + f.service.GateDir = "" + return f + }, + }, + { + name: "unverified head with an unreadable gate branch", + wantSafety: "blocked_recover_unverified_head", + setup: func(t *testing.T) *recoverFixture { + f := newRecoverFixture(t, types.RunCancelled) + if err := f.db.UpdateRunStatus(f.run.ID, types.RunCancelled); err != nil { + t.Fatal(err) + } + run, err := f.db.GetRun(f.run.ID) + if err != nil || run == nil { + t.Fatalf("reload run: %#v, %v", run, err) + } + f.run = run + mustRun(t, f.gate, "update-ref", "-d", "refs/heads/feature/recover") + return f + }, + }, + { + name: "the owning run is still active", + wantSafety: "blocked_recover_run_active", + setup: func(t *testing.T) *recoverFixture { + return newRecoverFixture(t, types.RunRunning) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + for _, keepLocal := range []bool{false, true} { + f := tc.setup(t) + state := f.service.Recover(f.ctx, keepLocal) + if state.Recovered { + t.Fatalf("keepLocal=%v recovered instead of refusing = %#v", keepLocal, state) + } + if state.Safety != tc.wantSafety { + t.Fatalf("keepLocal=%v safety = %q, want %q: %#v", keepLocal, state.Safety, tc.wantSafety, state) + } + if state.NextAction == nil || strings.TrimSpace(state.NextAction.Command) == "" { + t.Fatalf("keepLocal=%v refusal named no runnable exit = %#v", keepLocal, state) + } + if f.custodyReturned() { + t.Fatalf("keepLocal=%v refusal stamped custody", keepLocal) + } + } + }) + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index e23dbaaed..781b049d6 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -592,23 +592,25 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { return blockedPlan(state, state.State, "blocked_recover_not_applicable", "nothing to recover: the branch is not held by a terminal run with unpublished pipeline commits; no files or refs were changed") } if !terminalRunStatus(run.Status) { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_run_active", "the run that owns this branch is still active; drive it to completion or abort it first; no files or refs were changed") + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_run_active", "the run that owns this branch is still active; drive it to completion or abort it first; no files or refs were changed") + blocked.NextAction = &NextAction{Code: "continue_active_run", Command: "no-mistakes axi status"} + return blocked } if run.TerminalHeadVerifiedAt == nil { branch := state.Local.Branch if strings.TrimSpace(s.GateDir) == "" { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_unverified_head", "the terminal run has no verified head and no gate is available to prove preserved custody; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_unverified_head", "the terminal run has no verified head and no gate is available to prove preserved custody; no files or refs were changed") } gateHead, err := git.Run(ctx, s.GateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") if err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_unverified_head", "the terminal run has no verified head and the preserved gate head could not be read; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_unverified_head", "the terminal run has no verified head and the preserved gate head could not be read; no files or refs were changed") } if gateHead != run.HeadSHA { if !isAncestor(ctx, s.GateDir, run.HeadSHA, gateHead) { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_unverified_head", "the terminal run has no verified head and the gate head does not descend from the recorded head; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_unverified_head", "the terminal run has no verified head and the gate head does not descend from the recorded head; no files or refs were changed") } if err := s.DB.UpdateRunHeadSHA(run.ID, gateHead); err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_unverified_head", "the verified gate head could not be preserved; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_unverified_head", "the verified gate head could not be preserved; no files or refs were changed") } run.HeadSHA = gateHead state.Pipeline.CurrentHead = gateHead @@ -659,13 +661,13 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } if !gateAvailable { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", "no local gate is configured for this repository, so the preserved pipeline head cannot be imported; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_gate_unavailable", "no local gate is configured for this repository, so the preserved pipeline head cannot be imported; no files or refs were changed") } gateAnchor := custody.RecoveryRef(run.ID) gateAnchorAvailable := false gateAnchorTarget, gateAnchorExists, targetErr := git.ExactRefTarget(ctx, gateDir, gateAnchor) if targetErr != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", "the run recovery ref could not be inspected; inspect the recorded and live heads before returning custody; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_anchor_mismatch", "the run recovery ref could not be inspected; inspect the recorded and live heads before returning custody; no files or refs were changed") } if gateAnchorExists { gateAnchored, err := git.Run(ctx, gateDir, "rev-parse", gateAnchor+"^{commit}") @@ -691,7 +693,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserved_head_missing", fmt.Sprintf("the recorded pipeline head %s is missing from the local gate; inspect the recorded and live heads before returning custody; no files or refs were changed", preserved)) } if err := custody.PreserveRecoveryHead(ctx, gateDir, run.ID, preserved); err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the recorded pipeline head exists but could not be anchored in the local gate; no files or worktree refs were changed") + return recoverBlocked(state, "blocked_recover_preserve_failed", "the recorded pipeline head exists but could not be anchored in the local gate; no files or worktree refs were changed") } } @@ -701,7 +703,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } if !anchored { if fetchErr := git.FetchRemoteRef(ctx, wd, gateDir, gateAnchor, preserved); fetchErr != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be fetched from the local gate; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be fetched from the local gate; no files or refs were changed") } if preserveErr := custody.PreserveRecoveryAnchor(ctx, wd, anchorRef, preserved); preserveErr != nil { if keepLocal { @@ -720,7 +722,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if keepLocal { gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") if err != nil { - return keepLocalBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) + return recoverBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) } return s.recoverKeepLocal(ctx, run, state, gateHead) } @@ -735,7 +737,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if keepLocal { gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") if err != nil { - return keepLocalBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) + return recoverBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) } return s.recoverKeepLocal(ctx, run, state, gateHead) } @@ -844,66 +846,78 @@ func anchoredElsewhere(pinned []string, ref string) string { return fmt.Sprintf("; the recorded head is now anchored at %s in %s", ref, strings.Join(pinned, " and ")) } +// recoverBlocked is the one constructor for a recovery refusal that has no +// more specific exit to offer, because blockedPlan clears NextAction and a +// refusal naming no exit is itself the R1/R5 dead end this subsystem exists to +// remove. That principle does not depend on --keep-local: a default --recover +// that refuses strands the operator exactly as badly. Manual reconciliation is +// the honest exit here - recovery is already the recorded head's last resort, +// so a refusal has nothing further to prescribe. Building it in one place is +// what keeps the guarantee true as new refusal sites appear. +func recoverBlocked(state State, safety, message string) State { + blocked := blockedPlan(state, StatePipelineOwned, safety, message) + blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} + return blocked +} + // recoverKeepLocal performs the explicit keep-local custody return: the // worktree is never touched; the gate branch moves to the kept local head with // an atomic compare-and-swap so a concurrent gate push refuses instead of // being clobbered. The kept head's objects reach the gate through a gate-side // fetch - never a push, which would fire the gate's receive hooks and start a // pipeline run. The preserved head stays reachable through the anchor ref. -// keepLocalBlocked is the one constructor for a keep-local refusal, because -// blockedPlan clears NextAction and every refusal on this path must still name -// an exit that completes. Manual reconciliation is that exit: the settlement -// is already the last resort, so a refusal here has nothing further to -// prescribe. Building it in one place is what keeps the guarantee true as new -// refusal sites appear. -func keepLocalBlocked(state State, safety, message string) State { - blocked := blockedPlan(state, StatePipelineOwned, safety, message) - blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} - return blocked -} - func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State, gateHead string) State { if s.beforeGateReset != nil { s.beforeGateReset() } + gateHeadAnchored := false if gateHead != state.Local.Head { if gateHead != run.HeadSHA { gateAnchor := custody.RecoveryGateRef(run.ID) existing, exists, err := git.ExactRefTarget(ctx, s.GateDir, gateAnchor) if err != nil || (exists && existing != gateHead) { - return keepLocalBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head conflicts with the existing run recovery anchor; inspect both refs before returning custody; no files or branch refs were changed") + return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head conflicts with the existing run recovery anchor; inspect both refs before returning custody; no files or branch refs were changed") } if !exists { err = custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead) } if err != nil { - return keepLocalBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; no files or branch refs were changed") + return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; no files or branch refs were changed") } + gateHeadAnchored = true } head, err := git.HeadSHA(ctx, s.workDir()) if err != nil || head != state.Local.Head { - return keepLocalBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") } // The fetch source must be absolute: the command runs inside the gate // directory, where a relative invoking-worktree path would resolve to // the gate itself. source, err := filepath.Abs(s.workDir()) if err != nil { - return keepLocalBlocked(state, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; no files or refs were changed") } stagingRef := "refs/no-mistakes/custody-return/" + run.ID if _, err := git.Run(ctx, s.GateDir, "fetch", "--no-tags", "--no-write-fetch-head", source, "+refs/heads/"+state.Local.Branch+":"+stagingRef); err != nil { - return keepLocalBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; no files or refs were changed") } staged, err := git.Run(ctx, s.GateDir, "rev-parse", stagingRef+"^{commit}") if err != nil || staged != state.Local.Head { _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) - return keepLocalBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") } _, casErr := git.Run(ctx, s.GateDir, "update-ref", "refs/heads/"+state.Local.Branch, state.Local.Head, gateHead) _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) if casErr != nil { - return keepLocalBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; while the run recovery anchor %s still names the gate head this attempt observed, a further attempt refuses on that conflict, so reconcile that anchor against the live gate head before returning custody; no local files or refs were changed", custody.RecoveryGateRef(run.ID))) + // The displaced-gate-head anchor is written only above, when the + // gate had already moved off the recorded head. Without it a retry + // simply observes the new head and succeeds; with it, the retry + // hits the anchor conflict instead, so only then is reconciling + // the anchor the honest advice. + if gateHeadAnchored { + return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; no local files or refs were changed", custody.RecoveryGateRef(run.ID))) + } + return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no run recovery anchor was written, so re-run the recovery to return custody against the new gate head; no local files or refs were changed") } } return s.finishRecover(ctx, run, false) From 7867fc2cce61ef0958f4e99b25c17c5db5fc18c7 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:39:27 -0400 Subject: [PATCH 09/31] no-mistakes(review): narrow refusal-exit claims and name conflicting anchor --- docs/src/content/docs/reference/cli.md | 4 ++-- internal/branchsync/recover_test.go | 28 ++++++++++++++++++-------- internal/branchsync/sync.go | 6 +++++- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 0d242d5e3..c2a3b7499 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -236,12 +236,12 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. A lost compare-and-swap leaves that anchor pinned at the head the attempt observed and nothing retires it, so once the gate has moved on, later attempts refuse on the anchor conflict until you reconcile that ref in the gate by hand; the refusal names the ref and the gate directory. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. -Every `--recover` refusal that leaves the branch held names a next action that can complete, so a refusal is never a dead end even for a shape status advertised in error: a still-active run points at `continue_active_run`, a dirty worktree at `inspect_worktree`, and every other refusal - including an unverifiable recorded head and the conflicting displaced-gate-head anchor a lost compare-and-swap leaves behind - at `inspect_and_reconcile_manually`. +The settlement's own refusals, and every refusal raised while `--keep-local` moves the gate branch, name `next_action.code: inspect_and_reconcile_manually`. Other `--recover` refusals carry whatever next action their state already defines, and some carry none - including a missing recorded head under the default `--recover`, and a failure to stamp custody after the move itself succeeded. A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. `no-mistakes axi abort` on an already-terminal run stays an idempotent no-op - there is nothing left to cancel - but its response names that settlement command when the invoking worktree's branch is still held by that run. `no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index e9854a1f1..f787dcb7c 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -1425,6 +1425,17 @@ func TestRecoverRetryDoesNotOverwriteIndependentGateAnchor(t *testing.T) { if got := mustRun(t, f.gate, "rev-parse", anchor); got != firstGate { t.Fatalf("independent gate anchor = %s, want original %s", got, firstGate) } + // Nothing retires this anchor, so hand-reconciling it in the bare gate is + // the operator's only exit; the refusal has to say which ref and where. + if !strings.Contains(second.Error, anchor) { + t.Fatalf("refusal did not name the conflicting anchor: %q", second.Error) + } + if !strings.Contains(second.Error, f.gate) { + t.Fatalf("refusal did not name the gate holding that anchor: %q", second.Error) + } + if !strings.Contains(second.Error, firstGate) { + t.Fatalf("refusal did not name what the anchor holds: %q", second.Error) + } if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != secondGate { t.Fatalf("retry moved gate branch = %s, want %s", got, secondGate) } @@ -2503,14 +2514,15 @@ func TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicGateAnchor(t *test } } -// TestRecoverRefusalAlwaysNamesAnExit is the durable pin for the R1/R5 -// invariant the CLI reference states: a `--recover` that refuses while the -// branch stays held must name a next action the operator can actually run. -// blockedPlan nils NextAction, so every new refusal site starts out violating -// this; enumerating the sites one review round at a time is exactly how the -// documented claim kept outrunning the code, hence one table over real git -// states rather than four isolated assertions. -func TestRecoverRefusalAlwaysNamesAnExit(t *testing.T) { +// TestRecoverGateAndRunStateRefusalsNameAnExit pins the next action on exactly +// four refusal shapes: an absent gate, the two unverified-head guards, and a +// still-active run. It is deliberately NOT a general guarantee - blockedPlan +// nils NextAction, and several other refusals (a missing preserved head under +// the default --recover, the anchorReachablePreserved sites, finishRecover's +// stamp failure) still return none. Naming this test for the broad invariant +// would restate the claim that kept outrunning the code, so it names its +// shapes; extending the table is what proving a wider claim would require. +func TestRecoverGateAndRunStateRefusalsNameAnExit(t *testing.T) { t.Parallel() cases := []struct { diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 781b049d6..efe2801a6 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -876,7 +876,11 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State gateAnchor := custody.RecoveryGateRef(run.ID) existing, exists, err := git.ExactRefTarget(ctx, s.GateDir, gateAnchor) if err != nil || (exists && existing != gateHead) { - return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head conflicts with the existing run recovery anchor; inspect both refs before returning custody; no files or branch refs were changed") + conflict := "could not be read" + if err == nil { + conflict = "names " + existing + } + return recoverBlocked(state, "blocked_recover_preserve_failed", fmt.Sprintf("the independently moved gate head %s conflicts with the existing run recovery anchor %s in the local gate %s, which %s; nothing retires that anchor, so reconcile it there before returning custody; no files or branch refs were changed", gateHead, gateAnchor, s.GateDir, conflict)) } if !exists { err = custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead) From 39fc187b100234c68e5f8ab9bef09ba0e3e06174 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:55:15 -0400 Subject: [PATCH 10/31] no-mistakes(review): correct settlement guidance and name stranded anchor ref --- docs/src/content/docs/guides/agents.md | 3 ++- internal/skill/skill.go | 3 ++- skills/no-mistakes/SKILL.md | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/guides/agents.md b/docs/src/content/docs/guides/agents.md index 1d54e2eb1..60306d184 100644 --- a/docs/src/content/docs/guides/agents.md +++ b/docs/src/content/docs/guides/agents.md @@ -178,7 +178,8 @@ no-mistakes axi abort --run Before any post-pipeline local commit or fresh run, read `branch_sync`. Only when its structured `next_action.code` is `sync`, run `no-mistakes axi sync` first. When `next_action.code` is `recover_custody` - a terminal run left unpublished pipeline commits preserved in the local gate - run `no-mistakes axi sync --recover` to return custody, or `no-mistakes rerun` to resume validating the preserved head. -When `next_action.code` is `return_custody_keep_local` - a terminal run whose recorded pipeline head can no longer be verified - run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. +When `next_action.code` is `return_custody_keep_local` - a terminal run whose recorded pipeline head cannot be verified, either because no reachable object store still has it or because the run's own recovery evidence names a different commit - run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. +That head may still exist and still carry unpushed pipeline commits, so the settlement anchors every surviving copy at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it; read it there, not at `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: it is immediately usable and needs no sync action. When `next_action.code` is `continue_active_run`, run the reported command and keep driving the active run. If synchronization is blocked, process that state instead of improvising reset, stash, merge, rebase, force, or branch replacement. diff --git a/internal/skill/skill.go b/internal/skill/skill.go index c1083dbe0..9c4f70da6 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -242,7 +242,8 @@ Recovery takes that head by fast-forward, or by adopting a diverged preserved he That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours. A ` + "`branch_sync.state`" + ` of ` + "`user_owned`" + ` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated ` + "`--recover`" + ` there is a harmless no-op. A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; ` + "`--keep-local`" + ` keeps your current head while the preserved commits stay anchored under ` + "`refs/no-mistakes/recover/`" + `. -When ` + "`next_action.code`" + ` is ` + "`return_custody_keep_local`" + `, the terminal run's recorded pipeline head can no longer be verified in your worktree or the local gate, so there is no preserved head to take: run ` + "`no-mistakes axi sync --recover --keep-local`" + ` to return custody at your current head and point the gate branch at it. +When ` + "`next_action.code`" + ` is ` + "`return_custody_keep_local`" + `, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run ` + "`no-mistakes axi sync --recover --keep-local`" + ` to return custody at your current head and point the gate branch at it. +That head may still exist and still carry unpushed pipeline commits, so the settlement first anchors every surviving copy at ` + "`refs/no-mistakes/recover-stranded/`" + ` before moving the gate branch off it; read it there, NOT at ` + "`refs/no-mistakes/recover/`" + `, which in this state is the conflicting evidence and can name an entirely different commit. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. After synchronization, commit the follow-up on top and re-run ` + "`no-mistakes axi run --intent \"...\"`" + ` with the original user intent. This preserves every prior gate-fix commit regardless of its configured subject. diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index 4d82b9825..e912806d9 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -242,7 +242,8 @@ Recovery takes that head by fast-forward, or by adopting a diverged preserved he That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours. A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated `--recover` there is a harmless no-op. A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/`. -When `next_action.code` is `return_custody_keep_local`, the terminal run's recorded pipeline head can no longer be verified in your worktree or the local gate, so there is no preserved head to take: run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. +When `next_action.code` is `return_custody_keep_local`, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. +That head may still exist and still carry unpushed pipeline commits, so the settlement first anchors every surviving copy at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it; read it there, NOT at `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. After synchronization, commit the follow-up on top and re-run `no-mistakes axi run --intent "..."` with the original user intent. This preserves every prior gate-fix commit regardless of its configured subject. From 7a4be00eb6dd1b3400dacafd30d1a5a2c6ca82bf Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:14:01 -0400 Subject: [PATCH 11/31] no-mistakes(review): scope stranded-anchor guidance and custody abort help --- docs/src/content/docs/guides/agents.md | 3 +- internal/cli/axi_abort_custody_test.go | 71 ++++++++++++++++++++++++++ internal/cli/axi_drive.go | 7 ++- internal/cli/axi_guidance_test.go | 7 +-- internal/skill/skill.go | 3 +- skills/no-mistakes/SKILL.md | 3 +- 6 files changed, 86 insertions(+), 8 deletions(-) diff --git a/docs/src/content/docs/guides/agents.md b/docs/src/content/docs/guides/agents.md index 60306d184..37c300ec4 100644 --- a/docs/src/content/docs/guides/agents.md +++ b/docs/src/content/docs/guides/agents.md @@ -179,7 +179,8 @@ Before any post-pipeline local commit or fresh run, read `branch_sync`. Only when its structured `next_action.code` is `sync`, run `no-mistakes axi sync` first. When `next_action.code` is `recover_custody` - a terminal run left unpublished pipeline commits preserved in the local gate - run `no-mistakes axi sync --recover` to return custody, or `no-mistakes rerun` to resume validating the preserved head. When `next_action.code` is `return_custody_keep_local` - a terminal run whose recorded pipeline head cannot be verified, either because no reachable object store still has it or because the run's own recovery evidence names a different commit - run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. -That head may still exist and still carry unpushed pipeline commits, so the settlement anchors every surviving copy at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it; read it there, not at `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. +If any copy of that head still exists, the settlement anchors it at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under `NM_HOME` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. +That anchor is not `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: it is immediately usable and needs no sync action. When `next_action.code` is `continue_active_run`, run the reported command and keep driving the active run. If synchronization is blocked, process that state instead of improvising reset, stash, merge, rebase, force, or branch replacement. diff --git a/internal/cli/axi_abort_custody_test.go b/internal/cli/axi_abort_custody_test.go index 154fe7a72..1c959e613 100644 --- a/internal/cli/axi_abort_custody_test.go +++ b/internal/cli/axi_abort_custody_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/kunchenguid/no-mistakes/internal/branchsync" "github.com/kunchenguid/no-mistakes/internal/db" "github.com/kunchenguid/no-mistakes/internal/git" "github.com/kunchenguid/no-mistakes/internal/ipc" @@ -271,3 +272,73 @@ func startNoActiveRunDaemon(t *testing.T, p *paths.Paths) { } t.Fatal("fake daemon did not become reachable") } + +// TestBareAbortNoOpEmitsNoHelpForOrdinaryDivergence pins the custody scope of +// the abort help. inspect_and_reconcile_manually is not custody-specific - +// classifyRelation emits it for ordinary divergence with a `git log` command - +// so a bare abort on a branch no run holds must stay silent rather than answer +// with unrelated reconciliation advice. +func TestBareAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { + runID, p, local := wedgedCustodyAbortFixture(t) + root := filepath.Dir(local) + + // A real pipeline push binding, then local work that conflicts with it, so + // classification lands on ordinary divergence rather than pipeline custody. + cliGit(t, local, "checkout", "-b", "pipeline-pushed") + if err := os.WriteFile(filepath.Join(local, "file.txt"), []byte("pipeline rewrite\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, local, "commit", "-am", "pipeline rewrite") + pushed := cliGit(t, local, "rev-parse", "HEAD") + cliGit(t, local, "checkout", "feature/wedged") + if err := os.WriteFile(filepath.Join(local, "file.txt"), []byte("local rewrite\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, local, "commit", "-am", "local rewrite") + + database, err := db.Open(p.DB()) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunPushBinding(runID, db.PushBinding{ + HeadSHA: pushed, + TargetKind: "upstream", + TargetFingerprint: branchsync.TargetFingerprint(filepath.Join(root, "remote.git")), + Ref: "refs/heads/feature/wedged", + }); err != nil { + t.Fatal(err) + } + if err := database.SetRunCustodyReturned(runID); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + startNoActiveRunDaemon(t, p) + + out, err := executeCmd("axi", "abort") + t.Logf("bare abort on a diverged, unheld branch:\n%s", out) + if err != nil { + t.Fatalf("bare abort with no active run must be a no-op success: %v\n%s", err, out) + } + if !strings.Contains(out, "aborted: false") { + t.Errorf("bare abort no-op output missing %q:\n%s", "aborted: false", out) + } + if !strings.Contains(out, "safety: blocked_diverged") { + t.Fatalf("fixture did not reach ordinary divergence:\n%s", out) + } + // The structured branch_sync object still REPORTS the branch's own + // next_action; that is ownership state, not a prescription. Only the + // abort's own help prescribes, and here it must prescribe nothing. + if !strings.Contains(out, "code: inspect_and_reconcile_manually") { + t.Errorf("branch_sync stopped reporting the branch's own next action:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "help[") { + t.Errorf("abort emitted help for a branch no run holds:\n%s", line) + } + if strings.HasPrefix(strings.TrimSpace(line), "Run `git log") { + t.Errorf("abort prescribed unrelated divergence advice:\n%s", line) + } + } +} diff --git a/internal/cli/axi_drive.go b/internal/cli/axi_drive.go index e4794542e..db80cc0aa 100644 --- a/internal/cli/axi_drive.go +++ b/internal/cli/axi_drive.go @@ -968,8 +968,11 @@ func runAxiAbort(cmd *cobra.Command, runID string) error { } // The branch a terminal run still holds must leave with a command that // can settle it, not just the no-op (issue #824) - but only a custody - // settlement, never an instruction to launch a fresh pipeline. - if help := custodySettlementHelp(state.NextAction); len(help) > 0 { + // settlement, never an instruction to launch a fresh pipeline. The + // pipeline_owned gate is what makes that true: inspect_and_reconcile_manually + // is also emitted for ordinary divergence, where it is git-log advice + // rather than a custody settlement and belongs on no abort response. + if help := custodySettlementHelp(state.NextAction); len(help) > 0 && state.State == branchsync.StatePipelineOwned { fields = append(fields, toon.Field{Key: "help", Value: help}) } emitDoc(cmd, fields...) diff --git a/internal/cli/axi_guidance_test.go b/internal/cli/axi_guidance_test.go index 696087a58..bb642c038 100644 --- a/internal/cli/axi_guidance_test.go +++ b/internal/cli/axi_guidance_test.go @@ -50,9 +50,10 @@ var canonicalBranchSyncPhrases = []string{ "user_owned", "before changing the submitted head", // Settlement of a self-inconsistent custody record (issue #824): a record - // whose recorded pipeline head can no longer be verified has no preserved - // head to take, so every surface must name the keep-local settlement that - // can actually complete instead of a recovery that always refuses. + // whose recorded pipeline head cannot be verified - absent from every + // object store, or contradicted by the run's own recovery evidence - must + // name the keep-local settlement that can actually complete instead of a + // recovery that always refuses. "return_custody_keep_local", "no-mistakes axi sync --recover --keep-local", } diff --git a/internal/skill/skill.go b/internal/skill/skill.go index 9c4f70da6..26ad52a6f 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -243,7 +243,8 @@ That proof is deliberately narrow, so a rebase whose fix rounds also rewrote you A ` + "`branch_sync.state`" + ` of ` + "`user_owned`" + ` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated ` + "`--recover`" + ` there is a harmless no-op. A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; ` + "`--keep-local`" + ` keeps your current head while the preserved commits stay anchored under ` + "`refs/no-mistakes/recover/`" + `. When ` + "`next_action.code`" + ` is ` + "`return_custody_keep_local`" + `, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run ` + "`no-mistakes axi sync --recover --keep-local`" + ` to return custody at your current head and point the gate branch at it. -That head may still exist and still carry unpushed pipeline commits, so the settlement first anchors every surviving copy at ` + "`refs/no-mistakes/recover-stranded/`" + ` before moving the gate branch off it; read it there, NOT at ` + "`refs/no-mistakes/recover/`" + `, which in this state is the conflicting evidence and can name an entirely different commit. +If any copy of that head still exists, the settlement anchors it at ` + "`refs/no-mistakes/recover-stranded/`" + ` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under ` + "`NM_HOME`" + ` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. +That anchor is NOT ` + "`refs/no-mistakes/recover/`" + `, which in this state is the conflicting evidence and can name an entirely different commit. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. After synchronization, commit the follow-up on top and re-run ` + "`no-mistakes axi run --intent \"...\"`" + ` with the original user intent. This preserves every prior gate-fix commit regardless of its configured subject. diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index e912806d9..e2d5c9a7f 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -243,7 +243,8 @@ That proof is deliberately narrow, so a rebase whose fix rounds also rewrote you A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated `--recover` there is a harmless no-op. A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/`. When `next_action.code` is `return_custody_keep_local`, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. -That head may still exist and still carry unpushed pipeline commits, so the settlement first anchors every surviving copy at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it; read it there, NOT at `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. +If any copy of that head still exists, the settlement anchors it at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under `NM_HOME` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. +That anchor is NOT `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. After synchronization, commit the follow-up on top and re-run `no-mistakes axi run --intent "..."` with the original user intent. This preserves every prior gate-fix commit regardless of its configured subject. From 1ace9ce31a34ac5afc252cda0e97687f404e34c3 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:59:37 -0400 Subject: [PATCH 12/31] fix: scope run-abort custody help and align anchor probe with its write Two review findings, both narrowly scoped: - The pipeline_owned gate added to the bare-abort help was never applied to the two `axi abort --run ` sites. Their only guard was state.Pipeline.RunID != runID, which proves the branch resolves to that run, not that a run still holds it - so a released, diverged branch reached custodySettlementHelp and answered an abort with `git log` reconciliation advice the bare site had already been taught not to emit. Both conditions are now required, matching the bare site. - settlementAnchorsFree predicts whether PreserveRecoveryAnchor can write an anchor, but that write refuses any symbolic ref first while the probe used ExactRefTarget alone. A dangling symref is invisible to for-each-ref yet visible to symbolic-ref, so the probe reported "free" for a ref the settlement can never write, and inspection advertised a settlement that refuses every time. The probe now mirrors recoveryAnchorCompatible, covering both the stranded and gate anchors. Each fix has a regression test that fails without it. Co-Authored-By: Claude Opus 5 --- internal/branchsync/recover_test.go | 40 +++++++++++ internal/branchsync/sync.go | 8 +++ internal/cli/axi_abort_custody_test.go | 91 ++++++++++++++++++++++++++ internal/cli/axi_drive.go | 7 +- 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index f787dcb7c..52d7c48e8 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2605,3 +2605,43 @@ func TestRecoverGateAndRunStateRefusalsNameAnExit(t *testing.T) { }) } } + +// TestInspectDoesNotAdvertiseSettlementForSymbolicStrandedAnchor is the review +// regression for the probe/write disagreement. settlementAnchorsFree predicts +// whether custody.PreserveRecoveryAnchor can write an anchor, but that write +// refuses ANY symbolic ref as its first check, while the probe used +// git.ExactRefTarget alone - and a DANGLING symref is invisible to +// for-each-ref while symbolic-ref still succeeds. So the probe reported "free" +// for a ref the settlement can never write, and inspection advertised a +// settlement that refuses on every attempt. +func TestInspectDoesNotAdvertiseSettlementForSymbolicStrandedAnchor(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + // The conflicting-gate-anchor shape: the recorded head is still in the + // gate, so the settlement's pin loop will try to write the stranded anchor + // there. + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) + strandedRef := custody.RecoveryStrandedRef(f.run.ID) + mustRun(t, f.gate, "symbolic-ref", strandedRef, "refs/no-mistakes/nonexistent/"+f.run.ID) + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, strandedRef); err != nil || exists { + t.Fatalf("fixture invariant broken: dangling symref is visible to for-each-ref (exists=%v err=%v)", exists, err) + } + + state := f.service.InspectCached(f.ctx) + if state.NextAction != nil && state.NextAction.Code == "return_custody_keep_local" { + t.Fatalf("advertised a settlement whose stranded anchor can never be written: %#v", state.NextAction) + } + + // The advertisement must match what recovery actually does. + recovered := f.service.Recover(f.ctx, true) + if recovered.Recovered || recovered.Safety != "blocked_recover_preserve_failed" { + t.Fatalf("keep-local with a symbolic stranded anchor = %#v", recovered) + } + if f.custodyReturned() { + t.Fatal("a symbolic stranded anchor stamped custody") + } + if got := mustRun(t, f.gate, "symbolic-ref", strandedRef); got != "refs/no-mistakes/nonexistent/"+f.run.ID { + t.Fatalf("symbolic evidence was rewritten = %s", got) + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index efe2801a6..834af0346 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -1742,6 +1742,14 @@ func (s *Service) settlementGateBranchUsable(ctx context.Context, state *State, // only refuse falls back to manual reconciliation. func (s *Service) settlementAnchorsFree(ctx context.Context, state *State, run *db.Run, gateDir, preserved string) bool { anchorFreeAt := func(dir, ref, want string) bool { + // PreserveRecoveryAnchor refuses ANY symbolic ref as its first check, + // and a dangling symref is invisible to for-each-ref while + // symbolic-ref still resolves it - so probing with ExactRefTarget + // alone reports "free" for a ref the settlement can never write. + // Mirror recoveryAnchorCompatible so the probe agrees with the write. + if symbolic, symErr := git.Run(ctx, dir, "symbolic-ref", "-q", ref); symErr == nil && symbolic != "" { + return false + } target, exists, err := git.ExactRefTarget(ctx, dir, ref) if err != nil { return false diff --git a/internal/cli/axi_abort_custody_test.go b/internal/cli/axi_abort_custody_test.go index 1c959e613..7690a2829 100644 --- a/internal/cli/axi_abort_custody_test.go +++ b/internal/cli/axi_abort_custody_test.go @@ -342,3 +342,94 @@ func TestBareAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { } } } + +// TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergence is the sibling of +// TestBareAbortNoOpEmitsNoHelpForOrdinaryDivergence for the two `--run ` +// abort sites. Their only guard is state.Pipeline.RunID != runID, which proves +// the branch RESOLVES to that run - not that a run still holds it - so a +// diverged, already-released branch still reached custodySettlementHelp and +// answered an abort with `git log` reconciliation advice the bare site had +// already been taught not to emit. +func TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { + runID, _, _ := divergedReleasedBranchFixture(t) + + t.Run("daemon unavailable", func(t *testing.T) { + out, err := executeCmd("axi", "abort", "--run", runID) + t.Logf("daemon-down --run abort on a diverged, unheld branch:\n%s", out) + if err != nil { + t.Fatalf("terminal run must resolve idempotently: %v\n%s", err, out) + } + assertNoAbortHelpEmitted(t, out) + }) +} + +// TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergenceWithDaemon covers the +// same guard on the daemon-up resolveInactiveAbortTruth path. +func TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergenceWithDaemon(t *testing.T) { + runID, p, _ := divergedReleasedBranchFixture(t) + startInactiveAbortDaemon(t, p, runID) + + out, err := executeCmd("axi", "abort", "--run", runID) + t.Logf("daemon-up --run abort on a diverged, unheld branch:\n%s", out) + if err != nil { + t.Fatalf("terminal run must resolve idempotently: %v\n%s", err, out) + } + assertNoAbortHelpEmitted(t, out) +} + +// divergedReleasedBranchFixture builds a branch whose run pushed successfully +// and whose custody was already returned, then diverges it locally, so +// classification lands on ordinary divergence rather than pipeline custody +// while the branch still resolves to that run. +func divergedReleasedBranchFixture(t *testing.T) (string, *paths.Paths, string) { + t.Helper() + runID, p, local := wedgedCustodyAbortFixture(t) + root := filepath.Dir(local) + + cliGit(t, local, "checkout", "-b", "pipeline-pushed") + if err := os.WriteFile(filepath.Join(local, "file.txt"), []byte("pipeline rewrite\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, local, "commit", "-am", "pipeline rewrite") + pushed := cliGit(t, local, "rev-parse", "HEAD") + cliGit(t, local, "checkout", "feature/wedged") + if err := os.WriteFile(filepath.Join(local, "file.txt"), []byte("local rewrite\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, local, "commit", "-am", "local rewrite") + + database, err := db.Open(p.DB()) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunPushBinding(runID, db.PushBinding{ + HeadSHA: pushed, + TargetKind: "upstream", + TargetFingerprint: branchsync.TargetFingerprint(filepath.Join(root, "remote.git")), + Ref: "refs/heads/feature/wedged", + }); err != nil { + t.Fatal(err) + } + if err := database.SetRunCustodyReturned(runID); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + return runID, p, local +} + +func assertNoAbortHelpEmitted(t *testing.T, out string) { + t.Helper() + if !strings.Contains(out, "aborted: false") { + t.Errorf("abort no-op output missing %q:\n%s", "aborted: false", out) + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "help[") { + t.Errorf("abort emitted help for a branch no run holds:\n%s", line) + } + if strings.HasPrefix(strings.TrimSpace(line), "Run `git log") { + t.Errorf("abort prescribed unrelated divergence advice:\n%s", line) + } + } +} diff --git a/internal/cli/axi_drive.go b/internal/cli/axi_drive.go index db80cc0aa..c2cdabfc1 100644 --- a/internal/cli/axi_drive.go +++ b/internal/cli/axi_drive.go @@ -327,7 +327,12 @@ func terminalRunCustodyHelpWithDB(ctx context.Context, p *paths.Paths, d *db.DB, Paths: p, } state := service.InspectCached(ctx) - if state.Pipeline.RunID != runID { + // Resolving to this run is not the same as being HELD by it: a released + // branch still resolves to its last run, and inspect_and_reconcile_manually + // is not custody-specific (classifyRelation emits it for ordinary + // divergence with a `git log` command). Both conditions are required, which + // is the same gate the bare-abort site applies. + if state.Pipeline.RunID != runID || state.State != branchsync.StatePipelineOwned { return nil } return custodySettlementHelp(state.NextAction) From 7a0e7a766075a0c65ecc0b78dad5b819543f947e Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:27:38 -0400 Subject: [PATCH 13/31] fix(branchsync): make custody-settlement refusals leave nothing behind Closes the merge blockers the maintainer named on #883, plus the three smaller published gaps. recoverKeepLocal wrote refs/no-mistakes/recover-gate/ before four refusals that each reported "no files or refs were changed" - and that ref is exactly what wedges every later attempt, so those refusals could recreate the condition this settlement exists to clear. The anchor guards one operation, the compare-and-swap that moves the gate branch off the displaced head, and nothing before that swap can strand it because refs/heads/ still names it. So the write moved to immediately before the swap and every refusal that can precede it now leaves nothing behind by construction rather than by a cleanup that could itself fail. The read-only anchor conflict check stays first. One post-write refusal remains, the lost swap, deliberately: it failed because the gate moved, so that pin may be the only ref still naming the displaced head. The TUI keyed its u action on blocked_pipeline_owned_recoverable while a self-inconsistent record carries the settlement next action, so it was the one operator surface with no exit. It now reaches the same settlement as the CLI, keyed on the advertised next action so it cannot drift from the predicate deciding where the settlement can complete, behind its own confirmation because settling KEEPS the local head where recovery takes the preserved one. The settlement's data-safety argument is "nothing still has this head, so nothing can be lost", which only a proven absence supports. Its probe read any non-zero git exit as absent, so an unreadable store and a present non-commit object both settled the record while the head was still there. git.CommitPresence now treats only exit 1 as absence; everything else refuses, and settlementAnchorsFree mirrors it so the advertisement agrees with the write. finishRecover's stamp failure runs after the Git side already succeeded and nilled NextAction, leaving a moved gate branch with no named exit - the same shape one layer down. It now reports that the changes are applied and names the same recovery command, which completes because every Git step it repeats is idempotent once applied. Also: --keep-local help no longer claims the gate always compare-and-swaps, since the equal/ahead, user_owned, already-recovered and absent-gate-branch paths return first; and the two --run abort tests regain the fixture invariants that prove they exercise the StatePipelineOwned clause - their responses carry no branch_sync object, so they could not have failed even with that clause deleted. Refs #824. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U --- .../branch-sync-and-push-safety/SKILL.md | 11 +- docs/src/content/docs/reference/cli.md | 8 +- internal/branchsync/recover_test.go | 420 ++++++++++++++++++ internal/branchsync/sync.go | 173 ++++++-- internal/cli/axi_abort_custody_test.go | 33 ++ internal/cli/sync.go | 12 +- internal/git/git.go | 40 ++ internal/tui/app.go | 7 + internal/tui/branch_sync.go | 40 ++ internal/tui/branch_sync_test.go | 119 +++++ internal/tui/commands.go | 20 + internal/tui/keys.go | 24 + internal/tui/view.go | 3 + 13 files changed, 870 insertions(+), 40 deletions(-) diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index 5a5d56c3e..50d37ae70 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -11,15 +11,18 @@ metadata: - `sync`, `axi sync`, and the TUI `u` action share one service whose only ordinary worktree mutation is a clean guarded move to an exact freshly verified pipeline push binding: strict fast-forward for behind branches, or an anchored reset to an equivalent diverged pipeline head when local unique work is already represented there. Under `--recover`, the worktree can only strict-fast-forward to the gate-preserved head, or adopt a diverged preserved head that `preservedContainsLocalWork` proves carries every local change. Passive status never fetches, and blocked states never reset, stash, merge, rebase, force, switch, delete, or update an external remote. - Give each network remote operation its own bounded child context derived from the caller: `Refresh` must not share one deadline across sequential `git.LsRemote` and `git.FetchRemoteBranchToPrivateRef` calls, and `Apply` uses the same per-operation budget for its final live check. The per-operation budget is `Service.RemoteTimeout`, sourced only from the operator's global `branch_sync_remote_timeout` setting (default `config.DefaultBranchSyncRemoteTimeout`, 60s); `RepoConfig` deliberately has no matching field. `Recover`'s local-gate fetch is outside this network deadline contract. Regressions: `TestRefreshSlowSuccessfulLsRemoteDoesNotStealFetchBudget`, `TestRefreshSlowButSuccessfulLsRemoteAloneExceedsItsOwnBudgetReportsOffline`, `TestRefreshRaisedRemoteTimeoutAcceptsTheSameLegitimateSlowLsRemote`, `TestRefreshParentCancellationStopsFetchAfterLsRemoteSucceeds`, `TestServiceRemoteTimeoutDefaultsToConfigDefault`, `TestLoadGlobal_InvalidBranchSyncRemoteTimeout`, `TestLoadRepo_BranchSyncRemoteTimeoutIsNotARepoSetting`. - Successful pipeline pushes persist the exact SHA, credential-free target fingerprint/ref, and generation; legacy rows remain nullable and must never infer provenance from mutable `head_sha`. Structured PR lifecycle retires merged/closed branches. The service rechecks the invoking worktree, target, live remote equality, ancestry or equivalent-divergence proof, generation, and all mutable assumptions immediately before apply. -- A TERMINAL run with unpublished pipeline commits (moved head) is recoverable only from verified, non-conflicting evidence: inspection and `Recover` share one eligibility model. Equal/ahead local ancestry can create the local anchor without requiring gate access, but available gate evidence must agree; importing a missing preserved head requires exact or safely anchorable gate evidence, a clean worktree, and either ancestry or the content-preservation proof below. Only then does inspection report `blocked_pipeline_owned_recoverable` + `next_action recover_custody` with the exact submitted/current-head and relation facts (active runs keep the plain block). Missing, non-commit, symbolic, or conflicting evidence, and import cases that are dirty or genuinely divergent, fail closed with manual reconciliation instead. `sync --recover` anchors the preserved head at `refs/no-mistakes/recover/` before stamping `runs.custody_returned_at`. +- A TERMINAL run with unpublished pipeline commits (moved head) is recoverable only from verified, non-conflicting evidence: inspection and `Recover` share one eligibility model. Equal/ahead local ancestry can create the local anchor without requiring gate access, but available gate evidence must agree; importing a missing preserved head requires exact or safely anchorable gate evidence, a clean worktree, and either ancestry or the content-preservation proof below. Only then does inspection report `blocked_pipeline_owned_recoverable` + `next_action recover_custody` with the exact submitted/current-head and relation facts (active runs keep the plain block). Missing, non-commit, symbolic, or conflicting evidence, and import cases that are dirty or genuinely divergent, never advertise `recover_custody`; where the record is self-inconsistent (missing, non-commit, or conflicting evidence) inspection names the keep-local settlement below, and every other shape falls back to manual reconciliation. `sync --recover` anchors the preserved head at `refs/no-mistakes/recover/` before stamping `runs.custody_returned_at`. Cancellation RELEASES a terminal run that never changed the submitted head (`head_sha == submitted_head_sha`, no push, no custody stamp): selection keeps it visible so it never misreports as `blocked_wrong_branch`, and it classifies `user_owned` - no `next_action`, non-blocking exit, never represented as recoverable custody, `--recover` there is an idempotent no-op that mutates nothing, and a fresh `axi run` or separately authorized direct push is never blocked. Equal/ahead worktrees anchor locally without requiring gate access, but an available gate's existing recovery ref must agree with the recorded head; behind/diverged worktrees verify and fetch the preserved head from the run-specific recovery ref, fast-forwarding only a clean behind worktree. A cancelled validation routinely leaves a preserved head that is a REBASE of the local branch, which equality and ancestry read as plain divergence, so a clean diverged worktree is adopted when `preservedContainsLocalWork` proves containment. That proof is an executable `merge-tree` three-way merge whose result must equal the preserved head's tree, anchored on the merge-base - never `runs.base_sha`, the previous gate head. It deliberately does NOT use patch identity: patch IDs discard hunk locations and whitespace, so they cannot tell a genuine replay from a same-shaped edit to another identical block, and a containment claim built on them is not a proof. Everything undecidable escalates, including a rebase whose fix rounds also rewrote operator lines, where nothing separates a deliberate fix from a dropped change. Adoption anchors the pre-recovery local head at `refs/no-mistakes/recover-local/`, then moves the branch with Git operations that fail closed on their own rather than after an observation - an atomic `update-ref` CAS plus `read-tree -m -u`, never check-then-act followed by `reset --hard`, which destroys anything landing in the gap. `recoverAdoptPreserved` owns the reasoning. - Terminalization pins every verified unpublished head at `refs/no-mistakes/recover/` before the managed worktree can be removed. Recovery reads that run-specific ref rather than requiring the gate branch to match, so aborts, rebases, and pre-push failures remain recoverable while an independently moved gate branch is preserved. Legacy recorded heads that still exist as dangling gate objects are anchored on recovery; a truly missing recorded head reports a distinct manual-reconciliation action instead of advertising an impossible `recover_custody` command. - When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. + Terminalization pins every verified unpublished head at `refs/no-mistakes/recover/` before the managed worktree can be removed. Recovery reads that run-specific ref rather than requiring the gate branch to match, so aborts, rebases, and pre-push failures remain recoverable while an independently moved gate branch is preserved. Legacy recorded heads that still exist as dangling gate objects are anchored on recovery; a truly missing recorded head never advertises an impossible `recover_custody` command and instead reports the keep-local settlement below, falling back to manual reconciliation whenever that settlement is itself unreachable. + When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, already-recovered, and absent-gate-branch paths return custody without changing any ref, and the flag help must keep saying so. + `--keep-local` is also the ONLY settlement for a SELF-INCONSISTENT custody record (#824): a terminal run whose recorded pipeline head is in no reachable object store, or whose own recovery evidence names something else, has nothing verifiable to import, so every default recovery refused, `abort` of the terminal run was a no-op, and the branch stayed `pipeline_owned` forever. Inspection names that exit (`next_action.code: return_custody_keep_local`) instead of #814's dead-end manual-reconciliation pointer, terminal-run `abort` responses carry the same command, and the TUI `u` action reaches the same settlement - keyed on that advertised next action, never on a safety code, so it cannot drift from the predicate that decides where the settlement can complete. #814's polarity (never advertise `recover_custody` for an unverifiable record) is preserved. Terminal-run no-op abort help is separately allowlisted to custody-settlement codes only (`custodySettlementHelp`) AND gated on `StatePipelineOwned`, so an abort that cancelled nothing never answers by prescribing a fresh run against a released branch, nor by repeating ordinary-divergence `git log` advice; a SUCCESSFUL cancellation still echoes the branch's own next action. + Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure runs AFTER the Git side already succeeded, so it reports that the changes are applied and names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Advertising any of those recreates the very wedge this change removes. + Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref behind - which is what makes their "no files or refs were changed" report true by construction rather than by a cleanup that could itself fail. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. The full relation matrix and fail-safe rules live in the `Recover` doc comment in `internal/branchsync/sync.go`. -- Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`), `internal/cli/sync_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. +- Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. **Post-Review Head Continuity and Push Binding** diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index c2a3b7499..a5041cb23 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -205,7 +205,7 @@ no-mistakes axi sync --recover --keep-local | -------------- | ------ | ------- | ---------------------------------------------------------------------------- | | `--check` | `bool` | `false` | Verify the live target and exact plan without changing `HEAD` | | `--recover` | `bool` | `false` | Return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch) | -| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and points the gate branch at the kept head | +| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and where the gate branch still names a different head, points it at the kept head | The default command is an explicit non-interactive apply request and never prompts. All modes return the complete `branch_sync` object as TOON. @@ -236,12 +236,12 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. A lost compare-and-swap leaves that anchor pinned at the head the attempt observed and nothing retires it, so once the gate has moved on, later attempts refuse on the anchor conflict until you reconcile that ref in the gate by hand; the refusal names the ref and the gate directory. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without changing any ref. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. The anchor is written immediately before the compare-and-swap, so every refusal that can precede it leaves no anchor behind and its "no files or refs were changed" report is exact. A lost compare-and-swap does leave that anchor pinned at the head the attempt observed, because the gate has moved and the anchor may be the only ref still naming the displaced head; nothing retires it, so once the gate has moved on, later attempts refuse on the anchor conflict until you reconcile that ref in the gate by hand. That refusal names the ref and the gate directory, and reports that no *local* files or refs were changed. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. -The settlement's own refusals, and every refusal raised while `--keep-local` moves the gate branch, name `next_action.code: inspect_and_reconcile_manually`. Other `--recover` refusals carry whatever next action their state already defines, and some carry none - including a missing recorded head under the default `--recover`, and a failure to stamp custody after the move itself succeeded. +The settlement's own refusals, and every refusal raised while `--keep-local` moves the gate branch, name `next_action.code: inspect_and_reconcile_manually`. Other `--recover` refusals carry whatever next action their state already defines, and some carry none - including a missing recorded head under the default `--recover`. A failure to record custody AFTER the move itself succeeded is not a refusal and is reported as `safety: blocked_recover_stamp_failed`: the Git changes are already applied, so it says so and names the same recovery command again (`recover_custody`, or `return_custody_keep_local` for `--keep-local`), which completes the record because every Git step it repeats is idempotent once applied. A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. `no-mistakes axi abort` on an already-terminal run stays an idempotent no-op - there is nothing left to cancel - but its response names that settlement command when the invoking worktree's branch is still held by that run. `no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back. @@ -370,7 +370,7 @@ no-mistakes sync --recover --keep-local | `--check` | `bool` | `false` | Verify and print the fresh plan without changing `HEAD` | | `-y`, `--yes` | `bool` | `false` | Apply an eligible guarded synchronization without an interactive prompt | | `--recover` | `bool` | `false` | Return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch) | -| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and points the gate branch at the kept head | +| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and where the gate branch still names a different head, points it at the kept head | Without `--yes`, apply prints the exact full-SHA plan and requires TTY confirmation; `--recover` prompts the same way before returning custody. A non-TTY apply or recovery refuses with a direct `--yes` hint. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 52d7c48e8..a77941c7d 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2645,3 +2645,423 @@ func TestInspectDoesNotAdvertiseSettlementForSymbolicStrandedAnchor(t *testing.T t.Fatalf("symbolic evidence was rewritten = %s", got) } } + +// assertKeepLocalRefusalLeftNothing is the shared check behind the four +// pre-swap refusals in recoverKeepLocal. Each of them tells the operator that +// no files or refs were changed, and the displaced-gate-head anchor is the one +// ref they could previously have left behind - the same ref whose staleness +// then makes settlementAnchorsFree stop advertising the settlement and +// PreserveRecoveryAnchor refuse every later attempt, which is the #824 wedge +// this settlement exists to clear. A refusal that recreates it while claiming +// to have changed nothing is the defect, so every such path is pinned here. +func assertKeepLocalRefusalLeftNothing(t *testing.T, f *recoverFixture, state State, staleGate string) { + t.Helper() + if state.Recovered || state.Changed { + t.Fatalf("pre-swap refusal did not refuse = %#v", state) + } + if f.custodyReturned() { + t.Fatal("pre-swap refusal stamped custody returned") + } + if !strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("refusal did not make the no-change claim under test: %q", state.Error) + } + anchor := custody.RecoveryGateRef(f.run.ID) + target, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, anchor) + if err != nil { + t.Fatalf("read %s: %v", anchor, err) + } + if exists { + t.Fatalf("refusal claiming no refs changed left %s behind at %s", anchor, target) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != staleGate { + t.Fatalf("refusal moved the gate branch = %s, want %s", got, staleGate) + } + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, "refs/no-mistakes/custody-return/"+f.run.ID); err != nil || exists { + t.Fatalf("refusal left the staging ref behind: exists=%v err=%v", exists, err) + } + // The wedge is only proven cleared if the settlement can still complete, + // so every case re-runs the recovery with its inducer removed. + f.service.beforeGateReset = nil + f.service.beforeGateStage = nil + f.service.absPathFn = nil + retry := f.service.Recover(f.ctx, true) + if !retry.Recovered { + t.Fatalf("settlement stayed wedged after a refusal that changed nothing = %#v", retry) + } +} + +// TestRecoverKeepLocalMovedLocalHeadRefusalLeavesNoGateAnchor covers the +// local-head re-read refusal. +func TestRecoverKeepLocalMovedLocalHeadRefusalLeavesNoGateAnchor(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + moved := false + f.service.beforeGateReset = func() { + if moved { + return + } + moved = true + mustWrite(t, filepath.Join(f.local, "raced.txt"), "local moved mid-recovery\n") + mustRun(t, f.local, "add", "raced.txt") + mustRun(t, f.local, "commit", "-m", "operator commit during recovery") + } + + state := f.service.Recover(f.ctx, true) + if state.Safety != "blocked_recover_assumptions_changed" { + t.Fatalf("moved-local-head refusal = %#v", state) + } + assertKeepLocalRefusalLeftNothing(t, f, state, staleGate) +} + +// TestRecoverKeepLocalUnresolvableWorktreeRefusalLeavesNoGateAnchor covers the +// worktree-path resolution refusal. +func TestRecoverKeepLocalUnresolvableWorktreeRefusalLeavesNoGateAnchor(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + f.service.absPathFn = func(string) (string, error) { + return "", errors.New("working directory has been removed") + } + + state := f.service.Recover(f.ctx, true) + if state.Safety != "blocked_recover_assumptions_changed" { + t.Fatalf("unresolvable-worktree refusal = %#v", state) + } + if !strings.Contains(state.Error, "could not be resolved") { + t.Fatalf("refusal did not name the resolution failure: %q", state.Error) + } + assertKeepLocalRefusalLeftNothing(t, f, state, staleGate) +} + +// TestRecoverKeepLocalFailedStagingRefusalLeavesNoGateAnchor covers the +// staging-fetch refusal. The fetch is failed by occupying the staging ref's +// name with a deeper ref, which Git refuses to turn into a directory. +func TestRecoverKeepLocalFailedStagingRefusalLeavesNoGateAnchor(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + blocker := "refs/no-mistakes/custody-return/" + f.run.ID + "/blocked" + f.service.beforeGateStage = func() { + mustRun(t, f.gate, "update-ref", blocker, staleGate) + } + + state := f.service.Recover(f.ctx, true) + if state.Safety != "blocked_recover_assumptions_changed" { + t.Fatalf("failed-staging refusal = %#v", state) + } + if !strings.Contains(state.Error, "could not be staged") { + t.Fatalf("refusal did not name the staging failure: %q", state.Error) + } + // The blocker is the operator's own ref, so the retry cannot succeed while + // it stands; clear it exactly as an operator would before re-running. + defer func() { mustRun(t, f.gate, "update-ref", "-d", blocker) }() + f.service.beforeGateStage = nil + if state.Recovered || state.Changed { + t.Fatalf("failed-staging refusal did not refuse = %#v", state) + } + if f.custodyReturned() { + t.Fatal("failed-staging refusal stamped custody returned") + } + if !strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("refusal did not make the no-change claim under test: %q", state.Error) + } + anchor := custody.RecoveryGateRef(f.run.ID) + if target, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, anchor); err != nil || exists { + t.Fatalf("refusal claiming no refs changed left %s behind at %s (err=%v)", anchor, target, err) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != staleGate { + t.Fatalf("refusal moved the gate branch = %s, want %s", got, staleGate) + } + mustRun(t, f.gate, "update-ref", "-d", blocker) + retry := f.service.Recover(f.ctx, true) + if !retry.Recovered { + t.Fatalf("settlement stayed wedged after a refusal that changed nothing = %#v", retry) + } +} + +// TestRecoverKeepLocalStagedHeadMismatchRefusalLeavesNoGateAnchor covers the +// staged-head verification refusal: the branch moves after the local head was +// re-read, so the gate stages a commit the operator never chose to keep. +func TestRecoverKeepLocalStagedHeadMismatchRefusalLeavesNoGateAnchor(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + moved := false + f.service.beforeGateStage = func() { + if moved { + return + } + moved = true + mustWrite(t, filepath.Join(f.local, "raced.txt"), "local moved after the head re-read\n") + mustRun(t, f.local, "add", "raced.txt") + mustRun(t, f.local, "commit", "-m", "operator commit after the head re-read") + } + + state := f.service.Recover(f.ctx, true) + if state.Safety != "blocked_recover_assumptions_changed" { + t.Fatalf("staged-head-mismatch refusal = %#v", state) + } + assertKeepLocalRefusalLeftNothing(t, f, state, staleGate) +} + +// TestRecoverKeepLocalLostAnchorWriteRefusalLeavesNoGateAnchor covers the last +// refusal that can precede the compare-and-swap: the anchor CONFLICT check is +// read-only and runs first, so the write itself happens immediately before the +// swap and can still lose a race to whoever occupied the ref in between. +// PreserveRecoveryAnchor refuses to retarget an anchor naming another commit, +// so nothing of ours is written - and the refusal has to leave the staging ref +// behind no more than it leaves an anchor of its own. +func TestRecoverKeepLocalLostAnchorWriteRefusalLeavesNoGateAnchor(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + anchor := custody.RecoveryGateRef(f.run.ID) + // Some other commit entirely, so PreserveRecoveryAnchor sees a conflict + // rather than an idempotent re-write. + intruder := f.base + if intruder == staleGate || intruder == "" { + t.Fatalf("fixture invariant broken: need a third commit distinct from the gate head %s", staleGate) + } + occupied := false + f.service.beforeGateStage = func() { + if occupied { + return + } + occupied = true + mustRun(t, f.gate, "update-ref", anchor, intruder) + } + + state := f.service.Recover(f.ctx, true) + if state.Safety != "blocked_recover_preserve_failed" { + t.Fatalf("lost-anchor-write refusal = %#v", state) + } + if state.Recovered || state.Changed { + t.Fatalf("lost-anchor-write refusal did not refuse = %#v", state) + } + if f.custodyReturned() { + t.Fatal("lost-anchor-write refusal stamped custody returned") + } + if !strings.Contains(state.Error, "no files or branch refs were changed") { + t.Fatalf("refusal did not make the no-change claim under test: %q", state.Error) + } + // The intruder's anchor is not ours to retire, so it must survive exactly + // as found - but nothing of ours may be left beside it. + if got := mustRun(t, f.gate, "rev-parse", anchor); got != intruder { + t.Fatalf("refusal rewrote an anchor it did not create = %s, want %s", got, intruder) + } + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, "refs/no-mistakes/custody-return/"+f.run.ID); err != nil || exists { + t.Fatalf("refusal left the staging ref behind: exists=%v err=%v", exists, err) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != staleGate { + t.Fatalf("refusal moved the gate branch = %s, want %s", got, staleGate) + } + // Clearing the intruding anchor is the exit the refusal prescribes, and it + // has to actually work. + mustRun(t, f.gate, "update-ref", "-d", anchor) + f.service.beforeGateStage = nil + retry := f.service.Recover(f.ctx, true) + if !retry.Recovered { + t.Fatalf("settlement stayed wedged after reconciling the anchor = %#v", retry) + } +} + +// TestSettlementRefusesWhenRecordedHeadAbsenceCannotBeProven pins the +// settlement's data-safety argument to actual evidence. The argument is "no +// reachable store still has this head, so settling cannot lose it" - which is +// a claim about PROVEN absence. A bare presence probe cannot make that claim: +// `git cat-file -e` exits 1 only when the store was read and the object really +// is not there, and exits 128 for every store or name it could not resolve, so +// reading any non-zero exit as "absent" skips the pin on evidence that was +// never gathered and then settles anyway. Undetermined must refuse, and the +// advertisement has to agree so the settlement is never offered where it can +// only refuse. +func TestSettlementRefusesWhenRecordedHeadAbsenceCannotBeProven(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + // A recorded head Git cannot resolve to an object name at all: every store + // answers 128, so absence is undetermined rather than proven. + unresolvable := "not-a-resolvable-object-name" + if err := f.db.UpdateRunStatusWithVerifiedHead(f.run.ID, types.RunFailed, unresolvable); err != nil { + t.Fatal(err) + } + run, err := f.db.GetRun(f.run.ID) + if err != nil || run == nil { + t.Fatalf("reload run: %#v, %v", run, err) + } + f.run = run + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Changed { + t.Fatalf("settlement completed without proving the recorded head absent = %#v", state) + } + if state.Safety != "blocked_recover_preserve_failed" { + t.Fatalf("undetermined-absence safety = %q: %#v", state.Safety, state) + } + if f.custodyReturned() { + t.Fatal("settlement stamped custody without proving the recorded head absent") + } + if !strings.Contains(state.Error, "could not be determined") { + t.Fatalf("refusal did not name the undetermined probe: %q", state.Error) + } + if state.NextAction == nil { + t.Fatalf("undetermined-absence refusal named no exit = %#v", state) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != staleGate { + t.Fatalf("refusal moved the gate branch = %s, want %s", got, staleGate) + } + // The advertisement must agree with the write, or the record is offered a + // settlement that can only refuse - the exact #824 shape. + inspected := f.service.InspectCached(f.ctx) + if inspected.NextAction != nil && inspected.NextAction.Code == "return_custody_keep_local" { + t.Fatalf("inspection advertised a settlement that always refuses = %#v", inspected.NextAction) + } +} + +// TestSettlementRefusesWhenRecordedHeadIsPresentButNotACommit closes the other +// half of the proven-absence rule. `git cat-file -e` exits 0 for ANY object, so +// a recorded head that resolves to a tree, blob, or tag is present in the store +// while not being a commit. Reporting that as a plain "not a commit" sends it +// back through the same door as a proven absence, and the settlement would then +// move the gate on the strength of "nothing still has this head" while the +// object is right there. Present-but-wrong-type is undetermined, not absent. +func TestSettlementRefusesWhenRecordedHeadIsPresentButNotACommit(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + tree := mustRun(t, f.local, "rev-parse", "HEAD^{tree}") + if tree == "" { + t.Fatal("fixture invariant broken: no tree to record") + } + // Present in the invoking worktree's store, and not a commit. + if !objectPresentInStore(t, f.local, tree) { + t.Fatalf("fixture invariant broken: tree %s is not present in %s", tree, f.local) + } + if err := f.db.UpdateRunStatusWithVerifiedHead(f.run.ID, types.RunFailed, tree); err != nil { + t.Fatal(err) + } + run, err := f.db.GetRun(f.run.ID) + if err != nil || run == nil { + t.Fatalf("reload run: %#v, %v", run, err) + } + f.run = run + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Changed { + t.Fatalf("settlement treated a present non-commit object as an absent head = %#v", state) + } + if f.custodyReturned() { + t.Fatal("settlement stamped custody while the recorded object was still present") + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != staleGate { + t.Fatalf("refusal moved the gate branch = %s, want %s", got, staleGate) + } + // And the advertisement has to agree, or the record is offered a + // settlement that can only refuse. + inspected := f.service.InspectCached(f.ctx) + if inspected.NextAction != nil && inspected.NextAction.Code == "return_custody_keep_local" { + t.Fatalf("inspection advertised a settlement that always refuses = %#v", inspected.NextAction) + } +} + +// objectPresentInStore is deliberately the raw presence question - any object +// type - so the test asserts its own fixture rather than trusting the predicate +// under test. +func objectPresentInStore(t *testing.T, dir, sha string) bool { + t.Helper() + _, err := gitpkg.Run(context.Background(), dir, "cat-file", "-e", sha) + return err == nil +} + +// TestKeepLocalStampFailureAfterTheGateMovedNamesACompletableRetry is the +// post-SUCCESS failure window, which a search for refusals does not surface. +// Once the gate compare-and-swap lands, the settlement's Git work is done and +// only the database stamp remains; if that write fails, the run is left with a +// moved gate branch, a written recovery anchor, and no custody record. Nilling +// NextAction there reproduced the #824 shape one layer down - refs changed and +// nothing named that could end it. The retry has to be named structurally, and +// it has to actually work. +func TestKeepLocalStampFailureAfterTheGateMovedNamesACompletableRetry(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + stampCalls := 0 + f.service.stampCustodyReturnedFn = func(string) error { + stampCalls++ + return errors.New("database is locked") + } + + state := f.service.Recover(f.ctx, true) + if state.Recovered { + t.Fatalf("failed stamp reported custody returned = %#v", state) + } + if stampCalls != 1 { + t.Fatalf("stamp calls = %d, want 1", stampCalls) + } + if state.Safety != "blocked_recover_stamp_failed" { + t.Fatalf("stamp-failure safety = %q: %#v", state.Safety, state) + } + if f.custodyReturned() { + t.Fatal("failed stamp recorded custody anyway") + } + // The Git side really did complete, which is exactly why the state must + // not claim otherwise and must not dead-end. + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.submitted { + t.Fatalf("gate branch = %s, want the kept local head %s", got, f.submitted) + } + if got := mustRun(t, f.gate, "rev-parse", custody.RecoveryGateRef(f.run.ID)); got != staleGate { + t.Fatalf("displaced gate head anchor = %s, want %s", got, staleGate) + } + if strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("stamp failure claimed nothing changed after moving the gate: %q", state.Error) + } + if state.NextAction == nil { + t.Fatalf("stamp failure named no exit at all = %#v", state) + } + if state.NextAction.Code != "return_custody_keep_local" { + t.Fatalf("stamp failure named %q, want the settlement it was performing", state.NextAction.Code) + } + if !strings.Contains(state.NextAction.Command, "--keep-local") { + t.Fatalf("stamp-failure retry command = %q", state.NextAction.Command) + } + + // The prescribed retry must complete, not refuse: the gate now already + // equals the kept head, so the settlement's whole move is skipped. + f.service.stampCustodyReturnedFn = nil + retry := f.service.Recover(f.ctx, true) + if !retry.Recovered { + t.Fatalf("the retry the stamp failure prescribes did not complete = %#v", retry) + } + if !f.custodyReturned() { + t.Fatal("the prescribed retry did not record custody") + } +} + +// TestDefaultRecoveryStampFailureNamesItsOwnRetry keeps the same guarantee on +// the default --recover path, whose worktree has already moved by then. +func TestDefaultRecoveryStampFailureNamesItsOwnRetry(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + f.service.stampCustodyReturnedFn = func(string) error { + return errors.New("database is locked") + } + + state := f.service.Recover(f.ctx, false) + if state.Safety != "blocked_recover_stamp_failed" { + t.Fatalf("default-recovery stamp-failure safety = %q: %#v", state.Safety, state) + } + if state.NextAction == nil || state.NextAction.Code != "recover_custody" { + t.Fatalf("default-recovery stamp failure named %#v, want recover_custody", state.NextAction) + } + if strings.Contains(state.NextAction.Command, "--keep-local") { + t.Fatalf("default recovery prescribed the keep-local settlement: %q", state.NextAction.Command) + } + + f.service.stampCustodyReturnedFn = nil + retry := f.service.Recover(f.ctx, false) + if !retry.Recovered { + t.Fatalf("the retry the stamp failure prescribes did not complete = %#v", retry) + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 834af0346..aed270519 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -154,8 +154,20 @@ type Service struct { lsRemote func(context.Context, string, string, string) (string, error) fetchRemote func(context.Context, string, string, string, string) error + // absPath resolves the invoking worktree to an absolute path. It is a + // seam because filepath.Abs fails only when the process working directory + // has been removed, which cannot be induced in-process without breaking + // every parallel test - and that refusal path has to prove it leaves no + // recovery anchor behind. + absPathFn func(string) (string, error) + + // stampCustodyReturnedFn is the recovery's database write. Nil uses the + // production DB. + stampCustodyReturnedFn func(string) error + beforeApply func() beforeGateReset func() + beforeGateStage func() beforeRecoverWorktreeMove func() beforeRecoverBranchMove func() afterRecoverBranchMove func() @@ -657,7 +669,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", "the run recovery ref in the local gate conflicts with the recorded pipeline head; inspect both objects before returning custody; no files or refs were changed") } } - return s.finishRecover(ctx, run, false) + return s.finishRecover(ctx, run, false, keepLocal) } if !gateAvailable { @@ -717,7 +729,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { case local == preserved, isAncestor(ctx, wd, preserved, local): // Equal or ahead, discovered only after anchoring made the preserved // head comparable locally. - return s.finishRecover(ctx, run, false) + return s.finishRecover(ctx, run, false, keepLocal) case isAncestor(ctx, wd, local, preserved): if keepLocal { gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") @@ -803,7 +815,22 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st {"the invoking worktree", s.workDir()}, {"the local gate", gateDir}, } { - if store.dir == "" || !objectExists(ctx, store.dir, preserved) { + if store.dir == "" { + continue + } + // The settlement's whole data-safety argument is "no store still has + // this head, so settling cannot lose it". That is a claim about proven + // absence, and a bare presence probe cannot make it: it reads an + // unreadable store exactly like an empty one and would skip the pin on + // evidence it never actually gathered. Only git.CommitPresence's exit-1 + // answer proves absence; anything else is undetermined and refuses. + present, err := git.CommitPresence(ctx, store.dir, preserved) + if err != nil { + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", fmt.Sprintf("whether the recorded pipeline head %s still exists could not be determined in %s, so its absence cannot be proven and custody cannot be settled without risking it; inspect that object store before retrying; no branch, worktree, or file changes were made%s", preserved, store.name, anchoredElsewhere(pinned, strandedRef))) + blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} + return blocked + } + if !present { continue } if err := custody.PreserveRecoveryAnchor(ctx, store.dir, strandedRef, preserved); err != nil { @@ -814,7 +841,7 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st pinned = append(pinned, store.name) } if gateDir == "" { - return s.finishRecover(ctx, run, false) + return s.finishRecover(ctx, run, false, true) } gateBranchRef := "refs/heads/" + state.Local.Branch _, gateBranchExists, err := git.ExactRefTarget(ctx, gateDir, gateBranchRef) @@ -826,7 +853,7 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st if !gateBranchExists { // Proven absent: the gate holds no branch ref for this run, so nothing // there holds custody and there is no ref to compare-and-swap. - return s.finishRecover(ctx, run, false) + return s.finishRecover(ctx, run, false, true) } gateHead, err := git.Run(ctx, gateDir, "rev-parse", gateBranchRef+"^{commit}") if err != nil { @@ -866,14 +893,39 @@ func recoverBlocked(state State, safety, message string) State { // being clobbered. The kept head's objects reach the gate through a gate-side // fetch - never a push, which would fire the gate's receive hooks and start a // pipeline run. The preserved head stays reachable through the anchor ref. +// +// Write ordering is load-bearing, not incidental. The displaced-gate-head +// anchor (RecoveryGateRef) is the ref whose staleness wedges every later +// attempt: settlementAnchorsFree stops advertising the settlement once it +// names a commit the gate has since moved off, PreserveRecoveryAnchor then +// refuses to retarget it, and nothing in the product retires it. So a refusal +// that leaves it behind recreates exactly the #824 dead end this settlement +// exists to clear - while telling the operator that nothing changed. +// +// The anchor therefore protects exactly one operation: the compare-and-swap +// that moves the gate branch off gateHead. Nothing before that swap can strand +// gateHead, because refs/heads/ still names it. So every check that can +// refuse - the local head re-read, the worktree path resolution, the staging +// fetch, and the staged-head verification - runs BEFORE the anchor is written, +// and the write happens immediately before the swap it guards. That keeps +// "no files or refs were changed" true on every one of those paths by +// construction rather than by a cleanup that could itself fail. +// +// The anchor CONFLICT check is deliberately still read-only and still runs +// first: it is the cheapest refusal and it must not be reached only after a +// staging ref exists. Once the swap has been attempted, the anchor is load +// bearing and is never retired - a failed compare-and-swap means the gate +// moved, so gateHead may now be reachable through the anchor alone. func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State, gateHead string) State { if s.beforeGateReset != nil { s.beforeGateReset() } gateHeadAnchored := false if gateHead != state.Local.Head { + gateAnchor := "" + writeGateAnchor := false if gateHead != run.HeadSHA { - gateAnchor := custody.RecoveryGateRef(run.ID) + gateAnchor = custody.RecoveryGateRef(run.ID) existing, exists, err := git.ExactRefTarget(ctx, s.GateDir, gateAnchor) if err != nil || (exists && existing != gateHead) { conflict := "could not be read" @@ -882,13 +934,11 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State } return recoverBlocked(state, "blocked_recover_preserve_failed", fmt.Sprintf("the independently moved gate head %s conflicts with the existing run recovery anchor %s in the local gate %s, which %s; nothing retires that anchor, so reconcile it there before returning custody; no files or branch refs were changed", gateHead, gateAnchor, s.GateDir, conflict)) } - if !exists { - err = custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead) - } - if err != nil { - return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; no files or branch refs were changed") - } + // An anchor that already names this head needs no write, but it + // still guards the swap, so the race refusal below must know it + // is there. gateHeadAnchored = true + writeGateAnchor = !exists } head, err := git.HeadSHA(ctx, s.workDir()) if err != nil || head != state.Local.Head { @@ -897,12 +947,18 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // The fetch source must be absolute: the command runs inside the gate // directory, where a relative invoking-worktree path would resolve to // the gate itself. - source, err := filepath.Abs(s.workDir()) + source, err := s.absPath(s.workDir()) if err != nil { return recoverBlocked(state, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; no files or refs were changed") } + if s.beforeGateStage != nil { + s.beforeGateStage() + } stagingRef := "refs/no-mistakes/custody-return/" + run.ID if _, err := git.Run(ctx, s.GateDir, "fetch", "--no-tags", "--no-write-fetch-head", source, "+refs/heads/"+state.Local.Branch+":"+stagingRef); err != nil { + // A partly completed fetch can still have created the staging ref, + // and the refusal claims nothing was left behind. + _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) return recoverBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; no files or refs were changed") } staged, err := git.Run(ctx, s.GateDir, "rev-parse", stagingRef+"^{commit}") @@ -910,6 +966,14 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") } + // Point of no return: from here the gate branch is about to leave + // gateHead, so the anchor has to exist first. + if writeGateAnchor { + if err := custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead); err != nil { + _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) + return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; no files or branch refs were changed") + } + } _, casErr := git.Run(ctx, s.GateDir, "update-ref", "refs/heads/"+state.Local.Branch, state.Local.Head, gateHead) _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) if casErr != nil { @@ -917,14 +981,16 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // gate had already moved off the recorded head. Without it a retry // simply observes the new head and succeeds; with it, the retry // hits the anchor conflict instead, so only then is reconciling - // the anchor the honest advice. + // the anchor the honest advice. It is never retired here: the swap + // failed because the gate moved, so gateHead may now be reachable + // through this anchor alone. if gateHeadAnchored { return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; no local files or refs were changed", custody.RecoveryGateRef(run.ID))) } return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no run recovery anchor was written, so re-run the recovery to return custody against the new gate head; no local files or refs were changed") } } - return s.finishRecover(ctx, run, false) + return s.finishRecover(ctx, run, false, true) } // recoverFastForward advances the clean checked-out branch to the preserved @@ -958,7 +1024,7 @@ func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state Sta state.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"} return state } - return s.finishRecover(ctx, run, true) + return s.finishRecover(ctx, run, true, false) } // preservedContainsLocalWork proves the preserved pipeline head already carries @@ -1120,7 +1186,7 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state state.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"} return state } - return s.finishRecover(ctx, run, true) + return s.finishRecover(ctx, run, true, false) } func (s *Service) anchorReachablePreserved(ctx context.Context, state State, runID, preserved string) (State, bool) { @@ -1136,13 +1202,24 @@ func (s *Service) anchorReachablePreserved(ctx context.Context, state State, run // finishRecover stamps custody returned and reports the fresh post-recovery // truth. changed reports whether this call moved the worktree HEAD. -func (s *Service) finishRecover(ctx context.Context, run *db.Run, changed bool) State { - if err := s.DB.SetRunCustodyReturned(run.ID); err != nil { +// finishRecover records the custody return. Its failure branch is the one +// place in recovery where the Git side has ALREADY succeeded - a keep-local +// settlement has moved the gate branch, a fast-forward or adoption has moved +// the worktree - and only the database write failed. It therefore must never +// be a dead end: leaving NextAction nil there reproduces the #824 shape one +// layer down, a state that changed refs and then names nothing that can end +// it. Re-running the SAME recovery command is genuinely completable, because +// every Git step it repeats is idempotent once applied: the gate branch now +// equals the kept head so recoverKeepLocal skips its whole move, and an +// already-advanced worktree takes the equal/ahead path. That is why the retry +// is named structurally here and not only in prose. +func (s *Service) finishRecover(ctx context.Context, run *db.Run, changed, keepLocal bool) State { + if err := s.stampCustodyReturned(run.ID); err != nil { state, _, _ := s.inspect(ctx) state.Changed = changed state.Safety = "blocked_recover_stamp_failed" - state.Error = "the custody return could not be recorded; re-run the recovery" - state.NextAction = nil + state.Error = "the recovery's Git changes are already applied, but the custody return could not be recorded; re-run the same recovery to complete the record" + state.NextAction = recoveryRetryAction(keepLocal) return state } state, _, _ := s.inspect(ctx) @@ -1151,6 +1228,25 @@ func (s *Service) finishRecover(ctx context.Context, run *db.Run, changed bool) return state } +// recoveryRetryAction names the exact command that finishes an interrupted +// recovery: the one the operator already ran. +func recoveryRetryAction(keepLocal bool) *NextAction { + if keepLocal { + return &NextAction{Code: "return_custody_keep_local", Command: "no-mistakes axi sync --recover --keep-local"} + } + return &NextAction{Code: "recover_custody", Command: "no-mistakes axi sync --recover"} +} + +// stampCustodyReturned is the recovery's only database write, behind a seam so +// its failure - the one post-success failure recovery has - is reachable in a +// test without corrupting a shared database. +func (s *Service) stampCustodyReturned(runID string) error { + if s.stampCustodyReturnedFn != nil { + return s.stampCustodyReturnedFn(runID) + } + return s.DB.SetRunCustodyReturned(runID) +} + func recoverAnchorRef(runID string) string { return custody.RecoveryRef(runID) } @@ -1463,6 +1559,15 @@ func (s *Service) remoteName(ctx context.Context) string { return "origin" } +// absPath resolves a path through the service's seam so the keep-local +// custody return can prove what it does when that resolution fails. +func (s *Service) absPath(path string) (string, error) { + if s.absPathFn != nil { + return s.absPathFn(path) + } + return filepath.Abs(path) +} + func (s *Service) workDir() string { if strings.TrimSpace(s.WorkDir) == "" { return "." @@ -1735,11 +1840,14 @@ func (s *Service) settlementGateBranchUsable(ctx context.Context, state *State, // the settlement refuse permanently. // // The gate ref is where recoverKeepLocal pins an independently moved gate head -// before its compare-and-swap. It is written BEFORE the swap, so a settlement -// that lost one race leaves it pinned at the head observed then; once the gate -// has moved on, that stale pin conflicts with every later attempt and nothing -// retires it. Probing both keeps the advertisement honest: a record that can -// only refuse falls back to manual reconciliation. +// immediately before its compare-and-swap. Every refusal that can precede that +// write leaves the ref absent, so only a settlement that lost the swap race +// itself leaves it pinned at the head observed then - and there it is load +// bearing, because the gate has moved and that pin may be the only thing still +// naming the displaced head. Once the gate moves on again, that pin conflicts +// with every later attempt and nothing retires it. Probing both keeps the +// advertisement honest: a record that can only refuse falls back to manual +// reconciliation. func (s *Service) settlementAnchorsFree(ctx context.Context, state *State, run *db.Run, gateDir, preserved string) bool { anchorFreeAt := func(dir, ref, want string) bool { // PreserveRecoveryAnchor refuses ANY symbolic ref as its first check, @@ -1766,7 +1874,18 @@ func (s *Service) settlementAnchorsFree(ctx context.Context, state *State, run * stranded := custody.RecoveryStrandedRef(run.ID) if preserved != "" { for _, dir := range []string{s.workDir(), gateDir} { - if dir == "" || !objectExists(ctx, dir, preserved) { + if dir == "" { + continue + } + // Mirror the pin loop's probe, not a weaker one: a store that + // cannot answer makes the settlement refuse, so advertising it + // here would restore the advertised-action-that-always-refuses + // shape this whole change exists to remove. + present, err := git.CommitPresence(ctx, dir, preserved) + if err != nil { + return false + } + if !present { continue } if !anchorFreeAt(dir, stranded, preserved) { diff --git a/internal/cli/axi_abort_custody_test.go b/internal/cli/axi_abort_custody_test.go index 7690a2829..1cb74db83 100644 --- a/internal/cli/axi_abort_custody_test.go +++ b/internal/cli/axi_abort_custody_test.go @@ -352,6 +352,7 @@ func TestBareAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { // already been taught not to emit. func TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { runID, _, _ := divergedReleasedBranchFixture(t) + assertDivergedReleasedFixtureInvariants(t) t.Run("daemon unavailable", func(t *testing.T) { out, err := executeCmd("axi", "abort", "--run", runID) @@ -367,6 +368,7 @@ func TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { // same guard on the daemon-up resolveInactiveAbortTruth path. func TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergenceWithDaemon(t *testing.T) { runID, p, _ := divergedReleasedBranchFixture(t) + assertDivergedReleasedFixtureInvariants(t) startInactiveAbortDaemon(t, p, runID) out, err := executeCmd("axi", "abort", "--run", runID) @@ -419,6 +421,37 @@ func divergedReleasedBranchFixture(t *testing.T) (string, *paths.Paths, string) return runID, p, local } +// assertDivergedReleasedFixtureInvariants proves what the `--run` abort sites +// cannot show from their own output. Their guard is +// `custodySettlementHelp(state.NextAction) != empty AND state == +// StatePipelineOwned`, but a `--run` response carries no branch_sync object at +// all, so "no help was printed" is equally true when the fixture never reached +// divergence, or when the branch carries no next action there was anything to +// suppress. Those are exactly the two invariants the bare-abort sibling +// asserts inline and these two sites dropped, leaving them unable to fail if +// the StatePipelineOwned clause were deleted. +// +// The bare abort site reports the same branchsync.InspectCached read over the +// same repository, and does render branch_sync, so it is where this fixture's +// classification can be read directly. Call it before any fake daemon is +// started, so it observes only the fixture. +func assertDivergedReleasedFixtureInvariants(t *testing.T) { + t.Helper() + out, err := executeCmd("axi", "abort") + if err != nil { + t.Fatalf("reading the fixture classification must not fail: %v\n%s", err, out) + } + if !strings.Contains(out, "safety: blocked_diverged") { + t.Fatalf("fixture did not reach ordinary divergence, so suppression proves nothing:\n%s", out) + } + // The branch's own next action is the value custodySettlementHelp would + // have turned into help, so its presence is what proves the + // pipeline_owned half of the guard is doing the suppressing. + if !strings.Contains(out, "code: inspect_and_reconcile_manually") { + t.Fatalf("fixture branch carries no next action for the guard to suppress:\n%s", out) + } +} + func assertNoAbortHelpEmitted(t *testing.T, out string) { t.Helper() if !strings.Contains(out, "aborted: false") { diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 03820ddcc..f980bbc48 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -34,9 +34,10 @@ func newSyncCmd() *cobra.Command { "carry every local change. Unproven divergence refuses. A run cancelled before\n" + "the pipeline changed anything releases the branch by itself (user_owned) and\n" + "makes --recover a no-op. --recover --keep-local keeps the current local head\n" + - "instead, never touches the worktree, and points the gate branch at the kept\n" + - "head with a compare-and-swap; that is also the settlement for a record whose\n" + - "preserved head can no longer be verified.", + "instead and never touches the worktree; where the gate branch still names a\n" + + "different head it is compare-and-swapped onto the kept head, and where it does\n" + + "not, custody returns without changing any ref. That is also the settlement for\n" + + "a record whose preserved head can no longer be verified.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if check && yes { @@ -57,7 +58,7 @@ func newSyncCmd() *cobra.Command { cmd.Flags().BoolVar(&check, "check", false, "freshly verify and show the synchronization plan without changing HEAD") cmd.Flags().BoolVarP(&yes, "yes", "y", false, "apply an eligible guarded synchronization without prompting") cmd.Flags().BoolVar(&recover, "recover", false, "return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch)") - cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; surviving preserved commits stay anchored and the gate branch compare-and-swaps onto the kept head, which also settles a record whose preserved head can no longer be verified") + cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; the worktree is never touched, surviving preserved commits stay anchored, and where the gate branch still names a different head it compare-and-swaps onto the kept head. Also settles a record whose preserved head can no longer be verified") return cmd } @@ -75,7 +76,8 @@ func newAxiSyncCmd() *cobra.Command { "--check performs the same fresh read-only plan. Blocked states change nothing.\n" + "--recover performs the guarded custody return offered by\n" + "next_action.code: recover_custody; --keep-local keeps the current local head\n" + - "and moves the gate branch to it, which is also the settlement offered by\n" + + "and, where the gate branch still names a different head, moves the gate branch\n" + + "to it, which is also the settlement offered by\n" + "next_action.code: return_custody_keep_local.", Args: cobra.NoArgs, SilenceErrors: true, diff --git a/internal/git/git.go b/internal/git/git.go index 91468dbe1..818bf3008 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -432,6 +432,46 @@ func IsDetachedHEAD(ctx context.Context, dir string) (bool, error) { return false, nil } +// CommitPresence reports whether dir's object store holds sha as a commit, +// and - unlike a bare boolean probe - separates a PROVEN absence from a store +// that could not answer. `git cat-file -e ` exits 1 only when the store +// was read and the object genuinely is not there; every other failure (an +// unreadable repository, a name Git cannot parse) exits 128. Peeling the +// argument with ^{commit} collapses both onto 128, so it must not be used +// where the difference is load bearing. +// +// A caller that only needs "can I use this object" should keep using the +// simple probe; this exists for the callers whose safety argument is "nothing +// still has this object, so nothing can be lost", which is a claim only exit 1 +// supports. For the same reason an object that is present but is not a commit +// is reported as an error rather than as false: the store still has it, so it +// is not an absence, and collapsing it into one would restore the hole. +func CommitPresence(ctx context.Context, dir, sha string) (bool, error) { + if strings.TrimSpace(sha) == "" { + return false, nil + } + if _, err := Run(ctx, dir, "cat-file", "-e", sha); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err + } + kind, err := Run(ctx, dir, "cat-file", "-t", sha) + if err != nil { + return false, err + } + if kind != "commit" { + // Present but the wrong type is NOT an absence - the store still has + // the object. Returning it as a plain false would send it back through + // the same door as a proven absence and re-open the very hole this + // function exists to close, so it is an error like every other answer + // that is not "the store was read and it is not there". + return false, fmt.Errorf("object %s is a %s, not a commit", sha, kind) + } + return true, nil +} + // DefaultBranch queries a remote to determine its default branch name. // Uses git ls-remote --symref to read the remote's HEAD symref. // Falls back to "main" if detection fails (e.g. empty remote, unreachable). diff --git a/internal/tui/app.go b/internal/tui/app.go index 0e3b0534b..c08a6c5fd 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -90,8 +90,10 @@ type Model struct { syncRefresh func() branchsync.State syncApply func() branchsync.State syncRecover func() branchsync.State + syncSettle func() branchsync.State syncConfirm bool recoverConfirm bool + settleConfirm bool syncRefreshing bool } @@ -382,6 +384,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.syncRefreshing = false m.syncConfirm = false m.recoverConfirm = false + m.settleConfirm = false m.branchSync = &msg.state if msg.state.Error != "" { m.err = fmt.Errorf("branch sync: %s", msg.state.Error) @@ -498,6 +501,10 @@ func Run(socketPath string, client *ipc.Client, run *ipc.RunInfo, latestVersion model.syncRefresh = func() branchsync.State { return service.Refresh(context.Background()) } model.syncApply = func() branchsync.State { return service.Apply(context.Background()) } model.syncRecover = func() branchsync.State { return service.Recover(context.Background(), false) } + // keepLocal=true is the settlement exit for a self-inconsistent + // record; it is a separate seam so the plain recovery can never + // silently become one. + model.syncSettle = func() branchsync.State { return service.Recover(context.Background(), true) } model.refreshCachedSync() } p := tea.NewProgram(model, tea.WithAltScreen()) diff --git a/internal/tui/branch_sync.go b/internal/tui/branch_sync.go index d96fc8707..b6c2bf224 100644 --- a/internal/tui/branch_sync.go +++ b/internal/tui/branch_sync.go @@ -23,6 +23,9 @@ func renderLocalBranchStatus(state *branchsync.State, refreshing bool, width int if recoverableBranchSync(state) { message = "Run ended without publishing its pipeline commits; they are preserved in the local gate. Recover custody to take the branch back, or rerun to resume validation." footer = "u recover custody" + } else if settleableBranchSync(state) { + message = "Run ended terminally and its recorded pipeline head can no longer be verified, so there is nothing to recover. Settle custody at your current head to take the branch back." + footer = "u settle custody at local head" } else { message = "Local branch unchanged; the pipeline fix is not pushed yet. Do not make follow-up commits." } @@ -106,6 +109,23 @@ func recoverableBranchSync(state *branchsync.State) bool { return state != nil && state.State == branchsync.StatePipelineOwned && state.Safety == "blocked_pipeline_owned_recoverable" } +// settleableBranchSync reports whether the state is the SELF-INCONSISTENT +// terminal custody record whose only exit is the keep-local settlement (#824). +// +// It keys on the advertised next action rather than on a safety code, and that +// is deliberate. A self-inconsistent record reaches the TUI under whichever +// safety code described how it failed to verify - blocked_recover_preserved_ +// head_missing is only the commonest - so a safety-code list here would drift +// out of agreement with the branchsync predicate that decides whether the +// settlement can actually complete. Keying on the action the state machine +// itself advertises is what makes this exactly the road the CLI offers: the +// service never names return_custody_keep_local for a record the settlement +// would only refuse, and the TUI must never offer one it does not. +func settleableBranchSync(state *branchsync.State) bool { + return state != nil && state.State == branchsync.StatePipelineOwned && + state.NextAction != nil && state.NextAction.Code == "return_custody_keep_local" +} + func renderRecoverConfirmation(state branchsync.State, width int) string { if width < 40 { width = 80 @@ -121,6 +141,26 @@ func renderRecoverConfirmation(state branchsync.State, width int) string { return renderBoxWithFooter("Confirm custody recovery", b.String(), width, "u/enter recover · esc cancel") } +// renderSettleConfirmation is deliberately NOT renderRecoverConfirmation with +// different words. Recovery takes the preserved pipeline head; settlement +// keeps the local head and moves the gate to it, abandoning a recorded head +// that can no longer be verified. The CLI makes that an explicit --keep-local +// choice, so the TUI has to state the same consequence before asking for it. +func renderSettleConfirmation(state branchsync.State, width int) string { + if width < 40 { + width = 80 + } + var b strings.Builder + fmt.Fprintf(&b, "The run ended %s and its recorded pipeline head can no longer be verified,\n", state.Pipeline.Status) + fmt.Fprintf(&b, "so there is nothing to recover. Settling returns custody at the head you\n") + fmt.Fprintf(&b, "already have and points the gate branch at it.\n\n") + fmt.Fprintf(&b, "Local branch: %s\n", state.Local.Branch) + fmt.Fprintf(&b, "Kept HEAD: %s\n", state.Local.Head) + fmt.Fprintf(&b, "Recorded HEAD: %s (unverifiable)\n\n", state.Pipeline.CurrentHead) + b.WriteString("Your worktree is never touched. Any still-reachable copy of the recorded head is anchored first, and the settlement refuses rather than proceeding if one exists and cannot be anchored. The gate moves only by compare-and-swap, so a concurrent gate push wins. This is `no-mistakes sync --recover --keep-local`.") + return renderBoxWithFooter("Confirm custody settlement at local head", b.String(), width, "u/enter settle · esc cancel") +} + func renderSyncConfirmation(state branchsync.State, width int) string { if width < 40 { width = 80 diff --git a/internal/tui/branch_sync_test.go b/internal/tui/branch_sync_test.go index f5608a82c..afc7bba71 100644 --- a/internal/tui/branch_sync_test.go +++ b/internal/tui/branch_sync_test.go @@ -252,3 +252,122 @@ func TestActivePipelineOwnedStateOffersNoRecoveryAction(t *testing.T) { t.Fatalf("u acted on an active pipeline_owned state: %#v", m) } } + +// TestWedgedCustodyRecordReachesTheSameSettlementExitAsTheCLI is the TUI half +// of issue #824. A self-inconsistent custody record - terminal run, recorded +// pipeline head no longer verifiable - carries the settlement next action +// rather than blocked_pipeline_owned_recoverable, so keying the u affordance on +// the recoverable safety code alone left the TUI as the one operator surface +// with no exit at all: the CLI could settle the record and the TUI could only +// describe it. The exit must be the same one, and it must be an explicit +// choice, because settling KEEPS the local head instead of taking the +// preserved one. +func TestWedgedCustodyRecordReachesTheSameSettlementExitAsTheCLI(t *testing.T) { + run := &ipc.RunInfo{ID: "run-1", Branch: "feature", Status: types.RunFailed} + m := NewModel("socket", nil, run) + wedged := branchsync.State{ + State: branchsync.StatePipelineOwned, Relation: branchsync.RelationUnknown, + Safety: "blocked_recover_preserved_head_missing", + Local: branchsync.LocalState{Branch: "feature", Head: strings.Repeat("a", 40), Clean: true}, + Pipeline: branchsync.PipelineState{RunID: "run-1", Status: "failed", Phase: "pre_push", CurrentHead: strings.Repeat("c", 40)}, + NextAction: &branchsync.NextAction{Code: "return_custody_keep_local", Command: "no-mistakes axi sync --recover --keep-local"}, + } + m.branchSync = &wedged + + // The dead end was visible here first: the status line described the block + // and offered no key at all. + view := stripANSI(renderLocalBranchStatus(m.branchSync, false, 80)) + for _, want := range []string{"can no longer be verified", "u settle custody at local head"} { + if !strings.Contains(view, want) { + t.Errorf("wedged status missing %q:\n%s", want, view) + } + } + + settleCalls := 0 + recoverCalls := 0 + m.syncRecover = func() branchsync.State { + recoverCalls++ + t.Error("a wedged record must not take the plain recovery path") + return branchsync.State{} + } + m.syncSettle = func() branchsync.State { + settleCalls++ + settled := wedged + settled.State = branchsync.StateCustodyReturned + settled.Safety = "custody_returned" + settled.Relation = branchsync.RelationEqual + settled.Recovered = true + settled.NextAction = nil + return settled + } + + nextModel, cmd := m.handleKey(keyMsg("u")) + m = nextModel.(Model) + if cmd != nil || !m.settleConfirm || settleCalls != 0 { + t.Fatalf("u must open the settlement confirmation without acting: confirm=%v calls=%d", m.settleConfirm, settleCalls) + } + if m.recoverConfirm { + t.Fatal("wedged record opened the recovery confirmation instead of the settlement one") + } + // Settling keeps the local head and abandons an unverifiable recorded one, + // so the confirmation has to say which head survives before asking. + plain := stripANSI(m.View()) + for _, want := range []string{"custody", strings.Repeat("a", 40), strings.Repeat("c", 40), "u/enter settle", "--keep-local"} { + if !strings.Contains(plain, want) { + t.Errorf("settlement confirmation missing %q:\n%s", want, plain) + } + } + + // esc must back out without touching anything. + escModel, escCmd := m.handleKey(keyMsg("esc")) + escaped := escModel.(Model) + if escCmd != nil || escaped.settleConfirm || settleCalls != 0 { + t.Fatalf("esc did not cancel the settlement cleanly: confirm=%v calls=%d", escaped.settleConfirm, settleCalls) + } + + nextModel, cmd = m.handleKey(keyMsg("enter")) + m = nextModel.(Model) + if cmd == nil || settleCalls != 0 { + t.Fatal("settlement did not wait for its async command") + } + next, _ := m.Update(cmd()) + m = next.(Model) + if settleCalls != 1 || recoverCalls != 0 { + t.Fatalf("settlement calls=%d recover calls=%d", settleCalls, recoverCalls) + } + if m.settleConfirm || m.branchSync.State != branchsync.StateCustodyReturned || !m.branchSync.Recovered { + t.Fatalf("settlement result = %#v", m.branchSync) + } + if m.err != nil { + t.Fatalf("successful settlement left an error: %v", m.err) + } +} + +// TestPipelineOwnedStateWithoutASettlementActionOffersNoSettlement keeps the +// TUI from inventing an exit the service did not advertise: the branchsync +// predicate decides where the settlement can actually complete, and a record it +// sent to manual reconciliation must not be offered a key that would only +// refuse - which is the #824 shape in reverse. +func TestPipelineOwnedStateWithoutASettlementActionOffersNoSettlement(t *testing.T) { + run := &ipc.RunInfo{ID: "run-1", Branch: "feature", Status: types.RunFailed} + m := NewModel("socket", nil, run) + m.branchSync = &branchsync.State{ + State: branchsync.StatePipelineOwned, Safety: "blocked_recover_preserved_head_missing", + Local: branchsync.LocalState{Branch: "feature", Head: strings.Repeat("a", 40), Clean: true}, + Pipeline: branchsync.PipelineState{RunID: "run-1", Status: "failed", Phase: "pre_push"}, + NextAction: &branchsync.NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"}, + } + m.syncSettle = func() branchsync.State { + t.Fatal("settlement must not be reachable for a record sent to manual reconciliation") + return branchsync.State{} + } + view := stripANSI(renderLocalBranchStatus(m.branchSync, false, 80)) + if strings.Contains(view, "u settle") { + t.Fatalf("unadvertised settlement offered a key:\n%s", view) + } + nextModel, cmd := m.handleKey(keyMsg("u")) + m = nextModel.(Model) + if cmd != nil || m.settleConfirm || m.recoverConfirm || m.syncConfirm { + t.Fatalf("u acted on a record with no advertised settlement: %#v", m) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index bb75630f2..aa2d0644c 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -378,6 +378,26 @@ func (m Model) applyRecoverCmd() tea.Cmd { } } +// applySettleCmd runs the keep-local settlement. It is reported under its own +// telemetry action so a settlement is never counted as a recovery: they end +// the same state but keep opposite heads. +func (m Model) applySettleCmd() tea.Cmd { + settle := m.syncSettle + if settle == nil { + return nil + } + return func() tea.Msg { + started := time.Now() + state := settle() + result := "refused" + if state.Recovered { + result = "applied" + } + trackTUISyncAttempt("settle", state, result, started) + return syncAppliedMsg{state: state} + } +} + func (m Model) spinnerTickCmd() tea.Cmd { return tea.Tick(spinnerTickInterval, func(time.Time) tea.Msg { return spinnerTickMsg{} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 3693b52e5..a39c1d7f9 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -52,6 +52,25 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } + if m.settleConfirm { + switch key { + case "esc": + m.settleConfirm = false + return m, nil + case "u", "enter": + if m.syncRefreshing { + return m, nil + } + m.syncRefreshing = true + return m, m.applySettleCmd() + case "q", "ctrl+c": + m.quitting = true + return m, tea.Sequence(tea.SetWindowTitle(""), tea.Quit) + default: + return m, nil + } + } + // Reset abort confirmation on any key except 'x'. if key != "x" { m.confirmAbort = false @@ -253,6 +272,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.recoverConfirm = true return m, nil } + if settleableBranchSync(m.branchSync) && m.syncSettle != nil { + m.err = nil + m.settleConfirm = true + return m, nil + } if m.syncRefresh == nil || m.branchSync.NextAction == nil || m.branchSync.NextAction.Code != "sync" { return m, nil } diff --git a/internal/tui/view.go b/internal/tui/view.go index 1cb4e8202..90edcdb1a 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -116,6 +116,9 @@ func (m Model) View() string { if m.recoverConfirm && m.branchSync != nil { extraSections = append(extraSections, renderRecoverConfirmation(*m.branchSync, rightWidth)) } + if m.settleConfirm && m.branchSync != nil { + extraSections = append(extraSections, renderSettleConfirmation(*m.branchSync, rightWidth)) + } // Modal editor takes priority over findings/logs so it always renders // when active. Bypass the content budget so it never gets dropped on From 87d8c11ac302f8fd48505f2dcc48db4d7b00c0b1 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:03:52 -0400 Subject: [PATCH 14/31] no-mistakes(review): make keep-local refusals name the anchor they wrote --- .../branch-sync-and-push-safety/SKILL.md | 4 +- docs/src/content/docs/reference/cli.md | 2 +- internal/branchsync/recover_test.go | 84 +++++++++++++++++++ internal/branchsync/sync.go | 57 +++++++++---- internal/cli/sync.go | 4 +- 5 files changed, 130 insertions(+), 21 deletions(-) diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index 50d37ae70..6dfa7d9ea 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -17,10 +17,10 @@ metadata: A cancelled validation routinely leaves a preserved head that is a REBASE of the local branch, which equality and ancestry read as plain divergence, so a clean diverged worktree is adopted when `preservedContainsLocalWork` proves containment. That proof is an executable `merge-tree` three-way merge whose result must equal the preserved head's tree, anchored on the merge-base - never `runs.base_sha`, the previous gate head. It deliberately does NOT use patch identity: patch IDs discard hunk locations and whitespace, so they cannot tell a genuine replay from a same-shaped edit to another identical block, and a containment claim built on them is not a proof. Everything undecidable escalates, including a rebase whose fix rounds also rewrote operator lines, where nothing separates a deliberate fix from a dropped change. Adoption anchors the pre-recovery local head at `refs/no-mistakes/recover-local/`, then moves the branch with Git operations that fail closed on their own rather than after an observation - an atomic `update-ref` CAS plus `read-tree -m -u`, never check-then-act followed by `reset --hard`, which destroys anything landing in the gap. `recoverAdoptPreserved` owns the reasoning. Terminalization pins every verified unpublished head at `refs/no-mistakes/recover/` before the managed worktree can be removed. Recovery reads that run-specific ref rather than requiring the gate branch to match, so aborts, rebases, and pre-push failures remain recoverable while an independently moved gate branch is preserved. Legacy recorded heads that still exist as dangling gate objects are anchored on recovery; a truly missing recorded head never advertises an impossible `recover_custody` command and instead reports the keep-local settlement below, falling back to manual reconciliation whenever that settlement is itself unreachable. - When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, already-recovered, and absent-gate-branch paths return custody without changing any ref, and the flag help must keep saying so. + When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, already-recovered, and absent-gate-branch paths return custody without moving any branch ref (equal/ahead still writes the private recovery anchor, which is what anchoring a locally reachable preserved head means), and the flag help must keep saying so. `--keep-local` is also the ONLY settlement for a SELF-INCONSISTENT custody record (#824): a terminal run whose recorded pipeline head is in no reachable object store, or whose own recovery evidence names something else, has nothing verifiable to import, so every default recovery refused, `abort` of the terminal run was a no-op, and the branch stayed `pipeline_owned` forever. Inspection names that exit (`next_action.code: return_custody_keep_local`) instead of #814's dead-end manual-reconciliation pointer, terminal-run `abort` responses carry the same command, and the TUI `u` action reaches the same settlement - keyed on that advertised next action, never on a safety code, so it cannot drift from the predicate that decides where the settlement can complete. #814's polarity (never advertise `recover_custody` for an unverifiable record) is preserved. Terminal-run no-op abort help is separately allowlisted to custody-settlement codes only (`custodySettlementHelp`) AND gated on `StatePipelineOwned`, so an abort that cancelled nothing never answers by prescribing a fresh run against a released branch, nor by repeating ordinary-divergence `git log` advice; a SUCCESSFUL cancellation still echoes the branch's own next action. Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure runs AFTER the Git side already succeeded, so it reports that the changes are applied and names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Advertising any of those recreates the very wedge this change removes. - Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref behind - which is what makes their "no files or refs were changed" report true by construction rather than by a cleanup that could itself fail. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. + Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. The full relation matrix and fail-safe rules live in the `Recover` doc comment in `internal/branchsync/sync.go`. - Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index a5041cb23..1ad3dbfac 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -236,7 +236,7 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without changing any ref. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. The anchor is written immediately before the compare-and-swap, so every refusal that can precede it leaves no anchor behind and its "no files or refs were changed" report is exact. A lost compare-and-swap does leave that anchor pinned at the head the attempt observed, because the gate has moved and the anchor may be the only ref still naming the displaced head; nothing retires it, so once the gate has moved on, later attempts refuse on the anchor conflict until you reconcile that ref in the gate by hand. That refusal names the ref and the gate directory, and reports that no *local* files or refs were changed. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. The anchor is written immediately before the compare-and-swap, so every refusal that can precede it leaves no anchor of its own behind. Its no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - the refusal reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. A lost compare-and-swap does leave that anchor pinned at the head the attempt observed, because the gate has moved and the anchor may be the only ref still naming the displaced head; nothing retires it, so once the gate has moved on, later attempts refuse on the anchor conflict until you reconcile that ref in the gate by hand. That refusal names the ref and the gate directory, and reports that no *local* files or refs were changed. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index a77941c7d..c667a0302 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2429,6 +2429,90 @@ func TestRecoverKeepLocalRefusalNamesTheAnchorItAlreadyWrote(t *testing.T) { } } +// TestKeepLocalRefusalAfterDelegationNamesTheAnchorTheAttemptWrote carries the +// same honesty claim across the DELEGATION seam, where it used to be dropped. +// recoverKeepLocal writes nothing of its own before the compare-and-swap, but +// both of its callers can already have anchored a surviving recorded head - +// the settlement pins every copy at the stranded ref, and the ordinary +// keep-local path anchors the preserved head at the run recovery ref - so a +// blanket "no files or refs were changed" there reports on an attempt that +// did write a ref, and hides the very anchor the operator needs to find their +// preserved commits. +func TestKeepLocalRefusalAfterDelegationNamesTheAnchorTheAttemptWrote(t *testing.T) { + t.Parallel() + + t.Run("settlement pin in the local gate", func(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + // Conflicting gate evidence routes keep-local through the settlement, + // whose pin loop finds the recorded head in the gate alone. + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) + f.service.absPathFn = func(string) (string, error) { + return "", errors.New("working directory has been removed") + } + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != "blocked_recover_assumptions_changed" { + t.Fatalf("delegated refusal = %#v", state) + } + if got := mustRun(t, f.gate, "rev-parse", custody.RecoveryStrandedRef(f.run.ID)); got != f.preserved { + t.Fatalf("gate stranded anchor = %s, want %s", got, f.preserved) + } + if strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("refusal claimed nothing changed after the settlement pinned the recorded head: %q", state.Error) + } + if !strings.Contains(state.Error, custody.RecoveryStrandedRef(f.run.ID)) || !strings.Contains(state.Error, "the local gate") { + t.Fatalf("refusal did not name where the recorded head is now anchored: %q", state.Error) + } + if f.custodyReturned() { + t.Fatal("delegated refusal stamped custody") + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.preserved { + t.Fatalf("delegated refusal moved the gate branch = %s, want %s", got, f.preserved) + } + f.service.absPathFn = nil + retry := f.service.Recover(f.ctx, true) + if !retry.Recovered { + t.Fatalf("the settlement stayed wedged after an honest refusal = %#v", retry) + } + }) + + t.Run("recovery anchor in the invoking worktree", func(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + mustWrite(t, filepath.Join(f.local, "rescope.txt"), "rescope\n") + mustRun(t, f.local, "add", "rescope.txt") + mustRun(t, f.local, "commit", "-m", "diverging rescope") + f.service.absPathFn = func(string) (string, error) { + return "", errors.New("working directory has been removed") + } + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != "blocked_recover_assumptions_changed" { + t.Fatalf("delegated refusal = %#v", state) + } + if got := mustRun(t, f.local, "rev-parse", f.anchorRef()); got != f.preserved { + t.Fatalf("worktree recovery anchor = %s, want %s", got, f.preserved) + } + if strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("refusal claimed nothing changed after anchoring the preserved head: %q", state.Error) + } + if !strings.Contains(state.Error, f.anchorRef()) || !strings.Contains(state.Error, "the invoking worktree") { + t.Fatalf("refusal did not name the anchor this attempt wrote: %q", state.Error) + } + if f.custodyReturned() { + t.Fatal("delegated refusal stamped custody") + } + f.service.absPathFn = nil + retry := f.service.Recover(f.ctx, true) + if !retry.Recovered { + t.Fatalf("keep-local stayed wedged after an honest refusal = %#v", retry) + } + }) +} + // TestInspectDoesNotAdvertiseSettlementForSymbolicGateAnchor is the review // regression for the residual advertise-then-refuse corner. A DANGLING // symbolic gate recovery ref is invisible to `for-each-ref` while diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index aed270519..485b979a7 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -710,6 +710,9 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } anchored := false + // What this attempt itself writes, so a later keep-local refusal reports + // the anchor rather than claiming nothing was written. + anchoredNote := "" if existing, anchorErr := git.Run(ctx, wd, "rev-parse", anchorRef+"^{commit}"); anchorErr == nil && existing == preserved { anchored = true } @@ -723,6 +726,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", "the invoking worktree recovery ref conflicts with the recorded pipeline head; inspect both objects before returning custody; no files or refs were changed") } + anchoredNote = anchoredElsewhere([]string{"the invoking worktree"}, anchorRef) } switch { @@ -736,7 +740,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if err != nil { return recoverBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) } - return s.recoverKeepLocal(ctx, run, state, gateHead) + return s.recoverKeepLocal(ctx, run, state, gateHead, anchoredNote) } if !state.Local.Clean { state.Relation = RelationBehind @@ -751,7 +755,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if err != nil { return recoverBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) } - return s.recoverKeepLocal(ctx, run, state, gateHead) + return s.recoverKeepLocal(ctx, run, state, gateHead, anchoredNote) } if preservedContainsLocalWork(ctx, wd, local, preserved) { if !state.Local.Clean { @@ -861,11 +865,11 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} return blocked } - return s.recoverKeepLocal(ctx, run, state, gateHead) + return s.recoverKeepLocal(ctx, run, state, gateHead, anchoredElsewhere(pinned, strandedRef)) } -// anchoredElsewhere keeps a settlement refusal honest about the one ref it can -// already have written before failing. +// anchoredElsewhere keeps a refusal honest about a recorded head its own +// attempt has already anchored before failing. func anchoredElsewhere(pinned []string, ref string) string { if len(pinned) == 0 { return "" @@ -873,6 +877,19 @@ func anchoredElsewhere(pinned []string, ref string) string { return fmt.Sprintf("; the recorded head is now anchored at %s in %s", ref, strings.Join(pinned, " and ")) } +// keepLocalNoChangeClause closes a recoverKeepLocal refusal with a claim that +// stays true for its caller too. recoverKeepLocal writes nothing before its +// compare-and-swap by construction, but its callers can already have anchored +// a surviving recorded head, so the blanket claim belongs only to a delegation +// that carries no such anchor; otherwise the refusal reports exactly what it +// did not change and hands over the anchor note naming where that head now is. +func keepLocalNoChangeClause(blanket, anchored string) string { + if anchored == "" { + return blanket + } + return "no branch, worktree, or file changes were made" + anchored +} + // recoverBlocked is the one constructor for a recovery refusal that has no // more specific exit to offer, because blockedPlan clears NextAction and a // refusal naming no exit is itself the R1/R5 dead end this subsystem exists to @@ -908,15 +925,23 @@ func recoverBlocked(state State, safety, message string) State { // refuse - the local head re-read, the worktree path resolution, the staging // fetch, and the staged-head verification - runs BEFORE the anchor is written, // and the write happens immediately before the swap it guards. That keeps -// "no files or refs were changed" true on every one of those paths by -// construction rather than by a cleanup that could itself fail. +// "no files or refs were changed" true of THIS function on every one of those +// paths by construction rather than by a cleanup that could itself fail. +// +// A refusal can only speak for itself, though, and both callers reach here +// after possibly anchoring a surviving recorded head of their own - Recover +// pins the preserved head at the run recovery ref, and +// recoverSettleInconsistent pins every surviving copy at the stranded ref. +// anchoredNote carries that fact in, so the blanket claim is made only by a +// delegation that wrote nothing and every other refusal names where the +// anchor now is instead of reporting that nothing was written. // // The anchor CONFLICT check is deliberately still read-only and still runs // first: it is the cheapest refusal and it must not be reached only after a // staging ref exists. Once the swap has been attempted, the anchor is load // bearing and is never retired - a failed compare-and-swap means the gate // moved, so gateHead may now be reachable through the anchor alone. -func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State, gateHead string) State { +func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State, gateHead, anchoredNote string) State { if s.beforeGateReset != nil { s.beforeGateReset() } @@ -932,7 +957,7 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State if err == nil { conflict = "names " + existing } - return recoverBlocked(state, "blocked_recover_preserve_failed", fmt.Sprintf("the independently moved gate head %s conflicts with the existing run recovery anchor %s in the local gate %s, which %s; nothing retires that anchor, so reconcile it there before returning custody; no files or branch refs were changed", gateHead, gateAnchor, s.GateDir, conflict)) + return recoverBlocked(state, "blocked_recover_preserve_failed", fmt.Sprintf("the independently moved gate head %s conflicts with the existing run recovery anchor %s in the local gate %s, which %s; nothing retires that anchor, so reconcile it there before returning custody; %s", gateHead, gateAnchor, s.GateDir, conflict, keepLocalNoChangeClause("no files or branch refs were changed", anchoredNote))) } // An anchor that already names this head needs no write, but it // still guards the swap, so the race refusal below must know it @@ -942,14 +967,14 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State } head, err := git.HeadSHA(ctx, s.workDir()) if err != nil || head != state.Local.Head { - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)) } // The fetch source must be absolute: the command runs inside the gate // directory, where a relative invoking-worktree path would resolve to // the gate itself. source, err := s.absPath(s.workDir()) if err != nil { - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)) } if s.beforeGateStage != nil { s.beforeGateStage() @@ -959,19 +984,19 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // A partly completed fetch can still have created the staging ref, // and the refusal claims nothing was left behind. _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)) } staged, err := git.Run(ctx, s.GateDir, "rev-parse", stagingRef+"^{commit}") if err != nil || staged != state.Local.Head { _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)) } // Point of no return: from here the gate branch is about to leave // gateHead, so the anchor has to exist first. if writeGateAnchor { if err := custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead); err != nil { _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) - return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; no files or branch refs were changed") + return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; "+keepLocalNoChangeClause("no files or branch refs were changed", anchoredNote)) } } _, casErr := git.Run(ctx, s.GateDir, "update-ref", "refs/heads/"+state.Local.Branch, state.Local.Head, gateHead) @@ -985,9 +1010,9 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // failed because the gate moved, so gateHead may now be reachable // through this anchor alone. if gateHeadAnchored { - return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; no local files or refs were changed", custody.RecoveryGateRef(run.ID))) + return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; %s", custody.RecoveryGateRef(run.ID), keepLocalNoChangeClause("no local files or refs were changed", anchoredNote))) } - return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no run recovery anchor was written, so re-run the recovery to return custody against the new gate head; no local files or refs were changed") + return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; "+keepLocalNoChangeClause("no local files or refs were changed", anchoredNote)) } } return s.finishRecover(ctx, run, false, true) diff --git a/internal/cli/sync.go b/internal/cli/sync.go index f980bbc48..c54d8c8f1 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -36,8 +36,8 @@ func newSyncCmd() *cobra.Command { "makes --recover a no-op. --recover --keep-local keeps the current local head\n" + "instead and never touches the worktree; where the gate branch still names a\n" + "different head it is compare-and-swapped onto the kept head, and where it does\n" + - "not, custody returns without changing any ref. That is also the settlement for\n" + - "a record whose preserved head can no longer be verified.", + "not, custody returns without moving any branch ref. That is also the settlement\n" + + "for a record whose preserved head can no longer be verified.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if check && yes { From 24c96a44e18330ce496f0bcd6be8782b1023dea2 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:26:43 -0400 Subject: [PATCH 15/31] no-mistakes(review): keep lost-swap refusal local-scoped; drop daemon from fixture check --- .../branch-sync-and-push-safety/SKILL.md | 2 +- docs/src/content/docs/reference/cli.md | 2 +- internal/branchsync/recover_test.go | 69 +++++++++++++++++++ internal/branchsync/sync.go | 21 ++++-- internal/cli/axi_abort_custody_test.go | 51 ++++++++++---- 5 files changed, 121 insertions(+), 24 deletions(-) diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index 6dfa7d9ea..d4d90b2cb 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -20,7 +20,7 @@ metadata: When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, already-recovered, and absent-gate-branch paths return custody without moving any branch ref (equal/ahead still writes the private recovery anchor, which is what anchoring a locally reachable preserved head means), and the flag help must keep saying so. `--keep-local` is also the ONLY settlement for a SELF-INCONSISTENT custody record (#824): a terminal run whose recorded pipeline head is in no reachable object store, or whose own recovery evidence names something else, has nothing verifiable to import, so every default recovery refused, `abort` of the terminal run was a no-op, and the branch stayed `pipeline_owned` forever. Inspection names that exit (`next_action.code: return_custody_keep_local`) instead of #814's dead-end manual-reconciliation pointer, terminal-run `abort` responses carry the same command, and the TUI `u` action reaches the same settlement - keyed on that advertised next action, never on a safety code, so it cannot drift from the predicate that decides where the settlement can complete. #814's polarity (never advertise `recover_custody` for an unverifiable record) is preserved. Terminal-run no-op abort help is separately allowlisted to custody-settlement codes only (`custodySettlementHelp`) AND gated on `StatePipelineOwned`, so an abort that cancelled nothing never answers by prescribing a fresh run against a released branch, nor by repeating ordinary-divergence `git log` advice; a SUCCESSFUL cancellation still echoes the branch's own next action. Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure runs AFTER the Git side already succeeded, so it reports that the changes are applied and names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Advertising any of those recreates the very wedge this change removes. - Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. + Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every PRE-SWAP refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The lost CAS is excluded from that substitution on purpose: it has already written the gate anchor, so it keeps its own narrower "no LOCAL files or refs were changed" claim and APPENDS the note. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. The full relation matrix and fail-safe rules live in the `Recover` doc comment in `internal/branchsync/sync.go`. - Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 1ad3dbfac..0c829bb33 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -236,7 +236,7 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. The anchor is written immediately before the compare-and-swap, so every refusal that can precede it leaves no anchor of its own behind. Its no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - the refusal reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. A lost compare-and-swap does leave that anchor pinned at the head the attempt observed, because the gate has moved and the anchor may be the only ref still naming the displaced head; nothing retires it, so once the gate has moved on, later attempts refuse on the anchor conflict until you reconcile that ref in the gate by hand. That refusal names the ref and the gate directory, and reports that no *local* files or refs were changed. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. The anchor is written immediately before the compare-and-swap, so every refusal that can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. A lost compare-and-swap does leave that anchor pinned at the head the attempt observed, because the gate has moved and the anchor may be the only ref still naming the displaced head; nothing retires it, so once the gate has moved on, later attempts refuse on the anchor conflict until you reconcile that ref in the gate by hand. That refusal names the ref and the gate directory, and reports that no *local* files or refs were changed - it keeps that narrower claim precisely because it has just written a ref in the gate, and where the attempt had already anchored the preserved head it appends the same anchor note rather than dropping the `local` qualifier. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index c667a0302..b57ef0c40 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -2169,6 +2169,75 @@ func TestRecoverKeepLocalSettlementLosesConcurrentGatePushCleanly(t *testing.T) } } +// TestSettlementLostSwapKeepsItsLocalScopedClaimBesideTheAnchorNote is the +// review regression for the one refusal that must NOT take the anchor note as +// a replacement for its own claim. The lost compare-and-swap has just written +// refs/no-mistakes/recover-gate/ into the gate, which is exactly why its +// message is scoped to LOCAL files and refs; substituting the settlement's +// broader "no branch, worktree, or file changes were made" there would deny a +// gate-side ref file this very attempt created. +func TestSettlementLostSwapKeepsItsLocalScopedClaimBesideTheAnchorNote(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + // A later run parked the gate branch past the recorded head, so the swap + // has a displaced gate head to anchor, while conflicting gate evidence + // routes the recovery through the settlement and its pin. + later := filepath.Join(t.TempDir(), "later-run") + mustRun(t, filepath.Dir(later), "-c", "core.autocrlf=false", "clone", f.gate, later) + configureIdentity(t, later) + mustRun(t, later, "checkout", "feature/recover") + mustWrite(t, filepath.Join(later, "later.txt"), "later cancelled run\n") + mustRun(t, later, "add", "later.txt") + mustRun(t, later, "commit", "-m", "no-mistakes(review): later run fix") + mustRun(t, later, "push", "origin", "HEAD:refs/heads/feature/recover") + staleGate := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover") + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) + + var raced string + f.service.beforeGateReset = func() { + if raced != "" { + return + } + mustWrite(t, filepath.Join(later, "raced.txt"), "raced\n") + mustRun(t, later, "add", "raced.txt") + mustRun(t, later, "commit", "-m", "concurrent gate push") + mustRun(t, later, "push", "origin", "HEAD:refs/heads/feature/recover") + raced = mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover") + } + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != "blocked_recover_gate_race" { + t.Fatalf("racing settlement = %#v", state) + } + if f.custodyReturned() { + t.Fatal("lost compare-and-swap stamped custody") + } + // The fixture has to have produced the exact state under test: a pinned + // recorded head, a written gate anchor, and a gate branch that moved on. + if got := mustRun(t, f.gate, "rev-parse", custody.RecoveryStrandedRef(f.run.ID)); got != f.preserved { + t.Fatalf("gate stranded anchor = %s, want the recorded head %s", got, f.preserved) + } + if got := mustRun(t, f.gate, "rev-parse", custody.RecoveryGateRef(f.run.ID)); got != staleGate { + t.Fatalf("displaced gate head anchor = %s, want %s", got, staleGate) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != raced { + t.Fatalf("gate branch = %s, want the concurrent push %s", got, raced) + } + if !strings.Contains(state.Error, "no local files or refs were changed") { + t.Fatalf("refusal dropped the local qualifier it earned by writing a gate ref: %q", state.Error) + } + if !strings.Contains(state.Error, custody.RecoveryStrandedRef(f.run.ID)) || !strings.Contains(state.Error, "the local gate") { + t.Fatalf("refusal did not name where the recorded head is now anchored: %q", state.Error) + } + if !strings.Contains(state.Error, custody.RecoveryGateRef(f.run.ID)) { + t.Fatalf("refusal did not name the anchor to reconcile: %q", state.Error) + } + if state.NextAction == nil { + t.Fatalf("lost compare-and-swap named no exit at all = %#v", state) + } +} + // TestInspectNamesKeepLocalSettlementForWedgedCustodyRecord is the R5 half of // issue #824: a refused recovery must still name a supported exit. Inspection // must not advertise recover_custody for a record it cannot verify, but the diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 485b979a7..0a7e9fe65 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -877,12 +877,17 @@ func anchoredElsewhere(pinned []string, ref string) string { return fmt.Sprintf("; the recorded head is now anchored at %s in %s", ref, strings.Join(pinned, " and ")) } -// keepLocalNoChangeClause closes a recoverKeepLocal refusal with a claim that -// stays true for its caller too. recoverKeepLocal writes nothing before its -// compare-and-swap by construction, but its callers can already have anchored -// a surviving recorded head, so the blanket claim belongs only to a delegation +// keepLocalNoChangeClause closes a PRE-SWAP recoverKeepLocal refusal with a +// claim that stays true for its caller too. Those refusals write nothing of +// their own by construction, but their callers can already have anchored a +// surviving recorded head, so the blanket claim belongs only to a delegation // that carries no such anchor; otherwise the refusal reports exactly what it // did not change and hands over the anchor note naming where that head now is. +// +// The lost compare-and-swap is deliberately NOT built here. That refusal has +// just written the displaced-gate-head anchor into the gate, so its narrower +// "no LOCAL files or refs were changed" claim is the accurate one and the +// anchor note is appended to it rather than replacing it. func keepLocalNoChangeClause(blanket, anchored string) string { if anchored == "" { return blanket @@ -934,7 +939,9 @@ func recoverBlocked(state State, safety, message string) State { // recoverSettleInconsistent pins every surviving copy at the stranded ref. // anchoredNote carries that fact in, so the blanket claim is made only by a // delegation that wrote nothing and every other refusal names where the -// anchor now is instead of reporting that nothing was written. +// anchor now is instead of reporting that nothing was written. The lost +// compare-and-swap keeps its own narrower local-scoped claim and appends the +// note, because by then this function has written the gate anchor itself. // // The anchor CONFLICT check is deliberately still read-only and still runs // first: it is the cheapest refusal and it must not be reached only after a @@ -1010,9 +1017,9 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // failed because the gate moved, so gateHead may now be reachable // through this anchor alone. if gateHeadAnchored { - return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; %s", custody.RecoveryGateRef(run.ID), keepLocalNoChangeClause("no local files or refs were changed", anchoredNote))) + return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; no local files or refs were changed%s", custody.RecoveryGateRef(run.ID), anchoredNote)) } - return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; "+keepLocalNoChangeClause("no local files or refs were changed", anchoredNote)) + return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; no local files or refs were changed"+anchoredNote) } } return s.finishRecover(ctx, run, false, true) diff --git a/internal/cli/axi_abort_custody_test.go b/internal/cli/axi_abort_custody_test.go index 1cb74db83..7bb83c5f7 100644 --- a/internal/cli/axi_abort_custody_test.go +++ b/internal/cli/axi_abort_custody_test.go @@ -351,8 +351,8 @@ func TestBareAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { // answered an abort with `git log` reconciliation advice the bare site had // already been taught not to emit. func TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { - runID, _, _ := divergedReleasedBranchFixture(t) - assertDivergedReleasedFixtureInvariants(t) + runID, p, _ := divergedReleasedBranchFixture(t) + assertDivergedReleasedFixtureInvariants(t, p, runID) t.Run("daemon unavailable", func(t *testing.T) { out, err := executeCmd("axi", "abort", "--run", runID) @@ -368,7 +368,7 @@ func TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergence(t *testing.T) { // same guard on the daemon-up resolveInactiveAbortTruth path. func TestRunScopedAbortNoOpEmitsNoHelpForOrdinaryDivergenceWithDaemon(t *testing.T) { runID, p, _ := divergedReleasedBranchFixture(t) - assertDivergedReleasedFixtureInvariants(t) + assertDivergedReleasedFixtureInvariants(t, p, runID) startInactiveAbortDaemon(t, p, runID) out, err := executeCmd("axi", "abort", "--run", runID) @@ -431,24 +431,45 @@ func divergedReleasedBranchFixture(t *testing.T) (string, *paths.Paths, string) // asserts inline and these two sites dropped, leaving them unable to fail if // the StatePipelineOwned clause were deleted. // -// The bare abort site reports the same branchsync.InspectCached read over the -// same repository, and does render branch_sync, so it is where this fixture's -// classification can be read directly. Call it before any fake daemon is -// started, so it observes only the fixture. -func assertDivergedReleasedFixtureInvariants(t *testing.T) { +// It reads the classification the abort sites themselves consult - the same +// branchsync.InspectCached over the same database, repository, and gate that +// terminalRunCustodyHelpWithDB builds - rather than going through a CLI +// surface. The bare `axi abort` renders it too, but that path ensures a live +// daemon, which this package cannot start, so reading the service directly is +// what keeps the invariant observable with or without a fake daemon. +func assertDivergedReleasedFixtureInvariants(t *testing.T, p *paths.Paths, runID string) { t.Helper() - out, err := executeCmd("axi", "abort") + database, err := db.Open(p.DB()) if err != nil { - t.Fatalf("reading the fixture classification must not fail: %v\n%s", err, out) - } - if !strings.Contains(out, "safety: blocked_diverged") { - t.Fatalf("fixture did not reach ordinary divergence, so suppression proves nothing:\n%s", out) + t.Fatalf("reading the fixture classification must not fail: %v", err) + } + defer database.Close() + repo, err := findRepo(database) + if err != nil || repo == nil { + t.Fatalf("reading the fixture classification must not fail: repo=%#v err=%v", repo, err) + } + service := &branchsync.Service{ + DB: database, + Repo: repo, + WorkDir: ".", + GateDir: p.RepoDir(repo.ID), + Paths: p, + } + state := service.InspectCached(context.Background()) + if state.State != branchsync.StateDiverged || state.Safety != "blocked_diverged" { + t.Fatalf("fixture did not reach ordinary divergence, so suppression proves nothing: state=%q safety=%q", state.State, state.Safety) + } + // The run guard is the other half of the gate under test; if the branch + // stopped resolving to this run, removing the pipeline_owned clause would + // suppress the help anyway and the tests below could not fail. + if state.Pipeline.RunID != runID { + t.Fatalf("fixture branch resolves to run %q, want %q, so the pipeline_owned clause is not what suppresses the help", state.Pipeline.RunID, runID) } // The branch's own next action is the value custodySettlementHelp would // have turned into help, so its presence is what proves the // pipeline_owned half of the guard is doing the suppressing. - if !strings.Contains(out, "code: inspect_and_reconcile_manually") { - t.Fatalf("fixture branch carries no next action for the guard to suppress:\n%s", out) + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("fixture branch carries no next action for the guard to suppress: %#v", state.NextAction) } } From 711ca0557f6a132c82453ab3ccd59af074d90b40 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:42:28 -0400 Subject: [PATCH 16/31] no-mistakes(review): condition axi sync keep-local flag help on gate state --- internal/cli/sync.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/sync.go b/internal/cli/sync.go index c54d8c8f1..b495d1374 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -94,7 +94,7 @@ func newAxiSyncCmd() *cobra.Command { } cmd.Flags().BoolVar(&check, "check", false, "freshly verify and return the plan without changing HEAD") cmd.Flags().BoolVar(&recover, "recover", false, "return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch)") - cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; surviving preserved commits stay anchored and the gate branch compare-and-swaps onto the kept head, which also settles a record whose preserved head can no longer be verified") + cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; the worktree is never touched, surviving preserved commits stay anchored, and where the gate branch still names a different head it compare-and-swaps onto the kept head; otherwise custody returns without moving any branch ref. Also settles a record whose preserved head can no longer be verified") return cmd } From 45a2a79b51ce00762cf08a04e86187c37e4bcc1c Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:16:40 -0400 Subject: [PATCH 17/31] no-mistakes(document): document TUI custody settlement and run-refusal settlement action --- .../branch-sync-and-push-safety/SKILL.md | 2 +- docs/src/content/docs/guides/tui.md | 3 +- docs/src/content/docs/reference/cli.md | 2 +- internal/cli/axi_settle_custody_test.go | 128 ++++++++++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 internal/cli/axi_settle_custody_test.go diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index d4d90b2cb..1460ed3f3 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -22,7 +22,7 @@ metadata: Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure runs AFTER the Git side already succeeded, so it reports that the changes are applied and names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Advertising any of those recreates the very wedge this change removes. Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every PRE-SWAP refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The lost CAS is excluded from that substitution on purpose: it has already written the gate anchor, so it keeps its own narrower "no LOCAL files or refs were changed" claim and APPENDS the note. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. The full relation matrix and fail-safe rules live in the `Recover` doc comment in `internal/branchsync/sync.go`. -- Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. +- Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/cli/axi_settle_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. **Post-Review Head Continuity and Push Binding** diff --git a/docs/src/content/docs/guides/tui.md b/docs/src/content/docs/guides/tui.md index 3f223ffb4..96d966616 100644 --- a/docs/src/content/docs/guides/tui.md +++ b/docs/src/content/docs/guides/tui.md @@ -144,6 +144,7 @@ Pressing `u` explicitly refreshes the configured upstream or fork target, then o Confirm with `u` or Enter, or cancel with Escape. The apply path rechecks every mutable assumption and can only perform the same exact strict fast-forward or anchored equivalent-diverged advance as `no-mistakes sync`; blocked states never trigger destructive Git recovery. When the owning run ended without publishing its pipeline commits, the same box offers `u recover custody` instead: `u` opens a confirmation naming the terminal status, the local head, and the preserved head, and applying routes through the guarded recovery documented in [`no-mistakes axi sync`](/no-mistakes/reference/cli/#no-mistakes-axi-sync). +When the state instead carries `next_action.code: return_custody_keep_local` - the terminal run's recorded pipeline head can no longer be verified, so there is nothing to recover - the box offers `u settle custody at local head`, and its own separate confirmation names the kept local head and the unverifiable recorded head before applying. Settlement keeps your head where recovery takes the preserved one, so the two never share a confirmation; applying routes through the same `--recover --keep-local` settlement documented in [`no-mistakes axi sync`](/no-mistakes/reference/cli/#no-mistakes-axi-sync). ### Footer @@ -193,7 +194,7 @@ When the instruction editor is open, press `Ctrl+s` or `Ctrl+enter` to save, or | `?` | Toggle help overlay | | `y` | Toggle yolo mode, which auto-resolves paused steps | | `r` | Retry a failed fix-review state or diff load; otherwise start a rerun after a failed or cancelled run | -| `u` | Refresh and confirm local branch synchronization, or confirm custody recovery, when offered | +| `u` | Refresh and confirm local branch synchronization, or confirm custody recovery or custody settlement at the local head, when offered | | `q` | Detach from TUI (or quit if run is done) | In diff view, `n`/`p` jumps the viewport to the file and line of the next/previous finding. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 0c829bb33..d6f16c72f 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -115,7 +115,7 @@ When starting a new run, `axi run` refuses the default branch and uncommitted wo Reattaching to an in-flight run does not require `--intent`. Reattachment accepts either the run's immutable submitted head or its current pipeline head, so pipeline-created fix commits do not detach an unchanged submitting worktree. When neither identity matches, `axi run` keeps the fresh-run path but refuses a gate push while `branch_sync` says the pipeline still owns the branch. -That refusal returns the complete structured state and its `continue_active_run` or `recover_custody` next action instead of a raw Git non-fast-forward. +That refusal returns the complete structured state and whichever next action that state carries - `continue_active_run`, `recover_custody`, or the `return_custody_keep_local` settlement described under [`no-mistakes axi sync`](#no-mistakes-axi-sync) - instead of a raw Git non-fast-forward. Reattaching to an in-flight run can proceed while the daemon is already running even if the global config file has become invalid, but starting a fresh run still requires valid global config. Starting a fresh run also requires a runnable effective pipeline agent. If the configured native agent or ACP runner is unavailable, the run fails before any pipeline step starts instead of reporting command-only validation as a passed gate. diff --git a/internal/cli/axi_settle_custody_test.go b/internal/cli/axi_settle_custody_test.go new file mode 100644 index 000000000..c694e057c --- /dev/null +++ b/internal/cli/axi_settle_custody_test.go @@ -0,0 +1,128 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kunchenguid/no-mistakes/internal/custody" + "github.com/kunchenguid/no-mistakes/internal/db" + "github.com/kunchenguid/no-mistakes/internal/paths" +) + +// TestWedgedCustodyRecordSettlesThroughTheCLI walks the whole issue #824 +// operator journey on the surface the operator actually types, rather than on +// the branchsync service the unit tests drive directly: a terminal run whose +// recorded pipeline head is in no object store leaves the branch +// pipeline_owned, and before this change every command the surfaces offered +// refused on that same unverifiable head, so the branch could never be taken +// back. +// +// The journey has to hold end to end, not just report a next action: the +// advertised command must be the one that completes, it must leave the gate +// branch at the head the operator kept, and a second read must show the branch +// released - which is the only proof the dead end is actually gone. +func TestWedgedCustodyRecordSettlesThroughTheCLI(t *testing.T) { + runID, p, local := wedgedCustodyAbortFixture(t) + gateDir, keptHead := wedgedFixtureGateAndHead(t, p, runID, local) + displacedGateHead := cliGit(t, gateDir, "rev-parse", "refs/heads/feature/wedged^{commit}") + if displacedGateHead == keptHead { + t.Fatalf("fixture must start with the gate branch off the kept head: %s", keptHead) + } + + blocked, err := executeCmd("axi", "sync", "--check") + t.Logf("STEP 1 - `no-mistakes axi sync --check` on the wedged record:\n%s", blocked) + if err == nil { + t.Fatalf("a wedged custody record must refuse the ordinary plan:\n%s", blocked) + } + for _, want := range []string{ + "state: pipeline_owned", + "code: return_custody_keep_local", + "command: no-mistakes axi sync --recover --keep-local", + "Run `no-mistakes axi sync --recover --keep-local`", + } { + if !strings.Contains(blocked, want) { + t.Errorf("wedged plan missing %q:\n%s", want, blocked) + } + } + + // The plain recovery is the command that can only refuse on this record, + // so the surface must not be quietly settling it under --recover alone. + refused, err := executeCmd("axi", "sync", "--recover") + t.Logf("STEP 2 - `no-mistakes axi sync --recover` (no --keep-local) still refuses:\n%s", refused) + if err == nil { + t.Fatalf("the plain recovery must refuse a record with no verifiable head:\n%s", refused) + } + if strings.Contains(refused, "state: custody_returned") { + t.Errorf("the plain recovery settled a wedged record:\n%s", refused) + } + if cliGit(t, gateDir, "rev-parse", "refs/heads/feature/wedged^{commit}") != displacedGateHead { + t.Error("a refused recovery moved the gate branch") + } + + settled, err := executeCmd("axi", "sync", "--recover", "--keep-local") + t.Logf("STEP 3 - the advertised `no-mistakes axi sync --recover --keep-local` completes:\n%s", settled) + if err != nil { + t.Fatalf("the advertised settlement must complete: %v\n%s", err, settled) + } + for _, want := range []string{"state: custody_returned", "recovered: true", "safety: custody_returned"} { + if !strings.Contains(settled, want) { + t.Errorf("settlement output missing %q:\n%s", want, settled) + } + } + + // The worktree is never touched, and the gate branch follows the head the + // operator kept - both are the settlement's stated contract. + if head := cliGit(t, local, "rev-parse", "HEAD"); head != keptHead { + t.Errorf("settlement moved the worktree HEAD: %s -> %s", keptHead, head) + } + if gateHead := cliGit(t, gateDir, "rev-parse", "refs/heads/feature/wedged^{commit}"); gateHead != keptHead { + t.Errorf("gate branch = %s, want the kept local head %s", gateHead, keptHead) + } + // The displaced gate head is never dropped on the floor: it stays + // reachable through the run's gate anchor. + if anchored := cliGit(t, gateDir, "rev-parse", custody.RecoveryGateRef(runID)+"^{commit}"); anchored != displacedGateHead { + t.Errorf("displaced gate head anchor = %s, want %s", anchored, displacedGateHead) + } + + after, err := executeCmd("axi", "sync", "--check") + t.Logf("STEP 4 - `no-mistakes axi sync --check` after settling:\n%s", after) + if strings.Contains(after, "state: pipeline_owned") { + t.Errorf("the branch is still held by the terminal run after settling:\n%s", after) + } + + database, err := db.Open(p.DB()) + if err != nil { + t.Fatal(err) + } + defer database.Close() + run, err := database.GetRun(runID) + if err != nil { + t.Fatal(err) + } + if run.CustodyReturnedAt == nil { + t.Error("the custody return was not recorded on the run row") + } +} + +// wedgedFixtureGateAndHead resolves the fixture's real gate directory and the +// head the operator is keeping, so the journey asserts against live Git state +// rather than restating what the fixture built. +func wedgedFixtureGateAndHead(t *testing.T, p *paths.Paths, runID, local string) (string, string) { + t.Helper() + database, err := db.Open(p.DB()) + if err != nil { + t.Fatal(err) + } + defer database.Close() + run, err := database.GetRun(runID) + if err != nil { + t.Fatal(err) + } + gateDir := p.RepoDir(run.RepoID) + if _, err := os.Stat(filepath.Join(gateDir, "HEAD")); err != nil { + t.Fatalf("fixture gate is not a repository: %v", err) + } + return gateDir, cliGit(t, local, "rev-parse", "HEAD") +} From c247762787080c8908a74a97eda852fa5696439a Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:48:33 -0400 Subject: [PATCH 18/31] fix(branchsync): make every custody refusal true of its own code path Corrective round over the settlement's message and probe honesty. Each fix was proven by reverting it and watching its new test fail. Two were more than wording. The compare-and-swap published the head the gate-side fetch had copied without re-reading the branch, so a commit landing in that window stamped custody at a head the worktree had already moved past - the test shows the old code returning custody_returned for exactly that. And a failed update-ref was reported as a concurrent gate push, when a held ref lock, a permission problem or an I/O error fails it identically; the gate head is now re-read and a race claimed only when it actually differs, with blocked_recover_swap_failed for everything else. The stamp-failure retry gets its own complete_custody_return code. recover_custody and return_custody_keep_local are each a claim about the record - that a preserved head is importable, or that the recorded head can no longer be verified - and the skill, the CLI guidance, the docs and the TUI confirmation all restate that claim in their own words. Neither is true of a retry whose recovery already ran, and the ordinary keep-local path reaches it with a fully verified head. Its message is scoped to "any Git changes this recovery makes" because three callers arrive having applied nothing. The advertisement predicates no longer conflate an error with a benign answer: an unreadable gate branch is not a proven-absent one, and the self-inconsistency probe now uses the same tri-state CommitPresence the pin loop does, so advertisement and write cannot disagree. Both were masked by a caller-side ordering the helpers never stated, which is how the original defect got in. Staging-ref cleanup failures are surfaced instead of swallowed, since the refusals around them deny leaving anything behind. The fixture work matters more than any single fix. Two defects survived a whole round because the fixtures encoded the same assumption the code did: every pre-swap refusal test used a fixture whose recorded head exists in no store, so the settlement pinned nothing and the anchor note was always empty; every stamp-failure test had the gate genuinely moving, so no test constructed a no-op recovery. Both paths now have fixtures. The four --keep-local help surfaces are pinned by a test driving real --help output, because the drift that missed one of them was an executable contract with no executable check. Refs #824. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U --- .../branch-sync-and-push-safety/SKILL.md | 4 +- docs/src/content/docs/reference/cli.md | 4 +- internal/branchsync/recover_test.go | 182 +++++++++++++++++- internal/branchsync/sync.go | 133 +++++++++++-- internal/cli/axi_drive.go | 2 +- internal/cli/sync_help_contract_test.go | 58 ++++++ 6 files changed, 357 insertions(+), 26 deletions(-) create mode 100644 internal/cli/sync_help_contract_test.go diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index 1460ed3f3..d56683fb6 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -19,8 +19,8 @@ metadata: Terminalization pins every verified unpublished head at `refs/no-mistakes/recover/` before the managed worktree can be removed. Recovery reads that run-specific ref rather than requiring the gate branch to match, so aborts, rebases, and pre-push failures remain recoverable while an independently moved gate branch is preserved. Legacy recorded heads that still exist as dangling gate objects are anchored on recovery; a truly missing recorded head never advertises an impossible `recover_custody` command and instead reports the keep-local settlement below, falling back to manual reconciliation whenever that settlement is itself unreachable. When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, already-recovered, and absent-gate-branch paths return custody without moving any branch ref (equal/ahead still writes the private recovery anchor, which is what anchoring a locally reachable preserved head means), and the flag help must keep saying so. `--keep-local` is also the ONLY settlement for a SELF-INCONSISTENT custody record (#824): a terminal run whose recorded pipeline head is in no reachable object store, or whose own recovery evidence names something else, has nothing verifiable to import, so every default recovery refused, `abort` of the terminal run was a no-op, and the branch stayed `pipeline_owned` forever. Inspection names that exit (`next_action.code: return_custody_keep_local`) instead of #814's dead-end manual-reconciliation pointer, terminal-run `abort` responses carry the same command, and the TUI `u` action reaches the same settlement - keyed on that advertised next action, never on a safety code, so it cannot drift from the predicate that decides where the settlement can complete. #814's polarity (never advertise `recover_custody` for an unverifiable record) is preserved. Terminal-run no-op abort help is separately allowlisted to custody-settlement codes only (`custodySettlementHelp`) AND gated on `StatePipelineOwned`, so an abort that cancelled nothing never answers by prescribing a fresh run against a released branch, nor by repeating ordinary-divergence `git log` advice; a SUCCESSFUL cancellation still echoes the branch's own next action. - Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure runs AFTER the Git side already succeeded, so it reports that the changes are applied and names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Advertising any of those recreates the very wedge this change removes. - Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every PRE-SWAP refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The lost CAS is excluded from that substitution on purpose: it has already written the gate anchor, so it keeps its own narrower "no LOCAL files or refs were changed" claim and APPENDS the note. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. + Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. It carries its OWN `complete_custody_return` code rather than reusing `recover_custody`/`return_custody_keep_local`, because those codes are claims about the record that the skill, the CLI guidance, the docs and the TUI each restate in their own words - and neither claim is true of a retry. Its message scopes to "any Git changes this recovery makes", since the proven-absent-gate-branch, no-gate and gate-already-at-the-kept-head paths reach it having applied nothing. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Advertising any of those recreates the very wedge this change removes. + Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every PRE-SWAP refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The lost CAS is excluded from that substitution on purpose: it has already written the gate anchor, so it keeps its own narrower "no LOCAL files or refs were changed" claim and APPENDS the note. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. A failed swap is only reported as a race once the gate head is re-read and actually differs; a still-unmoved or unreadable head is `blocked_recover_swap_failed` instead, because a held lock or an I/O error fails `update-ref` identically and inventing a racing actor is the same overstatement in a different place. The staged head proves only what the fetch copied, so the branch is re-read before the swap: a commit landing in that window would otherwise stamp custody at a head the worktree has already moved past. The full relation matrix and fail-safe rules live in the `Recover` doc comment in `internal/branchsync/sync.go`. - Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/cli/axi_settle_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index d6f16c72f..7b9ecdec8 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -236,12 +236,12 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`; a conflicting pre-existing anchor makes recovery refuse, and a concurrent gate push wins the compare-and-swap and also makes recovery refuse. The anchor is written immediately before the compare-and-swap, so every refusal that can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. A lost compare-and-swap does leave that anchor pinned at the head the attempt observed, because the gate has moved and the anchor may be the only ref still naming the displaced head; nothing retires it, so once the gate has moved on, later attempts refuse on the anchor conflict until you reconcile that ref in the gate by hand. That refusal names the ref and the gate directory, and reports that no *local* files or refs were changed - it keeps that narrower claim precisely because it has just written a ref in the gate, and where the attempt had already anchored the preserved head it appends the same anchor note rather than dropping the `local` qualifier. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, naming the anchor to reconcile and reporting that no *local* files or refs were changed - it keeps that narrower claim because it has just written that anchor into the gate, and appends the preserved-head anchor note rather than dropping the `local` qualifier. That anchor stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. The gate branch then moves by the same compare-and-swap, so a concurrent gate push still wins and the settlement refuses; only a gate branch proven absent settles without that swap, since an unreadable gate branch is not evidence of absence. -The settlement's own refusals, and every refusal raised while `--keep-local` moves the gate branch, name `next_action.code: inspect_and_reconcile_manually`. Other `--recover` refusals carry whatever next action their state already defines, and some carry none - including a missing recorded head under the default `--recover`. A failure to record custody AFTER the move itself succeeded is not a refusal and is reported as `safety: blocked_recover_stamp_failed`: the Git changes are already applied, so it says so and names the same recovery command again (`recover_custody`, or `return_custody_keep_local` for `--keep-local`), which completes the record because every Git step it repeats is idempotent once applied. +The settlement's own refusals, and every refusal raised while `--keep-local` moves the gate branch, name `next_action.code: inspect_and_reconcile_manually`. Other `--recover` refusals carry whatever next action their state already defines, and some carry none - including a missing recorded head under the default `--recover`. A failure to record custody AFTER the Git side finished is not a refusal and is reported as `safety: blocked_recover_stamp_failed`: it names the same recovery command again under its own `next_action.code: complete_custody_return`, which completes the record because every Git step it repeats is idempotent once applied. That code is deliberately not `recover_custody` or `return_custody_keep_local`: each of those is a claim about the record itself, and neither is being made here. The message scopes its claim to "any Git changes this recovery makes" rather than asserting a mutation, because the paths that return before the gate is reached arrive there having changed nothing. A terminal run whose head was never verified is excluded: recovery refuses it earlier, so status keeps reporting manual reconciliation rather than advertising a settlement that would refuse. `no-mistakes axi abort` on an already-terminal run stays an idempotent no-op - there is nothing left to cancel - but its response names that settlement command when the invoking worktree's branch is still held by that run. `no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index b57ef0c40..75663f932 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3172,12 +3172,24 @@ func TestKeepLocalStampFailureAfterTheGateMovedNamesACompletableRetry(t *testing if state.NextAction == nil { t.Fatalf("stamp failure named no exit at all = %#v", state) } - if state.NextAction.Code != "return_custody_keep_local" { - t.Fatalf("stamp failure named %q, want the settlement it was performing", state.NextAction.Code) + // The retry must NOT reuse the settlement's code. return_custody_keep_local + // promises - in the skill, the CLI guidance, the docs and the TUI + // confirmation - that the recorded head can no longer be verified, and the + // ordinary keep-local path reaches this failure with a fully verified head. + if state.NextAction.Code == "return_custody_keep_local" { + t.Fatal("stamp failure reused the settlement code, which tells the operator the recorded head is unverifiable") + } + if state.NextAction.Code != "complete_custody_return" { + t.Fatalf("stamp failure named %q, want complete_custody_return", state.NextAction.Code) } if !strings.Contains(state.NextAction.Command, "--keep-local") { t.Fatalf("stamp-failure retry command = %q", state.NextAction.Command) } + // It must also not overstate: three callers reach finishRecover having + // applied nothing, so the claim is scoped rather than asserted. + if !strings.Contains(state.Error, "any Git changes this recovery makes") { + t.Fatalf("stamp-failure message asserts a mutation instead of scoping it: %q", state.Error) + } // The prescribed retry must complete, not refuse: the gate now already // equals the kept head, so the settlement's whole move is skipped. @@ -3205,8 +3217,11 @@ func TestDefaultRecoveryStampFailureNamesItsOwnRetry(t *testing.T) { if state.Safety != "blocked_recover_stamp_failed" { t.Fatalf("default-recovery stamp-failure safety = %q: %#v", state.Safety, state) } - if state.NextAction == nil || state.NextAction.Code != "recover_custody" { - t.Fatalf("default-recovery stamp failure named %#v, want recover_custody", state.NextAction) + if state.NextAction == nil || state.NextAction.Code != "complete_custody_return" { + t.Fatalf("default-recovery stamp failure named %#v, want complete_custody_return", state.NextAction) + } + if state.NextAction.Code == "recover_custody" { + t.Fatal("stamp failure reused the advertisement code, which claims a preserved head is importable") } if strings.Contains(state.NextAction.Command, "--keep-local") { t.Fatalf("default recovery prescribed the keep-local settlement: %q", state.NextAction.Command) @@ -3218,3 +3233,162 @@ func TestDefaultRecoveryStampFailureNamesItsOwnRetry(t *testing.T) { t.Fatalf("the retry the stamp failure prescribes did not complete = %#v", retry) } } + +// TestPreSwapRefusalNamesTheStrandedPinTheSettlementWrote closes a FIXTURE gap +// rather than a code gap, and the gap is why the defect survived a whole round. +// Every earlier pre-swap refusal test uses wedgedCustodyFixture, where the +// recorded head exists in no store, so the settlement's pin loop pins nothing +// and anchoredNote is always empty - the fixture encoded the same assumption +// the code did, so no test could fail. This one uses the shape where the +// recorded head IS still present: the settlement pins it, delegates, and the +// pre-swap refusal must then report the pin instead of claiming a blanket +// nothing-changed. +func TestPreSwapRefusalNamesTheStrandedPinTheSettlementWrote(t *testing.T) { + t.Parallel() + + // Conflicting gate recovery evidence, recorded head still present. + f := newRecoverFixture(t, types.RunCancelled) + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.submitted) + moved := false + f.service.beforeGateReset = func() { + if moved { + return + } + moved = true + mustWrite(t, filepath.Join(f.local, "raced.txt"), "local moved mid-settlement\n") + mustRun(t, f.local, "add", "raced.txt") + mustRun(t, f.local, "commit", "-m", "operator commit during settlement") + } + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != "blocked_recover_assumptions_changed" { + t.Fatalf("pre-swap refusal = %#v", state) + } + stranded := custody.RecoveryStrandedRef(f.run.ID) + if got := mustRun(t, f.gate, "rev-parse", stranded); got != f.preserved { + t.Fatalf("fixture invariant broken: stranded pin = %s, want the preserved head %s", got, f.preserved) + } + // The pin exists, so the blanket denial would be false. + if strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("refusal denied every change while a pin it wrote survives: %q", state.Error) + } + if !strings.Contains(state.Error, stranded) { + t.Fatalf("refusal did not name the pin the settlement wrote: %q", state.Error) + } + // And the displaced-gate-head anchor is still not left behind. + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, custody.RecoveryGateRef(f.run.ID)); err != nil || exists { + t.Fatalf("pre-swap refusal left a gate anchor: exists=%v err=%v", exists, err) + } +} + +// TestNoOpSettlementStampFailureDoesNotClaimGitChangesItNeverMade covers the +// three callers that reach finishRecover having applied nothing - here the +// settlement returning early because the gate branch is PROVEN absent. The +// earlier stamp-failure fixtures all had the gate genuinely moving, so nothing +// exercised a no-op recovery whose stamp then fails, and the message could +// assert a mutation that never happened without any test noticing. +func TestNoOpSettlementStampFailureDoesNotClaimGitChangesItNeverMade(t *testing.T) { + t.Parallel() + + f, _, _ := wedgedCustodyFixture(t, types.RunFailed) + // Proven absent: no gate branch at all, so the settlement completes with + // no compare-and-swap and touches nothing. + mustRun(t, f.gate, "update-ref", "-d", "refs/heads/feature/recover") + f.service.stampCustodyReturnedFn = func(string) error { + return errors.New("database is locked") + } + + state := f.service.Recover(f.ctx, true) + if state.Safety != "blocked_recover_stamp_failed" { + t.Fatalf("no-op settlement stamp failure = %#v", state) + } + if strings.Contains(state.Error, "the recovery's Git changes are already applied") { + t.Fatalf("stamp failure asserted a mutation on a path that made none: %q", state.Error) + } + if !strings.Contains(state.Error, "any Git changes this recovery makes") { + t.Fatalf("stamp failure did not scope its claim: %q", state.Error) + } + if state.NextAction == nil || state.NextAction.Code != "complete_custody_return" { + t.Fatalf("no-op settlement stamp failure named %#v", state.NextAction) + } + f.service.stampCustodyReturnedFn = nil + if retry := f.service.Recover(f.ctx, true); !retry.Recovered { + t.Fatalf("the prescribed retry did not complete = %#v", retry) + } +} + +// TestKeepLocalRefusesWhenTheBranchMovesAfterItIsStaged covers the window +// between the gate-side fetch and the compare-and-swap. The staged SHA is read +// from the GATE, so it only proves what the fetch copied; without re-reading +// the branch itself, a commit landing in that window is published nowhere while +// custody is stamped at the older head. +func TestKeepLocalRefusesWhenTheBranchMovesAfterItIsStaged(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + staged := false + f.service.afterGateStage = func() { + if staged { + return + } + staged = true + mustWrite(t, filepath.Join(f.local, "late.txt"), "committed after staging\n") + mustRun(t, f.local, "add", "late.txt") + mustRun(t, f.local, "commit", "-m", "operator commit after staging") + } + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != "blocked_recover_assumptions_changed" { + t.Fatalf("post-staging move = %#v", state) + } + if f.custodyReturned() { + t.Fatal("custody was stamped at a head the branch had already moved past") + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != staleGate { + t.Fatalf("refusal moved the gate branch = %s, want %s", got, staleGate) + } + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, "refs/no-mistakes/custody-return/"+f.run.ID); err != nil || exists { + t.Fatalf("refusal left the staging ref behind: exists=%v err=%v", exists, err) + } +} + +// TestUnmovedGateSwapFailureIsNotReportedAsAConcurrentPush keeps the refusal +// honest about its own cause. A failed update-ref is not evidence that the gate +// moved - a held lock, a permission problem or an I/O error fails identically - +// so claiming a concurrent push would invent a racing actor that may not exist. +func TestUnmovedGateSwapFailureIsNotReportedAsAConcurrentPush(t *testing.T) { + t.Parallel() + + f, staleGate, _ := wedgedCustodyFixture(t, types.RunFailed) + // Hold the branch's ref lock so update-ref fails while the head is unmoved. + lock := filepath.Join(f.gate, "refs", "heads", "feature", "recover.lock") + if err := os.MkdirAll(filepath.Dir(lock), 0o755); err != nil { + t.Fatal(err) + } + f.service.afterGateStage = func() { + if err := os.WriteFile(lock, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { _ = os.Remove(lock) }) + + state := f.service.Recover(f.ctx, true) + if state.Recovered { + t.Fatalf("locked swap reported success = %#v", state) + } + if state.Safety == "blocked_recover_gate_race" { + t.Fatalf("an unmoved gate was reported as a concurrent push: %#v", state) + } + if state.Safety != "blocked_recover_swap_failed" { + t.Fatalf("locked swap safety = %q, want blocked_recover_swap_failed: %#v", state.Safety, state) + } + if !strings.Contains(state.Error, "not a concurrent gate push") { + t.Fatalf("refusal did not disclaim the race it could not prove: %q", state.Error) + } + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != staleGate { + t.Fatalf("failed swap moved the gate branch = %s, want %s", got, staleGate) + } + if state.NextAction == nil { + t.Fatalf("swap failure named no exit = %#v", state) + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 0a7e9fe65..4c3c92800 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -168,6 +168,7 @@ type Service struct { beforeApply func() beforeGateReset func() beforeGateStage func() + afterGateStage func() beforeRecoverWorktreeMove func() beforeRecoverBranchMove func() afterRecoverBranchMove func() @@ -989,26 +990,59 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State stagingRef := "refs/no-mistakes/custody-return/" + run.ID if _, err := git.Run(ctx, s.GateDir, "fetch", "--no-tags", "--no-write-fetch-head", source, "+refs/heads/"+state.Local.Branch+":"+stagingRef); err != nil { // A partly completed fetch can still have created the staging ref, - // and the refusal claims nothing was left behind. - _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)) + // and the refusal claims nothing was left behind - so a cleanup + // that itself fails has to be reported rather than swallowed, + // exactly like every other write this function denies making. + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)+s.releaseStagingRef(ctx, stagingRef)) + } + if s.afterGateStage != nil { + s.afterGateStage() } staged, err := git.Run(ctx, s.GateDir, "rev-parse", stagingRef+"^{commit}") if err != nil || staged != state.Local.Head { - _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)) + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)+s.releaseStagingRef(ctx, stagingRef)) + } + // The staged head was read from the gate, not from the worktree, so it + // only proves what the fetch copied. Re-read the branch itself: a + // commit landing between the fetch and the swap would otherwise be + // published nowhere while custody is stamped at the older head, + // leaving the operator ahead of the gate with the same stale-gate + // shape this settlement exists to clear. + if live, err := git.Run(ctx, s.workDir(), "rev-parse", "refs/heads/"+state.Local.Branch+"^{commit}"); err != nil || live != state.Local.Head { + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch moved after its head was staged into the gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)+s.releaseStagingRef(ctx, stagingRef)) } // Point of no return: from here the gate branch is about to leave // gateHead, so the anchor has to exist first. if writeGateAnchor { if err := custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead); err != nil { - _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) - return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; "+keepLocalNoChangeClause("no files or branch refs were changed", anchoredNote)) + return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; "+keepLocalNoChangeClause("no files or branch refs were changed", anchoredNote)+s.releaseStagingRef(ctx, stagingRef)) } } _, casErr := git.Run(ctx, s.GateDir, "update-ref", "refs/heads/"+state.Local.Branch, state.Local.Head, gateHead) - _, _ = git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef) + stagingLeft := s.releaseStagingRef(ctx, stagingRef) if casErr != nil { + // A failed update-ref is not evidence that the gate moved: a lock + // held by another process, a permission problem, or an I/O error + // fails the same call. Re-read the branch and only claim a race + // when the head actually differs; anything else - including a head + // we cannot read - is reported as an unexplained swap failure + // rather than as a concurrent push that may not have happened. + live, liveErr := git.Run(ctx, s.GateDir, "rev-parse", "refs/heads/"+state.Local.Branch+"^{commit}") + if liveErr != nil || live == gateHead { + detail := "the gate branch could not be re-read afterwards" + if liveErr == nil { + detail = "the gate branch is still at " + gateHead + ", so nothing had moved it" + } + // This site sits AFTER the anchor write, so it carries the + // same obligation as the lost-swap refusals below: when this + // attempt pinned the displaced gate head, say so and name the + // ref, rather than leaving the operator to discover it. + wrote := "" + if gateHeadAnchored { + wrote = fmt.Sprintf("; the run recovery anchor %s names the gate head this attempt observed and is not retired here", custody.RecoveryGateRef(run.ID)) + } + return recoverBlocked(state, "blocked_recover_swap_failed", fmt.Sprintf("the compare-and-swap onto the kept local head failed and %s, so this is not a concurrent gate push; inspect the local gate %s before returning custody%s; no local files or refs were changed%s%s", detail, s.GateDir, wrote, anchoredNote, stagingLeft)) + } // The displaced-gate-head anchor is written only above, when the // gate had already moved off the recorded head. Without it a retry // simply observes the new head and succeeds; with it, the retry @@ -1017,14 +1051,29 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // failed because the gate moved, so gateHead may now be reachable // through this anchor alone. if gateHeadAnchored { - return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; no local files or refs were changed%s", custody.RecoveryGateRef(run.ID), anchoredNote)) + return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; no local files or refs were changed%s%s", custody.RecoveryGateRef(run.ID), anchoredNote, stagingLeft)) } - return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; no local files or refs were changed"+anchoredNote) + return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; no local files or refs were changed"+anchoredNote+stagingLeft) } } return s.finishRecover(ctx, run, false, true) } +// releaseStagingRef removes the temporary ref the gate-side fetch stages the +// kept head under, and returns a clause naming it when the removal FAILED. +// Every refusal around it denies leaving anything behind, so a swallowed +// cleanup error would make that denial false in exactly the way this whole +// change exists to prevent. The empty string is the ordinary case, so callers +// append it unconditionally. +func (s *Service) releaseStagingRef(ctx context.Context, stagingRef string) string { + if _, err := git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef); err != nil { + if _, exists, probeErr := git.ExactRefTarget(ctx, s.GateDir, stagingRef); probeErr != nil || exists { + return fmt.Sprintf("; the staging ref %s could not be removed from the local gate %s and remains there", stagingRef, s.GateDir) + } + } + return "" +} + // recoverFastForward advances the clean checked-out branch to the preserved // pipeline head with the same strict fast-forward and honesty rules as Apply. func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state State, preserved string) State { @@ -1245,12 +1294,25 @@ func (s *Service) anchorReachablePreserved(ctx context.Context, state State, run // equals the kept head so recoverKeepLocal skips its whole move, and an // already-advanced worktree takes the equal/ahead path. That is why the retry // is named structurally here and not only in prose. +// +// Two details keep the report honest. The message says "any Git changes this +// recovery makes" rather than asserting that changes were made, because three +// callers reach here having applied nothing at all: the settlement returns +// early when the gate branch is PROVEN absent or no gate is configured, and +// recoverKeepLocal skips its whole block when the gate already names the kept +// head. Claiming a mutation on those paths would overstate, which is the +// direction that matters in this subsystem. And the retry carries its OWN +// action code rather than reusing the settlement's: return_custody_keep_local +// promises the operator, in the skill, the CLI guidance, the docs and the TUI +// confirmation, that the recorded head can no longer be verified - which is +// false here, since the ordinary keep-local path reaches this with a fully +// verified head. func (s *Service) finishRecover(ctx context.Context, run *db.Run, changed, keepLocal bool) State { if err := s.stampCustodyReturned(run.ID); err != nil { state, _, _ := s.inspect(ctx) state.Changed = changed state.Safety = "blocked_recover_stamp_failed" - state.Error = "the recovery's Git changes are already applied, but the custody return could not be recorded; re-run the same recovery to complete the record" + state.Error = "any Git changes this recovery makes are already applied, but the custody return could not be recorded; re-run the same recovery to complete the record" state.NextAction = recoveryRetryAction(keepLocal) return state } @@ -1261,12 +1323,19 @@ func (s *Service) finishRecover(ctx context.Context, run *db.Run, changed, keepL } // recoveryRetryAction names the exact command that finishes an interrupted -// recovery: the one the operator already ran. +// recovery: the one the operator already ran. Its code is deliberately +// complete_custody_return on both paths rather than the code that ORIGINALLY +// advertised the recovery. recover_custody and return_custody_keep_local are +// each a claim about the record - that a preserved head is importable, or that +// the recorded head can no longer be verified - and every consumer repeats +// that claim in its own words. Here neither claim is being made: the recovery +// already ran and only its bookkeeping is missing, so the retry needs an +// identity of its own. func recoveryRetryAction(keepLocal bool) *NextAction { if keepLocal { - return &NextAction{Code: "return_custody_keep_local", Command: "no-mistakes axi sync --recover --keep-local"} + return &NextAction{Code: "complete_custody_return", Command: "no-mistakes axi sync --recover --keep-local"} } - return &NextAction{Code: "recover_custody", Command: "no-mistakes axi sync --recover"} + return &NextAction{Code: "complete_custody_return", Command: "no-mistakes axi sync --recover"} } // stampCustodyReturned is the recovery's only database write, behind a seam so @@ -1825,7 +1894,19 @@ func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, state *Stat if preserved == "" { return true } - if !objectExists(ctx, wd, preserved) && !objectExists(ctx, gateDir, preserved) { + // Mirror the settlement's own probe rather than the bare boolean one: the + // pin loop treats only a PROVEN absence as safe, so an unreadable store or + // a present-but-wrong-type object must not be read here as "nothing has + // it" and advertised as settleable when the settlement would refuse. + // settlementAnchorsFree already fails closed on those shapes above, so + // this is agreement rather than a second gate - but the two probes must + // not be allowed to disagree. + wdPresent, wdErr := git.CommitPresence(ctx, wd, preserved) + gatePresent, gateErr := git.CommitPresence(ctx, gateDir, preserved) + if wdErr != nil || gateErr != nil { + return false + } + if !wdPresent && !gatePresent { return true } if !gateCompatible { @@ -1928,8 +2009,26 @@ func (s *Service) settlementAnchorsFree(ctx context.Context, state *State, run * if state == nil || strings.TrimSpace(state.Local.Branch) == "" { return false } - gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+state.Local.Branch+"^{commit}") - if err != nil || gateHead == state.Local.Head || gateHead == preserved { + // Only a PROVEN absent gate branch is the deliberate no-swap-needed case. + // An unreadable one is not evidence of absence, and reading it as "free" + // would advertise a settlement that then refuses inside itself with + // blocked_recover_gate_unavailable. settlementGateBranchUsable happens to + // reject that shape before this function runs, but relying on a + // caller-side ordering this function never states is how the original bug + // got in, so it fails closed on its own too. + gateRef := "refs/heads/" + state.Local.Branch + _, gateBranchExists, err := git.ExactRefTarget(ctx, gateDir, gateRef) + if err != nil { + return false + } + if !gateBranchExists { + return true + } + gateHead, err := git.Run(ctx, gateDir, "rev-parse", gateRef+"^{commit}") + if err != nil { + return false + } + if gateHead == state.Local.Head || gateHead == preserved { return true } return anchorFreeAt(gateDir, custody.RecoveryGateRef(run.ID), gateHead) diff --git a/internal/cli/axi_drive.go b/internal/cli/axi_drive.go index c2cdabfc1..22699b231 100644 --- a/internal/cli/axi_drive.go +++ b/internal/cli/axi_drive.go @@ -301,7 +301,7 @@ func custodySettlementHelp(action *branchsync.NextAction) []string { return nil } switch action.Code { - case "recover_custody", "return_custody_keep_local", "inspect_and_reconcile_manually": + case "recover_custody", "return_custody_keep_local", "complete_custody_return", "inspect_and_reconcile_manually": return []string{ "Run `" + action.Command + "`", branchSyncAgentGuidance, diff --git a/internal/cli/sync_help_contract_test.go b/internal/cli/sync_help_contract_test.go new file mode 100644 index 000000000..74b8785b1 --- /dev/null +++ b/internal/cli/sync_help_contract_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "strings" + "testing" +) + +// TestKeepLocalHelpSurfacesStayConditional pins the four operator-facing +// descriptions of `--keep-local` to what the code actually does. +// +// This exists because the drift it catches already happened twice. The flag +// help claimed unconditionally that the gate branch compare-and-swaps onto the +// kept head, which is false for every path that returns before recoverKeepLocal +// is reached: the equal/ahead branch stamps custody without touching the gate, +// the settlement returns early when the gate branch is proven absent or no gate +// is configured, and recoverKeepLocal skips its whole block when the gate +// already names the kept head. The first correction fixed three of the four +// surfaces and missed the `axi sync` flag - the agent-facing one - because a +// help string is an executable contract that had no executable check. +// +// The assertions drive the real cobra help output rather than reading source, +// so they describe what an operator or agent actually receives. +func TestKeepLocalHelpSurfacesStayConditional(t *testing.T) { + for _, tc := range []struct{ name, command string }{ + {"human sync", "sync"}, + {"axi sync", "axi"}, + } { + t.Run(tc.name, func(t *testing.T) { + var out string + var err error + if tc.command == "sync" { + out, err = executeCmd("sync", "--help") + } else { + out, err = executeCmd("axi", "sync", "--help") + } + if err != nil { + t.Fatalf("%s --help: %v\n%s", tc.command, err, out) + } + if !strings.Contains(out, "--keep-local") { + t.Fatalf("%s help does not document --keep-local:\n%s", tc.command, out) + } + // Every surface must condition the gate move on the gate branch + // actually naming something else. + if !strings.Contains(out, "still names a different head") { + t.Errorf("%s help states the gate move unconditionally:\n%s", tc.command, out) + } + // And must not assert the swap as an unconditional consequence. + for _, overstatement := range []string{ + "anchored and the gate branch compare-and-swaps onto the kept head", + "and points the gate branch at the kept head", + } { + if strings.Contains(out, overstatement) { + t.Errorf("%s help carries the unconditional claim %q:\n%s", tc.command, overstatement, out) + } + } + }) + } +} From f84fd42bdadcc353342ad2c53ab6cda4c1dd083e Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:05:00 -0400 Subject: [PATCH 19/31] no-mistakes(review): isolate keep-local help surfaces; drop false custody-return clause --- internal/cli/sync.go | 2 +- internal/cli/sync_help_contract_test.go | 95 ++++++++++++++++++------- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/internal/cli/sync.go b/internal/cli/sync.go index b495d1374..0e52aeb11 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -94,7 +94,7 @@ func newAxiSyncCmd() *cobra.Command { } cmd.Flags().BoolVar(&check, "check", false, "freshly verify and return the plan without changing HEAD") cmd.Flags().BoolVar(&recover, "recover", false, "return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch)") - cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; the worktree is never touched, surviving preserved commits stay anchored, and where the gate branch still names a different head it compare-and-swaps onto the kept head; otherwise custody returns without moving any branch ref. Also settles a record whose preserved head can no longer be verified") + cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; the worktree is never touched, surviving preserved commits stay anchored, and where the gate branch still names a different head it compare-and-swaps onto the kept head. Also settles a record whose preserved head can no longer be verified") return cmd } diff --git a/internal/cli/sync_help_contract_test.go b/internal/cli/sync_help_contract_test.go index 74b8785b1..6567509c4 100644 --- a/internal/cli/sync_help_contract_test.go +++ b/internal/cli/sync_help_contract_test.go @@ -5,6 +5,14 @@ import ( "testing" ) +// unconditionalGateMoveClaims are the phrasings that assert the gate +// compare-and-swap as an unconditional consequence of --keep-local. +var unconditionalGateMoveClaims = []string{ + "anchored and the gate branch compare-and-swaps onto the kept head", + "and points the gate branch at the kept head", + "the preserved commits stay anchored and the gate follows the kept head", +} + // TestKeepLocalHelpSurfacesStayConditional pins the four operator-facing // descriptions of `--keep-local` to what the code actually does. // @@ -19,40 +27,75 @@ import ( // help string is an executable contract that had no executable check. // // The assertions drive the real cobra help output rather than reading source, -// so they describe what an operator or agent actually receives. +// so they describe what an operator or agent actually receives. Each of the +// four surfaces is asserted on its own extracted text: greping the whole help +// blob let one surface satisfy the check on another's behalf, which is how the +// agent-facing flag line stayed unpinned through the first correction. func TestKeepLocalHelpSurfacesStayConditional(t *testing.T) { - for _, tc := range []struct{ name, command string }{ - {"human sync", "sync"}, - {"axi sync", "axi"}, + for _, tc := range []struct { + name string + args []string + }{ + {"human sync", []string{"sync", "--help"}}, + {"axi sync", []string{"axi", "sync", "--help"}}, } { t.Run(tc.name, func(t *testing.T) { - var out string - var err error - if tc.command == "sync" { - out, err = executeCmd("sync", "--help") - } else { - out, err = executeCmd("axi", "sync", "--help") - } + out, err := executeCmd(tc.args...) if err != nil { - t.Fatalf("%s --help: %v\n%s", tc.command, err, out) - } - if !strings.Contains(out, "--keep-local") { - t.Fatalf("%s help does not document --keep-local:\n%s", tc.command, out) + t.Fatalf("%s: %v\n%s", strings.Join(tc.args, " "), err, out) } - // Every surface must condition the gate move on the gate branch - // actually naming something else. - if !strings.Contains(out, "still names a different head") { - t.Errorf("%s help states the gate move unconditionally:\n%s", tc.command, out) - } - // And must not assert the swap as an unconditional consequence. - for _, overstatement := range []string{ - "anchored and the gate branch compare-and-swaps onto the kept head", - "and points the gate branch at the kept head", + + for _, surface := range []struct{ name, text string }{ + {"flag help", keepLocalFlagUsage(t, out)}, + {"long description", helpLongDescription(t, out)}, } { - if strings.Contains(out, overstatement) { - t.Errorf("%s help carries the unconditional claim %q:\n%s", tc.command, overstatement, out) + // Every surface must condition the gate move on the gate + // branch actually naming something else. + if !strings.Contains(surface.text, "still names a different head") { + t.Errorf("%s %s states the gate move unconditionally:\n%s", tc.name, surface.name, surface.text) + } + for _, overstatement := range unconditionalGateMoveClaims { + if strings.Contains(surface.text, overstatement) { + t.Errorf("%s %s carries the unconditional claim %q:\n%s", tc.name, surface.name, overstatement, surface.text) + } } } }) } } + +// keepLocalFlagUsage returns the single `--keep-local` usage line from real +// cobra help output, whitespace-normalized. pflag renders one flag per line and +// does not wrap when no width is configured, so the flag's whole description is +// on the one line whose first token is the flag name. That excludes the Long +// description, which mentions `--keep-local` mid-sentence. +func keepLocalFlagUsage(t *testing.T, help string) string { + t.Helper() + var found []string + for _, line := range strings.Split(help, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "--keep-local") { + found = append(found, normalizeHelpText(line)) + } + } + if len(found) != 1 { + t.Fatalf("want exactly one --keep-local flag usage line, got %d:\n%s", len(found), help) + } + return found[0] +} + +// helpLongDescription returns the command's Long text - everything cobra prints +// before the usage block - whitespace-normalized so hard-wrapped phrases match. +func helpLongDescription(t *testing.T, help string) string { + t.Helper() + long, _, ok := strings.Cut(help, "\nUsage:") + if !ok { + t.Fatalf("help output has no usage block to delimit the long description:\n%s", help) + } + long = normalizeHelpText(long) + if !strings.Contains(long, "--keep-local") { + t.Fatalf("long description does not document --keep-local:\n%s", long) + } + return long +} + +func normalizeHelpText(s string) string { return strings.Join(strings.Fields(s), " ") } From 6901b9c35560a0feddb91b9a7f071a093efdf08f Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:30:39 -0400 Subject: [PATCH 20/31] no-mistakes(review): scope post-swap no-change claims by anchor store; fix settlement trigger --- .../branch-sync-and-push-safety/SKILL.md | 4 +- docs/src/content/docs/reference/cli.md | 2 +- internal/branchsync/recover_test.go | 190 ++++++++++++++++++ internal/branchsync/sync.go | 148 ++++++++++---- 4 files changed, 307 insertions(+), 37 deletions(-) diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index d56683fb6..0d5cc90dd 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -19,8 +19,8 @@ metadata: Terminalization pins every verified unpublished head at `refs/no-mistakes/recover/` before the managed worktree can be removed. Recovery reads that run-specific ref rather than requiring the gate branch to match, so aborts, rebases, and pre-push failures remain recoverable while an independently moved gate branch is preserved. Legacy recorded heads that still exist as dangling gate objects are anchored on recovery; a truly missing recorded head never advertises an impossible `recover_custody` command and instead reports the keep-local settlement below, falling back to manual reconciliation whenever that settlement is itself unreachable. When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, already-recovered, and absent-gate-branch paths return custody without moving any branch ref (equal/ahead still writes the private recovery anchor, which is what anchoring a locally reachable preserved head means), and the flag help must keep saying so. `--keep-local` is also the ONLY settlement for a SELF-INCONSISTENT custody record (#824): a terminal run whose recorded pipeline head is in no reachable object store, or whose own recovery evidence names something else, has nothing verifiable to import, so every default recovery refused, `abort` of the terminal run was a no-op, and the branch stayed `pipeline_owned` forever. Inspection names that exit (`next_action.code: return_custody_keep_local`) instead of #814's dead-end manual-reconciliation pointer, terminal-run `abort` responses carry the same command, and the TUI `u` action reaches the same settlement - keyed on that advertised next action, never on a safety code, so it cannot drift from the predicate that decides where the settlement can complete. #814's polarity (never advertise `recover_custody` for an unverifiable record) is preserved. Terminal-run no-op abort help is separately allowlisted to custody-settlement codes only (`custodySettlementHelp`) AND gated on `StatePipelineOwned`, so an abort that cancelled nothing never answers by prescribing a fresh run against a released branch, nor by repeating ordinary-divergence `git log` advice; a SUCCESSFUL cancellation still echoes the branch's own next action. - Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. It carries its OWN `complete_custody_return` code rather than reusing `recover_custody`/`return_custody_keep_local`, because those codes are claims about the record that the skill, the CLI guidance, the docs and the TUI each restate in their own words - and neither claim is true of a retry. Its message scopes to "any Git changes this recovery makes", since the proven-absent-gate-branch, no-gate and gate-already-at-the-kept-head paths reach it having applied nothing. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Advertising any of those recreates the very wedge this change removes. - Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every PRE-SWAP refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The lost CAS is excluded from that substitution on purpose: it has already written the gate anchor, so it keeps its own narrower "no LOCAL files or refs were changed" claim and APPENDS the note. The anchor CONFLICT check stays first because it is read-only. Exactly one post-write refusal remains, the lost CAS, and it deliberately keeps the anchor: the swap failed because the gate moved, so that pin may be the only ref still naming the displaced head, and its message says "no LOCAL files or refs were changed" and names the ref to reconcile. A failed swap is only reported as a race once the gate head is re-read and actually differs; a still-unmoved or unreadable head is `blocked_recover_swap_failed` instead, because a held lock or an I/O error fails `update-ref` identically and inventing a racing actor is the same overstatement in a different place. The staged head proves only what the fetch copied, so the branch is re-read before the swap: a commit landing in that window would otherwise stamp custody at a head the worktree has already moved past. + Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. It carries its OWN `complete_custody_return` code rather than reusing `recover_custody`/`return_custody_keep_local`, because those codes are claims about the record that the skill, the CLI guidance, the docs and the TUI each restate in their own words - and neither claim is true of a retry. Its message scopes to "any Git changes this recovery makes", since the proven-absent-gate-branch, no-gate and gate-already-at-the-kept-head paths reach it having applied nothing. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Each trigger must therefore be probed the way `Recover` itself probes it: the worktree recovery ref goes through `recoveryAnchorIntercepts`, mirroring Recover's `rev-parse ^{commit}` (which DEREFERENCES a resolving symref, so such a record is never intercepted) rather than the stricter `recoveryAnchorCompatible`, which rejects every symref and advertised a settlement that could only refuse. Advertising any of those recreates the very wedge this change removes. + Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every PRE-SWAP refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The refusals AFTER the swap are excluded from that substitution on purpose: they may already have written the gate anchor, so they keep the narrower "no LOCAL files or refs were changed" claim and APPEND the note - but only where the note names a GATE-side pin. When it names the invoking worktree, which the ordinary keep-local path always pins, that qualifier would deny the very ref the sentence discloses, so the substitution applies after all; the note carries the STORES and not just the prose so the two cannot be confused. The anchor CONFLICT check stays first because it is read-only. TWO post-anchor-write refusals remain - the lost CAS and the unproven-race swap failure - plus the separate post-success stamp failure above. Both keep the anchor deliberately: the swap failed, so that pin may be the only ref still naming the displaced head, and each names the ref to reconcile. A failed swap is only reported as a race once the gate head is re-read and actually differs; a still-unmoved or unreadable head is `blocked_recover_swap_failed` instead, because a held lock or an I/O error fails `update-ref` identically and inventing a racing actor is the same overstatement in a different place. The staged head proves only what the fetch copied, so the branch is re-read before the swap: a commit landing in that window would otherwise stamp custody at a head the worktree has already moved past. The full relation matrix and fail-safe rules live in the `Recover` doc comment in `internal/branchsync/sync.go`. - Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/cli/axi_settle_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 7b9ecdec8..0edaf34f2 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -236,7 +236,7 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, naming the anchor to reconcile and reporting that no *local* files or refs were changed - it keeps that narrower claim because it has just written that anchor into the gate, and appends the preserved-head anchor note rather than dropping the `local` qualifier. That anchor stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, naming the anchor to reconcile. It reports that no *local* files or refs were changed only where that narrower claim is earned - beside a preserved-head anchor pinned in the local gate, which is what the settlement does when the recorded head survives there - and appends the anchor note rather than dropping the `local` qualifier. Where the preserved head was instead anchored in the invoking worktree, which is what the ordinary `--recover --keep-local` path always does, the `local` qualifier would deny the very ref the note names, so the refusal reports that no branch, worktree, or file changes were made and discloses the anchor separately. That anchor stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 75663f932..56570e791 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3392,3 +3392,193 @@ func TestUnmovedGateSwapFailureIsNotReportedAsAConcurrentPush(t *testing.T) { t.Fatalf("swap failure named no exit = %#v", state) } } + +// TestPostSwapRefusalDoesNotDenyTheLocalRefItsOwnNoteNames is the review +// regression for the one claim class this whole area exists to police: a +// refusal must never deny changing a category of thing while its own appended +// note reports one of that category. +// +// The refusals after the compare-and-swap keep a claim scoped to LOCAL files +// and refs, which they earn by having possibly written the displaced-gate-head +// anchor into the gate. That reasoning holds only for a gate-side pin. On the +// ORDINARY keep-local path the caller's anchor is always +// refs/no-mistakes/recover/ in the INVOKING WORKTREE, so appending the +// note verbatim produced "no local files or refs were changed; the recorded +// head is now anchored at in the invoking worktree" - a sentence +// that denies and reports the same write. +func TestPostSwapRefusalDoesNotDenyTheLocalRefItsOwnNoteNames(t *testing.T) { + t.Parallel() + + // divergeLocally gives the branch a commit the gate has never seen, which + // is what routes Recover down the diverged keep-local arm and makes it + // anchor the preserved head in the invoking worktree first. + divergeLocally := func(t *testing.T, f *recoverFixture) string { + t.Helper() + mustWrite(t, filepath.Join(f.local, "kept.txt"), "kept local work\n") + mustRun(t, f.local, "add", "kept.txt") + mustRun(t, f.local, "commit", "-m", "kept local work") + return mustRun(t, f.local, "rev-parse", "HEAD") + } + // raceTheGate pushes a new commit onto the gate branch from a separate + // clone the moment the keep-local move begins, so the compare-and-swap + // loses against a head this attempt never observed. + raceTheGate := func(t *testing.T, f *recoverFixture) func() { + t.Helper() + writer := filepath.Join(t.TempDir(), "concurrent") + mustRun(t, filepath.Dir(writer), "-c", "core.autocrlf=false", "clone", f.gate, writer) + configureIdentity(t, writer) + mustRun(t, writer, "checkout", "feature/recover") + raced := false + return func() { + if raced { + return + } + raced = true + mustWrite(t, filepath.Join(writer, "raced.txt"), "raced\n") + mustRun(t, writer, "add", "raced.txt") + mustRun(t, writer, "commit", "-m", "concurrent gate push") + mustRun(t, writer, "push", "origin", "HEAD:refs/heads/feature/recover") + } + } + + for _, tc := range []struct { + name string + wantSafety string + setup func(t *testing.T, f *recoverFixture) + }{ + { + // The gate branch still names the recorded head, so this attempt + // writes no gate anchor at all - the local qualifier has nothing + // left to justify it - and a held ref lock fails the swap. + name: "swap failure with no gate anchor of its own", + wantSafety: "blocked_recover_swap_failed", + setup: func(t *testing.T, f *recoverFixture) { + lock := filepath.Join(f.gate, "refs", "heads", "feature", "recover.lock") + if err := os.MkdirAll(filepath.Dir(lock), 0o755); err != nil { + t.Fatal(err) + } + f.service.afterGateStage = func() { + if err := os.WriteFile(lock, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { _ = os.Remove(lock) }) + }, + }, + { + name: "lost compare-and-swap with no gate anchor of its own", + wantSafety: "blocked_recover_gate_race", + setup: func(t *testing.T, f *recoverFixture) { + f.service.beforeGateReset = raceTheGate(t, f) + }, + }, + { + // A later run parked the gate branch past the recorded head, so + // this attempt DOES write refs/no-mistakes/recover-gate/ + // before losing the swap - yet the caller's note still names a + // worktree ref. + name: "lost compare-and-swap that wrote the gate anchor", + wantSafety: "blocked_recover_gate_race", + setup: func(t *testing.T, f *recoverFixture) { + later := filepath.Join(t.TempDir(), "later-run") + mustRun(t, filepath.Dir(later), "-c", "core.autocrlf=false", "clone", f.gate, later) + configureIdentity(t, later) + mustRun(t, later, "checkout", "feature/recover") + mustWrite(t, filepath.Join(later, "later.txt"), "later run\n") + mustRun(t, later, "add", "later.txt") + mustRun(t, later, "commit", "-m", "no-mistakes(review): later run fix") + mustRun(t, later, "push", "origin", "HEAD:refs/heads/feature/recover") + f.service.beforeGateReset = raceTheGate(t, f) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + kept := divergeLocally(t, f) + tc.setup(t, f) + + state := f.service.Recover(f.ctx, true) + if state.Recovered || state.Safety != tc.wantSafety { + t.Fatalf("post-swap refusal = %#v", state) + } + // The fixture must have produced the exact state under test: the + // preserved head anchored by THIS attempt in the invoking + // worktree, and a refusal that reached the swap. + if got := mustRun(t, f.local, "rev-parse", f.anchorRef()); got != f.preserved { + t.Fatalf("worktree recovery anchor = %s, want the preserved head %s", got, f.preserved) + } + if !strings.Contains(state.Error, f.anchorRef()) || !strings.Contains(state.Error, "the invoking worktree") { + t.Fatalf("refusal did not name the local anchor this attempt wrote: %q", state.Error) + } + if strings.Contains(state.Error, "no local files or refs were changed") { + t.Fatalf("refusal denied changing local refs beside a note naming one it wrote: %q", state.Error) + } + if !strings.Contains(state.Error, "no branch, worktree, or file changes were made") { + t.Fatalf("refusal did not enumerate the categories it left alone: %q", state.Error) + } + // Nothing the claim denies may actually have happened. + if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != kept { + t.Fatalf("refusal moved the worktree = %s, want the kept head %s", got, kept) + } + if f.custodyReturned() { + t.Fatal("post-swap refusal stamped custody") + } + if state.NextAction == nil { + t.Fatalf("post-swap refusal named no exit = %#v", state) + } + }) + } +} + +// TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicWorktreeAnchor is +// the review regression for the last of the three positive settlement triggers +// still probed with recoveryAnchorCompatible, which rejects every symbolic ref. +// Recover instead asks whether `rev-parse ^{commit}` names the recorded +// head, and that DEREFERENCES a resolving symref - so the record is anchored as +// far as Recover is concerned, the keep-local interception never fires, and the +// ordinary path refuses on the absent gate branch every single time. Status and +// the TUI advertised a settlement that could only refuse: the #824 wedge again. +// +// It mirrors the gate-side pair above and, like them, deliberately +// UNDER-advertises: the honest pointer is manual reconciliation. +func TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicWorktreeAnchor(t *testing.T) { + t.Parallel() + + f := newRecoverFixture(t, types.RunCancelled) + // The worktree needs the preserved object for the symref to resolve. + evidence := "refs/no-mistakes/evidence/" + f.run.ID + mustRun(t, f.local, "fetch", f.gate, "refs/heads/feature/recover:"+evidence) + mustRun(t, f.local, "symbolic-ref", f.anchorRef(), evidence) + if got := mustRun(t, f.local, "rev-parse", f.anchorRef()+"^{commit}"); got != f.preserved { + t.Fatalf("fixture invariant broken: the symref must resolve to the recorded head, got %s", got) + } + // Unique local work the gate has never seen keeps the record out of the + // ordinary recover_custody classification. + mustWrite(t, filepath.Join(f.local, "kept.txt"), "kept local work\n") + mustRun(t, f.local, "add", "kept.txt") + mustRun(t, f.local, "commit", "-m", "kept local work") + // The gate branch is gone, which is what makes the settlement refuse. + mustRun(t, f.gate, "update-ref", "-d", "refs/heads/feature/recover") + + state := f.service.InspectCached(f.ctx) + if state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually" { + t.Fatalf("resolving-symbolic worktree-anchor next action = %#v", state.NextAction) + } + + // The advertisement must match what the command actually does. + recovered := f.service.Recover(f.ctx, true) + if recovered.Recovered { + t.Fatalf("keep-local completed against an absent gate branch = %#v", recovered) + } + if recovered.Safety != "blocked_recover_gate_unavailable" { + t.Fatalf("keep-local with a resolving symbolic worktree anchor = %#v", recovered) + } + if f.custodyReturned() { + t.Fatal("a refused settlement stamped custody") + } + if got := mustRun(t, f.local, "symbolic-ref", f.anchorRef()); got != evidence { + t.Fatalf("symbolic evidence was rewritten = %s", got) + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 4c3c92800..c36ea8722 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -712,8 +712,10 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { anchored := false // What this attempt itself writes, so a later keep-local refusal reports - // the anchor rather than claiming nothing was written. - anchoredNote := "" + // the anchor rather than claiming nothing was written. This path can only + // ever pin in the invoking worktree, which is what scopes the no-change + // claims of the refusals it delegates to. + anchoredNote := recoveryAnchorNote{ref: anchorRef} if existing, anchorErr := git.Run(ctx, wd, "rev-parse", anchorRef+"^{commit}"); anchorErr == nil && existing == preserved { anchored = true } @@ -727,7 +729,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", "the invoking worktree recovery ref conflicts with the recorded pipeline head; inspect both objects before returning custody; no files or refs were changed") } - anchoredNote = anchoredElsewhere([]string{"the invoking worktree"}, anchorRef) + anchoredNote.stores = []string{invokingWorktreeStore} } switch { @@ -815,10 +817,10 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st // The stranded anchor is the ONLY ref this function can write before the // gate move, so a refusal reports precisely where it now exists rather // than claiming nothing changed. - pinned := []string{} + pinned := recoveryAnchorNote{ref: strandedRef} for _, store := range []struct{ name, dir string }{ - {"the invoking worktree", s.workDir()}, - {"the local gate", gateDir}, + {invokingWorktreeStore, s.workDir()}, + {localGateStore, gateDir}, } { if store.dir == "" { continue @@ -831,7 +833,7 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st // answer proves absence; anything else is undetermined and refuses. present, err := git.CommitPresence(ctx, store.dir, preserved) if err != nil { - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", fmt.Sprintf("whether the recorded pipeline head %s still exists could not be determined in %s, so its absence cannot be proven and custody cannot be settled without risking it; inspect that object store before retrying; no branch, worktree, or file changes were made%s", preserved, store.name, anchoredElsewhere(pinned, strandedRef))) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", fmt.Sprintf("whether the recorded pipeline head %s still exists could not be determined in %s, so its absence cannot be proven and custody cannot be settled without risking it; inspect that object store before retrying; no branch, worktree, or file changes were made%s", preserved, store.name, pinned.clause())) blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} return blocked } @@ -839,11 +841,11 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st continue } if err := custody.PreserveRecoveryAnchor(ctx, store.dir, strandedRef, preserved); err != nil { - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", fmt.Sprintf("the recorded pipeline head %s still exists but could not be anchored at %s in %s, so custody cannot be settled without stranding it; inspect that object before retrying; no branch, worktree, or file changes were made%s", preserved, strandedRef, store.name, anchoredElsewhere(pinned, strandedRef))) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", fmt.Sprintf("the recorded pipeline head %s still exists but could not be anchored at %s in %s, so custody cannot be settled without stranding it; inspect that object before retrying; no branch, worktree, or file changes were made%s", preserved, strandedRef, store.name, pinned.clause())) blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} return blocked } - pinned = append(pinned, store.name) + pinned.stores = append(pinned.stores, store.name) } if gateDir == "" { return s.finishRecover(ctx, run, false, true) @@ -851,7 +853,7 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st gateBranchRef := "refs/heads/" + state.Local.Branch _, gateBranchExists, err := git.ExactRefTarget(ctx, gateDir, gateBranchRef) if err != nil { - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s could not be read, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, anchoredElsewhere(pinned, strandedRef))) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s could not be read, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, pinned.clause())) blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} return blocked } @@ -862,11 +864,41 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st } gateHead, err := git.Run(ctx, gateDir, "rev-parse", gateBranchRef+"^{commit}") if err != nil { - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s does not resolve to a commit, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, anchoredElsewhere(pinned, strandedRef))) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate branch %s does not resolve to a commit, so the kept local head cannot be compared and swapped onto it; no branch refs were changed%s", state.Local.Branch, pinned.clause())) blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} return blocked } - return s.recoverKeepLocal(ctx, run, state, gateHead, anchoredElsewhere(pinned, strandedRef)) + return s.recoverKeepLocal(ctx, run, state, gateHead, pinned) +} + +// The two object stores a recovery can pin a surviving recorded head in. They +// are named rather than spelled out at each site because a refusal's no-change +// claim is scoped by WHICH of them holds the anchor, so a typo in one of them +// would silently widen or narrow that claim. +const ( + invokingWorktreeStore = "the invoking worktree" + localGateStore = "the local gate" +) + +// recoveryAnchorNote records WHERE an earlier step of this recovery anchored a +// surviving recorded head, not merely the sentence describing it. A refusal +// needs the store and not only the prose: a claim scoped to LOCAL files and +// refs is accurate beside a gate-side pin and self-contradictory beside one in +// the invoking worktree, and a bare string cannot tell those apart. +type recoveryAnchorNote struct { + ref string + stores []string +} + +func (n recoveryAnchorNote) clause() string { return anchoredElsewhere(n.stores, n.ref) } + +func (n recoveryAnchorNote) namesInvokingWorktree() bool { + for _, store := range n.stores { + if store == invokingWorktreeStore { + return true + } + } + return false } // anchoredElsewhere keeps a refusal honest about a recorded head its own @@ -885,10 +917,8 @@ func anchoredElsewhere(pinned []string, ref string) string { // that carries no such anchor; otherwise the refusal reports exactly what it // did not change and hands over the anchor note naming where that head now is. // -// The lost compare-and-swap is deliberately NOT built here. That refusal has -// just written the displaced-gate-head anchor into the gate, so its narrower -// "no LOCAL files or refs were changed" claim is the accurate one and the -// anchor note is appended to it rather than replacing it. +// The refusals that follow the swap are built by keepLocalPostSwapNoChangeClause +// instead, because by then this function may have written a gate ref of its own. func keepLocalNoChangeClause(blanket, anchored string) string { if anchored == "" { return blanket @@ -896,6 +926,27 @@ func keepLocalNoChangeClause(blanket, anchored string) string { return "no branch, worktree, or file changes were made" + anchored } +// keepLocalPostSwapNoChangeClause closes the refusals reachable only AFTER the +// compare-and-swap was attempted. Those keep the narrower "no LOCAL files or +// refs were changed" claim, because by then this function may have written the +// displaced-gate-head anchor into the gate - a ref that is deliberately not +// retired, so denying it outright would be false. +// +// That narrowing INVERTS when the caller's own anchor sits in the invoking +// worktree, which is what the ordinary keep-local path always pins. The raw +// claim would then deny changing local refs in the very sentence whose note +// names one, so the refusal falls back to enumerating the categories it did +// not change and disclosing the anchor separately, exactly as the pre-swap +// sites do. The distinction is the store, not the caller: the settlement pins +// the invoking worktree too whenever the recorded head still exists there. +func keepLocalPostSwapNoChangeClause(anchoredNote recoveryAnchorNote) string { + const blanket = "no local files or refs were changed" + if anchoredNote.namesInvokingWorktree() { + return keepLocalNoChangeClause(blanket, anchoredNote.clause()) + } + return blanket + anchoredNote.clause() +} + // recoverBlocked is the one constructor for a recovery refusal that has no // more specific exit to offer, because blockedPlan clears NextAction and a // refusal naming no exit is itself the R1/R5 dead end this subsystem exists to @@ -938,18 +989,21 @@ func recoverBlocked(state State, safety, message string) State { // after possibly anchoring a surviving recorded head of their own - Recover // pins the preserved head at the run recovery ref, and // recoverSettleInconsistent pins every surviving copy at the stranded ref. -// anchoredNote carries that fact in, so the blanket claim is made only by a -// delegation that wrote nothing and every other refusal names where the -// anchor now is instead of reporting that nothing was written. The lost -// compare-and-swap keeps its own narrower local-scoped claim and appends the -// note, because by then this function has written the gate anchor itself. +// anchoredNote carries that fact in - the ref AND the stores holding it - so +// the blanket claim is made only by a delegation that wrote nothing and every +// other refusal names where the anchor now is instead of reporting that +// nothing was written. The refusals after the swap keep their own narrower +// local-scoped claim and append the note, because by then this function may +// have written the gate anchor itself; that narrowing is withdrawn when the +// note names the invoking worktree, since the claim would otherwise deny a +// local ref the same sentence discloses. // // The anchor CONFLICT check is deliberately still read-only and still runs // first: it is the cheapest refusal and it must not be reached only after a // staging ref exists. Once the swap has been attempted, the anchor is load // bearing and is never retired - a failed compare-and-swap means the gate // moved, so gateHead may now be reachable through the anchor alone. -func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State, gateHead, anchoredNote string) State { +func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State, gateHead string, anchoredNote recoveryAnchorNote) State { if s.beforeGateReset != nil { s.beforeGateReset() } @@ -965,7 +1019,7 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State if err == nil { conflict = "names " + existing } - return recoverBlocked(state, "blocked_recover_preserve_failed", fmt.Sprintf("the independently moved gate head %s conflicts with the existing run recovery anchor %s in the local gate %s, which %s; nothing retires that anchor, so reconcile it there before returning custody; %s", gateHead, gateAnchor, s.GateDir, conflict, keepLocalNoChangeClause("no files or branch refs were changed", anchoredNote))) + return recoverBlocked(state, "blocked_recover_preserve_failed", fmt.Sprintf("the independently moved gate head %s conflicts with the existing run recovery anchor %s in the local gate %s, which %s; nothing retires that anchor, so reconcile it there before returning custody; %s", gateHead, gateAnchor, s.GateDir, conflict, keepLocalNoChangeClause("no files or branch refs were changed", anchoredNote.clause()))) } // An anchor that already names this head needs no write, but it // still guards the swap, so the race refusal below must know it @@ -975,14 +1029,14 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State } head, err := git.HeadSHA(ctx, s.workDir()) if err != nil || head != state.Local.Head { - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)) + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } // The fetch source must be absolute: the command runs inside the gate // directory, where a relative invoking-worktree path would resolve to // the gate itself. source, err := s.absPath(s.workDir()) if err != nil { - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)) + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the invoking worktree path could not be resolved; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } if s.beforeGateStage != nil { s.beforeGateStage() @@ -993,14 +1047,14 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // and the refusal claims nothing was left behind - so a cleanup // that itself fails has to be reported rather than swallowed, // exactly like every other write this function denies making. - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)+s.releaseStagingRef(ctx, stagingRef)) + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the kept local head could not be staged into the gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())+s.releaseStagingRef(ctx, stagingRef)) } if s.afterGateStage != nil { s.afterGateStage() } staged, err := git.Run(ctx, s.GateDir, "rev-parse", stagingRef+"^{commit}") if err != nil || staged != state.Local.Head { - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)+s.releaseStagingRef(ctx, stagingRef)) + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch head changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())+s.releaseStagingRef(ctx, stagingRef)) } // The staged head was read from the gate, not from the worktree, so it // only proves what the fetch copied. Re-read the branch itself: a @@ -1009,13 +1063,13 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // leaving the operator ahead of the gate with the same stale-gate // shape this settlement exists to clear. if live, err := git.Run(ctx, s.workDir(), "rev-parse", "refs/heads/"+state.Local.Branch+"^{commit}"); err != nil || live != state.Local.Head { - return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch moved after its head was staged into the gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote)+s.releaseStagingRef(ctx, stagingRef)) + return recoverBlocked(state, "blocked_recover_assumptions_changed", "the local branch moved after its head was staged into the gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())+s.releaseStagingRef(ctx, stagingRef)) } // Point of no return: from here the gate branch is about to leave // gateHead, so the anchor has to exist first. if writeGateAnchor { if err := custody.PreserveRecoveryAnchor(ctx, s.GateDir, gateAnchor, gateHead); err != nil { - return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; "+keepLocalNoChangeClause("no files or branch refs were changed", anchoredNote)+s.releaseStagingRef(ctx, stagingRef)) + return recoverBlocked(state, "blocked_recover_preserve_failed", "the independently moved gate head could not be anchored before returning custody; "+keepLocalNoChangeClause("no files or branch refs were changed", anchoredNote.clause())+s.releaseStagingRef(ctx, stagingRef)) } } _, casErr := git.Run(ctx, s.GateDir, "update-ref", "refs/heads/"+state.Local.Branch, state.Local.Head, gateHead) @@ -1041,7 +1095,7 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State if gateHeadAnchored { wrote = fmt.Sprintf("; the run recovery anchor %s names the gate head this attempt observed and is not retired here", custody.RecoveryGateRef(run.ID)) } - return recoverBlocked(state, "blocked_recover_swap_failed", fmt.Sprintf("the compare-and-swap onto the kept local head failed and %s, so this is not a concurrent gate push; inspect the local gate %s before returning custody%s; no local files or refs were changed%s%s", detail, s.GateDir, wrote, anchoredNote, stagingLeft)) + return recoverBlocked(state, "blocked_recover_swap_failed", fmt.Sprintf("the compare-and-swap onto the kept local head failed and %s, so this is not a concurrent gate push; inspect the local gate %s before returning custody%s; %s%s", detail, s.GateDir, wrote, keepLocalPostSwapNoChangeClause(anchoredNote), stagingLeft)) } // The displaced-gate-head anchor is written only above, when the // gate had already moved off the recorded head. Without it a retry @@ -1051,9 +1105,9 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // failed because the gate moved, so gateHead may now be reachable // through this anchor alone. if gateHeadAnchored { - return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; no local files or refs were changed%s%s", custody.RecoveryGateRef(run.ID), anchoredNote, stagingLeft)) + return recoverBlocked(state, "blocked_recover_gate_race", fmt.Sprintf("the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; the run recovery anchor %s still names the gate head this attempt observed, so a further attempt refuses on that conflict - reconcile that anchor against the live gate head before returning custody; %s%s", custody.RecoveryGateRef(run.ID), keepLocalPostSwapNoChangeClause(anchoredNote), stagingLeft)) } - return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; no local files or refs were changed"+anchoredNote+stagingLeft) + return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; "+keepLocalPostSwapNoChangeClause(anchoredNote)+stagingLeft) } } return s.finishRecover(ctx, run, false, true) @@ -1912,10 +1966,36 @@ func (s *Service) selfInconsistentCustodyRecord(ctx context.Context, state *Stat if !gateCompatible { return true } - if compatible, err := recoveryAnchorCompatible(ctx, wd, run.ID, preserved); err == nil && !compatible { + return recoveryAnchorIntercepts(ctx, wd, run.ID, preserved) +} + +// recoveryAnchorIntercepts reports whether the invoking worktree's run +// recovery ref is the evidence that makes Recover(keepLocal) hand over to the +// settlement, decided with RECOVER'S OWN probe rather than a stricter one. +// +// recoveryAnchorCompatible cannot answer this: it rejects every symbolic ref, +// while Recover asks only whether `rev-parse ^{commit}` names the +// recorded head - which DEREFERENCES a symref that resolves there. Such a +// record is anchored as far as Recover is concerned, so it never reaches +// PreserveRecoveryAnchor, never intercepts, and must not be advertised as +// settleable. An ABSENT ref is not evidence of inconsistency either: +// PreserveRecoveryAnchor simply creates it and Recover carries on down the +// ordinary keep-local path, which has guards of its own that this predicate +// does not model. What remains - a ref that exists in any form and does not +// resolve to the recorded head - is exactly what PreserveRecoveryAnchor +// refuses, and therefore exactly where the interception happens. +func recoveryAnchorIntercepts(ctx context.Context, wd, runID, preserved string) bool { + anchorRef := custody.RecoveryRef(runID) + if resolved, err := git.Run(ctx, wd, "rev-parse", anchorRef+"^{commit}"); err == nil && resolved == preserved { + return false + } + // A DANGLING symref is invisible to for-each-ref while symbolic-ref still + // succeeds, so existence has to be asked both ways. + if symbolic, err := git.Run(ctx, wd, "symbolic-ref", "-q", anchorRef); err == nil && symbolic != "" { return true } - return false + _, exists, err := git.ExactRefTarget(ctx, wd, anchorRef) + return err == nil && exists } // settlementGateBranchUsable answers the one question recoverSettleInconsistent From af8f5a9941404259864371fe9b7498cfb4de098e Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:50:25 -0400 Subject: [PATCH 21/31] no-mistakes(review): scope swap-failure disclaimer; wire complete_custody_return surfaces --- docs/src/content/docs/guides/agents.md | 1 + internal/branchsync/recover_test.go | 46 +++++++ internal/branchsync/sync.go | 6 +- internal/cli/axi_guidance.go | 2 +- internal/cli/axi_guidance_test.go | 7 ++ internal/cli/sync.go | 10 +- internal/cli/sync_help_contract_test.go | 7 ++ internal/skill/skill.go | 2 + internal/tui/app.go | 22 ++-- internal/tui/branch_sync.go | 54 ++++++++ internal/tui/branch_sync_test.go | 156 ++++++++++++++++++++++++ internal/tui/commands.go | 35 ++++++ internal/tui/keys.go | 24 ++++ internal/tui/view.go | 3 + skills/no-mistakes/SKILL.md | 2 + 15 files changed, 359 insertions(+), 18 deletions(-) diff --git a/docs/src/content/docs/guides/agents.md b/docs/src/content/docs/guides/agents.md index 37c300ec4..d642735c9 100644 --- a/docs/src/content/docs/guides/agents.md +++ b/docs/src/content/docs/guides/agents.md @@ -181,6 +181,7 @@ When `next_action.code` is `recover_custody` - a terminal run left unpublished p When `next_action.code` is `return_custody_keep_local` - a terminal run whose recorded pipeline head cannot be verified, either because no reachable object store still has it or because the run's own recovery evidence names a different commit - run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. If any copy of that head still exists, the settlement anchors it at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under `NM_HOME` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. That anchor is not `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. +When `next_action.code` is `complete_custody_return` - an earlier recovery already applied its Git changes and only the custody record is missing - re-run the exact command it names, `no-mistakes axi sync --recover` or `no-mistakes axi sync --recover --keep-local`, to complete the record; every Git step it repeats is idempotent once applied. A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: it is immediately usable and needs no sync action. When `next_action.code` is `continue_active_run`, run the reported command and keep driving the active run. If synchronization is blocked, process that state instead of improvising reset, stash, merge, rebase, force, or branch replacement. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 56570e791..f11c3aa3b 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3582,3 +3582,49 @@ func TestInspectDoesNotAdvertiseSettlementForResolvingSymbolicWorktreeAnchor(t * t.Fatalf("symbolic evidence was rewritten = %s", got) } } + +// TestUnreadableGateSwapFailureDoesNotDisclaimARaceItCannotRuleOut is the +// review regression for a refusal asserting a negative it has not proven. When +// `update-ref` fails and the gate branch cannot be re-read afterwards, the code +// knows the swap failed and nothing else - an unreadable head is not evidence +// that nothing moved, and a concurrent DELETE of the gate branch is itself a +// concurrent gate change. Telling the operator "this is not a concurrent gate +// push" there sends them hunting for held locks and I/O errors while a +// concurrent gate mutation is exactly what happened. +func TestUnreadableGateSwapFailureDoesNotDisclaimARaceItCannotRuleOut(t *testing.T) { + t.Parallel() + + f, _, _ := wedgedCustodyFixture(t, types.RunFailed) + // Delete the gate branch after the kept head is staged, so the + // compare-and-swap fails on its old value and the branch cannot be re-read. + f.service.afterGateStage = func() { + mustRun(t, f.gate, "update-ref", "-d", "refs/heads/feature/recover") + } + + state := f.service.Recover(f.ctx, true) + if state.Recovered { + t.Fatalf("swap against a deleted gate branch reported success = %#v", state) + } + if state.Safety != "blocked_recover_swap_failed" { + t.Fatalf("unreadable-gate swap safety = %q, want blocked_recover_swap_failed: %#v", state.Safety, state) + } + // The fixture must have produced the state under test. + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, "refs/heads/feature/recover"); err != nil || exists { + t.Fatalf("fixture invariant broken: the gate branch must be gone: exists=%v err=%v", exists, err) + } + if strings.Contains(state.Error, "not a concurrent gate push") { + t.Fatalf("refusal ruled out a concurrent gate change it could not rule out: %q", state.Error) + } + if !strings.Contains(state.Error, "could not be re-read") || !strings.Contains(state.Error, "unknown") { + t.Fatalf("refusal did not report what it actually knows: %q", state.Error) + } + if !strings.Contains(state.Error, f.gate) { + t.Fatalf("refusal did not name the gate to inspect: %q", state.Error) + } + if f.custodyReturned() { + t.Fatal("failed swap stamped custody") + } + if state.NextAction == nil { + t.Fatalf("swap failure named no exit = %#v", state) + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index c36ea8722..1925d539e 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -1083,9 +1083,9 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State // rather than as a concurrent push that may not have happened. live, liveErr := git.Run(ctx, s.GateDir, "rev-parse", "refs/heads/"+state.Local.Branch+"^{commit}") if liveErr != nil || live == gateHead { - detail := "the gate branch could not be re-read afterwards" + detail := "the gate branch could not be re-read afterwards, so whether anything moved it is unknown" if liveErr == nil { - detail = "the gate branch is still at " + gateHead + ", so nothing had moved it" + detail = "the gate branch is still at " + gateHead + ", so nothing had moved it and this is not a concurrent gate push" } // This site sits AFTER the anchor write, so it carries the // same obligation as the lost-swap refusals below: when this @@ -1095,7 +1095,7 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State if gateHeadAnchored { wrote = fmt.Sprintf("; the run recovery anchor %s names the gate head this attempt observed and is not retired here", custody.RecoveryGateRef(run.ID)) } - return recoverBlocked(state, "blocked_recover_swap_failed", fmt.Sprintf("the compare-and-swap onto the kept local head failed and %s, so this is not a concurrent gate push; inspect the local gate %s before returning custody%s; %s%s", detail, s.GateDir, wrote, keepLocalPostSwapNoChangeClause(anchoredNote), stagingLeft)) + return recoverBlocked(state, "blocked_recover_swap_failed", fmt.Sprintf("the compare-and-swap onto the kept local head failed and %s; inspect the local gate %s before returning custody%s; %s%s", detail, s.GateDir, wrote, keepLocalPostSwapNoChangeClause(anchoredNote), stagingLeft)) } // The displaced-gate-head anchor is written only above, when the // gate had already moved off the recorded head. Without it a retry diff --git a/internal/cli/axi_guidance.go b/internal/cli/axi_guidance.go index b361894ef..9ffe18611 100644 --- a/internal/cli/axi_guidance.go +++ b/internal/cli/axi_guidance.go @@ -26,4 +26,4 @@ const preserveGateFixCommitsGuidance = "Commit post-pipeline follow-up work on t // branchSyncAgentGuidance is emitted only when a relevant branch_sync object // is present. Keeping it conditional avoids flooding ordinary runs whose local // and pipeline heads never differed. -const branchSyncAgentGuidance = "Before a post-pipeline local commit or fresh run, follow the structured `branch_sync.next_action`. Run `no-mistakes axi sync` only when its code is `sync`; that guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics. Run `no-mistakes axi sync --recover` only when its code is `recover_custody` (a terminal run left unpublished pipeline commits preserved in the local gate). Run `no-mistakes axi sync --recover --keep-local` only when its code is `return_custody_keep_local` (a terminal run whose recorded pipeline head can no longer be verified): it returns custody at your current head and points the gate branch at it. A `user_owned` state means cancellation released the branch before changing the submitted head: the exact branch and head are yours, immediately usable, and no sync action is needed. Process blocked or pipeline-owned states instead of improvising reset, stash, merge, rebase, force, or branch replacement." +const branchSyncAgentGuidance = "Before a post-pipeline local commit or fresh run, follow the structured `branch_sync.next_action`. Run `no-mistakes axi sync` only when its code is `sync`; that guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics. Run `no-mistakes axi sync --recover` only when its code is `recover_custody` (a terminal run left unpublished pipeline commits preserved in the local gate). Run `no-mistakes axi sync --recover --keep-local` only when its code is `return_custody_keep_local` (a terminal run whose recorded pipeline head can no longer be verified): it returns custody at your current head and points the gate branch at it. A `complete_custody_return` code separately authorizes whichever of those two recovery commands it names: an earlier recovery already applied its Git changes and only the custody record is missing, so re-running the exact command it names completes the record. A `user_owned` state means cancellation released the branch before changing the submitted head: the exact branch and head are yours, immediately usable, and no sync action is needed. Process blocked or pipeline-owned states instead of improvising reset, stash, merge, rebase, force, or branch replacement." diff --git a/internal/cli/axi_guidance_test.go b/internal/cli/axi_guidance_test.go index bb642c038..0cd76cdf3 100644 --- a/internal/cli/axi_guidance_test.go +++ b/internal/cli/axi_guidance_test.go @@ -56,6 +56,13 @@ var canonicalBranchSyncPhrases = []string{ // recovery that always refuses. "return_custody_keep_local", "no-mistakes axi sync --recover --keep-local", + // The retry that completes a custody return whose Git side already applied + // and whose record is missing. It needs the same pin as its siblings: the + // guidance enumerates permissions with exclusive "only when" clauses, so a + // surface that names the code without authorizing its command forbids the + // very command the same response prescribes. + "complete_custody_return", + "only the custody record is missing", } const canonicalPipelineAgentPrerequisite = "a supported native agent binary, the `agent: cursor` ACP alias, or an explicit `acp:` through `acpx`" diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 0e52aeb11..146bf1afc 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -35,9 +35,8 @@ func newSyncCmd() *cobra.Command { "the pipeline changed anything releases the branch by itself (user_owned) and\n" + "makes --recover a no-op. --recover --keep-local keeps the current local head\n" + "instead and never touches the worktree; where the gate branch still names a\n" + - "different head it is compare-and-swapped onto the kept head, and where it does\n" + - "not, custody returns without moving any branch ref. That is also the settlement\n" + - "for a record whose preserved head can no longer be verified.", + "different head it is compare-and-swapped onto the kept head. That is also the\n" + + "settlement for a record whose preserved head can no longer be verified.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if check && yes { @@ -78,7 +77,10 @@ func newAxiSyncCmd() *cobra.Command { "next_action.code: recover_custody; --keep-local keeps the current local head\n" + "and, where the gate branch still names a different head, moves the gate branch\n" + "to it, which is also the settlement offered by\n" + - "next_action.code: return_custody_keep_local.", + "next_action.code: return_custody_keep_local.\n" + + "next_action.code: complete_custody_return means an earlier recovery already\n" + + "applied its Git changes and only the custody record is missing: re-run the\n" + + "exact command it names to complete the record.", Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, diff --git a/internal/cli/sync_help_contract_test.go b/internal/cli/sync_help_contract_test.go index 6567509c4..29c82912d 100644 --- a/internal/cli/sync_help_contract_test.go +++ b/internal/cli/sync_help_contract_test.go @@ -11,6 +11,13 @@ var unconditionalGateMoveClaims = []string{ "anchored and the gate branch compare-and-swaps onto the kept head", "and points the gate branch at the kept head", "the preserved commits stay anchored and the gate follows the kept head", + // The converse overstatement, and the one that survived a per-surface + // deletion: where the gate branch does NOT already name a different head, + // custody does not simply return. A record whose local gate branch is + // deleted or unreadable refuses with blocked_recover_gate_unavailable on + // the ordinary keep-local path, so no surface may promise a custody return + // there. + "custody returns without moving any branch ref", } // TestKeepLocalHelpSurfacesStayConditional pins the four operator-facing diff --git a/internal/skill/skill.go b/internal/skill/skill.go index 26ad52a6f..d9f6c1d6a 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -245,6 +245,8 @@ A dirty worktree, or divergence that cannot be proven contained, makes the recov When ` + "`next_action.code`" + ` is ` + "`return_custody_keep_local`" + `, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run ` + "`no-mistakes axi sync --recover --keep-local`" + ` to return custody at your current head and point the gate branch at it. If any copy of that head still exists, the settlement anchors it at ` + "`refs/no-mistakes/recover-stranded/`" + ` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under ` + "`NM_HOME`" + ` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. That anchor is NOT ` + "`refs/no-mistakes/recover/`" + `, which in this state is the conflicting evidence and can name an entirely different commit. +When ` + "`next_action.code`" + ` is ` + "`complete_custody_return`" + `, an earlier recovery already applied its Git changes and only the custody record is missing: re-run the exact command it names - ` + "`no-mistakes axi sync --recover`" + ` or ` + "`no-mistakes axi sync --recover --keep-local`" + `, whichever it carries - to complete the record. +That code is deliberately neither ` + "`recover_custody`" + ` nor ` + "`return_custody_keep_local`" + `: each of those is a claim about the record, and neither is being made here. Re-running is safe because every Git step it repeats is idempotent once applied. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. After synchronization, commit the follow-up on top and re-run ` + "`no-mistakes axi run --intent \"...\"`" + ` with the original user intent. This preserves every prior gate-fix commit regardless of its configured subject. diff --git a/internal/tui/app.go b/internal/tui/app.go index c08a6c5fd..27f758924 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -85,16 +85,17 @@ type Model struct { // Guarded local-branch synchronization. Cached state is rendered passively; // only the explicit u flow calls Refresh or Apply. - branchSync *branchsync.State - syncService *branchsync.Service - syncRefresh func() branchsync.State - syncApply func() branchsync.State - syncRecover func() branchsync.State - syncSettle func() branchsync.State - syncConfirm bool - recoverConfirm bool - settleConfirm bool - syncRefreshing bool + branchSync *branchsync.State + syncService *branchsync.Service + syncRefresh func() branchsync.State + syncApply func() branchsync.State + syncRecover func() branchsync.State + syncSettle func() branchsync.State + syncConfirm bool + recoverConfirm bool + settleConfirm bool + completeConfirm bool + syncRefreshing bool } // NewModel creates a TUI model for the given run. @@ -385,6 +386,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.syncConfirm = false m.recoverConfirm = false m.settleConfirm = false + m.completeConfirm = false m.branchSync = &msg.state if msg.state.Error != "" { m.err = fmt.Errorf("branch sync: %s", msg.state.Error) diff --git a/internal/tui/branch_sync.go b/internal/tui/branch_sync.go index b6c2bf224..d7829226a 100644 --- a/internal/tui/branch_sync.go +++ b/internal/tui/branch_sync.go @@ -26,6 +26,9 @@ func renderLocalBranchStatus(state *branchsync.State, refreshing bool, width int } else if settleableBranchSync(state) { message = "Run ended terminally and its recorded pipeline head can no longer be verified, so there is nothing to recover. Settle custody at your current head to take the branch back." footer = "u settle custody at local head" + } else if _, ok := custodyReturnCompletion(state); ok { + message = "An earlier custody return applied its Git changes but could not record the custody return. Re-run the same recovery to complete the record." + footer = "u complete custody return" } else { message = "Local branch unchanged; the pipeline fix is not pushed yet. Do not make follow-up commits." } @@ -126,6 +129,33 @@ func settleableBranchSync(state *branchsync.State) bool { state.NextAction != nil && state.NextAction.Code == "return_custody_keep_local" } +// custodyReturnCompletion reports the recovery a complete_custody_return state +// must RE-RUN, and whether this state is one at all. That code is emitted when +// a recovery's Git side already applied and only the custody record is +// missing, so the TUI must offer the same command the service advertised - +// keeping the local head or taking the preserved one - and never the other. +// +// It is read off the advertised command rather than from a safety code for the +// same reason settleableBranchSync keys on the next action: the state machine +// alone knows which recovery ran. An unrecognized command yields no +// affordance, so a future command shape fails closed to no key instead of +// silently running the wrong recovery. +func custodyReturnCompletion(state *branchsync.State) (keepLocal bool, ok bool) { + if state == nil || state.State != branchsync.StatePipelineOwned || state.NextAction == nil { + return false, false + } + if state.NextAction.Code != "complete_custody_return" { + return false, false + } + switch strings.TrimSpace(state.NextAction.Command) { + case "no-mistakes axi sync --recover --keep-local": + return true, true + case "no-mistakes axi sync --recover": + return false, true + } + return false, false +} + func renderRecoverConfirmation(state branchsync.State, width int) string { if width < 40 { width = 80 @@ -161,6 +191,30 @@ func renderSettleConfirmation(state branchsync.State, width int) string { return renderBoxWithFooter("Confirm custody settlement at local head", b.String(), width, "u/enter settle · esc cancel") } +// renderCompleteConfirmation is deliberately NOT renderSettleConfirmation with +// different words. The settlement box tells the operator their recorded head +// can no longer be verified and asks them to abandon it; this state makes no +// such claim - the recovery already ran and succeeded on the Git side, and +// only its bookkeeping is missing. Saying otherwise here would be the exact +// false claim complete_custody_return was given its own code to avoid. +func renderCompleteConfirmation(state branchsync.State, width int) string { + if width < 40 { + width = 80 + } + command := "" + if state.NextAction != nil { + command = strings.TrimSpace(state.NextAction.Command) + } + var b strings.Builder + fmt.Fprintf(&b, "An earlier custody return applied its Git changes but could not record the\n") + fmt.Fprintf(&b, "custody return, so the branch still reads as pipeline-owned. Re-running the\n") + fmt.Fprintf(&b, "same recovery completes the record.\n\n") + fmt.Fprintf(&b, "Local branch: %s\n", state.Local.Branch) + fmt.Fprintf(&b, "Local HEAD: %s\n\n", state.Local.Head) + b.WriteString("Every Git step it repeats is idempotent once applied, so this finishes the record rather than moving anything again. This is `" + command + "`.") + return renderBoxWithFooter("Confirm custody-return completion", b.String(), width, "u/enter complete \u00b7 esc cancel") +} + func renderSyncConfirmation(state branchsync.State, width int) string { if width < 40 { width = 80 diff --git a/internal/tui/branch_sync_test.go b/internal/tui/branch_sync_test.go index afc7bba71..ee9a1eb5e 100644 --- a/internal/tui/branch_sync_test.go +++ b/internal/tui/branch_sync_test.go @@ -371,3 +371,159 @@ func TestPipelineOwnedStateWithoutASettlementActionOffersNoSettlement(t *testing t.Fatalf("u acted on a record with no advertised settlement: %#v", m) } } + +// TestStampFailureOffersTheCompletionTheServiceAdvertised closes the TUI half +// of the complete_custody_return gap. That code is emitted when a custody +// return's Git side already applied and only the database write failed - refs +// changed, custody unrecorded. settleableBranchSync correctly stopped matching +// it (its confirmation claims the recorded head is unverifiable, which is false +// here), but nothing else matched it either, so the TUI rendered the state with +// no exit at all: the #824 dead end one layer down on the operator surface. +func TestStampFailureOffersTheCompletionTheServiceAdvertised(t *testing.T) { + run := &ipc.RunInfo{ID: "run-1", Branch: "feature", Status: types.RunFailed} + m := NewModel("socket", nil, run) + stamped := branchsync.State{ + State: branchsync.StatePipelineOwned, + Safety: "blocked_recover_stamp_failed", + Local: branchsync.LocalState{Branch: "feature", Head: strings.Repeat("a", 40), Clean: true}, + Pipeline: branchsync.PipelineState{RunID: "run-1", Status: "failed", Phase: "pre_push", CurrentHead: strings.Repeat("c", 40)}, + NextAction: &branchsync.NextAction{Code: "complete_custody_return", Command: "no-mistakes axi sync --recover --keep-local"}, + } + m.branchSync = &stamped + + view := stripANSI(renderLocalBranchStatus(m.branchSync, false, 80)) + if !strings.Contains(view, "u complete custody return") { + t.Errorf("stamp-failure status offered no key at all:\n%s", view) + } + if strings.Contains(view, "can no longer be verified") { + t.Errorf("stamp-failure status reused the settlement's unverifiable-head claim:\n%s", view) + } + + settleCalls, recoverCalls := 0, 0 + m.syncRecover = func() branchsync.State { + recoverCalls++ + t.Error("a keep-local completion must not run the plain recovery") + return branchsync.State{} + } + m.syncSettle = func() branchsync.State { + settleCalls++ + done := stamped + done.State = branchsync.StateCustodyReturned + done.Safety = "custody_returned" + done.Recovered = true + done.NextAction = nil + return done + } + + next, cmd := m.handleKey(keyMsg("u")) + m = next.(Model) + if cmd != nil || !m.completeConfirm || settleCalls != 0 { + t.Fatalf("u must open the completion confirmation without acting: confirm=%v calls=%d", m.completeConfirm, settleCalls) + } + if m.settleConfirm || m.recoverConfirm { + t.Fatal("stamp failure opened the settlement or recovery confirmation instead of the completion one") + } + // The box must describe a retry, never the settlement's abandonment of an + // unverifiable recorded head. + plain := stripANSI(m.View()) + for _, want := range []string{"could not record the custody return", "u/enter complete", "no-mistakes axi sync --recover --keep-local"} { + if !strings.Contains(plain, want) { + t.Errorf("completion confirmation missing %q:\n%s", want, plain) + } + } + if strings.Contains(plain, "can no longer be verified") { + t.Errorf("completion confirmation told the operator their recorded head is unverifiable:\n%s", plain) + } + + escModel, escCmd := m.handleKey(keyMsg("esc")) + escaped := escModel.(Model) + if escCmd != nil || escaped.completeConfirm || settleCalls != 0 { + t.Fatalf("esc did not cancel the completion cleanly: confirm=%v calls=%d", escaped.completeConfirm, settleCalls) + } + + next, cmd = m.handleKey(keyMsg("enter")) + m = next.(Model) + if cmd == nil || settleCalls != 0 { + t.Fatal("completion did not wait for its async command") + } + applied, _ := m.Update(cmd()) + m = applied.(Model) + if settleCalls != 1 || recoverCalls != 0 { + t.Fatalf("completion settle calls=%d recover calls=%d", settleCalls, recoverCalls) + } + if m.completeConfirm || m.branchSync.State != branchsync.StateCustodyReturned || !m.branchSync.Recovered { + t.Fatalf("completion result = %#v", m.branchSync) + } +} + +// TestDefaultRecoveryStampFailureCompletesWithThePlainRecovery pins the other +// half of the same contract: the completion runs the recovery the service +// NAMED, so a stamp failure after a default `--recover` must not silently +// become the keep-local settlement, which keeps the opposite head. +func TestDefaultRecoveryStampFailureCompletesWithThePlainRecovery(t *testing.T) { + run := &ipc.RunInfo{ID: "run-1", Branch: "feature", Status: types.RunFailed} + m := NewModel("socket", nil, run) + m.branchSync = &branchsync.State{ + State: branchsync.StatePipelineOwned, + Safety: "blocked_recover_stamp_failed", + Local: branchsync.LocalState{Branch: "feature", Head: strings.Repeat("a", 40), Clean: true}, + Pipeline: branchsync.PipelineState{RunID: "run-1", Status: "failed", Phase: "pre_push"}, + NextAction: &branchsync.NextAction{Code: "complete_custody_return", Command: "no-mistakes axi sync --recover"}, + } + recoverCalls := 0 + m.syncRecover = func() branchsync.State { + recoverCalls++ + return branchsync.State{State: branchsync.StateCustodyReturned, Recovered: true} + } + m.syncSettle = func() branchsync.State { + t.Fatal("a default-recovery completion must not run the keep-local settlement") + return branchsync.State{} + } + + next, _ := m.handleKey(keyMsg("u")) + m = next.(Model) + if !m.completeConfirm { + t.Fatal("default-recovery stamp failure offered no completion") + } + next, cmd := m.handleKey(keyMsg("enter")) + m = next.(Model) + if cmd == nil { + t.Fatal("completion produced no command") + } + m.Update(cmd()) + if recoverCalls != 1 { + t.Fatalf("plain recovery calls = %d, want 1", recoverCalls) + } +} + +// TestUnrecognizedCompletionCommandOffersNoKey keeps the completion fail-closed. +// The TUI resolves which recovery to re-run from the advertised command, so a +// command shape it does not recognize must yield no affordance rather than +// guessing - guessing wrong would run the recovery that takes the OTHER head. +func TestUnrecognizedCompletionCommandOffersNoKey(t *testing.T) { + run := &ipc.RunInfo{ID: "run-1", Branch: "feature", Status: types.RunFailed} + m := NewModel("socket", nil, run) + m.branchSync = &branchsync.State{ + State: branchsync.StatePipelineOwned, + Safety: "blocked_recover_stamp_failed", + Local: branchsync.LocalState{Branch: "feature", Head: strings.Repeat("a", 40), Clean: true}, + Pipeline: branchsync.PipelineState{RunID: "run-1", Status: "failed", Phase: "pre_push"}, + NextAction: &branchsync.NextAction{Code: "complete_custody_return", Command: "no-mistakes axi sync --recover --some-future-mode"}, + } + m.syncRecover = func() branchsync.State { + t.Fatal("an unrecognized completion command must not run a recovery") + return branchsync.State{} + } + m.syncSettle = func() branchsync.State { + t.Fatal("an unrecognized completion command must not run a settlement") + return branchsync.State{} + } + if view := stripANSI(renderLocalBranchStatus(m.branchSync, false, 80)); strings.Contains(view, "u complete") { + t.Fatalf("unrecognized completion command offered a key:\n%s", view) + } + next, cmd := m.handleKey(keyMsg("u")) + m = next.(Model) + if cmd != nil || m.completeConfirm || m.settleConfirm || m.recoverConfirm { + t.Fatalf("u acted on an unrecognized completion command: %#v", m) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index aa2d0644c..d2b3ff9b5 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -398,6 +398,41 @@ func (m Model) applySettleCmd() tea.Cmd { } } +// completionSeam picks the recovery a complete_custody_return state must +// re-run. The two seams are separate on purpose (a settlement must never +// silently become a plain recovery), so the completion resolves to exactly the +// one the advertised command names. +func (m Model) completionSeam(keepLocal bool) func() branchsync.State { + if keepLocal { + return m.syncSettle + } + return m.syncRecover +} + +// applyCompleteCmd finishes a custody return whose Git side already applied +// and whose record is missing. It is reported under its own telemetry action +// so a completion is never counted as the recovery or settlement it repeats. +func (m Model) applyCompleteCmd() tea.Cmd { + keepLocal, ok := custodyReturnCompletion(m.branchSync) + if !ok { + return nil + } + complete := m.completionSeam(keepLocal) + if complete == nil { + return nil + } + return func() tea.Msg { + started := time.Now() + state := complete() + result := "refused" + if state.Recovered { + result = "applied" + } + trackTUISyncAttempt("complete", state, result, started) + return syncAppliedMsg{state: state} + } +} + func (m Model) spinnerTickCmd() tea.Cmd { return tea.Tick(spinnerTickInterval, func(time.Time) tea.Msg { return spinnerTickMsg{} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index a39c1d7f9..a5c0e0610 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -71,6 +71,25 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } + if m.completeConfirm { + switch key { + case "esc": + m.completeConfirm = false + return m, nil + case "u", "enter": + if m.syncRefreshing { + return m, nil + } + m.syncRefreshing = true + return m, m.applyCompleteCmd() + case "q", "ctrl+c": + m.quitting = true + return m, tea.Sequence(tea.SetWindowTitle(""), tea.Quit) + default: + return m, nil + } + } + // Reset abort confirmation on any key except 'x'. if key != "x" { m.confirmAbort = false @@ -277,6 +296,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.settleConfirm = true return m, nil } + if keepLocal, ok := custodyReturnCompletion(m.branchSync); ok && m.completionSeam(keepLocal) != nil { + m.err = nil + m.completeConfirm = true + return m, nil + } if m.syncRefresh == nil || m.branchSync.NextAction == nil || m.branchSync.NextAction.Code != "sync" { return m, nil } diff --git a/internal/tui/view.go b/internal/tui/view.go index 90edcdb1a..1212a73c6 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -119,6 +119,9 @@ func (m Model) View() string { if m.settleConfirm && m.branchSync != nil { extraSections = append(extraSections, renderSettleConfirmation(*m.branchSync, rightWidth)) } + if m.completeConfirm && m.branchSync != nil { + extraSections = append(extraSections, renderCompleteConfirmation(*m.branchSync, rightWidth)) + } // Modal editor takes priority over findings/logs so it always renders // when active. Bypass the content budget so it never gets dropped on diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index e2d5c9a7f..8d4574c1e 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -245,6 +245,8 @@ A dirty worktree, or divergence that cannot be proven contained, makes the recov When `next_action.code` is `return_custody_keep_local`, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. If any copy of that head still exists, the settlement anchors it at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under `NM_HOME` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. That anchor is NOT `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. +When `next_action.code` is `complete_custody_return`, an earlier recovery already applied its Git changes and only the custody record is missing: re-run the exact command it names - `no-mistakes axi sync --recover` or `no-mistakes axi sync --recover --keep-local`, whichever it carries - to complete the record. +That code is deliberately neither `recover_custody` nor `return_custody_keep_local`: each of those is a claim about the record, and neither is being made here. Re-running is safe because every Git step it repeats is idempotent once applied. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. After synchronization, commit the follow-up on top and re-run `no-mistakes axi run --intent "..."` with the original user intent. This preserves every prior gate-fix commit regardless of its configured subject. From cbc0796bda1d84282303ae504cb75d5c9b739f3f Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:12:18 -0400 Subject: [PATCH 22/31] no-mistakes(review): condition gate-move promise across all surfaces; surface staging leftovers --- docs/src/content/docs/guides/agents.md | 2 +- docs/src/content/docs/guides/tui.md | 1 + docs/src/content/docs/reference/cli.md | 2 +- internal/branchsync/claims.go | 70 ++++++++ internal/branchsync/recover_test.go | 45 +++++ internal/branchsync/sync.go | 27 ++- internal/cli/axi_guidance.go | 2 +- .../cli/settlement_claim_contract_test.go | 168 ++++++++++++++++++ internal/cli/sync.go | 6 +- internal/skill/skill.go | 2 +- internal/tui/branch_sync.go | 14 +- internal/tui/view.go | 2 +- skills/no-mistakes/SKILL.md | 2 +- 13 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 internal/branchsync/claims.go create mode 100644 internal/cli/settlement_claim_contract_test.go diff --git a/docs/src/content/docs/guides/agents.md b/docs/src/content/docs/guides/agents.md index d642735c9..ceee29c91 100644 --- a/docs/src/content/docs/guides/agents.md +++ b/docs/src/content/docs/guides/agents.md @@ -178,7 +178,7 @@ no-mistakes axi abort --run Before any post-pipeline local commit or fresh run, read `branch_sync`. Only when its structured `next_action.code` is `sync`, run `no-mistakes axi sync` first. When `next_action.code` is `recover_custody` - a terminal run left unpublished pipeline commits preserved in the local gate - run `no-mistakes axi sync --recover` to return custody, or `no-mistakes rerun` to resume validating the preserved head. -When `next_action.code` is `return_custody_keep_local` - a terminal run whose recorded pipeline head cannot be verified, either because no reachable object store still has it or because the run's own recovery evidence names a different commit - run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. +When `next_action.code` is `return_custody_keep_local` - a terminal run whose recorded pipeline head cannot be verified, either because no reachable object store still has it or because the run's own recovery evidence names a different commit - run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it where that branch still names a different head. If any copy of that head still exists, the settlement anchors it at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under `NM_HOME` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. That anchor is not `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. When `next_action.code` is `complete_custody_return` - an earlier recovery already applied its Git changes and only the custody record is missing - re-run the exact command it names, `no-mistakes axi sync --recover` or `no-mistakes axi sync --recover --keep-local`, to complete the record; every Git step it repeats is idempotent once applied. diff --git a/docs/src/content/docs/guides/tui.md b/docs/src/content/docs/guides/tui.md index 96d966616..0128649e0 100644 --- a/docs/src/content/docs/guides/tui.md +++ b/docs/src/content/docs/guides/tui.md @@ -145,6 +145,7 @@ Confirm with `u` or Enter, or cancel with Escape. The apply path rechecks every mutable assumption and can only perform the same exact strict fast-forward or anchored equivalent-diverged advance as `no-mistakes sync`; blocked states never trigger destructive Git recovery. When the owning run ended without publishing its pipeline commits, the same box offers `u recover custody` instead: `u` opens a confirmation naming the terminal status, the local head, and the preserved head, and applying routes through the guarded recovery documented in [`no-mistakes axi sync`](/no-mistakes/reference/cli/#no-mistakes-axi-sync). When the state instead carries `next_action.code: return_custody_keep_local` - the terminal run's recorded pipeline head can no longer be verified, so there is nothing to recover - the box offers `u settle custody at local head`, and its own separate confirmation names the kept local head and the unverifiable recorded head before applying. Settlement keeps your head where recovery takes the preserved one, so the two never share a confirmation; applying routes through the same `--recover --keep-local` settlement documented in [`no-mistakes axi sync`](/no-mistakes/reference/cli/#no-mistakes-axi-sync). +When the state instead carries `next_action.code: complete_custody_return` - an earlier custody return applied its Git changes but could not record the custody return - the box offers `u complete custody return`, and its own confirmation says that the record is what is missing rather than repeating the settlement's claim that the recorded head cannot be verified. Applying re-runs the exact recovery the state names, `--recover` or `--recover --keep-local`, because every Git step it repeats is idempotent once applied; a command shape the TUI does not recognize offers no key at all rather than guessing which head to keep. ### Footer diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 0edaf34f2..509ff3f07 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -236,7 +236,7 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, naming the anchor to reconcile. It reports that no *local* files or refs were changed only where that narrower claim is earned - beside a preserved-head anchor pinned in the local gate, which is what the settlement does when the recorded head survives there - and appends the anchor note rather than dropping the `local` qualifier. Where the preserved head was instead anchored in the invoking worktree, which is what the ordinary `--recover --keep-local` path always does, the `local` qualifier would deny the very ref the note names, so the refusal reports that no branch, worktree, or file changes were made and discloses the anchor separately. That anchor stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and, where the gate branch still names a different head, atomically points that branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, in one of two variants that prescribe different exits. Where the attempt had anchored an independently moved gate head, the refusal names that anchor to reconcile. Where the gate branch still named the recorded head, no `refs/no-mistakes/recover-gate/` was ever written, the refusal says so, and the exit is simply re-running the recovery against the new gate head - there is no anchor to reconcile by hand. It reports that no *local* files or refs were changed only where that narrower claim is earned - beside a preserved-head anchor pinned in the local gate, which is what the settlement does when the recorded head survives there - and appends the anchor note rather than dropping the `local` qualifier. Where the preserved head was instead anchored in the invoking worktree, which is what the ordinary `--recover --keep-local` path always does, the `local` qualifier would deny the very ref the note names, so the refusal reports that no branch, worktree, or file changes were made and discloses the anchor separately. Where that anchor was written it stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. diff --git a/internal/branchsync/claims.go b/internal/branchsync/claims.go new file mode 100644 index 000000000..d31946b10 --- /dev/null +++ b/internal/branchsync/claims.go @@ -0,0 +1,70 @@ +package branchsync + +import "strings" + +// SettlementGateMoveQualifier is the condition under which the keep-local +// custody return actually moves the gate branch. Every path that returns +// before recoverKeepLocal's swap leaves the gate untouched: the equal/ahead +// branch stamps custody directly, recoverSettleInconsistent returns early when +// the gate branch is PROVEN ABSENT or no gate is configured, and +// recoverKeepLocal skips its whole block when the gate already names the kept +// head. A record whose gate branch was deleted is admitted by the +// advertisement predicates on purpose, so a surface promising the settlement +// points the gate branch anywhere is telling that operator something false. +const SettlementGateMoveQualifier = "still names a different head" + +// settlementGateMovePromises are the ways a surface states that promise. The +// list lives beside the code whose behavior makes the promise conditional, not +// beside any one surface, because the drift this guards against was never +// confined to a single file: a correction applied to the four cobra help +// strings left the identical claim standing on the structured branch_sync +// error, the TUI confirmation, the agent guidance, the skill and the agents +// guide. +var settlementGateMovePromises = []string{ + "points the gate branch at", + "point the gate branch at", + "points that branch at", + "point that branch at", + "points the gate branch to", + "moves the gate branch to", + "move the gate branch to", + "compare-and-swaps onto the kept head", + "the gate follows the kept head", + "points it at the kept head", +} + +// UnqualifiedGateMovePromise returns the first sentence of text that promises +// the keep-local custody return moves the gate branch onto the kept head +// without conditioning it on SettlementGateMoveQualifier, or "" when the +// invariant holds. Sentences are the unit because the qualifier has to travel +// with the promise an operator reads, not merely appear somewhere in the same +// document. +func UnqualifiedGateMovePromise(text string) string { + for _, sentence := range claimSentences(text) { + promised := false + for _, promise := range settlementGateMovePromises { + if strings.Contains(sentence, promise) { + promised = true + break + } + } + if !promised || strings.Contains(sentence, SettlementGateMoveQualifier) { + continue + } + return sentence + } + return "" +} + +// claimSentences normalizes whitespace before splitting so a claim stays one +// sentence across hard-wrapped help text, TUI box lines, and Go string +// concatenation. +func claimSentences(text string) []string { + var out []string + for _, part := range strings.Split(strings.Join(strings.Fields(text), " "), ". ") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index f11c3aa3b..abcce034f 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3628,3 +3628,48 @@ func TestUnreadableGateSwapFailureDoesNotDisclaimARaceItCannotRuleOut(t *testing t.Fatalf("swap failure named no exit = %#v", state) } } + +// TestSuccessfulKeepLocalSurfacesAStagingRefItCouldNotRemove closes the one +// path where releaseStagingRef's guarantee was dropped. Every refusal around +// the compare-and-swap discloses a staging ref it could not remove, but the +// SUCCESS path computed that clause and threw it away, so a failed cleanup left +// refs/no-mistakes/custody-return/ in the gate reported nowhere at all - +// one dangling ref per affected run, invisibly. +func TestSuccessfulKeepLocalSurfacesAStagingRefItCouldNotRemove(t *testing.T) { + t.Parallel() + + f, _, _ := wedgedCustodyFixture(t, types.RunFailed) + stagingRef := "refs/no-mistakes/custody-return/" + f.run.ID + // Hold the staging ref's own lock - and only that one - so its deletion + // fails while the compare-and-swap onto the kept head still succeeds. + lock := filepath.Join(f.gate, filepath.FromSlash(stagingRef)+".lock") + f.service.afterGateStage = func() { + if err := os.MkdirAll(filepath.Dir(lock), 0o755); err != nil { + t.Error(err) + return + } + if err := os.WriteFile(lock, []byte(""), 0o644); err != nil { + t.Error(err) + } + } + t.Cleanup(func() { _ = os.Remove(lock) }) + + state := f.service.Recover(f.ctx, true) + if !state.Recovered { + t.Fatalf("a failed staging-ref cleanup must not refuse a custody return that succeeded = %#v", state) + } + // The fixture must have produced the state under test: the swap really + // happened and the staging ref really survived. + if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover^{commit}"); got != f.submitted { + t.Fatalf("gate branch = %s, want the kept local head %s", got, f.submitted) + } + if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, stagingRef); err != nil || !exists { + t.Fatalf("fixture invariant broken: the staging ref must have survived: exists=%v err=%v", exists, err) + } + if !strings.Contains(state.Error, stagingRef) || !strings.Contains(state.Error, "remains there") { + t.Fatalf("successful custody return did not report the staging ref it left behind: %q", state.Error) + } + if !f.custodyReturned() { + t.Fatal("custody was not stamped") + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 1925d539e..e8e57dea6 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -1008,6 +1008,7 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State s.beforeGateReset() } gateHeadAnchored := false + stagingLeft := "" if gateHead != state.Local.Head { gateAnchor := "" writeGateAnchor := false @@ -1073,7 +1074,7 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State } } _, casErr := git.Run(ctx, s.GateDir, "update-ref", "refs/heads/"+state.Local.Branch, state.Local.Head, gateHead) - stagingLeft := s.releaseStagingRef(ctx, stagingRef) + stagingLeft = s.releaseStagingRef(ctx, stagingRef) if casErr != nil { // A failed update-ref is not evidence that the gate moved: a lock // held by another process, a permission problem, or an I/O error @@ -1110,7 +1111,27 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; "+keepLocalPostSwapNoChangeClause(anchoredNote)+stagingLeft) } } - return s.finishRecover(ctx, run, false, true) + // A cleanup failure is not a reason to refuse a custody return that + // succeeded, but it is a ref this attempt left in the gate. Every refusal + // around it discloses that; dropping it on the one path that reaches the + // end would leave the leftover reported nowhere at all. + return withCleanupNote(s.finishRecover(ctx, run, false, true), stagingLeft) +} + +// withCleanupNote attaches a leftover-ref disclosure to a state that is not +// refusing, without clobbering a message the state already carries - the +// custody stamp can have failed on the very same call. +func withCleanupNote(state State, note string) State { + note = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(note), ";")) + if note == "" { + return state + } + if state.Error == "" { + state.Error = note + return state + } + state.Error += "; " + note + return state } // releaseStagingRef removes the temporary ref the gate-side fetch stages the @@ -1852,7 +1873,7 @@ func (s *Service) classifyPipelineOwned(ctx context.Context, state *State, run * // evidence intact falls back to manual reconciliation, because // there the refusal is protecting something real. if s.selfInconsistentCustodyRecord(ctx, state, run) { - state.Error = "the run finished " + string(run.Status) + " but its recorded pipeline head cannot be verified in the invoking worktree or local gate; return custody at the current local head, which also points the gate branch at it" + state.Error = "the run finished " + string(run.Status) + " but its recorded pipeline head cannot be verified in the invoking worktree or local gate; return custody at the current local head, which also points the gate branch at it where that branch still names a different head" state.NextAction = &NextAction{Code: "return_custody_keep_local", Command: "no-mistakes axi sync --recover --keep-local"} return } diff --git a/internal/cli/axi_guidance.go b/internal/cli/axi_guidance.go index 9ffe18611..a0f7cfcf5 100644 --- a/internal/cli/axi_guidance.go +++ b/internal/cli/axi_guidance.go @@ -26,4 +26,4 @@ const preserveGateFixCommitsGuidance = "Commit post-pipeline follow-up work on t // branchSyncAgentGuidance is emitted only when a relevant branch_sync object // is present. Keeping it conditional avoids flooding ordinary runs whose local // and pipeline heads never differed. -const branchSyncAgentGuidance = "Before a post-pipeline local commit or fresh run, follow the structured `branch_sync.next_action`. Run `no-mistakes axi sync` only when its code is `sync`; that guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics. Run `no-mistakes axi sync --recover` only when its code is `recover_custody` (a terminal run left unpublished pipeline commits preserved in the local gate). Run `no-mistakes axi sync --recover --keep-local` only when its code is `return_custody_keep_local` (a terminal run whose recorded pipeline head can no longer be verified): it returns custody at your current head and points the gate branch at it. A `complete_custody_return` code separately authorizes whichever of those two recovery commands it names: an earlier recovery already applied its Git changes and only the custody record is missing, so re-running the exact command it names completes the record. A `user_owned` state means cancellation released the branch before changing the submitted head: the exact branch and head are yours, immediately usable, and no sync action is needed. Process blocked or pipeline-owned states instead of improvising reset, stash, merge, rebase, force, or branch replacement." +const branchSyncAgentGuidance = "Before a post-pipeline local commit or fresh run, follow the structured `branch_sync.next_action`. Run `no-mistakes axi sync` only when its code is `sync`; that guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics. Run `no-mistakes axi sync --recover` only when its code is `recover_custody` (a terminal run left unpublished pipeline commits preserved in the local gate). Run `no-mistakes axi sync --recover --keep-local` only when its code is `return_custody_keep_local` (a terminal run whose recorded pipeline head can no longer be verified): it returns custody at your current head and points the gate branch at it where that branch still names a different head. A `complete_custody_return` code separately authorizes whichever of those two recovery commands it names: an earlier recovery already applied its Git changes and only the custody record is missing, so re-running the exact command it names completes the record. A `user_owned` state means cancellation released the branch before changing the submitted head: the exact branch and head are yours, immediately usable, and no sync action is needed. Process blocked or pipeline-owned states instead of improvising reset, stash, merge, rebase, force, or branch replacement." diff --git a/internal/cli/settlement_claim_contract_test.go b/internal/cli/settlement_claim_contract_test.go new file mode 100644 index 000000000..9bece1f2b --- /dev/null +++ b/internal/cli/settlement_claim_contract_test.go @@ -0,0 +1,168 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kunchenguid/no-mistakes/internal/branchsync" + "github.com/kunchenguid/no-mistakes/internal/db" + "github.com/kunchenguid/no-mistakes/internal/skill" + "github.com/kunchenguid/no-mistakes/internal/tui" +) + +// TestSettlementGateMovePromiseStaysConditional is the ONE guard on a single +// CLAIM across every surface that states it, rather than one guard per file +// family. +// +// The claim: the keep-local custody return points the gate branch at the kept +// head. It is false for a state the advertisement predicates deliberately +// admit - with the gate branch PROVEN ABSENT, recoverSettleInconsistent stamps +// custody with no compare-and-swap and no gate branch touched at all - so every +// surface must carry branchsync.SettlementGateMoveQualifier in the same +// sentence as the promise. +// +// This test's population is the claim, because the previous scoping was the +// help text: a correction purged the phrasing from the four cobra help strings +// and pinned it with TestKeepLocalHelpSurfacesStayConditional, while the +// identical promise survived on the structured branch_sync error, the TUI +// confirmation box, the agent guidance, the skill and the agents guide. Drift +// on ANY of these now fails here. +// +// Where a surface has a real interface it is executed: cobra renders its own +// help, the TUI renders its own confirmation, branchsync classifies a real +// wedged record from a real repository, and the guidance constant is the value +// the CLI emits. The generated skill and the two docs pages are read as the +// owned text contracts they already are, following the precedent in +// axi_guidance_test.go. +func TestSettlementGateMovePromiseStaysConditional(t *testing.T) { + for name, text := range settlementClaimSurfaces(t) { + if offender := branchsync.UnqualifiedGateMovePromise(text); offender != "" { + t.Errorf("%s promises the settlement moves the gate branch without conditioning it on %q:\n\t%s", + name, branchsync.SettlementGateMoveQualifier, offender) + } + } +} + +// TestSettlementGateMovePromiseSurfacePopulationIsComplete keeps the guard +// above honest. A surface that silently stopped stating the claim - or a +// fixture that stopped producing the settlement advertisement - would make the +// invariant vacuously true there, which is exactly how the promise survived +// the last correction. +func TestSettlementGateMovePromiseSurfacePopulationIsComplete(t *testing.T) { + for name, text := range settlementClaimSurfaces(t) { + // Normalized, because hard-wrapped help, boxed TUI output and + // concatenated Go strings all break the qualifier across lines. + if !strings.Contains(strings.Join(strings.Fields(text), " "), branchsync.SettlementGateMoveQualifier) { + t.Errorf("%s no longer states the qualified gate-move consequence at all, so the invariant is vacuous there:\n%s", name, text) + } + } +} + +// settlementClaimSurfaces collects every surface that describes what the +// keep-local custody return does to the gate branch. +func settlementClaimSurfaces(t *testing.T) map[string]string { + t.Helper() + + humanHelp, err := executeCmd("sync", "--help") + if err != nil { + t.Fatalf("sync --help: %v\n%s", err, humanHelp) + } + axiHelp, err := executeCmd("axi", "sync", "--help") + if err != nil { + t.Fatalf("axi sync --help: %v\n%s", err, axiHelp) + } + + // The docs pages and the agents guide are read through repo-relative paths, + // so they must be loaded BEFORE the wedged fixture changes the working + // directory to its operator worktree. + agentsGuide := readAgentsGuide(t) + cliReference := readDocsPage(t, "reference", "cli.md") + generatedSkill := skill.Markdown() + advertisement := wedgedSettlementAdvertisement(t) + + return map[string]string{ + // Keep the per-surface isolation the help contract test established: + // the flag line and the Long text are separate surfaces, and a shared + // grep over the whole blob let one satisfy the check for the other. + "sync --keep-local flag help": keepLocalFlagUsage(t, humanHelp), + "sync long description": helpLongDescription(t, humanHelp), + "axi sync --keep-local flag help": keepLocalFlagUsage(t, axiHelp), + "axi sync long description": helpLongDescription(t, axiHelp), + "live branch-sync agent guidance": branchSyncAgentGuidance, + "structured branch_sync error": advertisement, + "TUI settlement confirmation": unboxed(tui.RenderSettleConfirmation(wedgedSettlementState(), 80)), + "generated skill": generatedSkill, + "agents guide": agentsGuide, + "cli reference": cliReference, + } +} + +// wedgedSettlementAdvertisement returns the structured branch_sync error a real +// wedged custody record produces, read from classifyPipelineOwned through the +// same service the CLI surfaces build. +func wedgedSettlementAdvertisement(t *testing.T) string { + t.Helper() + _, p, _ := wedgedCustodyAbortFixture(t) + + database, err := db.Open(p.DB()) + if err != nil { + t.Fatalf("open fixture database: %v", err) + } + defer database.Close() + repo, err := findRepo(database) + if err != nil || repo == nil { + t.Fatalf("find fixture repo: repo=%#v err=%v", repo, err) + } + service := &branchsync.Service{ + DB: database, + Repo: repo, + WorkDir: ".", + GateDir: p.RepoDir(repo.ID), + Paths: p, + } + state := service.InspectCached(context.Background()) + // The fixture must actually reach the settlement advertisement, or the + // message under test is not the one an operator would read. + if state.NextAction == nil || state.NextAction.Code != "return_custody_keep_local" { + t.Fatalf("fixture did not advertise the keep-local settlement: %#v", state.NextAction) + } + return state.Error +} + +// wedgedSettlementState is the state shape the TUI receives for that same +// record: pipeline_owned carrying the settlement next action. +func wedgedSettlementState() branchsync.State { + return branchsync.State{ + State: branchsync.StatePipelineOwned, + Safety: "blocked_recover_preserved_head_missing", + Local: branchsync.LocalState{Branch: "feature", Head: strings.Repeat("a", 40), Clean: true}, + Pipeline: branchsync.PipelineState{RunID: "run-1", Status: "failed", Phase: "pre_push", CurrentHead: strings.Repeat("c", 40)}, + NextAction: &branchsync.NextAction{Code: "return_custody_keep_local", Command: "no-mistakes axi sync --recover --keep-local"}, + } +} + +// unboxed reduces a rendered TUI box to the prose inside it. The border runes +// and their padding land mid-sentence on every wrapped line, so a claim would +// otherwise be unreadable as the one sentence an operator sees. +func unboxed(rendered string) string { + plain := ansiEscape.ReplaceAllString(rendered, "") + return strings.Map(func(r rune) rune { + if strings.ContainsRune("│╭╮╰╯─", r) { + return ' ' + } + return r + }, plain) +} + +func readDocsPage(t *testing.T, section, name string) string { + t.Helper() + path := filepath.Join("..", "..", "docs", "src", "content", "docs", section, name) + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(body) +} diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 146bf1afc..50ca2e060 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -275,7 +275,11 @@ func printHumanSyncState(cmd *cobra.Command, state branchsync.State) { fmt.Fprintf(w, " target: %s %s (%s)\n", state.Target.Remote, state.Target.Ref, state.Target.Kind) } if state.Error != "" { - fmt.Fprintf(w, " blocked: %s\n", state.Error) + label := "blocked:" + if state.Recovered { + label = "note:" + } + fmt.Fprintf(w, " %-9s %s\n", label, state.Error) } } diff --git a/internal/skill/skill.go b/internal/skill/skill.go index d9f6c1d6a..c8b199188 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -242,7 +242,7 @@ Recovery takes that head by fast-forward, or by adopting a diverged preserved he That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours. A ` + "`branch_sync.state`" + ` of ` + "`user_owned`" + ` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated ` + "`--recover`" + ` there is a harmless no-op. A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; ` + "`--keep-local`" + ` keeps your current head while the preserved commits stay anchored under ` + "`refs/no-mistakes/recover/`" + `. -When ` + "`next_action.code`" + ` is ` + "`return_custody_keep_local`" + `, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run ` + "`no-mistakes axi sync --recover --keep-local`" + ` to return custody at your current head and point the gate branch at it. +When ` + "`next_action.code`" + ` is ` + "`return_custody_keep_local`" + `, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run ` + "`no-mistakes axi sync --recover --keep-local`" + ` to return custody at your current head and point the gate branch at it where that branch still names a different head. If any copy of that head still exists, the settlement anchors it at ` + "`refs/no-mistakes/recover-stranded/`" + ` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under ` + "`NM_HOME`" + ` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. That anchor is NOT ` + "`refs/no-mistakes/recover/`" + `, which in this state is the conflicting evidence and can name an entirely different commit. When ` + "`next_action.code`" + ` is ` + "`complete_custody_return`" + `, an earlier recovery already applied its Git changes and only the custody record is missing: re-run the exact command it names - ` + "`no-mistakes axi sync --recover`" + ` or ` + "`no-mistakes axi sync --recover --keep-local`" + `, whichever it carries - to complete the record. diff --git a/internal/tui/branch_sync.go b/internal/tui/branch_sync.go index d7829226a..8cfe06188 100644 --- a/internal/tui/branch_sync.go +++ b/internal/tui/branch_sync.go @@ -171,19 +171,25 @@ func renderRecoverConfirmation(state branchsync.State, width int) string { return renderBoxWithFooter("Confirm custody recovery", b.String(), width, "u/enter recover · esc cancel") } -// renderSettleConfirmation is deliberately NOT renderRecoverConfirmation with +// RenderSettleConfirmation is deliberately NOT renderRecoverConfirmation with // different words. Recovery takes the preserved pipeline head; settlement // keeps the local head and moves the gate to it, abandoning a recorded head // that can no longer be verified. The CLI makes that an explicit --keep-local // choice, so the TUI has to state the same consequence before asking for it. -func renderSettleConfirmation(state branchsync.State, width int) string { +// +// It is the one exported render here because the cross-surface invariant test +// that guards that consequence lives in internal/cli - the only package able to +// see the cobra help and the agent guidance too - and must execute this box +// rather than restate its wording. +func RenderSettleConfirmation(state branchsync.State, width int) string { if width < 40 { width = 80 } var b strings.Builder fmt.Fprintf(&b, "The run ended %s and its recorded pipeline head can no longer be verified,\n", state.Pipeline.Status) fmt.Fprintf(&b, "so there is nothing to recover. Settling returns custody at the head you\n") - fmt.Fprintf(&b, "already have and points the gate branch at it.\n\n") + fmt.Fprintf(&b, "already have, and points the gate branch at it where that branch still\n") + fmt.Fprintf(&b, "names a different head.\n\n") fmt.Fprintf(&b, "Local branch: %s\n", state.Local.Branch) fmt.Fprintf(&b, "Kept HEAD: %s\n", state.Local.Head) fmt.Fprintf(&b, "Recorded HEAD: %s (unverifiable)\n\n", state.Pipeline.CurrentHead) @@ -191,7 +197,7 @@ func renderSettleConfirmation(state branchsync.State, width int) string { return renderBoxWithFooter("Confirm custody settlement at local head", b.String(), width, "u/enter settle · esc cancel") } -// renderCompleteConfirmation is deliberately NOT renderSettleConfirmation with +// renderCompleteConfirmation is deliberately NOT RenderSettleConfirmation with // different words. The settlement box tells the operator their recorded head // can no longer be verified and asks them to abandon it; this state makes no // such claim - the recovery already ran and succeeded on the Git side, and diff --git a/internal/tui/view.go b/internal/tui/view.go index 1212a73c6..3c3b9e2e9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -117,7 +117,7 @@ func (m Model) View() string { extraSections = append(extraSections, renderRecoverConfirmation(*m.branchSync, rightWidth)) } if m.settleConfirm && m.branchSync != nil { - extraSections = append(extraSections, renderSettleConfirmation(*m.branchSync, rightWidth)) + extraSections = append(extraSections, RenderSettleConfirmation(*m.branchSync, rightWidth)) } if m.completeConfirm && m.branchSync != nil { extraSections = append(extraSections, renderCompleteConfirmation(*m.branchSync, rightWidth)) diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index 8d4574c1e..d77c267c5 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -242,7 +242,7 @@ Recovery takes that head by fast-forward, or by adopting a diverged preserved he That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours. A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated `--recover` there is a harmless no-op. A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/`. -When `next_action.code` is `return_custody_keep_local`, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it. +When `next_action.code` is `return_custody_keep_local`, the terminal run's recorded pipeline head cannot be verified - it is in no reachable object store, or the run's own recovery evidence names a different commit: run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it where that branch still names a different head. If any copy of that head still exists, the settlement anchors it at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under `NM_HOME` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. That anchor is NOT `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. When `next_action.code` is `complete_custody_return`, an earlier recovery already applied its Git changes and only the custody record is missing: re-run the exact command it names - `no-mistakes axi sync --recover` or `no-mistakes axi sync --recover --keep-local`, whichever it carries - to complete the record. From 7c62035e707b042d9d65e18816cbdb67447f52f2 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:28:27 -0400 Subject: [PATCH 23/31] no-mistakes(review): keep cleanup note off success path; guard consent prompt claim --- .../branch-sync-and-push-safety/SKILL.md | 2 +- internal/branchsync/claims.go | 3 + internal/branchsync/recover_test.go | 25 ++++--- internal/branchsync/sync.go | 31 ++------ .../cli/settlement_claim_contract_test.go | 75 ++++++++++++++++--- internal/cli/sync.go | 19 ++--- 6 files changed, 101 insertions(+), 54 deletions(-) diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index 0d5cc90dd..e459cd7a8 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -17,7 +17,7 @@ metadata: A cancelled validation routinely leaves a preserved head that is a REBASE of the local branch, which equality and ancestry read as plain divergence, so a clean diverged worktree is adopted when `preservedContainsLocalWork` proves containment. That proof is an executable `merge-tree` three-way merge whose result must equal the preserved head's tree, anchored on the merge-base - never `runs.base_sha`, the previous gate head. It deliberately does NOT use patch identity: patch IDs discard hunk locations and whitespace, so they cannot tell a genuine replay from a same-shaped edit to another identical block, and a containment claim built on them is not a proof. Everything undecidable escalates, including a rebase whose fix rounds also rewrote operator lines, where nothing separates a deliberate fix from a dropped change. Adoption anchors the pre-recovery local head at `refs/no-mistakes/recover-local/`, then moves the branch with Git operations that fail closed on their own rather than after an observation - an atomic `update-ref` CAS plus `read-tree -m -u`, never check-then-act followed by `reset --hard`, which destroys anything landing in the gap. `recoverAdoptPreserved` owns the reasoning. Terminalization pins every verified unpublished head at `refs/no-mistakes/recover/` before the managed worktree can be removed. Recovery reads that run-specific ref rather than requiring the gate branch to match, so aborts, rebases, and pre-push failures remain recoverable while an independently moved gate branch is preserved. Legacy recorded heads that still exist as dangling gate objects are anchored on recovery; a truly missing recorded head never advertises an impossible `recover_custody` command and instead reports the keep-local settlement below, falling back to manual reconciliation whenever that settlement is itself unreachable. - When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, already-recovered, and absent-gate-branch paths return custody without moving any branch ref (equal/ahead still writes the private recovery anchor, which is what anchoring a locally reachable preserved head means), and the flag help must keep saying so. + When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and, where the gate branch still names a different head, CAS-moves that branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, and already-recovered paths return custody without moving any branch ref (equal/ahead still writes the private recovery anchor, which is what anchoring a locally reachable preserved head means), and the flag help must keep saying so. A PROVEN-absent gate branch does the same on the SETTLEMENT path only; the ordinary keep-local path resolves the gate branch before delegating, so a deleted or unreadable one refuses there with `blocked_recover_gate_unavailable` rather than returning custody, and no surface may promise otherwise. `--keep-local` is also the ONLY settlement for a SELF-INCONSISTENT custody record (#824): a terminal run whose recorded pipeline head is in no reachable object store, or whose own recovery evidence names something else, has nothing verifiable to import, so every default recovery refused, `abort` of the terminal run was a no-op, and the branch stayed `pipeline_owned` forever. Inspection names that exit (`next_action.code: return_custody_keep_local`) instead of #814's dead-end manual-reconciliation pointer, terminal-run `abort` responses carry the same command, and the TUI `u` action reaches the same settlement - keyed on that advertised next action, never on a safety code, so it cannot drift from the predicate that decides where the settlement can complete. #814's polarity (never advertise `recover_custody` for an unverifiable record) is preserved. Terminal-run no-op abort help is separately allowlisted to custody-settlement codes only (`custodySettlementHelp`) AND gated on `StatePipelineOwned`, so an abort that cancelled nothing never answers by prescribing a fresh run against a released branch, nor by repeating ordinary-divergence `git log` advice; a SUCCESSFUL cancellation still echoes the branch's own next action. Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. It carries its OWN `complete_custody_return` code rather than reusing `recover_custody`/`return_custody_keep_local`, because those codes are claims about the record that the skill, the CLI guidance, the docs and the TUI each restate in their own words - and neither claim is true of a retry. Its message scopes to "any Git changes this recovery makes", since the proven-absent-gate-branch, no-gate and gate-already-at-the-kept-head paths reach it having applied nothing. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Each trigger must therefore be probed the way `Recover` itself probes it: the worktree recovery ref goes through `recoveryAnchorIntercepts`, mirroring Recover's `rev-parse ^{commit}` (which DEREFERENCES a resolving symref, so such a record is never intercepted) rather than the stricter `recoveryAnchorCompatible`, which rejects every symref and advertised a settlement that could only refuse. Advertising any of those recreates the very wedge this change removes. Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every PRE-SWAP refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The refusals AFTER the swap are excluded from that substitution on purpose: they may already have written the gate anchor, so they keep the narrower "no LOCAL files or refs were changed" claim and APPEND the note - but only where the note names a GATE-side pin. When it names the invoking worktree, which the ordinary keep-local path always pins, that qualifier would deny the very ref the sentence discloses, so the substitution applies after all; the note carries the STORES and not just the prose so the two cannot be confused. The anchor CONFLICT check stays first because it is read-only. TWO post-anchor-write refusals remain - the lost CAS and the unproven-race swap failure - plus the separate post-success stamp failure above. Both keep the anchor deliberately: the swap failed, so that pin may be the only ref still naming the displaced head, and each names the ref to reconcile. A failed swap is only reported as a race once the gate head is re-read and actually differs; a still-unmoved or unreadable head is `blocked_recover_swap_failed` instead, because a held lock or an I/O error fails `update-ref` identically and inventing a racing actor is the same overstatement in a different place. The staged head proves only what the fetch copied, so the branch is re-read before the swap: a commit landing in that window would otherwise stamp custody at a head the worktree has already moved past. diff --git a/internal/branchsync/claims.go b/internal/branchsync/claims.go index d31946b10..cd4d04274 100644 --- a/internal/branchsync/claims.go +++ b/internal/branchsync/claims.go @@ -28,6 +28,9 @@ var settlementGateMovePromises = []string{ "points the gate branch to", "moves the gate branch to", "move the gate branch to", + "moving the gate branch to", + "moving the local gate branch to", + "moving that branch to", "compare-and-swaps onto the kept head", "the gate follows the kept head", "points it at the kept head", diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index abcce034f..55b5e550b 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3629,13 +3629,17 @@ func TestUnreadableGateSwapFailureDoesNotDisclaimARaceItCannotRuleOut(t *testing } } -// TestSuccessfulKeepLocalSurfacesAStagingRefItCouldNotRemove closes the one -// path where releaseStagingRef's guarantee was dropped. Every refusal around -// the compare-and-swap discloses a staging ref it could not remove, but the -// SUCCESS path computed that clause and threw it away, so a failed cleanup left -// refs/no-mistakes/custody-return/ in the gate reported nowhere at all - -// one dangling ref per affected run, invisibly. -func TestSuccessfulKeepLocalSurfacesAStagingRefItCouldNotRemove(t *testing.T) { +// TestSuccessfulKeepLocalReportsNoFailureWhenOnlyCleanupFailed pins where the +// staging-ref disclosure belongs. The obligation is a REFUSAL's: those deny +// leaving anything behind, so a swallowed cleanup error would make that denial +// false. A successful custody return makes no such claim, and State.Error is +// the field every consumer reads as failure - `axi sync` emits it as a +// top-level `error`, and the TUI raises an error banner on it - so putting the +// leftover there made a fully completed settlement report itself failed. +// +// The leftover is benign: the next attempt's fetch force-overwrites the same +// ref, and the kept head stays reachable from the gate branch regardless. +func TestSuccessfulKeepLocalReportsNoFailureWhenOnlyCleanupFailed(t *testing.T) { t.Parallel() f, _, _ := wedgedCustodyFixture(t, types.RunFailed) @@ -3666,8 +3670,11 @@ func TestSuccessfulKeepLocalSurfacesAStagingRefItCouldNotRemove(t *testing.T) { if _, exists, err := gitpkg.ExactRefTarget(f.ctx, f.gate, stagingRef); err != nil || !exists { t.Fatalf("fixture invariant broken: the staging ref must have survived: exists=%v err=%v", exists, err) } - if !strings.Contains(state.Error, stagingRef) || !strings.Contains(state.Error, "remains there") { - t.Fatalf("successful custody return did not report the staging ref it left behind: %q", state.Error) + if state.Error != "" { + t.Fatalf("a fully completed custody return reported a failure on the field every consumer reads as one: %q", state.Error) + } + if state.Safety != "custody_returned" { + t.Fatalf("cleanup failure changed the settlement's safety = %q", state.Safety) } if !f.custodyReturned() { t.Fatal("custody was not stamped") diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index e8e57dea6..0c9b2eff0 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -1008,7 +1008,6 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State s.beforeGateReset() } gateHeadAnchored := false - stagingLeft := "" if gateHead != state.Local.Head { gateAnchor := "" writeGateAnchor := false @@ -1074,7 +1073,7 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State } } _, casErr := git.Run(ctx, s.GateDir, "update-ref", "refs/heads/"+state.Local.Branch, state.Local.Head, gateHead) - stagingLeft = s.releaseStagingRef(ctx, stagingRef) + stagingLeft := s.releaseStagingRef(ctx, stagingRef) if casErr != nil { // A failed update-ref is not evidence that the gate moved: a lock // held by another process, a permission problem, or an I/O error @@ -1111,27 +1110,7 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State return recoverBlocked(state, "blocked_recover_gate_race", "the gate branch changed while custody was being returned, so the compare-and-swap refused instead of clobbering it; no displaced-gate-head anchor was written, so re-run the recovery to return custody against the new gate head; "+keepLocalPostSwapNoChangeClause(anchoredNote)+stagingLeft) } } - // A cleanup failure is not a reason to refuse a custody return that - // succeeded, but it is a ref this attempt left in the gate. Every refusal - // around it discloses that; dropping it on the one path that reaches the - // end would leave the leftover reported nowhere at all. - return withCleanupNote(s.finishRecover(ctx, run, false, true), stagingLeft) -} - -// withCleanupNote attaches a leftover-ref disclosure to a state that is not -// refusing, without clobbering a message the state already carries - the -// custody stamp can have failed on the very same call. -func withCleanupNote(state State, note string) State { - note = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(note), ";")) - if note == "" { - return state - } - if state.Error == "" { - state.Error = note - return state - } - state.Error += "; " + note - return state + return s.finishRecover(ctx, run, false, true) } // releaseStagingRef removes the temporary ref the gate-side fetch stages the @@ -1140,6 +1119,12 @@ func withCleanupNote(state State, note string) State { // cleanup error would make that denial false in exactly the way this whole // change exists to prevent. The empty string is the ordinary case, so callers // append it unconditionally. +// +// A SUCCESSFUL custody return deliberately does not carry the clause. It makes +// no no-change claim for a leftover to falsify, State.Error is the field every +// consumer reads as failure, and the leftover is benign: the next attempt's +// fetch force-overwrites the same ref, and the kept head stays reachable from +// the gate branch regardless. func (s *Service) releaseStagingRef(ctx context.Context, stagingRef string) string { if _, err := git.Run(ctx, s.GateDir, "update-ref", "-d", stagingRef); err != nil { if _, exists, probeErr := git.ExactRefTarget(ctx, s.GateDir, stagingRef); probeErr != nil || exists { diff --git a/internal/cli/settlement_claim_contract_test.go b/internal/cli/settlement_claim_contract_test.go index 9bece1f2b..5ecf181c8 100644 --- a/internal/cli/settlement_claim_contract_test.go +++ b/internal/cli/settlement_claim_contract_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "github.com/kunchenguid/no-mistakes/internal/branchsync" "github.com/kunchenguid/no-mistakes/internal/db" + "github.com/kunchenguid/no-mistakes/internal/paths" "github.com/kunchenguid/no-mistakes/internal/skill" "github.com/kunchenguid/no-mistakes/internal/tui" ) @@ -31,12 +33,20 @@ import ( // confirmation box, the agent guidance, the skill and the agents guide. Drift // on ANY of these now fails here. // +// The population is enumerated in settlementClaimSurfaces and is exactly: +// both `--keep-local` flag help lines, both sync Long descriptions, the +// interactive consent prompt an operator reads immediately before typing `y`, +// the live agent-guidance constant, the structured branch_sync error, the TUI +// settlement confirmation, the generated skill, the agents guide, the CLI +// reference, and the checked-in branch-sync agent skill. Adding a surface that +// states the claim means adding it there. +// // Where a surface has a real interface it is executed: cobra renders its own -// help, the TUI renders its own confirmation, branchsync classifies a real -// wedged record from a real repository, and the guidance constant is the value -// the CLI emits. The generated skill and the two docs pages are read as the -// owned text contracts they already are, following the precedent in -// axi_guidance_test.go. +// help, the recovery command renders its own consent prompt against a real +// wedged repository, the TUI renders its own confirmation, branchsync +// classifies that same record, and the guidance constant is the value the CLI +// emits. The generated skill and the two docs pages are read as the owned text +// contracts they already are, following the precedent in axi_guidance_test.go. func TestSettlementGateMovePromiseStaysConditional(t *testing.T) { for name, text := range settlementClaimSurfaces(t) { if offender := branchsync.UnqualifiedGateMovePromise(text); offender != "" { @@ -80,8 +90,12 @@ func settlementClaimSurfaces(t *testing.T) map[string]string { // directory to its operator worktree. agentsGuide := readAgentsGuide(t) cliReference := readDocsPage(t, "reference", "cli.md") + branchSyncSkill := readRepoFile(t, filepath.Join(".agents", "skills", "branch-sync-and-push-safety", "SKILL.md")) generatedSkill := skill.Markdown() - advertisement := wedgedSettlementAdvertisement(t) + + _, p, _ := wedgedCustodyAbortFixture(t) + advertisement := wedgedSettlementAdvertisement(t, p) + consent := keepLocalConsentPrompt(t) return map[string]string{ // Keep the per-surface isolation the help contract test established: @@ -91,22 +105,22 @@ func settlementClaimSurfaces(t *testing.T) map[string]string { "sync long description": helpLongDescription(t, humanHelp), "axi sync --keep-local flag help": keepLocalFlagUsage(t, axiHelp), "axi sync long description": helpLongDescription(t, axiHelp), + "keep-local consent prompt": consent, "live branch-sync agent guidance": branchSyncAgentGuidance, "structured branch_sync error": advertisement, "TUI settlement confirmation": unboxed(tui.RenderSettleConfirmation(wedgedSettlementState(), 80)), "generated skill": generatedSkill, "agents guide": agentsGuide, "cli reference": cliReference, + "branch-sync agent skill": branchSyncSkill, } } // wedgedSettlementAdvertisement returns the structured branch_sync error a real // wedged custody record produces, read from classifyPipelineOwned through the // same service the CLI surfaces build. -func wedgedSettlementAdvertisement(t *testing.T) string { +func wedgedSettlementAdvertisement(t *testing.T, p *paths.Paths) string { t.Helper() - _, p, _ := wedgedCustodyAbortFixture(t) - database, err := db.Open(p.DB()) if err != nil { t.Fatalf("open fixture database: %v", err) @@ -132,6 +146,42 @@ func wedgedSettlementAdvertisement(t *testing.T) string { return state.Error } +// keepLocalConsentPrompt drives the real `sync --recover --keep-local` +// confirmation against the wedged record and returns only the consent block - +// the sentences an operator reads immediately before typing y. The surrounding +// state print is excluded so this surface cannot be satisfied by the structured +// error that is already in the population separately. +// +// The answer is "n": the prompt is the surface under test, and declining leaves +// the fixture untouched for any other surface. +func keepLocalConsentPrompt(t *testing.T) string { + t.Helper() + previous := syncInteractive + syncInteractive = func() bool { return true } + t.Cleanup(func() { syncInteractive = previous }) + + cmd := newRootCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetIn(strings.NewReader("n\n")) + cmd.SetArgs([]string{"sync", "--recover", "--keep-local"}) + _ = cmd.Execute() + + out := buf.String() + const opener = "Recovery returns custody of this branch" + const closer = "Return custody of this branch?" + start := strings.Index(out, opener) + end := strings.Index(out, closer) + if start < 0 || end < start { + t.Fatalf("the keep-local consent prompt was not rendered:\n%s", out) + } + if !strings.Contains(out, "Cancelled; no files or refs were changed.") { + t.Fatalf("declining the consent prompt did not cancel cleanly:\n%s", out) + } + return out[start:end] +} + // wedgedSettlementState is the state shape the TUI receives for that same // record: pipeline_owned carrying the settlement next action. func wedgedSettlementState() branchsync.State { @@ -159,7 +209,12 @@ func unboxed(rendered string) string { func readDocsPage(t *testing.T, section, name string) string { t.Helper() - path := filepath.Join("..", "..", "docs", "src", "content", "docs", section, name) + return readRepoFile(t, filepath.Join("docs", "src", "content", "docs", section, name)) +} + +func readRepoFile(t *testing.T, relative string) string { + t.Helper() + path := filepath.Join("..", "..", relative) body, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 50ca2e060..ae73abcd3 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -218,14 +218,15 @@ func runHumanRecover(cmd *cobra.Command, keepLocal, yes bool) error { result = "refused" return &exitError{code: 1} } - fmt.Fprintln(cmd.OutOrStdout(), " Recovery returns custody of this branch from its terminal run. The only") + fmt.Fprintln(cmd.OutOrStdout(), " Recovery returns custody of this branch from its terminal run.") if keepLocal { - fmt.Fprintln(cmd.OutOrStdout(), " possible changes are anchoring the preserved pipeline commits and moving the") - fmt.Fprintln(cmd.OutOrStdout(), " local gate branch to your current head; the worktree is never touched.") + fmt.Fprintln(cmd.OutOrStdout(), " Your worktree is never touched. Any surviving copy of the recorded pipeline") + fmt.Fprintln(cmd.OutOrStdout(), " head is anchored first, and where the gate branch still names a different") + fmt.Fprintln(cmd.OutOrStdout(), " head it is compare-and-swapped onto your current head.") } else { - fmt.Fprintln(cmd.OutOrStdout(), " possible worktree change is a fast-forward of this clean behind branch, or") - fmt.Fprintln(cmd.OutOrStdout(), " adoption of a diverged preserved head proven to carry every local change;") - fmt.Fprintln(cmd.OutOrStdout(), " unproven divergence refuses, and --keep-local keeps the current head.") + fmt.Fprintln(cmd.OutOrStdout(), " The only possible worktree change is a fast-forward of this clean behind") + fmt.Fprintln(cmd.OutOrStdout(), " branch, or adoption of a diverged preserved head proven to carry every local") + fmt.Fprintln(cmd.OutOrStdout(), " change; unproven divergence refuses, and --keep-local keeps the current head.") } fmt.Fprint(cmd.OutOrStdout(), " Return custody of this branch? [y/N] ") line, readErr := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') @@ -275,11 +276,7 @@ func printHumanSyncState(cmd *cobra.Command, state branchsync.State) { fmt.Fprintf(w, " target: %s %s (%s)\n", state.Target.Remote, state.Target.Ref, state.Target.Kind) } if state.Error != "" { - label := "blocked:" - if state.Recovered { - label = "note:" - } - fmt.Fprintf(w, " %-9s %s\n", label, state.Error) + fmt.Fprintf(w, " blocked: %s\n", state.Error) } } From 67c333c5adf52c2c8960aef45dd428aa7ab0ece9 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:14:43 -0400 Subject: [PATCH 24/31] no-mistakes(document): scope keep-local no-change claim; drop duplicated agent-guide prose --- docs/src/content/docs/guides/agents.md | 2 -- docs/src/content/docs/guides/tui.md | 2 +- docs/src/content/docs/reference/cli.md | 6 +++--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/guides/agents.md b/docs/src/content/docs/guides/agents.md index ceee29c91..0739862df 100644 --- a/docs/src/content/docs/guides/agents.md +++ b/docs/src/content/docs/guides/agents.md @@ -179,8 +179,6 @@ Before any post-pipeline local commit or fresh run, read `branch_sync`. Only when its structured `next_action.code` is `sync`, run `no-mistakes axi sync` first. When `next_action.code` is `recover_custody` - a terminal run left unpublished pipeline commits preserved in the local gate - run `no-mistakes axi sync --recover` to return custody, or `no-mistakes rerun` to resume validating the preserved head. When `next_action.code` is `return_custody_keep_local` - a terminal run whose recorded pipeline head cannot be verified, either because no reachable object store still has it or because the run's own recovery evidence names a different commit - run `no-mistakes axi sync --recover --keep-local` to return custody at your current head and point the gate branch at it where that branch still names a different head. -If any copy of that head still exists, the settlement anchors it at `refs/no-mistakes/recover-stranded/` before moving the gate branch off it - but only in whichever repository still holds the object, your worktree or the local gate under `NM_HOME` or both, so look in both rather than assuming your own; if no store still has it, nothing is anchored because nothing was left to anchor and the settlement took nothing from you. -That anchor is not `refs/no-mistakes/recover/`, which in this state is the conflicting evidence and can name an entirely different commit. When `next_action.code` is `complete_custody_return` - an earlier recovery already applied its Git changes and only the custody record is missing - re-run the exact command it names, `no-mistakes axi sync --recover` or `no-mistakes axi sync --recover --keep-local`, to complete the record; every Git step it repeats is idempotent once applied. A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: it is immediately usable and needs no sync action. When `next_action.code` is `continue_active_run`, run the reported command and keep driving the active run. diff --git a/docs/src/content/docs/guides/tui.md b/docs/src/content/docs/guides/tui.md index 0128649e0..acd85a618 100644 --- a/docs/src/content/docs/guides/tui.md +++ b/docs/src/content/docs/guides/tui.md @@ -195,7 +195,7 @@ When the instruction editor is open, press `Ctrl+s` or `Ctrl+enter` to save, or | `?` | Toggle help overlay | | `y` | Toggle yolo mode, which auto-resolves paused steps | | `r` | Retry a failed fix-review state or diff load; otherwise start a rerun after a failed or cancelled run | -| `u` | Refresh and confirm local branch synchronization, or confirm custody recovery or custody settlement at the local head, when offered | +| `u` | Refresh and confirm local branch synchronization, or confirm custody recovery, custody settlement at the local head, or completion of an unrecorded custody return, when offered | | `q` | Detach from TUI (or quit if run is done) | In diff view, `n`/`p` jumps the viewport to the file and line of the next/previous finding. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 509ff3f07..0b5c4b94c 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -205,7 +205,7 @@ no-mistakes axi sync --recover --keep-local | -------------- | ------ | ------- | ---------------------------------------------------------------------------- | | `--check` | `bool` | `false` | Verify the live target and exact plan without changing `HEAD` | | `--recover` | `bool` | `false` | Return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch) | -| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and where the gate branch still names a different head, points it at the kept head | +| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and where the gate branch still names a different head, points it at the kept head; also settles a record whose recorded pipeline head can no longer be verified | The default command is an explicit non-interactive apply request and never prompts. All modes return the complete `branch_sync` object as TOON. @@ -236,7 +236,7 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and, where the gate branch still names a different head, atomically points that branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, in one of two variants that prescribe different exits. Where the attempt had anchored an independently moved gate head, the refusal names that anchor to reconcile. Where the gate branch still named the recorded head, no `refs/no-mistakes/recover-gate/` was ever written, the refusal says so, and the exit is simply re-running the recovery against the new gate head - there is no anchor to reconcile by hand. It reports that no *local* files or refs were changed only where that narrower claim is earned - beside a preserved-head anchor pinned in the local gate, which is what the settlement does when the recorded head survives there - and appends the anchor note rather than dropping the `local` qualifier. Where the preserved head was instead anchored in the invoking worktree, which is what the ordinary `--recover --keep-local` path always does, the `local` qualifier would deny the very ref the note names, so the refusal reports that no branch, worktree, or file changes were made and discloses the anchor separately. Where that anchor was written it stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and, where the gate branch still names a different head, atomically points that branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, in one of two variants that prescribe different exits. Where the attempt had anchored an independently moved gate head, the refusal names that anchor to reconcile. Where the gate branch still named the recorded head, no `refs/no-mistakes/recover-gate/` was ever written, the refusal says so, and the exit is simply re-running the recovery against the new gate head - there is no anchor to reconcile by hand. It reports that no *local* files or refs were changed wherever that narrower claim stays true of the whole attempt: when no preserved-head anchor was written at all, which is the ordinary settlement of a recorded head no store still has, and when one was pinned in the local gate, which is what the settlement does when the recorded head survives there - the note is then appended rather than the `local` qualifier dropped. Where the preserved head was instead anchored in the invoking worktree, which is what the ordinary `--recover --keep-local` path always does, the `local` qualifier would deny the very ref the note names, so the refusal reports that no branch, worktree, or file changes were made and discloses the anchor separately. Where that anchor was written it stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. @@ -370,7 +370,7 @@ no-mistakes sync --recover --keep-local | `--check` | `bool` | `false` | Verify and print the fresh plan without changing `HEAD` | | `-y`, `--yes` | `bool` | `false` | Apply an eligible guarded synchronization without an interactive prompt | | `--recover` | `bool` | `false` | Return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch) | -| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and where the gate branch still names a different head, points it at the kept head | +| `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree, and where the gate branch still names a different head, points it at the kept head; also settles a record whose recorded pipeline head can no longer be verified | Without `--yes`, apply prints the exact full-SHA plan and requires TTY confirmation; `--recover` prompts the same way before returning custody. A non-TTY apply or recovery refuses with a direct `--yes` hint. From 7354a3a3fc05c4903d4126d15cc988f7f6bda200 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:40:50 -0400 Subject: [PATCH 25/31] fix(branchsync): stop default recovery denying the anchor it just wrote An automated review caught this on the one path this branch had deliberately scoped out. Before its own refusals are reached, the default --recover flow fetches the preserved head and writes refs/no-mistakes/recover/ into the invoking worktree - and three refusals then reported "no files or refs were changed". The keep-local siblings twenty lines away already appended the anchor note; these did not, so the claim was true only where we had looked. The three are the behind-and-dirty refusal, the diverged-but-contained dirty refusal, and the genuinely diverged refusal. The last was the clearest case: it named the anchor and denied writing any ref in the same sentence. Each now closes with the existing keepLocalNoChangeClause and the recovery anchor note, so the sentence the keep-local path already used is reused rather than reinvented. No behaviour changes: same refusals, same next actions, same refs written. Only the wording stops contradicting what the attempt did. The guard is behavioural rather than textual, because this claim cannot be judged from text. Whether it is true depends on whether the anchor was written, which happens only when it did not already exist - so the same sentence is honest on a re-run and false on a first run. The test induces each refusal, reads whether the ref actually exists, and requires the message to agree in either direction: the plain claim when nothing was written, and the anchor named when something was. Refs #824. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U --- internal/branchsync/recover_test.go | 91 +++++++++++++++++++++++++++++ internal/branchsync/sync.go | 13 ++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 55b5e550b..756107a44 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3680,3 +3680,94 @@ func TestSuccessfulKeepLocalReportsNoFailureWhenOnlyCleanupFailed(t *testing.T) t.Fatal("custody was not stamped") } } + +// TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote covers the sibling path +// this change had scoped out, and which an automated reviewer caught: the +// DEFAULT --recover flow fetches the preserved head and writes +// refs/no-mistakes/recover/ into the invoking worktree before its own +// refusals are reached, so a refusal claiming "no files or refs were changed" +// denied a ref the same attempt had just created. The keep-local siblings +// twenty lines away already appended the anchor note; these three did not. +// +// Each case INDUCES the refusal and asserts against the anchor's real presence +// rather than against the message text alone, because the whole class of defect +// here is a sentence that disagrees with what the code actually did. +func TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + fixture func(*testing.T) *recoverFixture + safety string + }{ + { + // Behind and dirty: local is an ancestor of the preserved head. + name: "behind and dirty", + fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, + safety: "blocked_recover_dirty", + }, + { + // Diverged, preserved head contains the local work, dirty. + name: "diverged contained and dirty", + fixture: func(t *testing.T) *recoverFixture { return newRebasedRecoverFixture(t, types.RunCancelled) }, + safety: "blocked_recover_dirty", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := tc.fixture(t) + mustWrite(t, filepath.Join(f.local, "feature.txt"), "uncommitted edit\n") + + state := f.service.Recover(f.ctx, false) + if state.Safety != tc.safety { + t.Fatalf("safety = %q, want %q: %#v", state.Safety, tc.safety, state) + } + assertRefusalMatchesAnchorReality(t, f, state) + }) + } + + // Genuinely diverged: the preserved head does not contain the local work, + // so recovery refuses without any worktree move. This message already + // named the anchor and then denied writing refs in the same sentence. + t.Run("diverged and unproven", func(t *testing.T) { + t.Parallel() + f := newRecoverFixture(t, types.RunCancelled) + mustWrite(t, filepath.Join(f.local, "divergent.txt"), "unique local work\n") + mustRun(t, f.local, "add", "divergent.txt") + mustRun(t, f.local, "commit", "-m", "unique local work") + + state := f.service.Recover(f.ctx, false) + if state.Safety != "blocked_recover_diverged" { + t.Fatalf("safety = %q, want blocked_recover_diverged: %#v", state.Safety, state) + } + assertRefusalMatchesAnchorReality(t, f, state) + }) +} + +// assertRefusalMatchesAnchorReality is the whole point: read whether the +// recovery anchor actually exists in the invoking worktree, then require the +// refusal's claim to agree with that fact in either direction. +func assertRefusalMatchesAnchorReality(t *testing.T, f *recoverFixture, state State) { + t.Helper() + if state.Recovered || state.Changed { + t.Fatalf("refusal reported a change = %#v", state) + } + anchor := f.anchorRef() + target, exists, err := gitpkg.ExactRefTarget(f.ctx, f.local, anchor) + if err != nil { + t.Fatalf("read %s: %v", anchor, err) + } + if !exists { + // Nothing was written, so the blanket denial is the honest claim. + if !strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("no anchor was written, but the refusal withheld the plain claim: %q", state.Error) + } + return + } + if strings.Contains(state.Error, "no files or refs were changed") { + t.Fatalf("refusal denied changing refs while %s exists at %s: %q", anchor, target, state.Error) + } + if !strings.Contains(state.Error, anchor) { + t.Fatalf("refusal did not name the anchor it wrote (%s at %s): %q", anchor, target, state.Error) + } +} diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 0c9b2eff0..ae22b4b43 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -715,6 +715,13 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { // the anchor rather than claiming nothing was written. This path can only // ever pin in the invoking worktree, which is what scopes the no-change // claims of the refusals it delegates to. + // Carried by BOTH paths. The keep-local delegations below pass it on, and + // the default path's own refusals append it too: this block can write + // refs/no-mistakes/recover/ into the invoking worktree, so a refusal + // reached afterwards that claimed "no files or refs were changed" would + // deny a ref this same attempt created. That is the identical defect this + // change fixes on the keep-local path, and leaving it on the sibling path + // would make the claim true only where we happened to look. anchoredNote := recoveryAnchorNote{ref: anchorRef} if existing, anchorErr := git.Run(ctx, wd, "rev-parse", anchorRef+"^{commit}"); anchorErr == nil && existing == preserved { anchored = true @@ -747,7 +754,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } if !state.Local.Clean { state.Relation = RelationBehind - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_dirty", fmt.Sprintf("the invoking worktree is not clean (%s); commit or stash first and re-run the recovery, or use --keep-local to return custody at the current head without moving the worktree; no files or refs were changed", state.Local.Reason)) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_dirty", fmt.Sprintf("the invoking worktree is not clean (%s); commit or stash first and re-run the recovery, or use --keep-local to return custody at the current head without moving the worktree; %s", state.Local.Reason, keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"} return blocked } @@ -763,14 +770,14 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if preservedContainsLocalWork(ctx, wd, local, preserved) { if !state.Local.Clean { state.Relation = RelationDiverged - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_dirty", fmt.Sprintf("the invoking worktree is not clean (%s); commit or stash first and re-run the recovery, or use --keep-local to return custody at the current head without moving the worktree; no files or refs were changed", state.Local.Reason)) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_dirty", fmt.Sprintf("the invoking worktree is not clean (%s); commit or stash first and re-run the recovery, or use --keep-local to return custody at the current head without moving the worktree; %s", state.Local.Reason, keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"} return blocked } return s.recoverAdoptPreserved(ctx, run, state, preserved) } state.Relation = RelationDiverged - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_diverged", fmt.Sprintf("the local branch and the preserved pipeline head have diverged; the preserved commits are anchored at %s - reconcile manually and re-run the recovery, run `no-mistakes rerun` to resume validating the preserved head, or use --keep-local to keep the current head; no files or refs were changed", anchorRef)) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_diverged", fmt.Sprintf("the local branch and the preserved pipeline head have diverged; the preserved commits are anchored at %s - reconcile manually and re-run the recovery, run `no-mistakes rerun` to resume validating the preserved head, or use --keep-local to keep the current head; %s", anchorRef, keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "git log --oneline --left-right HEAD..." + anchorRef} return blocked } From 1cc584087f9377be845fe0b90ffaf8b98fc2abb0 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:04:17 -0400 Subject: [PATCH 26/31] no-mistakes(review): disclose gate-side recovery anchor in default recover refusals --- internal/branchsync/recover_test.go | 175 ++++++++++++++++++++++------ internal/branchsync/sync.go | 100 +++++++++------- 2 files changed, 200 insertions(+), 75 deletions(-) diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 756107a44..a7d83c646 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3683,91 +3683,194 @@ func TestSuccessfulKeepLocalReportsNoFailureWhenOnlyCleanupFailed(t *testing.T) // TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote covers the sibling path // this change had scoped out, and which an automated reviewer caught: the -// DEFAULT --recover flow fetches the preserved head and writes -// refs/no-mistakes/recover/ into the invoking worktree before its own -// refusals are reached, so a refusal claiming "no files or refs were changed" -// denied a ref the same attempt had just created. The keep-local siblings -// twenty lines away already appended the anchor note; these three did not. +// DEFAULT --recover flow pins the recorded head at refs/no-mistakes/recover/ +// - in the LOCAL GATE first, then in the invoking worktree after fetching it - +// before its own refusals are reached, so a refusal claiming "no files or refs +// were changed" denied a ref the same attempt had just created. The keep-local +// siblings twenty lines away already appended the anchor note; these did not. // -// Each case INDUCES the refusal and asserts against the anchor's real presence -// rather than against the message text alone, because the whole class of defect -// here is a sentence that disagrees with what the code actually did. +// The claim spans BOTH stores, so the guard does too. Each case INDUCES the +// refusal and asserts against the anchor's real presence in each store rather +// than against the message text alone, because the whole class of defect here +// is a sentence that disagrees with what the code actually did. func TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote(t *testing.T) { t.Parallel() for _, tc := range []struct { name string fixture func(*testing.T) *recoverFixture + setup func(*testing.T, *recoverFixture) safety string }{ { // Behind and dirty: local is an ancestor of the preserved head. name: "behind and dirty", fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, - safety: "blocked_recover_dirty", + setup: func(t *testing.T, f *recoverFixture) { + mustWrite(t, filepath.Join(f.local, "feature.txt"), "uncommitted edit\n") + }, + safety: "blocked_recover_dirty", }, { // Diverged, preserved head contains the local work, dirty. name: "diverged contained and dirty", fixture: func(t *testing.T) *recoverFixture { return newRebasedRecoverFixture(t, types.RunCancelled) }, - safety: "blocked_recover_dirty", + setup: func(t *testing.T, f *recoverFixture) { + mustWrite(t, filepath.Join(f.local, "feature.txt"), "uncommitted edit\n") + }, + safety: "blocked_recover_dirty", + }, + { + // Genuinely diverged: the preserved head does not contain the + // local work, so recovery refuses without any worktree move. This + // message already named the anchor and then denied writing any ref + // in the same sentence. + name: "diverged and unproven", + fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustWrite(t, filepath.Join(f.local, "divergent.txt"), "unique local work\n") + mustRun(t, f.local, "add", "divergent.txt") + mustRun(t, f.local, "commit", "-m", "unique local work") + }, + safety: "blocked_recover_diverged", + }, + { + // The gate-side pin succeeds and the fetch that follows it fails, + // which is the refusal one layer ABOVE the three above: the note + // used to be constructed after that write, so it structurally + // could not report a gate-side anchor at all. A ref occupying + // refs/no-mistakes/fetch makes every fetch under that prefix + // unable to lock its temporary ref, which is a real Git state + // rather than a stubbed failure. + name: "fetch fails after the local gate was pinned", + fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustRun(t, f.local, "update-ref", "refs/no-mistakes/fetch", f.submitted) + }, + safety: "blocked_recover_preserve_failed", + }, + { + // Same gate-side pin, then the invoking worktree already holds a + // recovery ref at another commit so the worktree pin refuses. + name: "worktree anchor conflicts after the local gate was pinned", + fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustRun(t, f.local, "update-ref", f.anchorRef(), f.submitted) + }, + safety: "blocked_recover_anchor_mismatch", + }, + { + // Both stores are pinned, then the clean fast-forward re-checks + // its assumptions and finds the worktree dirty. The refusal sits + // one function below the block that wrote the anchors, which is + // why the note has to travel with the delegation. + name: "fast-forward assumptions change after both stores were pinned", + fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + f.service.beforeRecoverWorktreeMove = func() { + mustWrite(t, filepath.Join(f.local, "feature.txt"), "raced edit\n") + } + }, + safety: "blocked_recover_assumptions_changed", + }, + { + // The same delegation on the adoption arm, whose refusal denied + // changing "files or refs" while both anchors existed. + name: "adoption assumptions change after both stores were pinned", + fixture: func(t *testing.T) *recoverFixture { return newRebasedRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + f.service.beforeRecoverWorktreeMove = func() { + mustWrite(t, filepath.Join(f.local, "feature.txt"), "raced edit\n") + } + }, + safety: "blocked_recover_assumptions_changed", }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() f := tc.fixture(t) - mustWrite(t, filepath.Join(f.local, "feature.txt"), "uncommitted edit\n") + tc.setup(t, f) + before := f.anchorPresence(t) state := f.service.Recover(f.ctx, false) if state.Safety != tc.safety { t.Fatalf("safety = %q, want %q: %#v", state.Safety, tc.safety, state) } - assertRefusalMatchesAnchorReality(t, f, state) + assertRefusalMatchesAnchorReality(t, f, before, state) }) } +} - // Genuinely diverged: the preserved head does not contain the local work, - // so recovery refuses without any worktree move. This message already - // named the anchor and then denied writing refs in the same sentence. - t.Run("diverged and unproven", func(t *testing.T) { - t.Parallel() - f := newRecoverFixture(t, types.RunCancelled) - mustWrite(t, filepath.Join(f.local, "divergent.txt"), "unique local work\n") - mustRun(t, f.local, "add", "divergent.txt") - mustRun(t, f.local, "commit", "-m", "unique local work") +// anchorStore is one object store a recovery can pin the recorded head in, +// paired with the name the refusals use for it. +type anchorStore struct { + label string + dir string +} - state := f.service.Recover(f.ctx, false) - if state.Safety != "blocked_recover_diverged" { - t.Fatalf("safety = %q, want blocked_recover_diverged: %#v", state.Safety, state) +func (f *recoverFixture) anchorStores() []anchorStore { + return []anchorStore{ + {invokingWorktreeStore, f.local}, + {localGateStore, f.gate}, + } +} + +// anchorPresence reads whether refs/no-mistakes/recover/ exists in each +// store. Compared before and after an attempt, it is what separates a pin THIS +// attempt made - the only kind a no-change claim can contradict - from one it +// merely found already there. +func (f *recoverFixture) anchorPresence(t *testing.T) map[string]bool { + t.Helper() + present := make(map[string]bool, 2) + for _, store := range f.anchorStores() { + _, exists, err := gitpkg.ExactRefTarget(f.ctx, store.dir, f.anchorRef()) + if err != nil { + t.Fatalf("read %s in %s: %v", f.anchorRef(), store.label, err) } - assertRefusalMatchesAnchorReality(t, f, state) - }) + present[store.label] = exists + } + return present } // assertRefusalMatchesAnchorReality is the whole point: read whether the -// recovery anchor actually exists in the invoking worktree, then require the -// refusal's claim to agree with that fact in either direction. -func assertRefusalMatchesAnchorReality(t *testing.T, f *recoverFixture, state State) { +// recovery anchor actually exists in each store, then require the refusal's +// claim to agree with that fact in either direction - the plain denial when +// this attempt wrote nothing, and the anchor plus every store it wrote when it +// did. A store holding no anchor at all may never be named as holding one. +func assertRefusalMatchesAnchorReality(t *testing.T, f *recoverFixture, before map[string]bool, state State) { t.Helper() if state.Recovered || state.Changed { t.Fatalf("refusal reported a change = %#v", state) } anchor := f.anchorRef() - target, exists, err := gitpkg.ExactRefTarget(f.ctx, f.local, anchor) - if err != nil { - t.Fatalf("read %s: %v", anchor, err) + after := f.anchorPresence(t) + var written []string + for _, store := range f.anchorStores() { + if after[store.label] && !before[store.label] { + written = append(written, store.label) + } + } + for _, store := range f.anchorStores() { + if !after[store.label] && strings.Contains(state.Error, store.label) { + t.Fatalf("refusal named %s, which holds no %s at all: %q", store.label, anchor, state.Error) + } } - if !exists { - // Nothing was written, so the blanket denial is the honest claim. + if len(written) == 0 { + // This attempt wrote nothing, so the blanket denial is the honest claim. if !strings.Contains(state.Error, "no files or refs were changed") { t.Fatalf("no anchor was written, but the refusal withheld the plain claim: %q", state.Error) } return } if strings.Contains(state.Error, "no files or refs were changed") { - t.Fatalf("refusal denied changing refs while %s exists at %s: %q", anchor, target, state.Error) + t.Fatalf("refusal denied changing refs while this attempt wrote %s in %s: %q", anchor, strings.Join(written, " and "), state.Error) } if !strings.Contains(state.Error, anchor) { - t.Fatalf("refusal did not name the anchor it wrote (%s at %s): %q", anchor, target, state.Error) + t.Fatalf("refusal did not name the anchor it wrote (%s in %s): %q", anchor, strings.Join(written, " and "), state.Error) + } + for _, store := range written { + if !strings.Contains(state.Error, store) { + t.Fatalf("refusal did not name %s, where this attempt anchored %s: %q", store, anchor, state.Error) + } } } diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index ae22b4b43..ed592fd53 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -643,6 +643,15 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { _, statErr := os.Stat(gateDir) gateAvailable = statErr == nil } + // What this attempt itself pins the recorded head at, and in WHICH store, + // so a later refusal reports that anchor rather than claiming nothing was + // written. It is hoisted above every write because this function can pin + // the recorded head in the local gate as well as in the invoking worktree, + // and a refusal claiming "no files or refs were changed" after EITHER write + // would deny a ref this same attempt created. Both the keep-local + // delegations and the default path's own refusals carry it, so the claim is + // true of the whole attempt rather than of the store we happened to look in. + anchoredNote := recoveryAnchorNote{ref: anchorRef} if anchoredLocal, err := git.Run(ctx, wd, "rev-parse", "--verify", localAnchor+"^{commit}"); err == nil && anchoredLocal != preserved && local == preserved && !state.Local.Clean { blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_incomplete_adoption", fmt.Sprintf("the branch reached the preserved pipeline head, but its worktree still differs from that head; the pre-recovery head remains anchored at %s; reconcile the worktree and re-run recovery; custody was not recorded", localAnchor)) @@ -651,7 +660,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } if objectExists(ctx, wd, preserved) && (local == preserved || isAncestor(ctx, wd, preserved, local)) { - if blocked, ok := s.anchorReachablePreserved(ctx, state, run.ID, preserved); !ok { + if blocked, ok := s.anchorReachablePreserved(ctx, state, run.ID, preserved, &anchoredNote); !ok { // The recovery ref in the invoking worktree is unusable, which is // the same self-inconsistency the gate-side sites settle - and here // the preserved head is already reachable from the local branch, so @@ -667,7 +676,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if keepLocal { return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) } - return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", "the run recovery ref in the local gate conflicts with the recorded pipeline head; inspect both objects before returning custody; no files or refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", fmt.Sprintf("the run recovery ref in the local gate conflicts with the recorded pipeline head; inspect both objects before returning custody; %s", keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) } } return s.finishRecover(ctx, run, false, keepLocal) @@ -708,35 +717,30 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if err := custody.PreserveRecoveryHead(ctx, gateDir, run.ID, preserved); err != nil { return recoverBlocked(state, "blocked_recover_preserve_failed", "the recorded pipeline head exists but could not be anchored in the local gate; no files or worktree refs were changed") } + // PreserveRecoveryHead returns nil both when it creates the ref and + // when it was already present at the recorded head, so the read-only + // probe above is what separates a pin THIS attempt made from one it + // merely found. + if !gateAnchorExists { + anchoredNote.record(localGateStore) + } } anchored := false - // What this attempt itself writes, so a later keep-local refusal reports - // the anchor rather than claiming nothing was written. This path can only - // ever pin in the invoking worktree, which is what scopes the no-change - // claims of the refusals it delegates to. - // Carried by BOTH paths. The keep-local delegations below pass it on, and - // the default path's own refusals append it too: this block can write - // refs/no-mistakes/recover/ into the invoking worktree, so a refusal - // reached afterwards that claimed "no files or refs were changed" would - // deny a ref this same attempt created. That is the identical defect this - // change fixes on the keep-local path, and leaving it on the sibling path - // would make the claim true only where we happened to look. - anchoredNote := recoveryAnchorNote{ref: anchorRef} if existing, anchorErr := git.Run(ctx, wd, "rev-parse", anchorRef+"^{commit}"); anchorErr == nil && existing == preserved { anchored = true } if !anchored { if fetchErr := git.FetchRemoteRef(ctx, wd, gateDir, gateAnchor, preserved); fetchErr != nil { - return recoverBlocked(state, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be fetched from the local gate; no files or refs were changed") + return recoverBlocked(state, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be fetched from the local gate; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } if preserveErr := custody.PreserveRecoveryAnchor(ctx, wd, anchorRef, preserved); preserveErr != nil { if keepLocal { return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) } - return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", "the invoking worktree recovery ref conflicts with the recorded pipeline head; inspect both objects before returning custody; no files or refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", fmt.Sprintf("the invoking worktree recovery ref conflicts with the recorded pipeline head; inspect both objects before returning custody; %s", keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) } - anchoredNote.stores = []string{invokingWorktreeStore} + anchoredNote.record(invokingWorktreeStore) } switch { @@ -758,7 +762,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"} return blocked } - return s.recoverFastForward(ctx, run, state, preserved) + return s.recoverFastForward(ctx, run, state, preserved, anchoredNote) default: if keepLocal { gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") @@ -774,7 +778,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"} return blocked } - return s.recoverAdoptPreserved(ctx, run, state, preserved) + return s.recoverAdoptPreserved(ctx, run, state, preserved, anchoredNote) } state.Relation = RelationDiverged blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_diverged", fmt.Sprintf("the local branch and the preserved pipeline head have diverged; the preserved commits are anchored at %s - reconcile manually and re-run the recovery, run `no-mistakes rerun` to resume validating the preserved head, or use --keep-local to keep the current head; %s", anchorRef, keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) @@ -899,6 +903,17 @@ type recoveryAnchorNote struct { func (n recoveryAnchorNote) clause() string { return anchoredElsewhere(n.stores, n.ref) } +// record names a store this attempt pinned the recorded head in. A store is +// recorded once: a re-entered write on the same store is still one anchor. +func (n *recoveryAnchorNote) record(store string) { + for _, existing := range n.stores { + if existing == store { + return + } + } + n.stores = append(n.stores, store) +} + func (n recoveryAnchorNote) namesInvokingWorktree() bool { for _, store := range n.stores { if store == invokingWorktreeStore { @@ -917,15 +932,18 @@ func anchoredElsewhere(pinned []string, ref string) string { return fmt.Sprintf("; the recorded head is now anchored at %s in %s", ref, strings.Join(pinned, " and ")) } -// keepLocalNoChangeClause closes a PRE-SWAP recoverKeepLocal refusal with a -// claim that stays true for its caller too. Those refusals write nothing of -// their own by construction, but their callers can already have anchored a -// surviving recorded head, so the blanket claim belongs only to a delegation +// keepLocalNoChangeClause closes a refusal with a no-change claim that stays +// true of the WHOLE attempt, not just of the site making it. Recover anchors a +// surviving recorded head - in the local gate, in the invoking worktree, or in +// both - before delegating, so the blanket claim belongs only to an attempt // that carries no such anchor; otherwise the refusal reports exactly what it // did not change and hands over the anchor note naming where that head now is. +// Every refusal reachable after one of those writes uses it, on the keep-local +// path and on the default one alike. // -// The refusals that follow the swap are built by keepLocalPostSwapNoChangeClause -// instead, because by then this function may have written a gate ref of its own. +// The recoverKeepLocal refusals that follow the swap are built by +// keepLocalPostSwapNoChangeClause instead, because by then that function may +// have written a gate ref of its own. func keepLocalNoChangeClause(blanket, anchored string) string { if anchored == "" { return blanket @@ -1143,7 +1161,7 @@ func (s *Service) releaseStagingRef(ctx context.Context, stagingRef string) stri // recoverFastForward advances the clean checked-out branch to the preserved // pipeline head with the same strict fast-forward and honesty rules as Apply. -func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state State, preserved string) State { +func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state State, preserved string, anchoredNote recoveryAnchorNote) State { if s.beforeRecoverWorktreeMove != nil { s.beforeRecoverWorktreeMove() } @@ -1151,7 +1169,7 @@ func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state Sta head, headErr := git.HeadSHA(ctx, s.workDir()) clean, _ := worktreeClean(ctx, s.workDir()) if branchErr != nil || branch != state.Local.Branch || headErr != nil || head != state.Local.Head || !clean { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch or worktree changed while custody was being returned; no files or refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch or worktree changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } _, mergeErr := git.Run(ctx, s.workDir(), "merge", "--ff-only", "--no-edit", preserved) finalHead, _ := git.HeadSHA(ctx, s.workDir()) @@ -1238,7 +1256,7 @@ func preservedContainsLocalWork(ctx context.Context, dir, local, preserved strin // uncommitted changes and loses nothing: containment was proven before the move // and the pre-recovery head stays anchored. Custody is stamped only after the // whole move is verified. -func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state State, preserved string) State { +func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state State, preserved string, anchoredNote recoveryAnchorNote) State { if s.beforeRecoverWorktreeMove != nil { s.beforeRecoverWorktreeMove() } @@ -1247,12 +1265,12 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state head, headErr := git.HeadSHA(ctx, wd) clean, _ := worktreeClean(ctx, wd) if branchErr != nil || branch != state.Local.Branch || headErr != nil || head != state.Local.Head || !clean { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch or worktree changed while custody was being returned; no files or refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch or worktree changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } // The containment proof runs before the anchor and the move so that no // slow work sits between the last guard and the mutation. if !preservedContainsLocalWork(ctx, wd, head, preserved) { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the containment proof changed while custody was being returned; no files or refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the containment proof changed while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } localAnchor := recoverLocalAnchorRef(run.ID) // Create-only: an empty old value requires the ref not to exist. A resumed @@ -1260,15 +1278,15 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state // at any other commit is unexplained and refuses. existingAnchor, existingErr := git.Run(ctx, wd, "rev-parse", "--verify", localAnchor+"^{commit}") if existingErr == nil && existingAnchor != head { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be anchored; no files or refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be anchored; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } if existingErr != nil { if err := custody.PreserveRecoveryAnchor(ctx, wd, localAnchor, head); err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be anchored; no files or refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be anchored; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } } if anchored, err := git.Run(ctx, wd, "rev-parse", localAnchor+"^{commit}"); err != nil || anchored != head { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be verified after anchoring; no files or worktree refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be verified after anchoring; "+keepLocalNoChangeClause("no files or worktree refs were changed", anchoredNote.clause())) } if s.beforeRecoverBranchMove != nil { @@ -1277,10 +1295,10 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state branchRef := "refs/heads/" + state.Local.Branch boundaryBranch, boundaryErr := git.CurrentBranch(ctx, wd) if boundaryErr != nil || boundaryBranch != state.Local.Branch { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the checked-out branch changed while custody was being returned; no branch or worktree changes were made") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the checked-out branch changed while custody was being returned; "+keepLocalNoChangeClause("no branch or worktree changes were made", anchoredNote.clause())) } if _, err := git.Run(ctx, wd, "update-ref", branchRef, preserved, head); err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch moved while custody was being returned; no files or refs were changed") + return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch moved while custody was being returned; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } if s.afterRecoverBranchMove != nil { s.afterRecoverBranchMove() @@ -1337,13 +1355,17 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state return s.finishRecover(ctx, run, true, false) } -func (s *Service) anchorReachablePreserved(ctx context.Context, state State, runID, preserved string) (State, bool) { +func (s *Service) anchorReachablePreserved(ctx context.Context, state State, runID, preserved string, anchoredNote *recoveryAnchorNote) (State, bool) { + anchorRef := custody.RecoveryRef(runID) + _, anchorExisted, probeErr := git.ExactRefTarget(ctx, s.workDir(), anchorRef) if err := custody.PreserveRecoveryHead(ctx, s.workDir(), runID, preserved); err != nil { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be anchored locally; no files or refs were changed"), false + return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be anchored locally; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())), false + } + if probeErr == nil && !anchorExisted { + anchoredNote.record(invokingWorktreeStore) } - anchorRef := custody.RecoveryRef(runID) if anchored, err := git.Run(ctx, s.workDir(), "rev-parse", anchorRef+"^{commit}"); err != nil || anchored != preserved { - return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be anchored locally; no files or refs were changed"), false + return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be anchored locally; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())), false } return State{}, true } From ceb4f564de0cab493a3275d0ca3a210c492f92ed Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:16:06 -0400 Subject: [PATCH 27/31] no-mistakes(review): disclose anchors in keep-local gate-unavailable refusals --- internal/branchsync/recover_test.go | 36 +++++++++++++++++++++++++---- internal/branchsync/sync.go | 6 ++--- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index a7d83c646..d1b08efec 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3697,10 +3697,11 @@ func TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote(t *testing.T) { t.Parallel() for _, tc := range []struct { - name string - fixture func(*testing.T) *recoverFixture - setup func(*testing.T, *recoverFixture) - safety string + name string + fixture func(*testing.T) *recoverFixture + setup func(*testing.T, *recoverFixture) + keepLocal bool + safety string }{ { // Behind and dirty: local is an ancestor of the preserved head. @@ -3785,6 +3786,31 @@ func TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote(t *testing.T) { }, safety: "blocked_recover_assumptions_changed", }, + { + // The claim is not scoped to the default flow: --keep-local pins + // the same anchors and then refuses when the gate branch it would + // move is gone. The behind arm reaches that refusal. + name: "behind keep-local with the gate branch deleted", + fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustRun(t, f.gate, "update-ref", "-d", "refs/heads/feature/recover") + }, + keepLocal: true, + safety: "blocked_recover_gate_unavailable", + }, + { + // The identical sibling on the diverged arm. + name: "diverged keep-local with the gate branch deleted", + fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustWrite(t, filepath.Join(f.local, "divergent.txt"), "unique local work\n") + mustRun(t, f.local, "add", "divergent.txt") + mustRun(t, f.local, "commit", "-m", "unique local work") + mustRun(t, f.gate, "update-ref", "-d", "refs/heads/feature/recover") + }, + keepLocal: true, + safety: "blocked_recover_gate_unavailable", + }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -3792,7 +3818,7 @@ func TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote(t *testing.T) { tc.setup(t, f) before := f.anchorPresence(t) - state := f.service.Recover(f.ctx, false) + state := f.service.Recover(f.ctx, tc.keepLocal) if state.Safety != tc.safety { t.Fatalf("safety = %q, want %q: %#v", state.Safety, tc.safety, state) } diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index ed592fd53..9a431c959 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -752,7 +752,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if keepLocal { gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") if err != nil { - return recoverBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) + return recoverBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; %s", branch, keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) } return s.recoverKeepLocal(ctx, run, state, gateHead, anchoredNote) } @@ -767,7 +767,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if keepLocal { gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") if err != nil { - return recoverBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; no files or refs were changed", branch)) + return recoverBlocked(state, "blocked_recover_gate_unavailable", fmt.Sprintf("the local gate no longer has branch %s, so it cannot be updated with the kept local head; %s", branch, keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) } return s.recoverKeepLocal(ctx, run, state, gateHead, anchoredNote) } @@ -1361,7 +1361,7 @@ func (s *Service) anchorReachablePreserved(ctx context.Context, state State, run if err := custody.PreserveRecoveryHead(ctx, s.workDir(), runID, preserved); err != nil { return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be anchored locally; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())), false } - if probeErr == nil && !anchorExisted { + if !anchorExisted || probeErr != nil { anchoredNote.record(invokingWorktreeStore) } if anchored, err := git.Run(ctx, s.workDir(), "rev-parse", anchorRef+"^{commit}"); err != nil || anchored != preserved { From 308b4d28a833429701a3f2447f9241c6333fc28c Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:48:43 -0400 Subject: [PATCH 28/31] no-mistakes(document): scope recovery anchor-note docs to whole attempt --- .../branch-sync-and-push-safety/SKILL.md | 2 +- docs/src/content/docs/reference/cli.md | 3 ++- internal/branchsync/sync.go | 19 ++++++++++++++----- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index e459cd7a8..574b25cc2 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -20,7 +20,7 @@ metadata: When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and, where the gate branch still names a different head, CAS-moves that branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, and already-recovered paths return custody without moving any branch ref (equal/ahead still writes the private recovery anchor, which is what anchoring a locally reachable preserved head means), and the flag help must keep saying so. A PROVEN-absent gate branch does the same on the SETTLEMENT path only; the ordinary keep-local path resolves the gate branch before delegating, so a deleted or unreadable one refuses there with `blocked_recover_gate_unavailable` rather than returning custody, and no surface may promise otherwise. `--keep-local` is also the ONLY settlement for a SELF-INCONSISTENT custody record (#824): a terminal run whose recorded pipeline head is in no reachable object store, or whose own recovery evidence names something else, has nothing verifiable to import, so every default recovery refused, `abort` of the terminal run was a no-op, and the branch stayed `pipeline_owned` forever. Inspection names that exit (`next_action.code: return_custody_keep_local`) instead of #814's dead-end manual-reconciliation pointer, terminal-run `abort` responses carry the same command, and the TUI `u` action reaches the same settlement - keyed on that advertised next action, never on a safety code, so it cannot drift from the predicate that decides where the settlement can complete. #814's polarity (never advertise `recover_custody` for an unverifiable record) is preserved. Terminal-run no-op abort help is separately allowlisted to custody-settlement codes only (`custodySettlementHelp`) AND gated on `StatePipelineOwned`, so an abort that cancelled nothing never answers by prescribing a fresh run against a released branch, nor by repeating ordinary-divergence `git log` advice; a SUCCESSFUL cancellation still echoes the branch's own next action. Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. It carries its OWN `complete_custody_return` code rather than reusing `recover_custody`/`return_custody_keep_local`, because those codes are claims about the record that the skill, the CLI guidance, the docs and the TUI each restate in their own words - and neither claim is true of a retry. Its message scopes to "any Git changes this recovery makes", since the proven-absent-gate-branch, no-gate and gate-already-at-the-kept-head paths reach it having applied nothing. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Each trigger must therefore be probed the way `Recover` itself probes it: the worktree recovery ref goes through `recoveryAnchorIntercepts`, mirroring Recover's `rev-parse ^{commit}` (which DEREFERENCES a resolving symref, so such a record is never intercepted) rather than the stricter `recoveryAnchorCompatible`, which rejects every symref and advertised a settlement that could only refuse. Advertising any of those recreates the very wedge this change removes. - Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole attempt if it also carries what its CALLER wrote, so `recoverKeepLocal` takes an anchor note (`keepLocalNoChangeClause`): a delegation that anchored the preserved head at `refs/no-mistakes/recover/`, or a settlement that pinned it at `refs/no-mistakes/recover-stranded/`, makes every PRE-SWAP refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only a delegation that wrote nothing makes the blanket "no files or refs were changed" claim. The refusals AFTER the swap are excluded from that substitution on purpose: they may already have written the gate anchor, so they keep the narrower "no LOCAL files or refs were changed" claim and APPEND the note - but only where the note names a GATE-side pin. When it names the invoking worktree, which the ordinary keep-local path always pins, that qualifier would deny the very ref the sentence discloses, so the substitution applies after all; the note carries the STORES and not just the prose so the two cannot be confused. The anchor CONFLICT check stays first because it is read-only. TWO post-anchor-write refusals remain - the lost CAS and the unproven-race swap failure - plus the separate post-success stamp failure above. Both keep the anchor deliberately: the swap failed, so that pin may be the only ref still naming the displaced head, and each names the ref to reconcile. A failed swap is only reported as a race once the gate head is re-read and actually differs; a still-unmoved or unreadable head is `blocked_recover_swap_failed` instead, because a held lock or an I/O error fails `update-ref` identically and inventing a racing actor is the same overstatement in a different place. The staged head proves only what the fetch copied, so the branch is re-read before the swap: a commit landing in that window would otherwise stamp custody at a head the worktree has already moved past. + Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole ATTEMPT if it carries what the attempt wrote and not merely what its own site did, so `Recover` hoists ONE anchor note above every write and every refusal reachable after one of them closes through `keepLocalNoChangeClause` - its own default-path refusals exactly as much as the ones it delegates to `recoverKeepLocal`; scoping the note to either path alone leaves the other denying a ref its own attempt just wrote. The note carries the STORES rather than the prose, because `Recover` can pin the recorded head at `refs/no-mistakes/recover/` in the local gate (legacy dangling evidence), in the invoking worktree (the fetch), or in both, and a settlement pins it at `refs/no-mistakes/recover-stranded/`; any of those makes the refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only an attempt that wrote nothing makes the blanket "no files or refs were changed" claim. A probe that cannot tell whether the ref pre-existed records the store anyway: over-reporting an anchor is noise, under-reporting one silently restores the false denial. The refusals AFTER the swap are excluded from that substitution on purpose: they may already have written the gate anchor, so they keep the narrower "no LOCAL files or refs were changed" claim and APPEND the note - but only where the note names a GATE-side pin. When it names the invoking worktree, which the ordinary keep-local path pins whenever it writes that anchor itself, that qualifier would deny the very ref the sentence discloses, so the substitution applies after all. The anchor CONFLICT check stays first because it is read-only. TWO post-anchor-write refusals remain - the lost CAS and the unproven-race swap failure - plus the separate post-success stamp failure above. Both keep the anchor deliberately: the swap failed, so that pin may be the only ref still naming the displaced head, and each names the ref to reconcile. A failed swap is only reported as a race once the gate head is re-read and actually differs; a still-unmoved or unreadable head is `blocked_recover_swap_failed` instead, because a held lock or an I/O error fails `update-ref` identically and inventing a racing actor is the same overstatement in a different place. The staged head proves only what the fetch copied, so the branch is re-read before the swap: a commit landing in that window would otherwise stamp custody at a head the worktree has already moved past. The full relation matrix and fail-safe rules live in the `Recover` doc comment in `internal/branchsync/sync.go`. - Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/cli/axi_settle_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 0b5c4b94c..73f30eed2 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -228,6 +228,7 @@ While a run is still active, it reports `state: pipeline_owned`, the exact submi `--recover` verifies the run is terminal, anchors the preserved head under `refs/no-mistakes/recover/` in the invoking repository, and stamps custody returned so a fresh run can start. For equal or ahead worktrees where the preserved head is already locally reachable, recovery writes that anchor locally without requiring gate access. If the gate is available, an existing symbolic, non-commit, or mismatched recovery ref is conflicting evidence and recovery refuses without overwriting it. For behind or diverged worktrees, recovery verifies the preserved head at the run-specific recovery ref in the local gate and fetches it into the anchor before moving or refusing. Legacy recorded heads that remain available as unreferenced gate objects are anchored before recovery continues. +Those anchor writes precede the remaining checks, so no `--recover` refusal claims nothing was written once its own attempt created an anchor: it reports exactly the categories it left alone and names the anchor and the repository holding it - the invoking worktree, the local gate, or both. A clean behind worktree fast-forwards. A diverged worktree is adopted only when the preserved head provably carries every local change, proven by an executable three-way merge whose result is exactly the preserved head's tree. This covers a pipeline rebase onto a newer base without requiring the gate branch to advance to the preserved head. @@ -236,7 +237,7 @@ That adoption anchors the pre-recovery local head under `refs/no-mistakes/recove The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere. Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change. A dirty worktree refuses with explicit choices. -When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and, where the gate branch still names a different head, atomically points that branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report stays exact for what the recovery as a whole wrote: where the attempt had already anchored the preserved head - under `refs/no-mistakes/recover/` on this path, or under `refs/no-mistakes/recover-stranded/` when it arrived through the settlement below - each of those refusals reports that no branch, worktree, or file changes were made and names the anchor and the repository holding it, instead of claiming nothing was written. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, in one of two variants that prescribe different exits. Where the attempt had anchored an independently moved gate head, the refusal names that anchor to reconcile. Where the gate branch still named the recorded head, no `refs/no-mistakes/recover-gate/` was ever written, the refusal says so, and the exit is simply re-running the recovery against the new gate head - there is no anchor to reconcile by hand. It reports that no *local* files or refs were changed wherever that narrower claim stays true of the whole attempt: when no preserved-head anchor was written at all, which is the ordinary settlement of a recorded head no store still has, and when one was pinned in the local gate, which is what the settlement does when the recorded head survives there - the note is then appended rather than the `local` qualifier dropped. Where the preserved head was instead anchored in the invoking worktree, which is what the ordinary `--recover --keep-local` path always does, the `local` qualifier would deny the very ref the note names, so the refusal reports that no branch, worktree, or file changes were made and discloses the anchor separately. Where that anchor was written it stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. +When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and, where the gate branch still names a different head, atomically points that branch at it. Where the gate branch already names the kept head - and on the paths that return before the gate is reached at all, such as a released `user_owned` branch, an already-returned custody record, and a local head that already contains the preserved one - custody returns without moving any branch ref. Those paths can still create the private recovery anchor, which is what anchoring a locally reachable preserved head means. If the gate branch moved independently, recovery first preserves that head under `refs/no-mistakes/recover-gate/`, written immediately before the compare-and-swap so that every refusal which can precede it leaves no anchor of its own behind. Their no-change report follows the same attempt-wide rule as every other `--recover` refusal, with the settlement's `refs/no-mistakes/recover-stranded/` as one more anchor they may have to name. Three distinct refusals can follow, and each reports only what is true of itself. A conflicting pre-existing `refs/no-mistakes/recover-gate/` refuses before anything is written, naming the conflicting anchor, the commit it holds, and the local gate directory containing it; nothing retires that anchor, so reconcile it there by hand. A concurrent gate push that wins the compare-and-swap refuses with `safety: blocked_recover_gate_race`, in one of two variants that prescribe different exits. Where the attempt had anchored an independently moved gate head, the refusal names that anchor to reconcile. Where the gate branch still named the recorded head, no `refs/no-mistakes/recover-gate/` was ever written, the refusal says so, and the exit is simply re-running the recovery against the new gate head - there is no anchor to reconcile by hand. It reports that no *local* files or refs were changed wherever that narrower claim stays true of the whole attempt: when no preserved-head anchor was written at all, which is the ordinary settlement of a recorded head no store still has, and when one was pinned only in the local gate - the note is then appended rather than the `local` qualifier dropped. Where the attempt pinned the preserved head in the invoking worktree, which it does whenever it writes that anchor itself, the `local` qualifier would deny the very ref the note names, so the refusal reports that no branch, worktree, or file changes were made and discloses the anchor separately. Where that anchor was written it stays pinned at the head the attempt observed, because the gate has moved and it may be the only ref still naming the displaced head, so later attempts refuse on the conflict above until you reconcile it. A compare-and-swap that fails while the gate branch is still at the head the attempt observed, or that cannot be re-read afterwards, is not a race and does not claim to be one: it refuses with `safety: blocked_recover_swap_failed`, says so explicitly, and names the local gate directory to inspect. If the temporary staging ref the kept head is fetched under cannot be removed, any of these refusals names it and reports that it remains. `--keep-local` is also the settlement for a self-inconsistent custody record: a terminal run whose recorded pipeline head cannot be verified has no preserved head to import, so the default `--recover` refuses - with `safety: blocked_recover_preserved_head_missing`, `blocked_recover_anchor_mismatch`, or `blocked_recover_preserve_failed` when the conflicting recovery ref is the invoking worktree's own and the preserved head is already reachable from the local branch - and nothing else could settle the branch. Those refusals are settleable with `--keep-local` only where recovery can actually reach the settlement and the settlement can complete: a record whose own recovery evidence cannot be inspected at all, is symbolic, or sits mid-adoption is refused earlier, and one whose local gate branch cannot be read or whose recovery anchors are already occupied by another commit is refused inside the settlement, so status keeps reporting manual reconciliation for all of those rather than advertising a settlement that would also refuse. Settlement pins every reachable copy of the recorded head under `refs/no-mistakes/recover-stranded/` first, so a head that still exists survives as inspectable evidence; if such a head exists and cannot be pinned, the settlement refuses with `safety: blocked_recover_preserve_failed` rather than stranding it. diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 9a431c959..90ad51eef 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -958,11 +958,11 @@ func keepLocalNoChangeClause(blanket, anchored string) string { // retired, so denying it outright would be false. // // That narrowing INVERTS when the caller's own anchor sits in the invoking -// worktree, which is what the ordinary keep-local path always pins. The raw -// claim would then deny changing local refs in the very sentence whose note -// names one, so the refusal falls back to enumerating the categories it did -// not change and disclosing the anchor separately, exactly as the pre-swap -// sites do. The distinction is the store, not the caller: the settlement pins +// worktree, which is what the ordinary keep-local path pins whenever it writes +// that anchor itself. The raw claim would then deny changing local refs in the +// very sentence whose note names one, so the refusal falls back to enumerating +// the categories it did not change and disclosing the anchor separately, +// exactly as the pre-swap sites do. The distinction is the store, not the caller: the settlement pins // the invoking worktree too whenever the recorded head still exists there. func keepLocalPostSwapNoChangeClause(anchoredNote recoveryAnchorNote) string { const blanket = "no local files or refs were changed" @@ -1355,6 +1355,15 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state return s.finishRecover(ctx, run, true, false) } +// anchorReachablePreserved pins a locally reachable recorded head in the +// invoking worktree and reports that pin on the shared anchor note, so a later +// refusal discloses it instead of denying every ref change. +// +// The read-only probe is what separates a pin THIS attempt made from one it +// merely found, because PreserveRecoveryHead returns nil for both. An +// UNDETERMINED probe records the store anyway: over-reporting an anchor is +// noise, while under-reporting one hands every downstream refusal back the +// blanket "no files or refs were changed" claim this note exists to prevent. func (s *Service) anchorReachablePreserved(ctx context.Context, state State, runID, preserved string, anchoredNote *recoveryAnchorNote) (State, bool) { anchorRef := custody.RecoveryRef(runID) _, anchorExisted, probeErr := git.ExactRefTarget(ctx, s.workDir(), anchorRef) From 144d7fb1d6a6ea8dcf5cf5d04d0e8666839c8a49 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:09:11 -0400 Subject: [PATCH 29/31] fix(branchsync): disclose the recovery anchor on mid-recovery failures The automated reviewer found four refusals that drop the anchor note: the strict-fast-forward failure, and three in the adoption path. None of them denied writing anything, so this was a disclosure gap rather than a false claim and the invariant this branch establishes still held. They disclose anyway, because the operator is left with a partly applied recovery to reconcile by hand, the preserved head is sitting at exactly the ref that reconciliation needs, and the sibling refusal ten lines above already named it. A function that discloses in one arm and stays silent in the next is not worth defending. The completion criterion is re-derived over the REACHABLE SET rather than over a line range, because the previous one was scoped to the region being edited and these four sat outside it. Starting from every site that writes a recovery ref and walking the call closure from Recover: 23 functions are reachable, 5 of them write a recovery ref, and every refusal in that closure that can be reached after a write now either discloses the anchor or is wrapped so its claim stays true. The refusals that still make the bare no-change claim are all on paths where nothing has been written yet: the anchorReachablePreserved write sits inside a block every path of which returns, so it cannot fall through to them, and the gate and worktree writes come later in the function. Refs #824. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U --- internal/branchsync/sync.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 90ad51eef..0b4dc6c77 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -1161,6 +1161,13 @@ func (s *Service) releaseStagingRef(ctx context.Context, stagingRef string) stri // recoverFastForward advances the clean checked-out branch to the preserved // pipeline head with the same strict fast-forward and honesty rules as Apply. +// These mid-recovery failures never claimed nothing was written, so unlike the +// refusals above they were not saying anything false. They still name the +// anchor: the operator is left with a partly applied recovery to reconcile by +// hand, the preserved head is sitting at exactly the ref that reconciliation +// needs, and the sibling refusal in the same function already discloses it. A +// function that disclosed in one arm and stayed silent in the next is not a +// defensible resting place. func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state State, preserved string, anchoredNote recoveryAnchorNote) State { if s.beforeRecoverWorktreeMove != nil { s.beforeRecoverWorktreeMove() @@ -1179,7 +1186,7 @@ func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state Sta state.Local.Reason = finalReason state.Changed = finalHead == preserved && finalHead != head if mergeErr != nil || finalHead != preserved { - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_apply_failed", fmt.Sprintf("strict fast-forward to the preserved pipeline head failed; final HEAD is %s and no destructive recovery was attempted", finalHead)) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_apply_failed", fmt.Sprintf("strict fast-forward to the preserved pipeline head failed; final HEAD is %s and no destructive recovery was attempted%s", finalHead, anchoredNote.clause())) return blocked } if !finalClean { @@ -1321,14 +1328,14 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state if _, rollbackErr := git.Run(ctx, wd, "update-ref", branchRef, head, preserved); rollbackErr != nil { rollbackDetail = fmt.Sprintf("; the branch could not be restored to %s and still requires manual reconciliation", head) } - return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", fmt.Sprintf("the checked-out branch changed while custody was being returned%s; custody was not recorded", rollbackDetail)) + return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", fmt.Sprintf("the checked-out branch changed while custody was being returned%s; custody was not recorded%s", rollbackDetail, anchoredNote.clause())) } if _, err := git.Run(ctx, wd, "read-tree", "-m", "-u", head, preserved); err != nil { rolledBack := "" if _, rollbackErr := git.Run(ctx, wd, "update-ref", branchRef, head, preserved); rollbackErr != nil { rolledBack = fmt.Sprintf("; the branch could not be restored to %s and now points at %s, whose content the pre-recovery head is contained in", head, preserved) } - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_worktree_busy", fmt.Sprintf("the working tree changed while custody was being returned, so no file was overwritten%s; re-run the recovery once the working tree is settled", rolledBack)) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_worktree_busy", fmt.Sprintf("the working tree changed while custody was being returned, so no file was overwritten%s; re-run the recovery once the working tree is settled%s", rolledBack, anchoredNote.clause())) blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"} return blocked } @@ -1340,7 +1347,7 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state state.Local.Reason = finalReason state.Changed = finalHead == preserved && finalHead != head if finalHead != preserved { - blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_apply_failed", fmt.Sprintf("adopting the preserved pipeline head did not reach it; final HEAD is %s and the pre-recovery head is anchored at %s; inspect the worktree before retrying", finalHead, localAnchor)) + blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_apply_failed", fmt.Sprintf("adopting the preserved pipeline head did not reach it; final HEAD is %s and the pre-recovery head is anchored at %s; inspect the worktree before retrying%s", finalHead, localAnchor, anchoredNote.clause())) blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"} return blocked } From d7a7fc462e0e6a96b367eabbd517d57bf04e5448 Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:34:34 -0400 Subject: [PATCH 30/31] no-mistakes(review): disclose every recovery anchor an attempt writes --- internal/branchsync/recover_test.go | 181 +++++++++++++++++++++++----- internal/branchsync/sync.go | 136 ++++++++++++++------- 2 files changed, 245 insertions(+), 72 deletions(-) diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index d1b08efec..4e474dff3 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3786,6 +3786,80 @@ func TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote(t *testing.T) { }, safety: "blocked_recover_assumptions_changed", }, + { + // The keep-local twin of the case above: the conflicting worktree + // ref sends --keep-local into the settlement, which pins the + // recorded head at its own stranded ref. It has to carry the gate + // anchor this attempt already wrote rather than start a fresh + // note. A held ref lock fails the swap so a refusal is reached. + name: "settlement refuses after the local gate was pinned", + fixture: func(t *testing.T) *recoverFixture { return newRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustRun(t, f.local, "update-ref", f.anchorRef(), f.submitted) + lock := filepath.Join(f.gate, "refs", "heads", "feature", "recover.lock") + if err := os.MkdirAll(filepath.Dir(lock), 0o755); err != nil { + t.Fatal(err) + } + f.service.afterGateStage = func() { + if err := os.WriteFile(lock, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { _ = os.Remove(lock) }) + }, + keepLocal: true, + safety: "blocked_recover_swap_failed", + }, + { + // Past both anchor writes and INTO the adoption, which pins the + // operator's pre-recovery head at a ref of its own before moving + // the branch. A commit landing after that pin loses the + // compare-and-swap, so the refusal is reached with a second, + // differently named anchor already on disk. + name: "adoption races a concurrent commit after the pre-recovery head was anchored", + fixture: func(t *testing.T) *recoverFixture { return newRebasedRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + f.service.beforeRecoverBranchMove = func() { + mustWrite(t, filepath.Join(f.local, "concurrent.txt"), "work committed mid-recovery\n") + mustRun(t, f.local, "add", "concurrent.txt") + mustRun(t, f.local, "commit", "-m", "concurrent local commit") + } + }, + safety: "blocked_recover_assumptions_changed", + }, + { + // The same adoption pin, reached on a RE-RUN: both copies of the + // run recovery ref already exist, so this attempt writes only the + // pre-recovery anchor and the shared note is empty. That is the + // shape where a blanket denial is not merely undisclosed but + // false, and only the pre-recovery anchor can contradict it. + name: "adoption races a concurrent commit with the recorded head already anchored", + fixture: func(t *testing.T) *recoverFixture { return newRebasedRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustRun(t, f.gate, "update-ref", f.anchorRef(), f.preserved) + mustRun(t, f.local, "fetch", f.gate, "refs/heads/feature/recover:"+f.anchorRef()) + f.service.beforeRecoverBranchMove = func() { + mustWrite(t, filepath.Join(f.local, "concurrent.txt"), "work committed mid-recovery\n") + mustRun(t, f.local, "add", "concurrent.txt") + mustRun(t, f.local, "commit", "-m", "concurrent local commit") + } + }, + safety: "blocked_recover_assumptions_changed", + }, + { + // The branch-identity guard between the pre-recovery pin and the + // swap, whose claim is scoped to branch and worktree changes and + // so has to account for the worktree ref it just wrote. + name: "adoption's branch identity changes after the pre-recovery head was anchored", + fixture: func(t *testing.T) *recoverFixture { return newRebasedRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustRun(t, f.local, "branch", "other-clean-branch", f.submitted) + f.service.beforeRecoverBranchMove = func() { + mustRun(t, f.local, "checkout", "other-clean-branch") + } + }, + safety: "blocked_recover_assumptions_changed", + }, { // The claim is not scoped to the default flow: --keep-local pins // the same anchors and then refuses when the gate branch it would @@ -3841,44 +3915,63 @@ func (f *recoverFixture) anchorStores() []anchorStore { } } -// anchorPresence reads whether refs/no-mistakes/recover/ exists in each -// store. Compared before and after an attempt, it is what separates a pin THIS +// trackedAnchors are the recovery refs a --recover attempt can create before +// refusing: the recorded pipeline head at the run recovery ref, and the +// operator's pre-recovery head at the adoption's own ref. A guard that watched +// only the first cannot see a refusal denying the second. +func (f *recoverFixture) trackedAnchors() []string { + return []string{f.anchorRef(), f.localAnchorRef()} +} + +// anchorPresence reads whether each tracked anchor exists in each store. +// Compared before and after an attempt, it is what separates a pin THIS // attempt made - the only kind a no-change claim can contradict - from one it // merely found already there. -func (f *recoverFixture) anchorPresence(t *testing.T) map[string]bool { +func (f *recoverFixture) anchorPresence(t *testing.T) map[string]map[string]bool { t.Helper() - present := make(map[string]bool, 2) - for _, store := range f.anchorStores() { - _, exists, err := gitpkg.ExactRefTarget(f.ctx, store.dir, f.anchorRef()) - if err != nil { - t.Fatalf("read %s in %s: %v", f.anchorRef(), store.label, err) + present := make(map[string]map[string]bool, 2) + for _, ref := range f.trackedAnchors() { + byStore := make(map[string]bool, 2) + for _, store := range f.anchorStores() { + _, exists, err := gitpkg.ExactRefTarget(f.ctx, store.dir, ref) + if err != nil { + t.Fatalf("read %s in %s: %v", ref, store.label, err) + } + byStore[store.label] = exists } - present[store.label] = exists + present[ref] = byStore } return present } -// assertRefusalMatchesAnchorReality is the whole point: read whether the +// assertRefusalMatchesAnchorReality is the whole point: read whether each // recovery anchor actually exists in each store, then require the refusal's -// claim to agree with that fact in either direction - the plain denial when -// this attempt wrote nothing, and the anchor plus every store it wrote when it -// did. A store holding no anchor at all may never be named as holding one. -func assertRefusalMatchesAnchorReality(t *testing.T, f *recoverFixture, before map[string]bool, state State) { +// claim to agree with those facts in either direction - the plain denial when +// this attempt wrote nothing, and EVERY anchor it wrote plus every store it +// wrote them in when it did. A ref that exists in no store at all, and a store +// holding no anchor at all, may never be named as holding one. +func assertRefusalMatchesAnchorReality(t *testing.T, f *recoverFixture, before map[string]map[string]bool, state State) { t.Helper() if state.Recovered || state.Changed { t.Fatalf("refusal reported a change = %#v", state) } - anchor := f.anchorRef() after := f.anchorPresence(t) - var written []string - for _, store := range f.anchorStores() { - if after[store.label] && !before[store.label] { - written = append(written, store.label) + written := make(map[string][]string, len(f.trackedAnchors())) + for _, anchor := range f.trackedAnchors() { + for _, store := range f.anchorStores() { + if after[anchor][store.label] && !before[anchor][store.label] { + written[anchor] = append(written[anchor], store.label) + } + } + } + for _, anchor := range f.trackedAnchors() { + if !anchorHeldAnywhere(f, after, anchor) && strings.Contains(state.Error, anchor) { + t.Fatalf("refusal named %s, which exists in no store at all: %q", anchor, state.Error) } } for _, store := range f.anchorStores() { - if !after[store.label] && strings.Contains(state.Error, store.label) { - t.Fatalf("refusal named %s, which holds no %s at all: %q", store.label, anchor, state.Error) + if !storeHoldsAnyAnchor(f, after, store.label) && strings.Contains(state.Error, store.label) { + t.Fatalf("refusal named %s, which holds no recovery anchor at all: %q", store.label, state.Error) } } if len(written) == 0 { @@ -3889,14 +3982,48 @@ func assertRefusalMatchesAnchorReality(t *testing.T, f *recoverFixture, before m return } if strings.Contains(state.Error, "no files or refs were changed") { - t.Fatalf("refusal denied changing refs while this attempt wrote %s in %s: %q", anchor, strings.Join(written, " and "), state.Error) + t.Fatalf("refusal denied changing refs while this attempt wrote %s: %q", describeAnchorWrites(f, written), state.Error) } - if !strings.Contains(state.Error, anchor) { - t.Fatalf("refusal did not name the anchor it wrote (%s in %s): %q", anchor, strings.Join(written, " and "), state.Error) + for _, anchor := range f.trackedAnchors() { + stores := written[anchor] + if len(stores) == 0 { + continue + } + if !strings.Contains(state.Error, anchor) { + t.Fatalf("refusal did not name the anchor it wrote (%s in %s): %q", anchor, strings.Join(stores, " and "), state.Error) + } + for _, store := range stores { + if !strings.Contains(state.Error, store) { + t.Fatalf("refusal did not name %s, where this attempt anchored %s: %q", store, anchor, state.Error) + } + } } - for _, store := range written { - if !strings.Contains(state.Error, store) { - t.Fatalf("refusal did not name %s, where this attempt anchored %s: %q", store, anchor, state.Error) +} + +func anchorHeldAnywhere(f *recoverFixture, present map[string]map[string]bool, anchor string) bool { + for _, store := range f.anchorStores() { + if present[anchor][store.label] { + return true + } + } + return false +} + +func storeHoldsAnyAnchor(f *recoverFixture, present map[string]map[string]bool, store string) bool { + for _, anchor := range f.trackedAnchors() { + if present[anchor][store] { + return true + } + } + return false +} + +func describeAnchorWrites(f *recoverFixture, written map[string][]string) string { + var wrote []string + for _, anchor := range f.trackedAnchors() { + if stores := written[anchor]; len(stores) > 0 { + wrote = append(wrote, anchor+" in "+strings.Join(stores, " and ")) } } + return strings.Join(wrote, ", ") } diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 0b4dc6c77..526528e4e 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "time" @@ -651,7 +652,9 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { // would deny a ref this same attempt created. Both the keep-local // delegations and the default path's own refusals carry it, so the claim is // true of the whole attempt rather than of the store we happened to look in. - anchoredNote := recoveryAnchorNote{ref: anchorRef} + // The functions it is handed to extend their own copy with the anchors they + // write, so a refusal there reports its own ref as well as this one. + var anchoredNote recoveryAnchorNote if anchoredLocal, err := git.Run(ctx, wd, "rev-parse", "--verify", localAnchor+"^{commit}"); err == nil && anchoredLocal != preserved && local == preserved && !state.Local.Clean { blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_incomplete_adoption", fmt.Sprintf("the branch reached the preserved pipeline head, but its worktree still differs from that head; the pre-recovery head remains anchored at %s; reconcile the worktree and re-run recovery; custody was not recorded", localAnchor)) @@ -666,7 +669,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { // the preserved head is already reachable from the local branch, so // keeping that head can lose nothing at all. if keepLocal { - return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved, anchoredNote) } return blocked } @@ -674,7 +677,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { compatible, err := recoveryAnchorCompatible(ctx, gateDir, run.ID, preserved) if err != nil || !compatible { if keepLocal { - return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved, anchoredNote) } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", fmt.Sprintf("the run recovery ref in the local gate conflicts with the recorded pipeline head; inspect both objects before returning custody; %s", keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) } @@ -695,13 +698,13 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { gateAnchored, err := git.Run(ctx, gateDir, "rev-parse", gateAnchor+"^{commit}") if err != nil { if keepLocal { - return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved, anchoredNote) } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", fmt.Sprintf("the run recovery ref points at non-commit object %s instead of the recorded pipeline head %s; inspect both objects before returning custody; no files or refs were changed", gateAnchorTarget, preserved)) } if gateAnchored != preserved { if keepLocal { - return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved, anchoredNote) } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", fmt.Sprintf("the run recovery ref points at %s instead of the recorded pipeline head %s; inspect both heads before returning custody; no files or refs were changed", gateAnchored, preserved)) } @@ -710,7 +713,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { if !gateAnchorAvailable { if !objectExists(ctx, gateDir, preserved) { if keepLocal { - return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved, anchoredNote) } return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserved_head_missing", fmt.Sprintf("the recorded pipeline head %s is missing from the local gate; inspect the recorded and live heads before returning custody; no files or refs were changed", preserved)) } @@ -722,7 +725,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { // probe above is what separates a pin THIS attempt made from one it // merely found. if !gateAnchorExists { - anchoredNote.record(localGateStore) + anchoredNote.record(recordedHeadSubject, anchorRef, localGateStore) } } @@ -736,11 +739,11 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { } if preserveErr := custody.PreserveRecoveryAnchor(ctx, wd, anchorRef, preserved); preserveErr != nil { if keepLocal { - return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved) + return s.recoverSettleInconsistent(ctx, run, state, gateDir, preserved, anchoredNote) } return blockedPlan(state, StatePipelineOwned, "blocked_recover_anchor_mismatch", fmt.Sprintf("the invoking worktree recovery ref conflicts with the recorded pipeline head; inspect both objects before returning custody; %s", keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause()))) } - anchoredNote.record(invokingWorktreeStore) + anchoredNote.record(recordedHeadSubject, anchorRef, invokingWorktreeStore) } switch { @@ -822,13 +825,16 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State { // default, and this function is the terminal settlement, so a refusal with no // next action would be the dead end the whole issue exists to remove - even for // a shape selfInconsistentCustodyRecord failed to disqualify. -func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, state State, gateDir, preserved string) State { +func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, state State, gateDir, preserved string, anchoredNote recoveryAnchorNote) State { strandedRef := custody.RecoveryStrandedRef(run.ID) gateDir = strings.TrimSpace(gateDir) // The stranded anchor is the ONLY ref this function can write before the // gate move, so a refusal reports precisely where it now exists rather - // than claiming nothing changed. - pinned := recoveryAnchorNote{ref: strandedRef} + // than claiming nothing changed. It extends the CALLER's note instead of + // starting over, because Recover can already have pinned the recorded head + // at the run recovery ref before delegating here, and nothing retires that + // ref either. + pinned := anchoredNote for _, store := range []struct{ name, dir string }{ {invokingWorktreeStore, s.workDir()}, {localGateStore, gateDir}, @@ -856,7 +862,7 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "no-mistakes axi status"} return blocked } - pinned.stores = append(pinned.stores, store.name) + pinned.record(recordedHeadSubject, strandedRef, store.name) } if gateDir == "" { return s.finishRecover(ctx, run, false, true) @@ -882,54 +888,93 @@ func (s *Service) recoverSettleInconsistent(ctx context.Context, run *db.Run, st return s.recoverKeepLocal(ctx, run, state, gateHead, pinned) } -// The two object stores a recovery can pin a surviving recorded head in. They -// are named rather than spelled out at each site because a refusal's no-change -// claim is scoped by WHICH of them holds the anchor, so a typo in one of them -// would silently widen or narrow that claim. +// The two object stores a recovery can pin a surviving head in. They are named +// rather than spelled out at each site because a refusal's no-change claim is +// scoped by WHICH of them holds the anchor, so a typo in one of them would +// silently widen or narrow that claim. const ( invokingWorktreeStore = "the invoking worktree" localGateStore = "the local gate" ) +// The heads a recovery can anchor. A refusal has to name the head the ref on +// ITS OWN path holds: the recorded pipeline head and the operator's +// pre-recovery head live at different refs, so a note naming one of them says +// nothing at all about the other. +const ( + recordedHeadSubject = "the recorded head" + preRecoveryHeadSubject = "the pre-recovery head" +) + +// recoveryAnchorPin is one anchor this attempt wrote: which head, at which +// ref, and in which stores. The stores are plural because the same head is +// pinned at the same ref in the local gate and in the invoking worktree alike. +type recoveryAnchorPin struct { + subject string + ref string + stores []string +} + // recoveryAnchorNote records WHERE an earlier step of this recovery anchored a -// surviving recorded head, not merely the sentence describing it. A refusal -// needs the store and not only the prose: a claim scoped to LOCAL files and -// refs is accurate beside a gate-side pin and self-contradictory beside one in -// the invoking worktree, and a bare string cannot tell those apart. +// surviving head, not merely the sentence describing it. A refusal needs the +// store and not only the prose: a claim scoped to LOCAL files and refs is +// accurate beside a gate-side pin and self-contradictory beside one in the +// invoking worktree, and a bare string cannot tell those apart. +// +// It holds a LIST of pins because one attempt can anchor more than one head at +// more than one ref - the recorded head at the run recovery ref, the same head +// at the stranded ref, the pre-recovery local head at its own ref - and a +// refusal that discloses one of them while denying the others is the same +// false claim, just narrower. type recoveryAnchorNote struct { - ref string - stores []string + pins []recoveryAnchorPin } -func (n recoveryAnchorNote) clause() string { return anchoredElsewhere(n.stores, n.ref) } +func (n recoveryAnchorNote) clause() string { + var clause strings.Builder + for _, pin := range n.pins { + clause.WriteString(anchoredElsewhere(pin.subject, pin.stores, pin.ref)) + } + return clause.String() +} -// record names a store this attempt pinned the recorded head in. A store is -// recorded once: a re-entered write on the same store is still one anchor. -func (n *recoveryAnchorNote) record(store string) { - for _, existing := range n.stores { - if existing == store { +// record names a store this attempt anchored subject at ref in. One (subject, +// ref) pair is a single anchor however many stores hold it, and a re-entered +// write on the same store is still one pin. Every extension allocates instead +// of appending in place, because the note is passed BY VALUE into the +// functions that extend it and must never write through the caller's copy. +func (n *recoveryAnchorNote) record(subject, ref, store string) { + for i, pin := range n.pins { + if pin.subject != subject || pin.ref != ref { + continue + } + if slices.Contains(pin.stores, store) { return } + pins := slices.Clone(n.pins) + pins[i].stores = append(slices.Clone(pin.stores), store) + n.pins = pins + return } - n.stores = append(n.stores, store) + n.pins = append(slices.Clone(n.pins), recoveryAnchorPin{subject: subject, ref: ref, stores: []string{store}}) } func (n recoveryAnchorNote) namesInvokingWorktree() bool { - for _, store := range n.stores { - if store == invokingWorktreeStore { + for _, pin := range n.pins { + if slices.Contains(pin.stores, invokingWorktreeStore) { return true } } return false } -// anchoredElsewhere keeps a refusal honest about a recorded head its own -// attempt has already anchored before failing. -func anchoredElsewhere(pinned []string, ref string) string { +// anchoredElsewhere keeps a refusal honest about a head its own attempt has +// already anchored before failing. +func anchoredElsewhere(subject string, pinned []string, ref string) string { if len(pinned) == 0 { return "" } - return fmt.Sprintf("; the recorded head is now anchored at %s in %s", ref, strings.Join(pinned, " and ")) + return fmt.Sprintf("; %s is now anchored at %s in %s", subject, ref, strings.Join(pinned, " and ")) } // keepLocalNoChangeClause closes a refusal with a no-change claim that stays @@ -1014,14 +1059,14 @@ func recoverBlocked(state State, safety, message string) State { // after possibly anchoring a surviving recorded head of their own - Recover // pins the preserved head at the run recovery ref, and // recoverSettleInconsistent pins every surviving copy at the stranded ref. -// anchoredNote carries that fact in - the ref AND the stores holding it - so -// the blanket claim is made only by a delegation that wrote nothing and every -// other refusal names where the anchor now is instead of reporting that -// nothing was written. The refusals after the swap keep their own narrower -// local-scoped claim and append the note, because by then this function may -// have written the gate anchor itself; that narrowing is withdrawn when the -// note names the invoking worktree, since the claim would otherwise deny a -// local ref the same sentence discloses. +// anchoredNote carries those facts in - each head, the ref holding it, and the +// stores it sits in - so the blanket claim is made only by a delegation that +// wrote nothing and every other refusal names where the anchors now are +// instead of reporting that nothing was written. The refusals after the swap +// keep their own narrower local-scoped claim and append the note, because by +// then this function may have written the gate anchor itself; that narrowing +// is withdrawn when the note names the invoking worktree, since the claim +// would otherwise deny a local ref the same sentence discloses. // // The anchor CONFLICT check is deliberately still read-only and still runs // first: it is the cheapest refusal and it must not be reached only after a @@ -1291,6 +1336,7 @@ func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state if err := custody.PreserveRecoveryAnchor(ctx, wd, localAnchor, head); err != nil { return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be anchored; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())) } + anchoredNote.record(preRecoveryHeadSubject, localAnchor, invokingWorktreeStore) } if anchored, err := git.Run(ctx, wd, "rev-parse", localAnchor+"^{commit}"); err != nil || anchored != head { return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be verified after anchoring; "+keepLocalNoChangeClause("no files or worktree refs were changed", anchoredNote.clause())) @@ -1378,7 +1424,7 @@ func (s *Service) anchorReachablePreserved(ctx context.Context, state State, run return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be anchored locally; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())), false } if !anchorExisted || probeErr != nil { - anchoredNote.record(invokingWorktreeStore) + anchoredNote.record(recordedHeadSubject, anchorRef, invokingWorktreeStore) } if anchored, err := git.Run(ctx, s.workDir(), "rev-parse", anchorRef+"^{commit}"); err != nil || anchored != preserved { return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be anchored locally; "+keepLocalNoChangeClause("no files or refs were changed", anchoredNote.clause())), false From 69caed2e05676294127547bbda101b0867618f0a Mon Sep 17 00:00:00 2001 From: Hunter <93848890+hmcdaniel03@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:05:55 -0400 Subject: [PATCH 31/31] no-mistakes(document): generalize recovery anchor disclosure docs to every anchor written --- .../branch-sync-and-push-safety/SKILL.md | 2 +- docs/src/content/docs/reference/cli.md | 4 ++- internal/branchsync/recover_test.go | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.agents/skills/branch-sync-and-push-safety/SKILL.md b/.agents/skills/branch-sync-and-push-safety/SKILL.md index 574b25cc2..86dcaa429 100644 --- a/.agents/skills/branch-sync-and-push-safety/SKILL.md +++ b/.agents/skills/branch-sync-and-push-safety/SKILL.md @@ -20,7 +20,7 @@ metadata: When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never touches the worktree and, where the gate branch still names a different head, CAS-moves that branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The CAS is reached only where the gate branch still names a different head; equal/ahead, `user_owned`, and already-recovered paths return custody without moving any branch ref (equal/ahead still writes the private recovery anchor, which is what anchoring a locally reachable preserved head means), and the flag help must keep saying so. A PROVEN-absent gate branch does the same on the SETTLEMENT path only; the ordinary keep-local path resolves the gate branch before delegating, so a deleted or unreadable one refuses there with `blocked_recover_gate_unavailable` rather than returning custody, and no surface may promise otherwise. `--keep-local` is also the ONLY settlement for a SELF-INCONSISTENT custody record (#824): a terminal run whose recorded pipeline head is in no reachable object store, or whose own recovery evidence names something else, has nothing verifiable to import, so every default recovery refused, `abort` of the terminal run was a no-op, and the branch stayed `pipeline_owned` forever. Inspection names that exit (`next_action.code: return_custody_keep_local`) instead of #814's dead-end manual-reconciliation pointer, terminal-run `abort` responses carry the same command, and the TUI `u` action reaches the same settlement - keyed on that advertised next action, never on a safety code, so it cannot drift from the predicate that decides where the settlement can complete. #814's polarity (never advertise `recover_custody` for an unverifiable record) is preserved. Terminal-run no-op abort help is separately allowlisted to custody-settlement codes only (`custodySettlementHelp`) AND gated on `StatePipelineOwned`, so an abort that cancelled nothing never answers by prescribing a fresh run against a released branch, nor by repeating ordinary-divergence `git log` advice; a SUCCESSFUL cancellation still echoes the branch's own next action. Three invariants make that safe, and the `recoverSettleInconsistent`, `recoverKeepLocal`, and `selfInconsistentCustodyRecord` doc comments own the mechanics and the per-shape rationale. First, the settlement is fail-closed and never a shortcut past unique content: every reachable copy of the recorded head is pinned before anything moves, a head that still exists but cannot be pinned refuses, the gate moves only by CAS, and the settlement's own refusals plus every refusal raised while keep-local moves the gate branch name `inspect_and_reconcile_manually` (`blockedPlan` nils `NextAction`, so this is per-site via `recoverBlocked`, never a global guarantee). The one failure that is not a refusal gets the same treatment: `finishRecover`'s stamp failure names the same recovery command again (`recoveryRetryAction`), which completes because every Git step it repeats is idempotent once applied. It carries its OWN `complete_custody_return` code rather than reusing `recover_custody`/`return_custody_keep_local`, because those codes are claims about the record that the skill, the CLI guidance, the docs and the TUI each restate in their own words - and neither claim is true of a retry. Its message scopes to "any Git changes this recovery makes", since the proven-absent-gate-branch, no-gate and gate-already-at-the-kept-head paths reach it having applied nothing. Second, absence must be PROVEN, not inferred: `git.CommitPresence` treats ONLY git's exit 1 (the store was read and the object is not there) as an absence, because the settlement's whole safety argument is "nothing still has this head, so nothing can be lost". An unreadable store and a present-but-wrong-type object (`cat-file -e` exits 0 for a tree, blob, or tag) are both undetermined and refuse; collapsing either into "absent" let the settlement complete while the object was still there. `settlementAnchorsFree` mirrors the same probe so the advertisement agrees with the write. Third, the predicate may name the settlement only where `Recover(keepLocal)` reaches it AND it can complete, so unverified (#707's scope), uninspectable, symbolic, and mid-adoption evidence, plus an unreadable gate branch or an occupied stranded/gate anchor, all fall back to manual reconciliation. Each trigger must therefore be probed the way `Recover` itself probes it: the worktree recovery ref goes through `recoveryAnchorIntercepts`, mirroring Recover's `rev-parse ^{commit}` (which DEREFERENCES a resolving symref, so such a record is never intercepted) rather than the stricter `recoveryAnchorCompatible`, which rejects every symref and advertised a settlement that could only refuse. Advertising any of those recreates the very wedge this change removes. - Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole ATTEMPT if it carries what the attempt wrote and not merely what its own site did, so `Recover` hoists ONE anchor note above every write and every refusal reachable after one of them closes through `keepLocalNoChangeClause` - its own default-path refusals exactly as much as the ones it delegates to `recoverKeepLocal`; scoping the note to either path alone leaves the other denying a ref its own attempt just wrote. The note carries the STORES rather than the prose, because `Recover` can pin the recorded head at `refs/no-mistakes/recover/` in the local gate (legacy dangling evidence), in the invoking worktree (the fetch), or in both, and a settlement pins it at `refs/no-mistakes/recover-stranded/`; any of those makes the refusal report "no branch, worktree, or file changes were made" plus where that anchor now is, and only an attempt that wrote nothing makes the blanket "no files or refs were changed" claim. A probe that cannot tell whether the ref pre-existed records the store anyway: over-reporting an anchor is noise, under-reporting one silently restores the false denial. The refusals AFTER the swap are excluded from that substitution on purpose: they may already have written the gate anchor, so they keep the narrower "no LOCAL files or refs were changed" claim and APPEND the note - but only where the note names a GATE-side pin. When it names the invoking worktree, which the ordinary keep-local path pins whenever it writes that anchor itself, that qualifier would deny the very ref the sentence discloses, so the substitution applies after all. The anchor CONFLICT check stays first because it is read-only. TWO post-anchor-write refusals remain - the lost CAS and the unproven-race swap failure - plus the separate post-success stamp failure above. Both keep the anchor deliberately: the swap failed, so that pin may be the only ref still naming the displaced head, and each names the ref to reconcile. A failed swap is only reported as a race once the gate head is re-read and actually differs; a still-unmoved or unreadable head is `blocked_recover_swap_failed` instead, because a held lock or an I/O error fails `update-ref` identically and inventing a racing actor is the same overstatement in a different place. The staged head proves only what the fetch copied, so the branch is re-read before the swap: a commit landing in that window would otherwise stamp custody at a head the worktree has already moved past. + Write ordering inside `recoverKeepLocal` is load-bearing: `refs/no-mistakes/recover-gate/` guards exactly the CAS that moves the gate branch off the displaced head, and nothing before that swap can strand it, so the anchor is written immediately before the CAS and every refusal that can precede it leaves no ref of ITS OWN behind - which is what makes that half of the claim true by construction rather than by a cleanup that could itself fail. A refusal only speaks for the whole ATTEMPT if it carries what the attempt wrote and not merely what its own site did, so `Recover` hoists ONE anchor note above every write and hands it to everything it delegates to; scoping the note to a single path leaves the others denying a ref their own attempt just wrote. The criterion is derived over the REACHABLE SET from `Recover`, never over the region being edited, since a refusal outside that region is still reached by the same attempt: enumerate every `custody.PreserveRecoveryHead`/`PreserveRecoveryAnchor` call in that closure, and every refusal reachable after one of them must either close through `keepLocalNoChangeClause` or - where it never claimed anything was left untouched, as the mid-recovery fast-forward and adoption failures do not - append the note anyway, because the operator is left reconciling a partly applied recovery at exactly that ref. The note is a LIST of (head, ref, stores) pins rather than one ref plus stores, because a single attempt can pin the recorded head at `refs/no-mistakes/recover/` in the local gate (legacy dangling evidence), in the invoking worktree (the fetch), or in both, pin that same head at `refs/no-mistakes/recover-stranded/` through a settlement - which EXTENDS the caller's note instead of starting over, since nothing retires the ref the caller pinned either - and pin the pre-recovery head at `refs/no-mistakes/recover-local/` during adoption; disclosing one of them while denying the others is the same false claim, just narrower. Each pin names WHICH head it holds, because those two heads live at different refs and a note naming one says nothing about the other, and callees extend a BY-VALUE copy so an extension never writes through the caller's note. Any of those makes the refusal report "no branch, worktree, or file changes were made" plus where the anchors now are, and only an attempt that wrote nothing makes the blanket "no files or refs were changed" claim. A probe that cannot tell whether the ref pre-existed records the store anyway: over-reporting an anchor is noise, under-reporting one silently restores the false denial. The refusals AFTER the swap are excluded from that substitution on purpose: they may already have written the gate anchor, so they keep the narrower "no LOCAL files or refs were changed" claim and APPEND the note - but only where the note names a GATE-side pin. When it names the invoking worktree, which the ordinary keep-local path pins whenever it writes that anchor itself, that qualifier would deny the very ref the sentence discloses, so the substitution applies after all. The anchor CONFLICT check stays first because it is read-only. TWO post-anchor-write refusals remain - the lost CAS and the unproven-race swap failure - plus the separate post-success stamp failure above. Both keep the anchor deliberately: the swap failed, so that pin may be the only ref still naming the displaced head, and each names the ref to reconcile. A failed swap is only reported as a race once the gate head is re-read and actually differs; a still-unmoved or unreadable head is `blocked_recover_swap_failed` instead, because a held lock or an I/O error fails `update-ref` identically and inventing a racing actor is the same overstatement in a different place. The staged head proves only what the fetch copied, so the branch is re-read before the swap: a commit landing in that window would otherwise stamp custody at a head the worktree has already moved past. The full relation matrix and fail-safe rules live in the `Recover` doc comment in `internal/branchsync/sync.go`. - Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`, whose `wedgedCustodyFixture` is the #824 shape), `internal/cli/sync_test.go`, `internal/cli/axi_abort_custody_test.go`, `internal/cli/axi_settle_custody_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `TestAxiCustodyRecoveryJourney` / `TestAxiCustodyRecoveryAfterRebaseJourney` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 73f30eed2..655aafd0e 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -228,7 +228,9 @@ While a run is still active, it reports `state: pipeline_owned`, the exact submi `--recover` verifies the run is terminal, anchors the preserved head under `refs/no-mistakes/recover/` in the invoking repository, and stamps custody returned so a fresh run can start. For equal or ahead worktrees where the preserved head is already locally reachable, recovery writes that anchor locally without requiring gate access. If the gate is available, an existing symbolic, non-commit, or mismatched recovery ref is conflicting evidence and recovery refuses without overwriting it. For behind or diverged worktrees, recovery verifies the preserved head at the run-specific recovery ref in the local gate and fetches it into the anchor before moving or refusing. Legacy recorded heads that remain available as unreferenced gate objects are anchored before recovery continues. -Those anchor writes precede the remaining checks, so no `--recover` refusal claims nothing was written once its own attempt created an anchor: it reports exactly the categories it left alone and names the anchor and the repository holding it - the invoking worktree, the local gate, or both. +Those anchor writes precede the remaining checks, so no `--recover` refusal claims nothing was written once its own attempt created an anchor: it reports exactly the categories it left alone and names every anchor the attempt created - which head each one holds, the ref holding it, and the repository it sits in, the invoking worktree, the local gate, or both. +One attempt can leave more than one, because the recorded head and the pre-recovery local head anchored by the adoption described below sit at separate refs. +A failure partway through a recovery names those anchors too, even though it makes no no-change claim of its own, because reconciling a partly applied recovery by hand starts at exactly those refs. A clean behind worktree fast-forwards. A diverged worktree is adopted only when the preserved head provably carries every local change, proven by an executable three-way merge whose result is exactly the preserved head's tree. This covers a pipeline rebase onto a newer base without requiring the gate branch to advance to the preserved head. diff --git a/internal/branchsync/recover_test.go b/internal/branchsync/recover_test.go index 4e474dff3..e91496dac 100644 --- a/internal/branchsync/recover_test.go +++ b/internal/branchsync/recover_test.go @@ -3860,6 +3860,37 @@ func TestDefaultRecoverRefusalsDiscloseTheAnchorTheyWrote(t *testing.T) { }, safety: "blocked_recover_assumptions_changed", }, + { + // PAST the branch swap: a checkout landing after the branch has + // already moved rolls the branch back and refuses. The recovery is + // half applied at that point, so the operator reconciles by hand + // with both anchors on disk and the refusal has to say where they + // are. + name: "adoption rolls back after a checkout that follows the branch move", + fixture: func(t *testing.T) *recoverFixture { return newRebasedRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + mustRun(t, f.local, "branch", "other-clean-branch", f.submitted) + f.service.afterRecoverBranchMove = func() { + mustRun(t, f.local, "checkout", "other-clean-branch") + } + }, + safety: "blocked_recover_assumptions_changed", + }, + { + // The working-tree update itself refuses: an untracked file the + // adoption would overwrite aborts read-tree inside Git, after both + // the recorded head and the pre-recovery head were anchored. + name: "adoption's worktree update is blocked by an untracked file it would overwrite", + fixture: func(t *testing.T) *recoverFixture { return newRebasedRecoverFixture(t, types.RunCancelled) }, + setup: func(t *testing.T, f *recoverFixture) { + f.service.beforeRecoverBranchMove = func() { + // upstream.txt exists only on the advanced base, so + // adopting the preserved head must write it. + mustWrite(t, filepath.Join(f.local, "upstream.txt"), "uncommitted local draft\n") + } + }, + safety: "blocked_recover_worktree_busy", + }, { // The claim is not scoped to the default flow: --keep-local pins // the same anchors and then refuses when the gate branch it would