Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
127 commits
Select commit Hold shift + click to select a range
12e96ba
F6a: push the rewritten head behind a durable marker
mchwang Oct 9, 2026
a63e5cf
Settle pushes cancel-safely and wait for the pushed head
mchwang Oct 9, 2026
b1eaaae
Wait for the pushed head even when the base moves
mchwang Oct 9, 2026
9e98ad7
State the pushed-head wait bound exactly
mchwang Oct 9, 2026
50ba765
Settle a stranded push on cancel; wait for the PR head after a refusal
mchwang Oct 9, 2026
e5ce306
Bind a post-push visibility failure; test startup settlement faithfully
mchwang Oct 9, 2026
496d4b3
Retry push settlement on a repeated cancel; test the push boundary
mchwang Oct 9, 2026
d0639c9
Trace a moved PR head through the whole rewrite lineage
mchwang Oct 9, 2026
6ab346d
Test the push boundary's state check and every settlement bound
mchwang Oct 9, 2026
0be7462
Cover cancel-safe settlement, admission and shutdown reads
mchwang Oct 9, 2026
7cc5477
Test that a failed push is reported as current
mchwang Oct 9, 2026
cedd594
Bind a failed push to the state its own settlement left
mchwang Oct 9, 2026
a0ecaf0
Bind every push exit to the state that push left
mchwang Oct 9, 2026
fc26627
Refuse checks after a push if the task changed while it was in flight
mchwang Oct 9, 2026
365cda0
Describe the pusher's expected-head rule in its doc comments
mchwang Oct 9, 2026
87dd3bf
Test the push claim's admission guards and the busy settlement skip
mchwang Oct 10, 2026
58e63e0
Address Copilot: capture the claim version, finalize at Git start, re…
mchwang Oct 10, 2026
9e1f695
State that a deadline-ended push leaves its marker unread
mchwang Oct 10, 2026
3ade421
F6b: scan for an earlier fix from the last clear check's base
mchwang Oct 12, 2026
4a3d23e
F6b: one-click merge job with a required-check wait
mchwang Oct 12, 2026
7cc6ab2
Stop the job's guarded merge on shutdown; end cancelled at every step
mchwang Oct 12, 2026
79b7fd5
Test the readiness hand-off for real; stop jobs as shutdown begins
mchwang Oct 12, 2026
e9575aa
Scan from the PR's own pre-PR check; settle a job from its attempt first
mchwang Oct 12, 2026
ffb995e
Settle a restarted job from its attempt; offer merge for a base conflict
mchwang Oct 12, 2026
922007f
Let a merge-blocked task start an amended plan; fix cancel test setups
mchwang Oct 12, 2026
def4b0a
Block job and task together; decide a job only from its own attempt
mchwang Oct 12, 2026
19d5abc
Scan from where the pre-PR check stopped; document the 250-commit limit
mchwang Oct 12, 2026
18fc568
Address Copilot: continuous scan, replayed refusals, strict wait, focus
mchwang Oct 12, 2026
727f279
Keep saved merge refusals out of the task's latest job
mchwang Oct 12, 2026
db189ae
Admit issue trust before a merge job starts; correct two doc claims
mchwang Oct 12, 2026
841e395
Say exactly when shutdown stops a merge job's preparation
mchwang Oct 12, 2026
fc57d20
Correct the task status the merge-handoff doc gives for queued and fa…
mchwang Oct 12, 2026
76c81f2
Test that a retried job's queue leaves the task approved, merge blocked
mchwang Oct 12, 2026
368495c
Keep the Merge button focused when a merge-job click is refused; qual…
mchwang Oct 12, 2026
9e0ee39
Draw an aria-disabled Merge button as inactive
mchwang Oct 12, 2026
1234091
Update only the merge control when a merge job ends
mchwang Oct 12, 2026
ab74aec
Scan for an earlier fix from the task's first clear check, whichever …
mchwang Oct 12, 2026
be4f391
Scan from the first opening into the current base branch
mchwang Oct 12, 2026
f6eb190
Scan from the task's original base when the first opening's check did…
mchwang Oct 12, 2026
887f0cd
Explain correctly why the already-fixed scan covers a rebase's commits
mchwang Oct 12, 2026
7302d68
Reflow the already-fixed range comment
mchwang Oct 12, 2026
b3ba376
Block a task only with its still-running job, and test the shared tra…
mchwang Oct 12, 2026
4ea8724
Test a queued attempt at startup and a failed GitHub read while waiting
mchwang Oct 12, 2026
3293278
Test that shutdown awaits a merge job before the store closes
mchwang Oct 12, 2026
2e3e860
Test that closing the merge job waits for it to settle
mchwang Oct 12, 2026
777c9b5
Name the pre-v19 fallback in the guarded-merge scan description
mchwang Oct 12, 2026
143b5f6
Merge origin/main (22e76a8) into F6a
mchwang Oct 12, 2026
93af176
Name schema version 18 in the architecture doc
mchwang Oct 12, 2026
0180256
Address Copilot: block only while readiness holds; shutdown answers 503
mchwang Oct 12, 2026
7180aad
Fail the job when GitHub refuses a changed head; answer 503 when shut…
mchwang Oct 12, 2026
cbacd19
Name the shutdown reload test for what it checks
mchwang Oct 12, 2026
f3bc3e1
Address Copilot: a branch read never waits on an escaped pipe holder
mchwang Oct 12, 2026
2c5ccc3
Merge F6a (f3bc3e1, with main 22e76a8) into F6b
mchwang Oct 12, 2026
97b6305
Address Copilot: bound only escaped pipe holders on branch reads
mchwang Oct 12, 2026
1b81ee9
Address Copilot: closed tasks never offered, refusals bound to their …
mchwang Oct 12, 2026
230e5b6
Say which branch-read case the settlement bound excludes
mchwang Oct 12, 2026
33450c8
Name both unbounded subprocess cases, and an inspection's full settle…
mchwang Oct 12, 2026
7604a79
Name the review-change case in the merge-handoff shutdown row
mchwang Oct 12, 2026
57b7ccb
Bound cat-file reads like ls-remote; correct stale rebaser doc line
mchwang Oct 12, 2026
d61da98
Merge F6a (57b7ccb) into F6b
mchwang Oct 12, 2026
749a977
Address Copilot: review a collaborator head found after an unknown pu…
mchwang Oct 12, 2026
8b36586
Test admission guards; say a retry after a refused merge and a rebase…
mchwang Oct 12, 2026
1874e6a
Merge F6a (749a977) into F6b
mchwang Oct 12, 2026
ad00767
Test the retry after a refused merge and a rebase through real prepar…
mchwang Oct 12, 2026
8c3b171
Word the replacement-review blocker by its cause
mchwang Oct 12, 2026
702d11e
Address Copilot: recheck the push binding before reviewing a refused …
mchwang Oct 13, 2026
da0204a
Address Copilot: new key after a job ends, keep in-flight drafts, ans…
mchwang Oct 13, 2026
f7754db
Test the binding check after the fetch; document the stale outcome
mchwang Oct 13, 2026
a734646
Test that the binding check before the fetch runs first
mchwang Oct 13, 2026
c265333
Merge F6a (a734646) into F6b
mchwang Oct 13, 2026
2a9534d
Drop a retained merge key once another job is the latest
mchwang Oct 13, 2026
ec2e2e4
Run one merge poll chain at a time in the browser (#48)
mchwang Oct 14, 2026
7fa9e07
Merge origin/main (#170, #171, #172) into F6a
mchwang Oct 14, 2026
a23ac78
Refuse a push whose post-rebase review reload adopted a change (F6a, …
mchwang Oct 14, 2026
9578d8c
Guard every preparation reload and report its refusals as stale (F6a,…
mchwang Oct 14, 2026
df8966a
Bind preparation failures as current or stale by their cause (F6a, #170)
mchwang Oct 14, 2026
0069d8f
Refuse a command check whose context changed after it settled (F6a, #…
mchwang Oct 14, 2026
aa34f36
Give the preparation reload rules their own doc section
mchwang Oct 14, 2026
a752482
Restore the blank line before the push table
mchwang Oct 14, 2026
1ac194c
Merge F6a (with main: #170, #171, #172) into F6b
mchwang Oct 14, 2026
7717bf8
Settle merge job admission and reload races introduced by #170 (F6b)
mchwang Oct 15, 2026
0605a85
Follow a started job when its click cannot reload the review (F6b)
mchwang Oct 15, 2026
3b74ef7
Ask for a refresh when a click's merge job has already ended (F6b)
mchwang Oct 15, 2026
445081f
Give the ended-job browser test a runner server's merge view
mchwang Oct 15, 2026
63b5525
Merge the updated F6b (with F6a and main) into PR B
mchwang Oct 15, 2026
79ed50c
Wait for the Merge button before pressing it in the refused-click foc…
mchwang Oct 15, 2026
9c4f845
Merge F6b 79ed50c into the #48 browser fixes
mchwang Oct 15, 2026
7fe156d
Give a second merge request time to arrive before counting clicks (F6b)
mchwang Oct 15, 2026
093c0df
Merge F6b 7fe156d into the #48 browser fixes
mchwang Oct 15, 2026
7b1b5fa
Await the merge job's admission in the check-wait limit test (F6b)
mchwang Oct 15, 2026
ff4aac9
Merge F6b 7b1b5fa into the #48 browser fixes
mchwang Oct 15, 2026
d21cb0e
Check the merge control, not the cleared banner, after the late old a…
mchwang Oct 15, 2026
19a1049
Refuse a rewritten-head push once shutdown has begun, and kill every …
mchwang Oct 15, 2026
14c2942
Answer merge-job clicks from durable records after failed reads and u…
mchwang Oct 15, 2026
fbde7d4
Keep a retry's key past an earlier job and newer job observations pas…
mchwang Oct 15, 2026
7ffd084
Scope the shutdown comment on the push failure path to its own read
mchwang Oct 15, 2026
82661d6
Merge F6a 7ffd084 (shutdown push guard) into F6b
mchwang Oct 15, 2026
fb8915b
Merge F6b 82661d6 into the #48 browser fixes
mchwang Oct 15, 2026
0eb8a21
Log a merge job that could not be ended, and document that it stays r…
mchwang Oct 15, 2026
7e62e34
Merge F6b 0eb8a21 into the #48 browser fixes
mchwang Oct 15, 2026
45bff7a
Never reject a merge job's run, log quoted, and test the restart that…
mchwang Oct 15, 2026
de8af78
Merge F6b 45bff7a into the #48 browser fixes
mchwang Oct 15, 2026
96cdee5
Merge main 209707e (F6a #153 squash) into F6b
mchwang Oct 15, 2026
cffdf4c
Log, not reject, when following a settled merge job fails; test the s…
mchwang Oct 15, 2026
8bdeaf9
Merge F6b cffdf4c (now on main) into the #48 browser fixes
mchwang Oct 15, 2026
715329b
Test that the wait between required-check inspections doubles to its cap
mchwang Oct 15, 2026
f684da4
Describe the merge_jobs table as stored: id, plan_key and a JSON record
mchwang Oct 15, 2026
b721ca6
Scan from the original base for a pre-v19 opening, never another bran…
mchwang Oct 15, 2026
d1f99fc
Pin that publish is refused while a merge job runs, and document why
mchwang Oct 15, 2026
a0b954a
Merge F6b d1f99fc into the #48 browser fixes
mchwang Oct 15, 2026
94c3557
Refuse start and resume while a merge job runs, and state what plan i…
mchwang Oct 15, 2026
25444dd
Describe getAlreadyFixed as the look-up by check ID it is
mchwang Oct 15, 2026
d948c5a
Merge F6b 25444dd into the #48 browser fixes
mchwang Oct 15, 2026
d2de1b3
Keep the Merge button pending across renders, and pin every full-resp…
mchwang Oct 15, 2026
c1a329d
Count neutral and skipped required checks as passing, and read a chec…
mchwang Oct 15, 2026
de892bf
Keep a GitHub failure that precedes the required-check wait limit
mchwang Oct 15, 2026
fbfe4ce
Order merge jobs by a per-task sequence when keeping a poll's newer r…
mchwang Oct 15, 2026
d5f844a
Merge F6b fbfe4ce into the #48 browser fixes
mchwang Oct 15, 2026
105ea74
Show a merge job's outcome, not the blockers it resolved, once it ends
mchwang Oct 15, 2026
6b20cf3
Merge F6b 105ea74 into the #48 browser fixes
mchwang Oct 15, 2026
a76b453
Drop job-settled blockers only after a job reached the merge, from th…
mchwang Oct 15, 2026
f1d3b35
Merge F6b a76b453 into the #48 browser fixes
mchwang Oct 15, 2026
0857fa2
Pin the served settled-codes field on a runner server, not on a body …
mchwang Oct 15, 2026
792581a
Merge F6b into the #48 browser fixes
mchwang Oct 15, 2026
6744f95
Merge main b4a1977 (F6b #161 squash) into the #48 browser fixes
mchwang Oct 17, 2026
121769c
Wait for the next poll before the cross-generation spec's last check
mchwang Oct 17, 2026
47d496b
Label the merge click's poll-timer clear defensive
mchwang Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/implementation/merge-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Every update compares the current attempt ID and legal source state. A delayed p

The coordinator owns at most one enqueue and one shared queue inspection. Cancellation does not release either operation until its GitHub call settles. One fourteen-second deadline covers every sequential validation, queue-correlation, and submission stage rather than restarting for each remote call. Shutdown rejects every new API request at admission and rechecks irreversible work after partially received request bodies, then gives admitted requests a bounded fourteen-and-a-half-second drain. At the deadline it aborts request-scoped status inspections, destroys requests still blocked on partial bodies, and cancels coordinator work before waiting for server closure and closing SQLite, so an admitted request or queue poll cannot deadlock teardown.

`GET /api/merge` is the narrow polling path for queue-backed attempts. It reads only the persisted attempt and the K1 queue observation; it does not reload Git history or reconstruct the review. Ambiguous direct submissions remain disabled and reconcile through a full review refresh instead of repeatedly calling the queue endpoint. The browser patches only the merge control and status banner, so current selection, scroll, code attachment, and composer drafts remain unchanged. Queue polling starts at two seconds, doubles while the same active state persists, and caps at thirty seconds; a lifecycle transition or explicit user action resets the interval. A review action advances the merge-poll generation, preventing an older response from re-enabling retry against pre-action review state. The action stays disabled while submitting or queued and after confirmed merge. Removal or failure exposes the reason but keeps retry disabled until a full review refresh revalidates current GitHub blockers. Retry is then offered only for an unchanged reviewed context, and the full gate is revalidated twice before another enqueue.
`GET /api/merge` is the narrow polling path for queue-backed attempts. It reads only the persisted attempt and the K1 queue observation; it does not reload Git history or reconstruct the review. Ambiguous direct submissions remain disabled and reconcile through a full review refresh instead of repeatedly calling the queue endpoint. The browser patches only the merge control and status banner, so current selection, scroll, code attachment, and composer drafts remain unchanged. Queue polling starts at two seconds, doubles while the same active state persists, and caps at thirty seconds; a lifecycle transition or explicit user action resets the interval. A review action advances the merge-poll generation, preventing an older response from re-enabling retry against pre-action review state. One merge poll runs at a time (#48): a render while a poll is in flight starts no second chain, and only the latest poll's answer or error is applied, and only while its generation is current. An older generation's poll that returns late neither applies nor releases the newer generation's poll. The page sets no timeout of its own on a poll; the server bounds `/api/merge` with its own read deadlines (traced, not tested). The merge click also clears the poll timer, as every generation change does; that is defensive, because a click needs `ready`, which excludes a running job or an active attempt (traced, not tested). The action stays disabled while submitting or queued and after confirmed merge. Removal or failure exposes the reason but keeps retry disabled until a full review refresh revalidates current GitHub blockers. Retry is then offered only for an unchanged reviewed context, and the full gate is revalidated twice before another enqueue.

Transient or incomplete GitHub observations leave the attempt active, surface an observation error, and continue polling rather than clearing the browser's active attempt. Submitting attempts use unknown-outcome wording until GitHub proves that the attempt is queued. Only a validated queued, merged, removed, or failed observation changes durable state. Queue rules count as the server-side current-base guard; adapters without queue inspection continue to fail closed.

Expand Down Expand Up @@ -51,4 +51,4 @@ The status cache in `GhMergeGateway` is cleared before and after every merge com

Controlled unit and browser regressions cover enqueue success, delayed merge, queue removal, unmergeable failure, head replacement, retry, stale observation publication, active-attempt retry refusal, shutdown settlement, durable restart recovery, and preservation of UI input while polling. Final evidence is recorded against the exact pushed PR head.

#48 regressions: `test/merge-stop-reasons.test.ts` (stop reasons and resendable timeouts; a saved success answered after the click's own abort with no further load or GitHub read; output past the limit while the pipes drain still fails the command; an outcome saved meanwhile answered after a timeout and this click's own abort; gh's answer kept when shutdown finds writes closed; one inspection stage's gh exit reported after its stopped siblings settle, including past the deadline, with no unhandled rejection; a caller abort answered in place of the already-fixed check's own earlier gh failure), `test/merge-queue-poll.test.ts` (a failed, timed-out or gh-answered queue read answers from the current attempt; GitHub's refusal survives a later shutdown, saved or not; the click's answer is inactive, also when a poll ends the attempt before the answer is built; an issue-trust timeout answers 503, alone or before the merge deadline, and a gh answer before the deadline is saved and replayed), `test/merge-status-cache.test.ts` (cache evidence; these passed before the fixes), `test/pre-merge.test.ts` and `test/runner-publishing.test.ts` (a preparation's or a publish's trust read that timed out or that gh answered before shutdown is saved or recorded as failed with that answer, not as the shutdown), and `test/runner-start.test.ts` (a runner action's timed-out trust read is not saved and answers 503, also when shutdown started while gh stopped; a saved outcome still wins). The browser's duplicate merge polls are fixed separately on top of F6b, which rewrites that polling.
#48 regressions: `test/merge-stop-reasons.test.ts` (stop reasons and resendable timeouts; a saved success answered after the click's own abort with no further load or GitHub read; output past the limit while the pipes drain still fails the command; an outcome saved meanwhile answered after a timeout and this click's own abort; gh's answer kept when shutdown finds writes closed; one inspection stage's gh exit reported after its stopped siblings settle, including past the deadline, with no unhandled rejection; a caller abort answered in place of the already-fixed check's own earlier gh failure), `test/merge-queue-poll.test.ts` (a failed, timed-out or gh-answered queue read answers from the current attempt; GitHub's refusal survives a later shutdown, saved or not; the click's answer is inactive, also when a poll ends the attempt before the answer is built; an issue-trust timeout answers 503, alone or before the merge deadline, and a gh answer before the deadline is saved and replayed), `test/merge-status-cache.test.ts` (cache evidence; these passed before the fixes), `test/pre-merge.test.ts` and `test/runner-publishing.test.ts` (a preparation's or a publish's trust read that timed out or that gh answered before shutdown is saved or recorded as failed with that answer, not as the shutdown), and `test/runner-start.test.ts` (a runner action's timed-out trust read is not saved and answers 503, also when shutdown started while gh stopped; a saved outcome still wins). The #48 browser regressions (`test/browser/issue-48-poll-ordering.spec.ts`, `test/browser/issue-48-stale-errors.spec.ts`) cover one poll chain under a re-render, a held error, an older generation's late poll, and an unusable current answer; a re-render during a merge-job poll takes the same guard (traced, not tested).
124 changes: 124 additions & 0 deletions test/browser/issue-48-poll-ordering.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { test, expect } from '@playwright/test';
import { chmodSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDemo } from '../../scripts/demo.ts';
import { startServer } from '../../web/server.ts';
import type { MergeGateway, MergeQueueGateway } from '../../github/merge.ts';
import { fixtureGit } from '../fixtures/git.ts';

// Issue #48, poll ordering (browser): a merge-queue poll answer computed before a newer poll answer must not be
// applied after it. Before the fix, any render() (for example selecting another item) while a poll was in flight
// scheduled a second poll chain with the same mergeGeneration, so two polls could be outstanding and nothing ordered
// their answers. Now one poll chain runs at a time.
let root: string, app: Awaited<ReturnType<typeof startServer>>;
test.beforeEach(async () => { root=mkdtempSync(join(tmpdir(),'codeboost-browser-'));app=await startServer(createDemo(join(root,'demo')),0); });
test.afterEach(async () => { await app.close();rmSync(root,{recursive:true,force:true}); });

test('a re-render while a merge-queue poll is held starts no second poll chain, so no older answer follows a newer one',async({page})=>{
const config={...app.service.config,demo:false};await app.close();
chmodSync(join(config.repository,'run.sh'),0o644);fixtureGit(config.repository,'commit','-am','Restore declared scope');
let appRef:typeof app,phase:'queued'|'merged'='queued';
const gateway:MergeGateway&MergeQueueGateway={
inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},
queueWatermark:async()=>null,
merge:async()=>({url:'https://github.com/example/repo/pull/24'}),
inspectQueue:async head=>phase==='merged'?{state:'merged',reviewedHead:head,mergedAt:'2026-09-24T08:10:00Z'}:{state:'queued',reviewedHead:head,entryId:'MQE_1',phase:'QUEUED',position:1,enqueuedAt:'2026-09-24T08:00:00Z',queueHead:head},
};
app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load();
for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});
for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token});

// Poll 1: the server answers (queued) at once, but delivery of that answer to the page is held. Later polls pass.
let release!:()=>void,arrived!:()=>void;
const held=new Promise<void>(resolve=>release=resolve),started=new Promise<void>(resolve=>arrived=resolve);
let polls=0,heldState:string|undefined;
await page.route('**/api/merge',async route=>{
const n=++polls;
if(n===1){const response=await route.fetch();const body=await response.json();heldState=body.queue?.state;arrived();await held;await route.fulfill({response,json:body});return;}
await route.continue();
});
await page.goto(app.url);page.on('dialog',dialog=>dialog.accept());
await page.getByRole('button',{name:'Merge PR',exact:true}).click();
await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled();
try{
await started;
expect(heldState).toBe('queued');
// GitHub now confirms the merge, so any later answer is newer than the held one.
phase='merged';
// Selecting another item re-renders while poll 1 is in flight. It must not start a second poll chain, whose newer
// answer the held older one could then overwrite (the first poll delay is 2 s; wait past it).
await page.getByRole('button',{name:/P2 Document retry behavior/}).click();
await page.waitForTimeout(3000);
expect(polls).toBe(1);
// The held answer is still the latest one issued, so it applies; the next poll then reports the merge.
release();
await expect(page.getByRole('button',{name:'Merged',exact:true})).toBeDisabled({timeout:10000});
await expect(page.locator('#banner')).toContainText('GitHub confirmed the reviewed head was merged.');
expect(polls).toBe(2);
// Nothing older can arrive later and move the control back.
await page.waitForTimeout(2500);
expect(await page.locator('#merge').textContent()).toBe('Merged');
}finally{release();}
});

// The poll in flight belongs to its generation: after a refresh starts a new generation and its own poll, the old poll's
// late answer is discarded and must not release the new generation's single-chain guard (AGENTS.md: "Poll starts, then
// a user action completes, then the old poll returns").
test('an older generation poll returning late neither applies nor lets a re-render start a second chain',async({page})=>{
const config={...app.service.config,demo:false};await app.close();
chmodSync(join(config.repository,'run.sh'),0o644);fixtureGit(config.repository,'commit','-am','Restore declared scope');
let appRef:typeof app;
const gateway:MergeGateway&MergeQueueGateway={
inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},
queueWatermark:async()=>null,
merge:async()=>({url:'https://github.com/example/repo/pull/24'}),
inspectQueue:async head=>({state:'queued',reviewedHead:head,entryId:'MQE_1',phase:'QUEUED',position:1,enqueuedAt:'2026-09-24T08:00:00Z',queueHead:head}),
};
app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load();
for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});
for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token});
// Polls 1 (old generation) and 2 (new generation) are held; any later poll is counted and passes.
const releases:Array<()=>void>=[],started:Array<()=>void>=[];
const starts=[0,1].map(index=>new Promise<void>(resolve=>started[index]=resolve));
const holds=[0,1].map(index=>new Promise<void>(resolve=>releases[index]=resolve));
let polls=0;
await page.route('**/api/merge',async route=>{
const n=++polls;
if(n<=2){
const response=await route.fetch();const body=await response.json();
// The old generation's answer says "merged", so applying it would be visible; the current generation's stays queued.
if(n===1&&body.queue)body.queue={...body.queue,state:'merged',mergedAt:'2026-09-24T08:10:00Z'};
started[n-1]!();await holds[n-1];await route.fulfill({response,json:body});return;
}
await route.continue();
});
await page.goto(app.url);page.on('dialog',dialog=>dialog.accept());
await page.getByRole('button',{name:'Merge PR',exact:true}).click();
await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled();
try{
await starts[0];
// A refresh starts a new merge generation; its own poll (2) starts after the poll delay and is held too.
await page.getByRole('button',{name:'Refresh',exact:true}).click();
await starts[1];
expect(polls).toBe(2);
// The old generation's poll now returns; it must not apply, nor clear the new generation's in-flight poll.
releases[0]!();
await page.waitForTimeout(300);
// Not applied: the control still shows the current generation's queued state.
await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled();
await expect(page.locator('#banner')).not.toContainText('confirmed the reviewed head was merged');
// A re-render now must not start a third poll while poll 2 is in flight (wait past the 2 s poll delay).
await page.getByRole('button',{name:/P2 Document retry behavior/}).click();
await page.waitForTimeout(3000);
expect(polls).toBe(2);
// Still not applied, however late the old answer arrived. The re-render cleared the banner, so the control is the
// evidence: an applied "merged" answer would show "Merged".
await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled();
await expect(page.locator('#banner')).not.toContainText('confirmed the reviewed head was merged');
releases[1]!();
// The current generation's poll applies its answer and continues the one chain: a third poll follows.
await expect.poll(()=>polls,{timeout:15_000}).toBe(3);
await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled();
}finally{releases.forEach(release=>release());}
});
Loading
Loading