fix(branchsync): settle wedged custody records via axi sync --recover --keep-local (#824) - #883
fix(branchsync): settle wedged custody records via axi sync --recover --keep-local (#824)#883hmcdaniel03 wants to merge 31 commits into
Conversation
Confidence Score: 4/5The PR is not yet safe to merge because a reachable partial default-recovery failure still hides the recovery anchor created by that attempt. A successful recovery fast-forward followed by a hook-dirtied worktree refuses without recording custody, but its error omits the invoking-worktree recovery ref needed to reconcile that partial operation. Files Needing Attention: internal/branchsync/sync.go, internal/branchsync/recover_test.go Reviews (5): Last reviewed commit: "no-mistakes(document): generalize recove..." | Re-trigger Greptile |
|
Speaking as Kun's firstmate: Thanks for the careful write-up and the known-gaps section — that honesty helps. Status on tip
We will not rebase or resolve conflicts from here while those safety gaps remain — conflict resolution is only for PRs that are otherwise auto-merge-ready. Please: rebase/resolve onto latest main, close gap (1) fail-closed (and preferably (2) so TUI has the same exit as CLI), then ping. Until then this stays waiting on you. Closes-ready-for-pr link to #824 is noted; #824 stays ready-for-pr. VISION (pre-merge, current tip — not merge-ready):
|
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#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/<run> 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 kunchenguid#814 polarity is preserved: an unverifiable record never advertises recover_custody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects in the kunchenguid#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 kunchenguid#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 <noreply@anthropic.com>
…document preserve-failed refusal
…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 <noreply@anthropic.com>
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 <id>` 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 <noreply@anthropic.com>
Closes the merge blockers the maintainer named on kunchenguid#883, plus the three smaller published gaps. recoverKeepLocal wrote refs/no-mistakes/recover-gate/<run> 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/<branch> 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 kunchenguid#824. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U
… from fixture check
…l settlement action
26f02ba to
45a2a79
Compare
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 kunchenguid#824. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U
…tody-return clause
…; fix settlement trigger
…tody_return surfaces
… surface staging leftovers
|
Speaking as Kun's firstmate: The two merge blockers on the previous tip are closed on
Also inspected: Fork CI approved this pass (runs 33228552615 / 33228552655 / 33228552712). Waiting on green CI. Not waiting on you for more product work unless CI fails. contract-class: restore (documented VISION (tip
|
…ted agent-guide prose
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/<run> 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 kunchenguid#824. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U
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 kunchenguid#824. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U
…every anchor written
| // 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 { |
There was a problem hiding this comment.
Fast-forward refusal hides anchor
When default recovery successfully fast-forwards to the preserved head but a Git hook leaves the worktree non-clean, this branch returns a fixed error without appending anchoredNote.clause(). The operator is told custody was not recorded but is not shown the refs/no-mistakes/recover/<run> anchor created by the same attempt, leaving the partial recovery without the ref location needed for manual reconciliation.
Knowledge Base Used: Git workspaces and branch synchronization
|
Speaking as Kun's firstmate: Tip moved to New Greptile P1 on this tip is real and blocking: after a successful Fork CI is approved and still running on this tip (check/docs/Guard green so far; tests/e2e pending). Not merging until the new P1 is closed and CI is green. Waiting on you for the disclosure fix. contract-class: restore (documented VISION (tip
|
Intent
Fixes #824: a custody record whose gate branch SHA and preserved pipeline head SHA disagree cannot currently be recovered or aborted —
recover_custodykeeps being advertised, bothaxi sync --recoverandaxi sync --recover --keep-localrefuse with the sameblocked_recover_gate_diverged, andaxi abort --run <id>is an idempotent no-op on the already-failed run.This branch implements the settlement described in the issue:
axi sync --recover --keep-localsettles a wedged custody record when the bound run is terminal and the preserved head is either missing or in disagreement with the gate. Custody is returned at the kept local head, and where the gate branch still names a different head it is compare-and-swapped onto the kept head. Every still-reachable copy of the recorded head is pinned first, and the settlement refuses rather than proceeding when a surviving head cannot be anchored.--recoverbehavior is unchanged.Addressing your review
Rebased onto current
main. OnlyAGENTS.mdconflicted, becausemainextracted that section into.agents/skills/; the resolution takesmain's copy verbatim and re-homes our prose into.agents/skills/branch-sync-and-push-safety/SKILL.md.AGENTS.mdis byte-identical tomain. #883 is mergeable againstkunchenguid:main.Gap (1) is closed by construction rather than by cleanup. The displaced-gate-head anchor guards exactly one operation — the compare-and-swap that moves the gate branch off
gateHead— and nothing before that swap can strand it, becauserefs/heads/<branch>still names it. So the write now happens immediately before the swap, and every refusal that can precede it leaves no anchor of its own. That is stronger than deleting the ref on the refusal path, because a cleanup can itself fail and leave the same false claim.The accounting, since our earlier "four" caused confusion: at the previous tip
recoverKeepLocalhad 8 refusal returns, 5 of them after the anchor write, and 4 of those 5 made the untrue "no files or refs were changed" claim — the fifth said "no local files or refs", named the anchor, and was already honest. Both numbers were right about different things. It now has 10 refusal returns: 7 that leave no anchor of their own, and 3 after the swap that each name what they wrote. A failure to record custody after the Git side finished is a separate post-success failure, not a refusal, and it names its owncomplete_custody_returnretry.Gap (2) is closed. The TUI reaches the same settlement, keyed on the advertised
next_action.coderather than a safety code so it cannot drift from the predicate that decides where the settlement can complete, behind its own confirmation because settling keeps the local head where recovery takes the preserved one.Gaps (3), (4) and (5) are closed. (3) was not cosmetic: with the old probe the settlement completed on a head that was still present, because any non-zero git exit read as "absent". Only exit 1 now counts as proven absence; an unreadable store and a present-but-wrong-type object both refuse. (5) was worse than we described — a
--runabort response carries nobranch_syncobject at all, so those tests could not have failed even with the clause deleted.What the review rounds found, including our own near-misses
Five review rounds ran against this work. We think the misses are worth showing rather than presenting a clean result:
update-refwas reported as a concurrent gate push, when a held ref lock or an I/O error fails it identically. The gate head is now re-read and a race claimed only when it actually differs.return_custody_keep_localtold the operator, in five places, that a fully verified head could no longer be verified. But it left the TUI with no exit for that state, reintroducing the Custody record with disagreeing gate vs preserved head cannot be recovered or aborted #824 shape in the surface this PR fixes the TUI for. An exhaustive enumeration of every surface carrying those codes found it; five review rounds had not.--recoverwritesrefs/no-mistakes/recover/<run>— into the local gate, then into the invoking worktree — before its own refusals are reached, and those refusals reported "no files or refs were changed". One of them named the anchor and denied writing any ref in the same sentence. The keep-local siblings twenty lines away already disclosed it; these did not, so the claim was true only where we had looked. Fixed, with no behaviour change: same refusals, same next actions, same refs written.Erroron a successful custody return, and callers read a non-emptyErroras failure — so a successful recovery would have reported itself failed. Caught and corrected before merge; the note now lives only on paths that would otherwise make a false claim.A structural observation, offered as data
Your note about
blockedPlannil-ingNextActionby default looks right, and this branch is evidence for the general shape rather than just that instance. The same propagation failure occurred four times, found by four different mechanisms: a review round, an exhaustive enumeration, a test whose population was scoped to a file family rather than to the claim, and a consent prompt missed by the guard built to stop exactly this. Each individual fix was correct; none of them generalised.We have added a cross-surface guard, and it is honestly limited: it enumerates forbidden phrasings, so it can only ever chase — the next drift picks a phrasing not on the list, and one surface is already unguarded for that reason. A guard that checked the property structurally would close the class: derive the surface population from the code that emits the action, or assert the claim against observable behaviour rather than against a string. We have not built that here, and we are not proposing the larger
blockedPlaninversion in this PR either. Both are your call.The remaining red check
The automated reviewer's latest finding is a sixth instance of the disclosure class, and we are listing rather than fixing it — deliberately, and we would rather say why than quietly leave it.
recoverFastForward's post-fast-forward refusal ("HEAD reached the preserved pipeline head, but a Git hook left the worktree non-clean; custody was not recorded") does not name the invoking-worktree recovery ref. Its sibling inrecoverAdoptPreserveddoes. We checked the distinction that decides it: this refusal omits the ref, it does not deny having written one — it makes no claim about refs at all. The invariant that blocks merge is about refusals denying writes they made, and that invariant holds.It is also the site that defeated derivation 4 above, which is the clearest illustration we have of why we stopped: the method could not see a refusal that skipped the two constructors it scanned.
Known limitations
--keep-localparagraph indocs/reference/cli.mdhas grown long and narrates per-site message rationale that therecoverKeepLocal,keepLocalPostSwapNoChangeClauseandreleaseStagingRefdoc comments already own. Follow-up: trim it back to the operator contract.refs/no-mistakes/custody-return/<run>behind without reporting it. Deliberate: the next attempt's fetch force-overwrites that ref and the kept head is reachable from the gate branch regardless, and the alternative made a successful recovery look failed.settlementAnchorsFreeandselfInconsistentCustodyRecordwere hardened to fail closed on an unreadable probe. Both were already protected by a caller-side ordering, so these are hardening rather than live-bug fixes — but a helper that is only safe because of an ordering it never states is how the original defect got in.What we can claim, and what we could not
The merge-blocking invariant is closed and behaviourally tested: a refusal must not leave
refs/no-mistakes/recover-gate/<run>behind while reporting that nothing changed. That is the defect you named, and it is proven by tests that fail when the write-ordering fix is reverted.We tried three times to publish a stronger claim — that every refusal reachable after any recovery ref is written discloses it — and all three derivations were wrong. They are worth naming, because the progression says something the claim itself would not have:
Line-scoped. Checked a range of
Recoverwe had been editing. MissedrecoverFastForwardandrecoverAdoptPreservedentirely — different functions, same reachable set.Flow-blind. Treated position in the file as reachability. Produced six false positives, because the
anchorReachablePreservedwrite sits inside a block every path of which returns, so it can never fall through to the refusals below it.Ref-blind. Asked whether a disclosure was present rather than whether the ref written on that path was named. Passed three refusals that denied writing
refs/no-mistakes/recover-local/<run>while disclosing a different ref.Constructor-blind. Scanned refusals built through
blockedPlanandrecoverBlocked.recoverFastForward's post-fast-forward refusal assignsstate.Errordirectly, so the analysis was structurally incapable of seeing it — and that is the sixth instance, found after this criterion was already retired.Each attempt was subtler than the last, and each was caught by review rather than by us. We have stopped attempting a fourth. The honest reading is that this property — every path discloses the particular ref that path wrote — is not closable by the inspection methods available to us, and that a constructive approach would have to handle per-path ref identity rather than mere presence of a disclosure.
That is offered as evidence for your structural note, not as a claim that the redesign is necessary. You wrote it on intuition; this is three dated failures of the alternative.
Verification## Verification
make lint,go test -race ./..., andmake e2eall green at69caed2e.Most fixes here are proven by reverting them and watching a test fail. Two are not, and we would rather say so than leave a blanket claim:
recoverFastForward'sblocked_recover_apply_failedandrecoverAdoptPreserved's "adopting the preserved pipeline head did not reach it" are verified by reading the code. Neither is portably inducible with the seams available — the untracked-file trick trips the earlier clean re-check, andchmodis a no-op on the Windows leg this package runs on. Both are disclosure-only and neither denies writing anything.CI is green on this head. Thank you for approving the runs: build,
teston ubuntu / macos / windows-core / windows-git,e2e, the generated-files guard and the docs check all pass — 8 passed, 1 skipped. The only remaining red is the automated reviewer, discussed below.