Skip to content

Run one merge poll chain at a time in the browser (#48) - #175

Merged
mchwang merged 127 commits into
mainfrom
feat/48-browser-polling
Oct 17, 2026
Merged

mchwang merged 127 commits into
mainfrom
feat/48-browser-polling

Conversation

@mchwang

@mchwang mchwang commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Part of #48: the browser part. The server part merged as #171. This PR was stacked on F6b (#161), which rewrote merge polling in web/public/app.js; F6b is now merged (b4a1977) and this PR targets main.

Defects

Both were reproduced on F6b at 2a9534d:

  • Poll ordering. renderMerge() calls scheduleMergePoll() on every render. While a poll was in flight its timer was already cleared, so any render (for example selecting another item) started a second poll chain in the same mergeGeneration. Nothing ordered the two answers, so an older "queued" answer could be applied after a newer "merged" one, and the control went back to "Merge queued".
  • Stale errors. In the same way, an older poll's error could replace a newer confirmed merge in the banner.

Fix

One merge poll chain at a time (web/public/app.js):

  • mergePollInFlight = { generation, sequence } is set when a poll starts. scheduleMergePoll() starts nothing while a poll of the current generation is in flight; that poll reschedules itself when it settles.
  • A settling poll clears the marker only if it is still the latest, and applies its answer or error only if it is the latest and its generation is current. An older generation's late poll therefore neither applies nor releases the newer generation's guard.
  • Applicability is decided once, so an error thrown while applying a current answer is still reported and polling continues.
  • The merge click also clears the old poll timer, like every other generation change. This is defensive: a click needs ready, which excludes a running job or an active attempt, so no timer is pending then (traced, not tested; removing it fails no test).

docs/implementation/merge-queue.md describes the single poll chain.

Traced, not tested:

  • The page sets no timeout of its own on a poll; the server bounds /api/merge.
  • A re-render during a merge-job poll takes the same guard.

Tests

  • test/browser/issue-48-poll-ordering.spec.ts:
    • A re-render while a poll is held starts no second chain, and the page ends on "Merged".
    • An older generation's late poll, holding a distinguishable "merged" answer, is never applied, and a re-render then starts no third poll.
  • test/browser/issue-48-stale-errors.spec.ts:
    • A held error starts no second chain, and polling recovers to "Merged".
    • An unusable current answer (200 with a null body) is reported, and polling continues.

Break checks:

  • On F6b's original app.js, the specs fail with 2 polls where 1 is expected.
  • Clearing the marker unconditionally fails the cross-generation spec.
  • Ignoring sequence and generation applies the old "merged" answer and fails it.
  • Settling twice in the catch fails the unusable-answer spec.

F6b merged into main as squash commit b4a1977. 6744f95 brings that in: since the squash duplicates F6b's commits, the merge takes main's tree and re-applies this PR's own diff (0857fa2→792581a) on top with git apply -3; its diff against main is exactly this PR's four files, identical to the diff it had against F6b. 121769c strengthens one assertion and a later commit labels the click's timer clear defensive (below).

Validation

Current head 47d496b, base main b4a1977 (47d496b changes a comment and a doc sentence only since 121769c):

Gate State
Full browser suite (test/browser, one worker) 194/194 pass on 6744f95; the cross-generation spec passes on 121769c
npm run typecheck pass on 6744f95
CI test pass on 47d496b

Earlier checkpoints: 8bdeaf9 on F6b cffdf4c; d21cb0e on F6b 7b1b5fa (CI test pass; Copilot 2 clean); ec2e2e4 on F6b 2a9534d (browser 102/102, CI test pass).

Review record

Round Reviewer Base / head Result
1 Independent agent 2a9534d / uncommitted Found: an error after accepting an answer was swallowed and polling stopped; spec titles and headers described the old interleaving; no cross-generation test; the merge click left the old timer; docs. All fixed.
2 Independent agent 2a9534d / uncommitted The cross-generation spec could not see a stale answer applied; an assertion inside a route handler could hang. Fixed and break-checked.
3 Independent agent 2a9534d / uncommitted Clean; one nit (late "not applied" check), applied.
4 Independent agent 2a9534d / uncommitted (committed unchanged as ec2e2e4) Clean
Copilot 1 copilot-pull-request-reviewer 2a9534d / ec2e2e4 0 open findings; approval recommended.
5 Independent agent 445081f / 63b5525 (merge of updated F6b) Clean. Checked the guard against F6b's new click branches, the doc conflict resolution, and the async spec port. Not a defect: the job-poll path still has no browser test (listed under "Traced, not tested").
6 Independent agent 7b1b5fa / ff4aac9 (F6b test fixes merged in; PR diff byte-identical) One low finding: after the item-selection re-render cleared the banner, the "still not applied, however late" check looked only at the banner. Fixed in d21cb0e: it also checks the control still reads "Merge queued". With settle() applying every answer, the test fails.
7 Independent agent 7b1b5fa / d21cb0e Clean. With the earlier checks removed and settle() applying every answer, the new check alone fails. Removing the generation check fails three existing plans.spec.ts tests.
Copilot 2 copilot-pull-request-reviewer 7b1b5fa / d21cb0e Requested 2026-10-15 after round 7 was clean and CI passed on d21cb0e. 0 open findings; approval recommended.
8 Independent agent 82661d6 / fb8915b (F6b's Copilot 5 fixes merged in: job observations advance the observation generation, newer job records survive full responses, the retry key records its job) Clean. A job poll advances the observation generation only after settle() accepts it; a full response that keeps a newer job record starts no second applied chain.
9 Independent agent 82661d6 / fb8915b Clean (fresh full pass).
10 Independent agent 0eb8a21 / 7e62e34 Clean; PR diff byte-identical to fb8915b's.
11 Independent agent 45bff7a / de8af78 Clean; PR diff byte-identical.
12 Independent agent cffdf4c / 8bdeaf9 Clean on code and tests; flagged this body's stale Validation section, updated here.
13 Independent agent b4a1977 / 6744f95 (new base main) Code clean: the one-chain guard holds with F6b's final browser code (generation bumps clear the timer; the job→queue handoff, a render during a pending click and a refused click's follow each start one chain; every observation passes settle()). Findings: this body was stale (fixed here); the cross-generation spec's last assertion could not fail, since the control already read "Merge queued" before poll 2 answered — fixed in 121769c by waiting for a third poll (fails with 2 polls when a settled answer stops the chain).
14 Independent agent b4a1977 / 121769c Code and specs clean; mutation checks of the guard, settle's sequence check, current ??= settle() and the new third-poll wait each fail a spec (settle's generation check fails three plans.spec.ts tests). One finding: the merge click's timer clear is untested and unreachable today (a click needs ready). Labelled defensive in the code, merge-queue.md and this body.
15 Independent agent b4a1977 / 47d496b Clean. The defensive reasoning holds against the server (withMergeJob, #withAttempt, statusAfterMerge) and every browser path that replaces data.merge; mutation checks match each break-check claim.
Copilot 3 copilot-pull-request-reviewer b4a1977 / 47d496b Requested after round 15 was clean and CI passed on 47d496b. Blocked: Copilot did not review — the requesting user's Copilot review quota is used up. Not a clean result; re-request once quota is available.
Copilot 4 copilot-pull-request-reviewer b4a1977 / 47d496b Re-requested 2026-10-17 on the same clean head. No open issues; approval recommended.

Review-lesson audit

  • A second poll chain started by a render: covered by AGENTS.md "Never apply a background response without proving it is still current", and the race "Poll starts, then a user action completes, then the old poll returns".

  • The swallowed error and stopped polling: covered by "Preserve the original timeout, cancellation, and shutdown reason through every layer. Do not replace actionable errors…".

  • Specs that did not assert their interleaving, or could hang: covered by "A race regression must assert that the interleaving it names actually happened" (added in Keep merge stop reasons and GitHub answers through every layer (#48) #171).

  • The merge click timer and docs: covered by "Treat every behavioural claim … as something to verify".

  • An assertion on a banner that a re-render had already cleared (round 6): covered by "A race regression must assert that the interleaving it names actually happened" and the Design system and plan format #1 rule of E2E tests.

No new rules.

🤖 Generated with Claude Code

mchwang and others added 30 commits October 9, 2026 09:18
prepare-merge now pushes a rebased head whose review is still clear over
exactly the PR head it rewrote, before running command checks. A schema
v18 marker (tasks.push_in_progress) is written before the first external
write and cleared only after a read of the branch settles the outcome;
while it exists, merge, runner admission, status changes, rebases and
other pushes refuse. Preparation and a tracked startup job settle an
interrupted push. GitBranchPusher gains an exact expected-head lease and
a branch read; remotePair reports the task PR's branch.

Part of #22 (F6a).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Push settlement reads now abort only on shutdown, so a cancel can no
longer strand the marker; the in-run read is bounded by the remaining
preparation deadline. Startup settlement runs under the verified runner
lock and closes the PRs of a task it closed. After a push, preparation
waits (bounded) until GitHub's PR API reports the pushed head, and a move
seen then is reviewed from the pushed head. Adds regressions for each,
plus production push wiring and server startup wiring tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub can report a moved base while its PR API still shows the pre-push
head. The visibility wait now ends only when the head changes, so the
review is never refreshed back onto a head the branch no longer holds.
The test remote's branch read honours its abort signal, so a cancel that
stopped push settlement would fail a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The visibility wait starts no inspection after 30 seconds, so it lasts at
most 30 seconds plus one inspection. Say so in the code and the docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cancel requested while no preparation runs and a push marker remains
now starts the tracked settlement job, so the task and its PRs close
without a restart. After a refused push, preparation waits for GitHub's
PR API to report the branch's new head before reviewing it, as it does
after a push. Renames settleAtStartup to settlePendingPush and corrects
the architecture doc's not-built list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A preparation that fails waiting for GitHub to show the pushed head now
binds its failure to the post-push task state, so it is not reported as
stale at once. The startup-settlement server test now builds the state a
crash leaves (a push marker plus a pending cancel) instead of having the
fake job cancel the task itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Repeating a cancel that waits on an unsettled push now reads the branch
again instead of refusing, so the task can close without a restart.
Adds push-boundary regressions for a pending cancel and a changed
snapshot, and documents the retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a push or a refusal, a moved PR head is now reviewed by tracing
history from the reviewed head and its rewrite lineage, nearest first,
so an earlier rewrite codeboost already pushed is reviewed against its
own base instead of the original one. Any failure after the push or
refusal is now bound to the post-push task state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds regressions for a plan revision applied at the push boundary (only
the task state version changes, so that check is load-bearing), for the
visibility wait ending at the preparation deadline, for the settlement
read's deadline bound, and for shutdown aborting a startup settlement.
States in the docs that a killed push may still land later, harmlessly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds regressions for a cancel during the next preparation's push
settlement, attempt admission and reassignment refusing while a push is
in flight, and shutdown starting no branch read. The push-boundary
cancel test now cancels through the coordinator while the push reads the
remote, as production does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed push's settlement read can take up to 30 seconds. The failure
now adopts the task state version that clearing the marker produced only
when nothing else wrote during the read; otherwise it stays bound to the
claim's version, so a plan revision applied meanwhile marks it stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Success, refusal and settlement now share one rule: the version clearing
the push marker produced counts only when nothing else wrote since the
claim; otherwise a later failure stays bound to the claim's version, so
a plan revision applied while the push was in flight marks it stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the pushed head appears, preparation now requires the task state,
review version and snapshot to be exactly what the push left before any
cmd: check runs, so a plan revision or review edit applied during the
push fails it instead of being adopted. The publish guard returns to its
rebase-only form: publishing cannot overlap a push by status, which the
docs now state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…serve settlement

- The push claim uses the task state version captured before the
  asynchronous trust refresh, so a reassignment during it refuses.
- Production returns the last guard as the pusher's synchronous
  finalizer, closing the gap between validation and the Git push.
- A settlement read keeps the process-settlement reserve inside its
  limit and does not start with less than that reserve left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the preparation's own deadline ends the push, less than Git's
settlement reserve is left, so no branch read starts and the marker
stays, as for an unreadable branch. The docs, the code comment and the
test now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pre-merge rebase moves the PR's base past base commits the pre-PR
already-fixed check never saw. Merge inspections of a runner-published
PR now scan from the base of the snapshot the last clear check ran on,
excluding the ledger's owned commits. Adds the merge-handoff design.

Part of #22 (F6b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a runner, Merge PR now starts one merge job and returns. The job
prepares (refresh, rebase, push, cmd: checks), waits up to 30 minutes
for GitHub's required checks on the pushed head (pending or missing
waits, a failure stops at once), then hands the exact pair to the
guarded merge. A failing or slow check, or GitHub's refusal, leaves the
task approved, merge blocked; review needs and other blockers end the
job for a person. Jobs live in schema v19 merge_jobs, so their progress
never moves the task state version that readiness is bound to; startup
fails an interrupted job. The merge status offers Merge PR while only
blockers the job resolves remain, and the browser follows the job.

Part of #22 (F6b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The job passes its signal into the guarded merge, so shutdown or a cancel
stops it as it stops a click. A task cancelled while the job runs ends
the job cancelled however the job learns of it. Adds regressions for the
queued outcome, a busy refusal, retry after a blocked job, startup
settlement and shutdown wiring; corrects the design doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The #22 acceptance cases through the job now run with a runner-published
PR, so the guarded merge checks the readiness real preparation records.
Another blocker now outranks a failing check, so checks failing on a
collaborator's head end the job failed rather than blocked. Shutdown
stops a merge job in its first synchronous step, before requests drain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Openings now record the clear already-fixed check they followed (schema
v19), and the merge's scan starts at that check's base, so a re-publish
from a rebased base cannot skip earlier base commits. Once the job's
merge attempt exists, its recorded state decides the job's outcome, so a
refusal that lands during shutdown still blocks. Adds server regressions
for the cancel wiring and the settlement hook that closes PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The job saves its merge action ID before the guarded merge starts, so a
restart settles it from its merge attempt. A pre-v19 opening scans from
the latest clear check. A merge conflict caused by a moved base no longer
disables Merge PR: the job's rebase resolves it. Moves a misplaced
comment back to its method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F6b is the first code to move a task to approved, merge blocked; start
now accepts it as it accepts a task in review, so an amended plan can fix
what blocked the merge. The cancel tests now cancel the task before
telling the job, and the server test's preparation holds a real push
marker, as production does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revert starting an amended plan from approved, merge blocked: a blocked
task always has runner commits, so the existing guard refuses that start
from review too; the doc now points to the scope-amendment flow. Blocking
now ends the job and moves the task in one Store transaction. Adds
regressions that a retried job is never decided by an earlier refused
attempt, in process or at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge's already-fixed scan now starts at the base-branch tip the
PR's own pre-PR check scanned through, the tightest sound start. The
check's 250-commit cap still fails closed for long-lived PRs, as it did
before any rebase; the docs say so, and that failing required checks
keep Merge PR available. Adds tests that blocking advances the task
state version and that a click during a running preparation is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A pre-v19 opening scans from the task's oldest clear check: a compare
  range excludes its start, so only that start is provably continuous.
- A definite admission refusal is saved under the click's action ID and
  replayed on resend; only a shutdown refusal stays resendable.
- No required-check inspection starts after the 30-minute limit, and one
  running into it is stopped, so a job never merges after its limit.
- While a job runs, the focused Merge button stays enabled and marked
  aria-disabled; a repeat activation sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refusal saved for a second click while a job ran became the newest
merge_jobs row, so the browser saw the running job as ended and Merge PR
was offered again. latestMergeJob now skips refused rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge route admitted a job without the trust read prepare-merge
performs. It now reads issue access first: an untrusted click is
refused and the refusal saved; a failed read saves nothing.

Docs: the wait's limit bounds which checks count, not when GitHub is
asked to merge; merge_jobs also holds refused clicks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mchwang and others added 16 commits October 15, 2026 10:03
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mport does meanwhile

A run admitted during a merge job's preparation or check wait (after a plan
import and approval) moved the head and published under the job. Runner
admission now refuses, as prepare-merge does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onse job guard

A render while a Merge PR click's request is in flight (selecting an item, a plan reload) re-derived the control from
merge.ready and offered "Merge PR" again. A click-pending flag now keeps it marked and labelled "Requesting merge…"
until the request settles.

Add browser regressions for the newerMergeJob guards at the review action, Plans refresh, plan import and Apply draft
reload sites, and give the two no-reload checks time for a background reload to arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k's latest run

GitHub counts a required check run that completed NEUTRAL or SKIPPED as passing; the inspection read both as failure,
which now ends a merge job blocked. When a check has several runs with one name (a re-run), the first listed won, so an
older failure could outrank a newer pending or passing run. The run that started last now counts (a run not started yet
counts as the latest; on a tie, the one that passes least).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the limit's own abort reason classifies an inspection as the wait timing
out; a stage failure that settles after the limit ends the job failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecord

A full review response held across another tab's click no longer replaces a
running newer job with an earlier ended one: jobs carry a sequence, and a kept
running job keeps Merge PR not ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the page learns a merge job ended (its poll, the click's answer, or a
newer record kept over an older full response), the review it holds was read
before or during the job and still lists preparation, base, cmd: check,
required-check and mergeability blockers. Drop those, keep the job-refresh
blocker and unrelated ones, and label the control with the outcome, as a
merge-queue merge reads. A job's Retry confirmation no longer promises the
exact reviewed head: it prepares again and may rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e server's list

An ended merge job hid preparation, base, acceptance, check and mergeable
blockers whatever its outcome, though a blocked, failed, review-required or
cancelled job settled none of them for certain (a failed job reports only
its first blocker, so a merge conflict stayed hidden until Refresh). Now
only merged, queued and submitted jobs, which ask GitHub to merge after no
blocker remains, drop them; every other outcome keeps the blockers the page
last read.

The browser no longer keeps its own copy of the server's JOB_RESOLVES: the
merge status carries settledBeforeJobMerge, built from JOB_RESOLVES plus
mergeable, and a unit test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the browser test made

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mchwang
mchwang changed the base branch from codex/f6b-merge-handoff to main October 17, 2026 01:39
mchwang and others added 3 commits October 16, 2026 18:40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/implementation/merge-handoff.md
#	github/merge.ts
#	runner/merge-job.ts
#	runner/merge.ts
#	runner/store.ts
#	test/browser/review.spec.ts
#	test/merge-job.test.ts
#	test/merge-task-pr.test.ts
#	test/merge.test.ts
#	test/runner-production.test.ts
#	test/runner-publishing.test.ts
#	web/public/app.js
#	web/server.ts
The control already read "Merge queued" before poll 2 answered, so the check proved nothing; a third poll shows poll 2's answer was applied and the one chain continued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mchwang
mchwang marked this pull request as ready for review October 17, 2026 02:51
@mchwang
mchwang requested a balanced review from Copilot October 17, 2026 02:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The ownership guards address the reproduced races, with focused regression coverage and no unresolved findings.

No open issues 🎉

Review effort: Balanced

@mchwang
mchwang merged commit 04b9973 into main Oct 17, 2026
2 of 3 checks passed
@mchwang
mchwang deleted the feat/48-browser-polling branch October 17, 2026 23:43
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.

2 participants