Skip to content

fix(gatedrive): stale predecessor receipt must not rotate or free a live worktree slot (change 0453) - #333

Merged
danielhanold merged 7 commits into
mainfrom
fix/two-successors-sharing-one-stale-predecessor-receipt-can-sti
Sep 25, 2026
Merged

danielhanold merged 7 commits into
mainfrom
fix/two-successors-sharing-one-stale-predecessor-receipt-can-sti

Conversation

@danielhanold

Copy link
Copy Markdown
Owner

↩ Change 0453 — Two successors sharing one stale predecessor receipt can still free a live worktree slot

Summary

Fixes a race in gate admission where a successor start holding a stale predecessor receipt could rotate, and then release, a worktree slot that another start was actively running a gate in. That broke one-execution-per-worktree.

  • Before rotating an executing same-scope slot, admission now runs the scope reservation's full ordered refusal predicate. It is extracted into the shared helper scopeReserveRefusal so the two sites cannot drift. If the scope record can't be read, admission fails closed. A refusal never touches the slot.
  • A successor that loses with ErrStalePredecessor no longer releases a reservation that a sibling adopted: the case where the scope shows the same receipt consumed, or the current drive holds this token. Epoch-less scopes are where that interleaving is reachable.
  • Regression tests cover each case. Every one was confirmed red against the unfixed code.

Review (docket-review-deep)

# Severity Finding Disposition
1 important "Rotate first, then a sibling adopts" interleaving still frees a live slot for epoch-less scopes fixed, 2c3c282
2 minor Pre-rotation guard copied only the staleness clause of the reserve predicate fixed, 6d39ed6

Residual (suspected, not reproduced): a check-then-release window remains for freshly reserved successors on epoch-less scopes, when the scope advances two drives during a stall. See the results file.

Build evidence

command: go run ./cmd/docket development test
result: green
head_sha: ade8b51
ran_at: 2026-09-25T10:05:17Z

…n guard

Docket-Plan-Path: docs/superpowers/plans/2026-09-25-two-successors-sharing-one-stale-predecessor-receipt-can-sti.md
… executing slot (change 0453)

Apply reserveScopeDrive's staleness predicate in admitScopedWorktree's
executing arm before rotateWorktreeExecutionForSuccessor, so a second
successor sharing a now-stale receipt can never rotate (and later release)
the first successor's live slot. A scope load failure fails closed.

TestSuccessorAdmissionFailureLegsReleaseRotatedSlot pinned the old
rotate-then-release behavior for a stale receipt; it now asserts the
pre-rotation refusal and reaches the post-rotation release leg through a
scope closed between precheck and admission (epoch-gate seam).
…oses to a stale receipt (change 0453)

Review finding (important): two successors presenting the same predecessor
receipt P on an unserialized (epoch-less) scope could still free a live slot.
S2 passes the pre-rotation staleness guard while P is current and rotates the
slot to T (reserved); S1, holding the same receipt, finds a same-scope reserved
slot and adopts T, wins reserveScopeDrive, retires P, launches, and confirms T
executing. S2's reserveScopeDrive then refuses ErrStalePredecessor, which
isSameScopeRaceLoss does not cover, so S2 (rotated) released T and freed S1's
executing slot. A freshly reserving successor had the same leg.

admitScoped's reserveScopeDrive failure leg now also keeps the reservation when
siblingMayHoldReservation holds: the error is ErrStalePredecessor on a successor
AND the reloaded scope's PriorDriveID still names the receipt's drive (a sibling
consumed this receipt, necessarily by adopting T for a rotated start, possibly
still on its way to the scope slot), or the scope's current drive carries T as
its AdmissionToken (a later drive adopted T), or either record is unreadable.
Keeping is the fail-closed direction: a leaked reserved slot is adopted by the
scope's next start and blocks other admissions until recovery, while releasing
an adopted one frees a live slot. A stale receipt with neither sign still
releases, so a bogus receipt leaks nothing.

The admitScopedWorktree guard comment no longer claims the unlocked scope read
is sufficient: it excludes only a successor arriving after a sibling launched.

TestStaleSuccessorLeavesSiblingAdoptedReservation drives the interleaving
deterministically through a package-private scopedAdmissionHook (fired between
worktree admission and reserveScopeDrive): rotated and fresh sibling-adopts-
and-wins, a later drive adopting after the scope moved on (token clause only),
and an adopter still pending when S2 loses (PriorDriveID clause only). All four
subtests are red with the guard disabled; dropping either clause reddens
exactly its subtest.
…the pre-rotation guard (change 0453)

Review finding (minor, "duplicated-gate-copies-the-whole-predicate"): the
pre-rotation guard in admitScopedWorktree copied only the final staleness
clause of reserveScopeDrive's ordered predicate (capability, closed,
half-filled receipt, reserved ErrScopeBusy, pending ack
ErrUnresolvedLaunchTransition, then staleness). A closed scope was reported
to a late stale successor as ErrStalePredecessor instead of ErrScopeClosed,
and a receipt naming the current drive that failed an earlier clause (a
half-filled receipt, a closed scope, a wrong capability) passed the guard and
rotated the live slot before the authority refused it.

Extract the ordered checks into one pure helper, scopeReserveRefusal(rec,
childCapability, receipt, op), called by reserveScopeDrive under the scope
lock and by the guard on its unlocked snapshot; the guard refuses on any
non-nil result without touching the slot. Every clause is kept at the guard:
none can turn away a successor the authority would admit (capability hash is
immutable, close is one-way, the authority refuses a reserved or pending-ack
slot whatever the receipt, and the scope never moves back). The fail-closed
LoadScope error leg and siblingMayHoldReservation are unchanged.

TestSameScopeSuccessorGuardAppliesWholeReservePredicate covers each earlier
clause (closed, capability, reserved, pending ack with a stale receipt; a
half-filled and a closed receipt naming the current drive): typed refusal,
slot and scope record untouched, and parity with reserveScopeDrive's verdict
on the same snapshot. All six subtests are red on the single-clause guard.
TestSuccessorAdmissionFailureLegsReleaseRotatedSlot's post-rotation release
leg now closes the scope through scopedAdmissionHook (after the rotation),
since a scope closed before admission is now refused before rotating.
@danielhanold
danielhanold merged commit 1bb1964 into main Sep 25, 2026
7 checks passed
@danielhanold
danielhanold deleted the fix/two-successors-sharing-one-stale-predecessor-receipt-can-sti branch September 25, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant