Skip to content

fix(hydration): preserve merge-poll gate write across concurrent wave republish (#84) - #85

Merged
jaylann merged 4 commits into
stagefrom
fix/84-gate-clobber
Jul 5, 2026
Merged

fix(hydration): preserve merge-poll gate write across concurrent wave republish (#84)#85
jaylann merged 4 commits into
stagefrom
fix/84-gate-clobber

Conversation

@jaylann

@jaylann jaylann commented Jul 5, 2026

Copy link
Copy Markdown
Owner

What

Fixes #84 — the post-approval merge-readiness poll's gate write being clobbered by a concurrent CI hydration wave's batch republish, which briefly hides (or wrongly shows) the Merge button after an approval.

Root cause

prGates (the per-PR Approve/Merge action gate) is written by two @MainActor flows that interleave across await:

  • the hydration wave (drainChecks/fold/publishChecks), which refetches many PRs and republishes gates in batches;
  • refreshPRState, a single-PR gate refetch driven by the post-approve poll.

A wave that read a PR's gate before the poll's single-key write would republish the whole map and clobber it. The checksGeneration guard only catches cross-generation clobbers; this is a same-generation interleave. #82's .checksOnly path widened the window.

Approach — issue-time write-recency

The naive fixes fail: "poll always wins" shows a Merge button that 405s when the base advanced; a commit-time clock still loses because the wave's full fetch reads its gate early (from the PR detail) but folds late (after its slow checks/reviews legs), so a wave that observed older state commits last and wrongly wins.

Fix: a monotonic issue-time clock (gateWriteClock + prGateSeq). Both writers take a tick when they issue their fetch — the wave stamps a per-full-fetch issue seq up front; the poll ticks before its detail request and defers to any concurrently-issued newer write. publishChecks merges gates onto the live map per key, applying a wave gate only when its issue tick beats the live one. So the observation that read the newer server state wins in both clobber directions, regardless of commit order. Checks-only carry-overs and failed fetches don't compete (they keep the live gate rather than blanking it).

prGateSeq is pruned alongside prGates at every mutation/reset site. A documented, bounded, self-healing residual remains (a whole wave shares one issue instant, so a mid-wave poll beats a late-queued key) — noted in code; per-key stamping would need a main-actor hop per fetch and isn't worth it for a transient gate flip.

Tests

Three deterministic GatedGitHubAPI regression tests pinning the interleavings by issue order:

just check clean, full suite (269 tests) green. Reviewed over three rounds (multi-agent + single-agent); final review passed with no blocking findings.

Closes #84

jaylann added 4 commits July 5, 2026 15:00
… republish (#84)

The merge-readiness poll (`refreshPRState`) writes a freshly-unblocked gate
directly to `prGates` under the current `checksGeneration`. A hydration wave
that snapshotted `prGates` before that write republishes the whole map at
publish time, clobbering the poll's write back to the stale "blocked" gate and
re-hiding the Merge button — the exact UX the poll exists to fix. The
`checksGeneration` guard only catches cross-generation clobbers; this is a
same-generation interleave across `await` suspension points.

Track keys the poll writes in `mergePollGateWrites` (cleared at each wave
start) and have `publishChecks` overlay their live values onto the batch before
the whole-map assign, so the poll's write survives. Skips keys the wave pruned.

Adds a deterministic `GatedGitHubAPI`-driven regression test.

Closes #84
…ays-wins (#84)

Addresses code-review findings on the first cut. The Set-based overlay
preferred the merge poll's gate for a key over the *whole* generation, which
(1) let a stale poll "mergeable" resurrect over the wave's fresher full-fetch
"not-mergeable" (Merge shown → 405 on click), and (2) conflated a failed
concurrent fetch with a prune, dropping the poll's fresh gate.

Replace it with a monotonic gate write-clock (`gateWriteClock`/`prGateSeq`):
both the poll's single-key write and the wave's fresh full-fetch stamp their
keys. `publishChecks` now merges gates onto the live map per key, applying a
wave gate only when it was freshly re-fetched AND its tick beats the live one —
so the most recent writer wins in both directions. A checks-only carry-over or
a failed fetch is not "fresh", so it leaves the live gate untouched instead of
clobbering or blanking it.

Adds a second regression test for the symmetric case (fresher wave fetch beats
a staler poll write).
…me (#84)

Second-review follow-up. The commit-time write-clock still reproduced #84 in the
primary post-approval case: the wave's full fetch issues its detail early (reads
the still-stale "blocked"), then folds *after* its slow checks/reviews legs — so
it commits after the poll's faster single-request "clean" write and, stamped at
fold time, wrongly wins and re-hides Merge.

Stamp the clock when each fetch is *issued* instead of when it folds/commits, for
both writers: the wave ticks a per-full-fetch issue seq up front, and the poll
ticks before its detail request and defers to any concurrently-issued newer write.
Now the observation that read the newer server state wins regardless of commit
order, in both clobber directions.

Tests: replace the two prior cases with three that pin the interleavings by
issue order — checks-only carry-over, later-issued poll beats earlier-issued wave
full-fetch (the primary #84 trigger; verified it fails under commit-time
stamping), and later-issued wave fetch beats earlier poll write.
@jaylann
jaylann merged commit b11c56c into stage Jul 5, 2026
9 checks passed
@jaylann
jaylann deleted the fix/84-gate-clobber branch July 5, 2026 15:27
jaylann added a commit that referenced this pull request Jul 5, 2026
Merge of stage (#85) added a required gateIssueSeq to fold(); the GraphQL batch
path now ticks nextGateWriteSeq() per PR before issuing the batch and passes it
when folding, so a batch-hydrated gate obeys the same merge-poll write-preserve
ordering as the REST drain (#84). Fixes the CI build/test break from the stale base.
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.

Merge-readiness poll gate write can be clobbered by a concurrent hydration wave's whole-map republish

1 participant