From 12e96bacf377cfc039610cbd1715a49a1485771a Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 09:18:47 -0700 Subject: [PATCH 001/163] F6a: push the rewritten head behind a durable marker 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 --- docs/architecture.md | 8 +- docs/implementation/pre-merge-rebase.md | 17 ++- runner/branch-push.ts | 32 ++++- runner/merge.ts | 13 +- runner/pre-merge.ts | 121 ++++++++++++++++- runner/production.ts | 8 +- runner/store.ts | 109 +++++++++++++-- test/merge-task-pr.test.ts | 19 +++ test/planning-drafts.test.ts | 4 +- test/pre-merge.test.ts | 172 +++++++++++++++++++++--- test/publish.test.ts | 2 +- test/runner-branch-push.test.ts | 27 ++++ test/runner-lifecycle-store.test.ts | 74 +++++++++- test/runner-publishing.test.ts | 4 +- test/store.test.ts | 2 +- web/server.ts | 2 + 16 files changed, 552 insertions(+), 62 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a107e91d..7c294ce4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,8 +42,8 @@ Writing standard: plain language, ISO 24495-1:2023 - **Build state:** - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, then refreshes attribution and approvals and runs exact-head `cmd:` checks; required-check refresh, the already-fixed rerun, rewritten-head push, and guarded merge handoff remain for F6. + - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. + - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks; required-check refresh, the already-fixed rerun, and the one-click guarded merge handoff remain for F6b. - **Not built:** screens for the runner actions, remote push/check refresh, review rounds, and the Queue and Learning screens. ## Terms used @@ -410,7 +410,7 @@ These are the decisions that shape the structure. The full list, with the reason | C, K | Guarded merge, merge queue | Done | | D | Agent isolation boundary | Done. Since the isolation gate: bounded diff export (#76), task-change inspection and the runner's commit as a bounded `git bundle` (#66, PRs #85 and #90), owner-scoped recovery for Ask (#95), the plan schema for Claude (#97), gitlink mounts and the pre-launch tree check (#81, #99, PR #100), and Codex refused in every phase (#93, PR #106). The runner-contract follow-ups (#51) are closed: the last one tests export and removal through a recovery handle after a real restart (PR #109). | | E | Planning library and suggestions | Done. E4 (PR #45) added the acceptance fixtures and recorded Claude output that replays through validation and the Store. | -| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). This branch adds F5: current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6 owns rewritten-head push, fresh required-check refresh, the already-fixed rerun, and exact-pair guarded-merge handoff. | +| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. This branch adds F6a: the durable push of the rewritten head. F6b owns fresh required-check refresh, the already-fixed rerun, and the one-click exact-pair guarded-merge handoff; F6c owns common CI. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | | G | Planning screen | Done (#145). The screen displays/imports the current plan, requests and renders a next-revision draft or suggestion cards, and applies results with revision-bound exact ambiguous replay. G4 composes the browser with the production planning setup, D-backed provider boundary, F endpoints and SQLite persistence; focused adapter, recorded-output and exact-head real-Docker planning suites cover the lower boundary. | @@ -419,7 +419,7 @@ These are the decisions that shape the structure. The full list, with the reason ## Known limits -- `prepare-merge` invokes the trusted rebase and sandboxed conflict machinery, refreshes the review, and runs head-bound `cmd:` checks. Until F6 of #22 lands, it does not push a rewritten head, wait for fresh required checks, rerun the already-fixed check, or hand the exact pair to guarded merge. +- `prepare-merge` invokes the trusted rebase and sandboxed conflict machinery, refreshes the review, pushes the rewritten head, and runs head-bound `cmd:` checks. Until F6b of #22 lands, it does not wait for fresh required checks, rerun the already-fixed check, or hand the exact pair to guarded merge; a person clicks Merge once GitHub's checks on the pushed head pass. - Linking is line-based. It cannot see an unrelated edit inside a declared file; only the review agent and you can catch that. - The container's guarantees are those of Docker. The agent can always reach its own vendor account. - `cmd:` acceptance checks run during `prepare-merge`, and their passing evidence is bound to the exact reviewed head. A plan item without current passing evidence still blocks merging. diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 157f6732..ae0d2c56 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -30,7 +30,22 @@ All other conflicts are a review outcome (`RebaseConflict`). Cancellation and fa Startup first validates every stored marker and child identity. A recovered `spawning` marker is ambiguous because the runner may have crashed before the synchronous spawn callback recorded the child. A recovered exact process group is also ambiguous even after its leader or numeric group disappears: the runner may have crashed before an escaped output-pipe holder was durably marked `unsettled`, and that process need not use the workspace. Recovery requests identity-bound termination for an exact live group when its process controller can provide it; the host controller instead fails closed on a live reusable numeric group. Once no live exact group blocks the transition, recovery changes either ambiguous owner to `unsettled` and keeps admission closed. The operator must stop any unknown process and run `--release-rebase-process ` under the runner lock. Release revalidates the complete runner/owner/rebases chain as owner-only plain directories before it inspects the attempt leaf, and it refuses any visible workspace user; preparation release applies the same check to the attempts chain. Lane D then removes this runner's labelled agent containers, proxies, and networks and returns authenticated storage handles. The production recovery adapter accepts only the configured plan key, then removes the matching worktree. It deletes a result ref only with Git's exact-old-value guard using the durably recorded `resultHead`; absence is already-clean success. A `prepared` marker preserves a different pre-existing value, while a `ready` marker fails closed if its ref changed. A legacy, pre-result, or reused-attempt marker without an intended value preserves any existing ref. An exact child storage identity is removed after the parent workspace and before either marker clears; a partial match fails closed. A live run whose child cleanup is uncertain retains the parent workspace instead of racing those resources. Only after cleanup succeeds does `Store.abortRebase` compare-and-swap the same attempt ID to clear the marker. A missing adapter, malformed marker, wrong plan, recovered or unknown process, cleanup failure, unsafe ancestor, workspace user, or changed marker blocks startup and retains the durable ownership evidence. -This ordering is intentionally local-only. The later push integration must extend the durable lifecycle before its first external write and reconcile an ambiguous push outcome; it must not reuse this local completion point as proof that GitHub moved. +This ordering is intentionally local-only. The push below extends the durable lifecycle before its first external write and reconciles an ambiguous outcome by reading the branch; it never treats this local completion point as proof that GitHub moved. + +## Push of the rewritten head (F6a) + +`prepare-merge` pushes a rewritten head only when the refreshed review has no blocker: every item approved, no ambiguous or unplanned change, no open change request. A rewrite that made an approval stale stays local until a person reviews it; a later preparation recognizes the unpushed rewrite through the durable rewrite lineage. + +| Step | What happens | +|---|---| +| Admission | Issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | +| Last local check | Immediately before the write, trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. | +| Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | +| Refused | Nothing was pushed. The marker clears, the PR head is reread and reviewed, and preparation returns `review-required`. | +| Unknown outcome | A failure, timeout or cancel during the push reads the branch once (30-second bound): `to` means pushed, `from` means not pushed, anything else means moved. The marker then clears. A cancel requested during the push closes the task at that point. | +| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. The next preparation, or the tracked startup job after restart, reads the branch and clears it; that preparation then asks for a reload. | + +A push left running by a crashed process cannot overwrite anything newer: its lease still names `from`. ## Regression evidence diff --git a/runner/branch-push.ts b/runner/branch-push.ts index 32fccb48..84272e2f 100644 --- a/runner/branch-push.ts +++ b/runner/branch-push.ts @@ -123,12 +123,13 @@ export class GitBranchPusher implements BranchPusher { await this.#git(['update-ref', `refs/codeboost/remote-commits/${commit}`, commit], signal); } - async push(identity: PlanIdentity, input: { head: string; branch: string; beforePush?: MutationBoundary }, signal?: AbortSignal): Promise { + async push(identity: PlanIdentity, input: { head: string; branch: string; expected?: string; beforePush?: MutationBoundary }, + signal?: AbortSignal): Promise { signal?.throwIfAborted(); if (identity.repositoryId !== this.#config.repositoryId) throw new Error('The task belongs to another repository.'); if (!COMMIT_ID.test(input.head)) throw new Error('A full commit ID is required.'); - if (!BRANCH.test(input.branch) || input.branch.length > 255) throw new Error('Only a codeboost/ task branch can be pushed.'); - const ref = `refs/heads/${input.branch}`; + if (input.expected !== undefined && !COMMIT_ID.test(input.expected)) throw new Error('A full commit ID is required.'); + const ref = this.#ref(input.branch); // Git answers " missing" for an absent object; any other failure (no repository, permissions) keeps Git's error. const found = await this.#git(['cat-file', '--batch-check=%(objectname) %(objecttype)'], signal, false, Buffer.from(`${input.head}\n`)); if (found === `${input.head} missing`) throw new Error(`The runner repository has no commit ${input.head}.`); @@ -136,9 +137,17 @@ export class GitBranchPusher implements BranchPusher { const remote = await this.#read(ref, signal); // Already there: a push whose outcome was lost, or a retry. Nothing to do. if (remote === input.head) return; - const owned = new Set(this.#config.ownedCommits(identity)); - if (remote !== null && !owned.has(remote)) - throw new BranchPushRefused(`The branch ${input.branch} holds commit ${remote}, which codeboost did not make. Nothing was pushed.`); + // `expected` is the exact head a pre-merge rebase rewrote (F6 of #22). Its commits, a collaborator's included, live on + // in the rewritten head, so it alone may be overwritten: any other value, even a commit codeboost made, refuses. + if (input.expected !== undefined) { + if (remote !== input.expected) + throw new BranchPushRefused(`The branch ${input.branch} holds ${remote ?? 'nothing'}, not the rebased head ${input.expected}. Nothing was pushed.`); + } else { + // Read after the remote branch, every time, so a ledger written meanwhile is seen. + const owned = new Set(this.#config.ownedCommits(identity)); + if (remote !== null && !owned.has(remote)) + throw new BranchPushRefused(`The branch ${input.branch} holds commit ${remote}, which codeboost did not make. Nothing was pushed.`); + } signal?.throwIfAborted(); const finalize = await input.beforePush?.(); finalize?.(); @@ -155,6 +164,17 @@ export class GitBranchPusher implements BranchPusher { if (after !== input.head) throw new Error(`After the push, the branch ${input.branch} is at ${after ?? 'nothing'}, not ${input.head}.`); } + /** The commit the task branch points at on the remote, or null when it does not exist. */ + async readBranch(identity: PlanIdentity, branch: string, signal?: AbortSignal): Promise { + if (identity.repositoryId !== this.#config.repositoryId) throw new Error('The task belongs to another repository.'); + return this.#read(this.#ref(branch), signal); + } + + #ref(branch: string): string { + if (!BRANCH.test(branch) || branch.length > 255) throw new Error('Only a codeboost/ task branch can be pushed.'); + return `refs/heads/${branch}`; + } + /** The commit `ref` points at on the remote, or null when it does not exist. */ async #read(ref: string, signal?: AbortSignal): Promise { const out = await this.#git(['ls-remote', '--refs', '--', this.#url, ref], signal, true); diff --git a/runner/merge.ts b/runner/merge.ts index e46f0e7e..e1eae8d6 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -162,8 +162,11 @@ export class MergeCoordinator { return (await this.#status(view, fresh, signal)).status; } - /** Fresh exact base/head pair for pre-merge preparation, using the same task-PR resolution as merge admission. */ - async remotePair(signal?: AbortSignal): Promise<{ base: string; head: string }> { + /** + * Fresh exact base/head pair for pre-merge preparation, using the same task-PR resolution as merge admission. `branch` + * is the task PR's head branch when the runner published it; only then may preparation push a rewritten head. + */ + async remotePair(signal?: AbortSignal): Promise<{ base: string; head: string; branch?: string }> { const resolved = this.#resolve(this.#attempt()); const remote = await this.gateway.inspect({ fresh: true, timeoutMs: 6_000, signal, ...(resolved.target ? { target: resolved.target } : {}) }); @@ -178,7 +181,7 @@ export class MergeCoordinator { const blockers = this.#publishedBlockers(remote, resolved); if (blockers.length) throw new GuardRefusal(blockers[0]!.message); } - return { base: remote.base, head: remote.head }; + return { base: remote.base, head: remote.head, ...(resolved.headBranch !== undefined ? { branch: resolved.headBranch } : {}) }; } async #status(view: ReviewView, fresh: boolean, signal?: AbortSignal): Promise<{ status: MergeStatus; resolved: ResolvedTarget }> { @@ -198,7 +201,9 @@ export class MergeCoordinator { if (this.service.store && this.service.config) { const task = this.service.store.getTask(this.service.config.identity); if (task.status !== 'merged' && !MERGEABLE_STATUSES.includes(task.status)) blockers.push({ code: 'task', message: `The task is ${task.status}; merge it from review.` }); - if (this.published?.requiresPreparation && view.expected.reviewVersion !== undefined + if (task.pushInProgress) blockers.push({ code: 'preparation', + message: 'A push of the rewritten head has not settled. Prepare the merge again to read its outcome.' }); + else if (this.published?.requiresPreparation && view.expected.reviewVersion !== undefined && !this.service.store.preMergeReady(this.service.config.identity, { stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index adb8bf8d..9248ecc3 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -6,13 +6,24 @@ import { GuardRefusal, MERGEABLE_STATUSES, settleWith, type ShutdownCapability } import { MAX_REBASE_TIMEOUT_MS, MIN_REBASE_CLEANUP_TIMEOUT_MS, MIN_REBASE_TIMEOUT_MS, RebaseResourcesUnsettled, type GitRebaser } from './rebase.ts'; import type { ReviewService } from './review.ts'; -import type { PreMergeReadiness, RebaseMarker } from './store.ts'; +import { BranchPushRefused } from './branch-push.ts'; +import type { PreMergeReadiness, PushMarker, PushOutcome, RebaseMarker } from './store.ts'; -export interface RemotePair { base: string; head: string } +/** `branch` is the task PR's head branch, present when the runner published the PR. */ +export interface RemotePair { base: string; head: string; branch?: string } export interface PreMergeRemote { inspect(signal?: AbortSignal): Promise; fetch(pair: RemotePair, signal?: AbortSignal): Promise; + /** + * Make the task branch point at `to`, leased to exactly `from`. Throws `BranchPushRefused` when nothing was pushed + * because the branch holds something else; any other failure leaves the outcome unknown. + */ + push(input: { branch: string; from: string; to: string; beforePush: () => void }, signal?: AbortSignal): Promise; + /** The commit the task branch points at on GitHub, or null when it does not exist. */ + readBranch(branch: string, signal?: AbortSignal): Promise; } +/** One bounded read of the branch settles a push whose outcome is unknown. */ +export const PUSH_SETTLEMENT_TIMEOUT_MS = 30_000; export interface PreMergeAuthorization { /** Complete the last external authorization read before a later external operation. */ refresh(): Promise; @@ -33,7 +44,7 @@ export const PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS = DEFAULT_PROCESS_SETTLEMEN /** Keep a full process-settlement window between live rebase expiry and its separately budgeted durable cleanup. */ const REBASE_CLEANUP_HANDOFF_RESERVE_MS = PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS; -/** Production preparation before F6: refresh, rebase, re-review, then exact-head command checks. */ +/** Production preparation: refresh, rebase, re-review, push the rewritten head, then exact-head command checks. */ export class PreMergeCoordinator { readonly service: ReviewService; readonly runner: RunnerCoordinator; @@ -44,7 +55,7 @@ export class PreMergeCoordinator { readonly commandSettlementReserveMs: number; readonly authorize?: (signal: AbortSignal) => Promise; readonly settle: (fn: () => T) => T; - #active: Promise | null = null; + #active: Promise | null = null; #abort: AbortController | null = null; #closing = false; #last: PreMergeResult | null = null; @@ -148,6 +159,34 @@ export class PreMergeCoordinator { return outcome; } + /** + * Settle a push whose outcome is unknown (a crash, shutdown or failure during the push) by reading the branch, then + * clear its marker. Returns null when no push is pending; throws when the branch could not be read. + */ + async settleInterruptedPush(signal?: AbortSignal): Promise { + const marker = this.service.store.getTask(this.service.config.identity).pushInProgress; + return marker ? this.#settlePush(marker, signal) : null; + } + /** + * After startup recovery: settle a push left by a crash or shutdown, as one tracked job that shutdown aborts and awaits + * and that a preparation waits behind. A failed read leaves the marker for the next preparation. + */ + settleAtStartup(): Promise { + this.assertStartable(); + const controller = new AbortController(); this.#abort = controller; + const active = this.settleInterruptedPush(controller.signal).catch(() => null) + .finally(() => { if (this.#active === active) { this.#active = null; this.#abort = null; } }); + this.#active = active; + return active; + } + async #settlePush(marker: PushMarker, signal?: AbortSignal): Promise { + const bounded = AbortSignal.timeout(PUSH_SETTLEMENT_TIMEOUT_MS); + const read = await this.remote.readBranch(marker.branch, signal ? AbortSignal.any([signal, bounded]) : bounded); + const outcome: PushOutcome = read === marker.to ? 'pushed' : read === marker.from ? 'not-pushed' : 'moved'; + this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(this.service.config.identity).planKey, marker.attemptId)); + return outcome; + } + #refresh(pair: RemotePair, remaining: () => number, priorHead?: string) { const historyOptions = () => ({ maxDurationMs: Math.min(30_000, remaining()) }); const view = this.service.load(historyOptions()); @@ -201,6 +240,51 @@ export class PreMergeCoordinator { if (!this.settle(() => this.service.store.abortRebase(this.service.store.getTask(identity).planKey, marker.attemptId))) throw new GuardRefusal('This rebase attempt no longer owns its durable marker.'); } + /** + * Push the reviewed, rewritten head over exactly the remote head it rewrote. The durable marker is written before the + * first external write and cleared only after a read of the branch settles the outcome; when that read is impossible + * (shutdown, or GitHub unreachable) the marker stays for the next preparation or startup. + */ + async #pushRewrite(remote: RemotePair, view: ReturnType, signal: AbortSignal, + authorize: () => Promise, + bind: (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair) => void): Promise<'pushed' | 'refused'> { + const identity = this.service.config.identity; + if (!remote.branch) throw new GuardRefusal('The pull request was not published by the runner, so its rewritten head cannot be pushed.'); + const reviewed = { revision: view.expected.revision, snapshotId: view.expected.snapshotId, + reviewVersion: view.expected.reviewVersion! }; + const authorization = await authorize(); + signal.throwIfAborted(); + const marker = this.service.store.beginPrePush(identity, reviewed, this.service.store.getTask(identity).stateVersion, + { branch: remote.branch, from: remote.head, to: view.snapshot.head }); + const binding = this.service.store.getTask(identity).stateVersion; + try { + await this.remote.push({ branch: marker.branch, from: marker.from, to: marker.to, beforePush: () => { + // The last local checks before the external write: trust, and no task or review change since the claim. + authorization?.validate(); + const task = this.service.store.getTask(identity); + if (task.stateVersion !== binding || task.cancelRequested !== null + || this.service.store.reviewVersion(identity) !== reviewed.reviewVersion + || this.service.store.getSnapshot(identity).id !== reviewed.snapshotId) + throw new GuardRefusal('The task or review changed before the rewritten head was pushed. Reload first.'); + signal.throwIfAborted(); + } }, signal); + } catch (error) { + if (error instanceof BranchPushRefused) { + this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(identity).planKey, marker.attemptId)); + bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }); + return 'refused'; + } + // Shutdown: no new GitHub read may start. Startup settles the marker. + if (!this.#closing) { + try { await this.#settlePush(marker); } + catch { /* The marker stays; the next preparation or startup reads the branch again. */ } + } + bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }); + throw error; + } + this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(identity).planKey, marker.attemptId)); + return 'pushed'; + } async #run(expected: { stateVersion: number; reviewVersion: number; snapshotId: string; actionId?: string }, signal: AbortSignal, deadline: number, onObserved: (value: { pair: RemotePair; binding: { stateVersion: number; reviewVersion: number; snapshotId: string } }) => void, onChecked: (checked: readonly string[]) => void): Promise { @@ -265,6 +349,16 @@ export class PreMergeCoordinator { throw new GuardRefusal('Runner cleanup is unresolved; restart and recover owned resources before preparing a merge.'); if (this.service.store.getTask(identity).rebaseInProgress !== null) throw new GuardRefusal('Rebase cleanup is unresolved; restart and recover the owned rebase before preparing a merge.'); + const pending = this.service.store.getTask(identity).pushInProgress; + if (pending) { + try { await this.#settlePush(pending, signal); } + catch (error) { + signal.throwIfAborted(); + throw new GuardRefusal(`The outcome of an earlier push of the rewritten head is unknown, and the branch could not be read: ${error instanceof Error ? error.message : String(error)}`); + } + // Settling advanced task state. The caller's binding is stale now; it reloads and starts again. + throw new GuardRefusal('An earlier push of the rewritten head was settled. Reload, then prepare the merge again.'); + } let view = load(), task = this.service.store.getTask(identity); if (task.stateVersion !== expected.stateVersion || view.expected.reviewVersion !== expected.reviewVersion || view.snapshot.id !== expected.snapshotId) throw new GuardRefusal('The review changed before preparation started. Reload first.'); @@ -282,10 +376,11 @@ export class PreMergeCoordinator { || this.service.store.getSnapshot(identity).id !== guard.snapshotId) throw new GuardRefusal('The task or review changed during preparation. Reload first.'); }; - const initial = await this.remote.inspect(signal); await this.remote.fetch(initial, signal); signal.throwIfAborted(); + let initial = await this.remote.inspect(signal); await this.remote.fetch(initial, signal); signal.throwIfAborted(); assertCurrent(expected); - // F6 will push the rewritten head. Until then, a retry must recognize the durable rewrite lineage instead of - // mistaking codeboost's still-remote predecessor for a collaborator push. + // A rewrite is pushed only once its review is clear. Until then (a review stop, or a failed or refused push), a retry + // must recognize the durable rewrite lineage instead of mistaking codeboost's still-remote predecessor for a + // collaborator push. const retainedRemoteHead = initial.head !== view.snapshot.head && this.service.store.isRewrittenHead(identity, initial.head, view.snapshot.head); if (initial.head !== view.snapshot.head && !retainedRemoteHead) { @@ -331,6 +426,18 @@ export class PreMergeCoordinator { const blocker = this.#reviewBlocker(view); if (blocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], reason: blocker }; + if (initial.head !== view.snapshot.head) { + const pushed = await this.#pushRewrite(initial, view, signal, authorize, bindRebaseFailure); + if (pushed !== 'pushed') { + // Refused: the branch holds neither the reviewed predecessor nor our head. Review whatever is there now. + const moved = await this.remote.inspect(signal); await this.remote.fetch(moved, signal); signal.throwIfAborted(); + view = track(this.#refresh(moved, reviewBudget, initial.head)); + return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], + reason: 'The pull request branch moved before the rewritten head could be pushed; attribution and approvals were refreshed.' }; + } + initial = { ...initial, head: view.snapshot.head }; + view = load(); + } const checked: string[] = []; for (const item of view.items.filter(candidate => candidate.acceptance.some(check => check.type === 'cmd'))) { signal.throwIfAborted(); diff --git a/runner/production.ts b/runner/production.ts index 6280032d..001e0112 100644 --- a/runner/production.ts +++ b/runner/production.ts @@ -317,8 +317,8 @@ export async function setUpRunner(o: { service: ReviewService; capability: Shutd runnerCommandLauncher({ imageId, runnerRoot: config.root, runnerOwner }), review.allowedCommands ?? [], runnerOwner)); const github = review.github; // Never a `url` here (#101 review, finding 6): the push goes to the configured repository on GH_HOST, from the runner's - // own repository. Only commits the ledger records as codeboost's may be overwritten. The F3 rebase foundation can - // write mapped foreign entries, but #22 does not wire their external push until its later durable-action slice. + // own repository. Publishing overwrites only commits the ledger records as codeboost's; pre-merge preparation overwrites + // only the exact head its durable rebase rewrote (F6 of #22). const pusher = new GitBranchPusher({ repository, repositoryId: identity.repositoryId, remote: github.repository, env: o.env as NodeJS.ProcessEnv, ownedCommits: requested => service.store.getLedger(requested).filter(entry => entry.origin === 'owned').map(entry => entry.sha) }); // The same environment as the push, so the PR calls go to the same GH_HOST with the same credentials. @@ -333,6 +333,10 @@ export async function setUpRunner(o: { service: ReviewService; capability: Shutd return new PreMergeCoordinator(service, runner, rebaser, { inspect, fetch: (pair, signal) => pusher.fetchCommits([pair.base, pair.head], signal), + // Leased to exactly the inspected head the rebase rewrote; the coordinator's durable marker brackets the write. + push: (input, signal) => pusher.push(identity, { head: input.to, branch: input.branch, expected: input.from, + beforePush: () => { input.beforePush(); } }, signal), + readBranch: (branch, signal) => pusher.readBranch(identity, branch, signal), }, undefined, o.capability, authorize); }; return { deps, sources, findings, recovery, publisher, env, preMerge }; diff --git a/runner/store.ts b/runner/store.ts index 3e359b50..56a9aa3f 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -92,7 +92,7 @@ export function preMergeActionResponse(result: PreMergeActionResult) { } export interface TaskRecord { planKey: string; status: TaskStatus; stateVersion: number; contextGeneration: number; assignmentId: string; referencedCodeHash: string; - currentAttemptId: string | null; requeuePending: boolean; cancelRequested: string | null; rebaseInProgress: unknown; budgetDeadline: number | null; + currentAttemptId: string | null; requeuePending: boolean; cancelRequested: string | null; rebaseInProgress: unknown; pushInProgress: PushMarker | null; budgetDeadline: number | null; createdAt: string; updatedAt: string; } export interface RebaseMarker { @@ -111,6 +111,24 @@ export interface RebaseMarker { conflict: { attemptId: string; allocationId: string; networkAllocationId: string; source: string } | null; processGroup: { pgid: number; startedAt: number; identity: string | null } | 'spawning' | 'unsettled' | null; } +/** + * A pre-merge push of the rewritten head (F6 of #22), recorded before the push's first external write. While it exists, + * GitHub may or may not hold `to`: nothing irreversible starts until a read of the branch settles the outcome. + */ +export interface PushMarker { + attemptId: string; + /** The task's PR branch on GitHub (`codeboost/...`). */ + branch: string; + /** The exact remote head read before the push; the push is leased to it. */ + from: string; + /** The reviewed, rewritten head being pushed. */ + to: string; + /** The snapshot whose head is `to`. */ + snapshotId: string; + startedAt: number; +} +/** What a read of the branch showed after a push: `to`, still `from`, or anything else. */ +export type PushOutcome = 'pushed' | 'not-pushed' | 'moved'; export interface AttemptRecord { id: string; kind: AttemptKind; phase: string; item: string | null; state: AttemptState; context: InvocationContext; deadline: number; firstReason: FirstReason | null; stopReason: StopReason | null; exitCode: number | null; signal: string | null; result: unknown; @@ -188,8 +206,8 @@ export class Store { this.#db.exec('PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;'); this.#transaction(() => { const version = this.#get('PRAGMA user_version')!.user_version as number; - if (![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].includes(version)) throw new Error('Unsupported store schema version.'); - if (version === 17) return; + if (![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].includes(version)) throw new Error('Unsupported store schema version.'); + if (version === 18) return; if (version === 0) this.#db.exec(` CREATE TABLE plans (key TEXT PRIMARY KEY, issue INTEGER NOT NULL, revision INTEGER NOT NULL, snapshot_id TEXT); CREATE TABLE revisions (key TEXT NOT NULL REFERENCES plans(key), revision INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(key,revision)); @@ -305,6 +323,12 @@ export class Store { this.#db.exec('ALTER TABLE attempts ADD COLUMN prompt_comments TEXT'); this.#db.exec('PRAGMA user_version=17'); } + // The durable marker of a pre-merge push of the rewritten head (F6 of #22). + if (version < 18) { + if (!this.#db.prepare('PRAGMA table_info(tasks)').all().some(column => column.name === 'push_in_progress')) + this.#db.exec('ALTER TABLE tasks ADD COLUMN push_in_progress TEXT'); + this.#db.exec('PRAGMA user_version=18'); + } }); } catch (error) { this.#db.close(); throw error; } } @@ -533,7 +557,7 @@ export class Store { const task = this.#task(key); if (this.#closed(task.status as TaskStatus)) throw new GuardRefusal(`The task is ${task.status}; it cannot be merged.`); if (task.cancel_requested !== null) throw new GuardRefusal('The task is being cancelled; it cannot be merged.'); - if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is in progress for this task.'); + this.#assertNoRewriteInFlight(task); if (!MERGEABLE_STATUSES.includes(task.status as TaskStatus)) throw new GuardRefusal(`The task is ${task.status}; merge it from review.`); if (this.#activeAttempt(key)) throw new GuardRefusal('An attempt is still active for this task; it cannot be merged.'); if (expectedTaskStateVersion !== null && task.state_version !== expectedTaskStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); @@ -760,6 +784,7 @@ export class Store { this.#expect(key, expected); this.#assertContextWritable(key); if (this.#task(key).rebase_in_progress !== null) throw new GuardRefusal('A claimed rebase is in progress; wait for it to settle.'); + if (this.#task(key).push_in_progress !== null) throw new GuardRefusal('A push of the rewritten head is in progress; wait for it to settle.'); return this.#applyRebase(identity, key, base, head, mappings); }); } @@ -791,6 +816,7 @@ export class Store { if (this.#activeAttempt(key)) throw new GuardRefusal('An attempt is still active for this task; it cannot be rebased.'); if (this.#activeMerge(key)) throw new GuardRefusal('A merge is in progress; wait for its outcome.'); if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is already in progress for this task.'); + if (task.push_in_progress !== null) throw new GuardRefusal('A push of the rewritten head is in progress for this task.'); const snapshot = this.getSnapshot(identity); if (snapshot.base !== input.oldBase || snapshot.head !== input.oldHead) throw new GuardRefusal('The reviewed base or head changed before the rebase started.'); @@ -966,6 +992,62 @@ export class Store { return snapshot; }); } + /** + * Claim the push of the reviewed, rewritten head before its first external write. `from` is the exact remote head + * read for the lease; it must be a durable rewrite predecessor of `to`, and `to` must be the current snapshot head. + */ + beginPrePush(identity: PlanIdentity, expected: ReviewState & { reviewVersion: number }, expectedTaskStateVersion: number, + input: { branch: string; from: string; to: string; attemptId?: string; startedAt?: number }): PushMarker { + sha(input.from); sha(input.to); + if (!/^codeboost\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.branch) || input.branch.length > 255) + throw new Error('Only a codeboost/ task branch can be pushed.'); + if (!Number.isSafeInteger(expected.reviewVersion) || expected.reviewVersion < 0) throw new Error('A current review version is required for pushing.'); + if (!Number.isSafeInteger(expectedTaskStateVersion) || expectedTaskStateVersion < 0) throw new Error('Invalid expected task state version.'); + const attemptId = input.attemptId ?? randomUUID(), startedAt = input.startedAt ?? Date.now(); + assertUuidV4(attemptId, 'Push attempt ID'); + if (!Number.isSafeInteger(startedAt) || startedAt < 0) throw new Error('Invalid push start time.'); + const key = identityKey(identity); + return this.#transaction(() => { + this.#expect(key, expected); + const task = this.#task(key); + if (task.state_version !== expectedTaskStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); + if (this.#closed(task.status as TaskStatus)) throw new GuardRefusal(`The task is ${task.status}; it cannot be pushed.`); + if (!MERGEABLE_STATUSES.includes(task.status as TaskStatus)) throw new GuardRefusal(`The task is ${task.status}; push it from review.`); + if (task.cancel_requested !== null) throw new GuardRefusal('The task is being cancelled; it cannot be pushed.'); + if (this.#activeAttempt(key)) throw new GuardRefusal('An attempt is still active for this task; it cannot be pushed.'); + if (this.#activeMerge(key)) throw new GuardRefusal('A merge is in progress; wait for its outcome.'); + this.#assertNoRewriteInFlight(task); + const snapshot = this.getSnapshot(identity); + if (snapshot.head !== input.to) throw new GuardRefusal('The reviewed head changed before the push started.'); + if (!this.isRewrittenHead(identity, input.from, input.to)) + throw new GuardRefusal('The pull request head is not a rewrite predecessor of the reviewed head.'); + const marker: PushMarker = { attemptId, branch: input.branch, from: input.from, to: input.to, snapshotId: snapshot.id, startedAt }; + this.#run('UPDATE tasks SET push_in_progress=? WHERE plan_key=?', encode(marker), key); + this.#touch(key); + return marker; + }); + } + /** + * Clear the matching push once a read of the branch settled its outcome. A cancel requested while the push ran closes + * the task now, as a settled rebase does. + */ + finishPrePush(planKey: string, attemptId: string): boolean { + assertUuidV4(attemptId, 'Push attempt ID'); + return this.#transaction(() => { + const task = this.#task(planKey); + const encoded = task.push_in_progress as string | null | undefined; + if (encoded === null || encoded === undefined || decode(encoded).attemptId !== attemptId) return false; + if (this.#run('UPDATE tasks SET push_in_progress=NULL WHERE plan_key=? AND push_in_progress=?', planKey, encoded).changes !== 1) return false; + if (task.cancel_requested !== null && !this.#closed(task.status as TaskStatus)) this.#closeTask(planKey, 'cancelled', task.cancel_requested as string); + else this.#touch(planKey); + return true; + }); + } + /** Pushes whose outcome no read has settled yet (a crash or shutdown during the push). */ + pushesInProgress(): { planKey: string; marker: PushMarker }[] { + return this.#db.prepare('SELECT plan_key, push_in_progress FROM tasks WHERE push_in_progress IS NOT NULL').all() + .map(row => ({ planKey: row.plan_key as string, marker: decode(row.push_in_progress) })); + } /** Clear only the matching rebase, after the live operation settles or recovery removes its resources. */ abortRebase(planKey: string, attemptId: string): boolean { assertUuidV4(attemptId, 'Rebase attempt ID'); @@ -1389,7 +1471,7 @@ export class Store { status TEXT NOT NULL CHECK (status IN (${list(TASK_STATUSES)})), state_version INTEGER NOT NULL DEFAULT 0, context_generation INTEGER NOT NULL DEFAULT 0, assignment_id TEXT NOT NULL, referenced_code_hash TEXT NOT NULL, - current_attempt_id TEXT, requeue_pending INTEGER NOT NULL DEFAULT 0, cancel_requested TEXT, rebase_in_progress TEXT, + current_attempt_id TEXT, requeue_pending INTEGER NOT NULL DEFAULT 0, cancel_requested TEXT, rebase_in_progress TEXT, push_in_progress TEXT, budget_deadline INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (plan_key, current_attempt_id) REFERENCES attempts(plan_key, id)); CREATE TABLE IF NOT EXISTS attempts ( @@ -1436,6 +1518,7 @@ export class Store { referencedCodeHash: row.referenced_code_hash as string, currentAttemptId: row.current_attempt_id as string | null, requeuePending: row.requeue_pending === 1, cancelRequested: row.cancel_requested as string | null, rebaseInProgress: row.rebase_in_progress === null ? null : decode(row.rebase_in_progress), + pushInProgress: row.push_in_progress === null || row.push_in_progress === undefined ? null : decode(row.push_in_progress), budgetDeadline: row.budget_deadline as number | null, createdAt: row.created_at as string, updatedAt: row.updated_at as string, }; } @@ -1677,7 +1760,7 @@ export class Store { if (task.state_version !== expectedStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); if (this.#closed(task.status as TaskStatus)) throw new GuardRefusal('A closed task never changes.'); if (this.#activeMerge(key)) throw new GuardRefusal('A merge is in progress; wait for its outcome.'); - if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is in progress for this task.'); + this.#assertNoRewriteInFlight(task); this.#run('UPDATE tasks SET assignment_id=?, referenced_code_hash=? WHERE plan_key=?', assignmentId, referencedCodeHash, key); this.#bumpContext(key); }); @@ -1692,11 +1775,17 @@ export class Store { if (this.#closed(task.status)) throw new GuardRefusal('A closed task never changes.'); if (this.#activeAttempt(key)) throw new GuardRefusal('An attempt is still active for this task.'); if (this.#activeMerge(key)) throw new GuardRefusal('A merge is in progress; wait for its outcome.'); - if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is in progress for this task.'); + this.#assertNoRewriteInFlight(task); this.#run('UPDATE tasks SET status=? WHERE plan_key=?', to, key); this.#touch(key); }); } + /** A rebase still owns local resources, or a push of its result may still move the PR head on GitHub. */ + #assertNoRewriteInFlight(task: Record): void { + if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is in progress for this task.'); + if (task.push_in_progress !== null && task.push_in_progress !== undefined) + throw new GuardRefusal('A push of the rewritten head is in progress for this task.'); + } /** GitHub may still merge the reviewed head while the latest merge attempt is submitting or queued. */ #activeMerge(key: string): boolean { const row = this.#get('SELECT data FROM merge_attempts WHERE key=? ORDER BY rowid DESC LIMIT 1', key); @@ -1736,7 +1825,7 @@ export class Store { if (task.cancel_requested !== null) throw new GuardRefusal('The task is being cancelled.'); if (this.#activeAttempt(key)) throw new GuardRefusal('An attempt is already active for this task.'); if (this.#activeMerge(key)) throw new GuardRefusal('A merge is in progress; wait for its outcome.'); - if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is in progress for this task.'); + this.#assertNoRewriteInFlight(task); // The code-writing task budget (null until execution starts) ends execution admission. Review checks have their // own exact-head deadline and must remain runnable after implementation ends. if (!reviewCheck && task.budget_deadline !== null && (task.budget_deadline as number) <= now) @@ -1870,7 +1959,7 @@ export class Store { if (merge && (merge.state === 'submitting' || merge.state === 'queued')) throw new GuardRefusal('A merge is in progress. Cancel the task after it finishes or fails.'); const active = this.#activeAttempt(key); - if (task.rebase_in_progress !== null) { + if (task.rebase_in_progress !== null || task.push_in_progress !== null) { if (task.cancel_requested !== null) throw new GuardRefusal('The task is already being cancelled.'); this.#run('UPDATE tasks SET cancel_requested=? WHERE plan_key=?', actionId, key); this.#touch(key); @@ -2081,7 +2170,7 @@ export class Store { if (this.#activeMerge(key)) throw new GuardRefusal('A merge is in progress; wait for its outcome.'); // Interrupted work waiting to be requeued, or a rebase in progress, is not finished work to publish. if (task.requeue_pending === 1) throw new GuardRefusal('The task has interrupted work waiting to be requeued.'); - if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is in progress for this task.'); + this.#assertNoRewriteInFlight(task); // A ready PR is finished work: every item of the current plan revision has a completed execute attempt. Checked here, // at every guarded publish write, so a plan revision that adds an item while a publish awaits GitHub refuses it. if (!draft) { diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index 89f44a90..4fc4bed5 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -313,6 +313,25 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.merged).toEqual([]); }); + it('gives pre-merge preparation the task PR\'s branch, the only one it may push a rewritten head to', async () => { + const { store } = published(), gh = github(store); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + await expect(merges.remotePair()).resolves.toEqual({ base: oid(1), head: oid(2), branch: BRANCH }); + }); + + it('names an unsettled push of the rewritten head as the preparation blocker', async () => { + const { store } = published(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + store.beginPrePush(identity, reviewed(), store.getTask(identity).stateVersion, { branch: BRANCH, from: oid(2), to: oid(12) }); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PREPARED_PUBLISHED); + const blockers = (await merges.status()).blockers.filter(blocker => blocker.code === 'preparation'); + expect(blockers).toEqual([{ code: 'preparation', + message: 'A push of the rewritten head has not settled. Prepare the merge again to read its outcome.' }]); + await expect(merges.merge('review-token')).rejects.toThrow(); + expect(gh.merged).toEqual([]); + }); + it('refuses pre-merge preparation for a pull request that is no longer open', async () => { const { store } = published(); const gh = github(store, () => ({ pullRequestState: 'CLOSED' })); diff --git a/test/planning-drafts.test.ts b/test/planning-drafts.test.ts index e7939170..56290fd1 100644 --- a/test/planning-drafts.test.ts +++ b/test/planning-drafts.test.ts @@ -170,7 +170,7 @@ it.each([9, 10])('migrates a v%i database to the current schema, reading its exi const migrated = open(); expect(migrated.getSuggestions(identity, id)).toMatchObject({ mode: 'suggest', state: 'pending' }); const db = new DatabaseSync(path); - try { expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); } finally { db.close(); } + try { expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); } finally { db.close(); } }); it('adds checkpoint bindings to v14 planning requests without losing their state', () => { const { store, path, open } = fixture(), id = store.beginSuggestions(identity, state(store), 'draft'); @@ -181,7 +181,7 @@ it('adds checkpoint bindings to v14 planning requests without losing their state expect(migrated.getDraft(identity, id).state).toBe('pending'); const db = new DatabaseSync(path); try { - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); expect(db.prepare('PRAGMA table_info(requests)').all().some(column => column.name === 'continuation')).toBe(true); } finally { db.close(); } }); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index e38b4a74..60b84fe5 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -14,8 +14,13 @@ import { RunnerCoordinator } from '../runner/coordinator.ts'; import type { InvocationHandle, InvocationResult } from '../agents/contract.ts'; import type { TaskWorkspace, WorkspaceRef } from '../runner/execution.ts'; import { MIN_REBASE_CLEANUP_TIMEOUT_MS, MIN_REBASE_TIMEOUT_MS, RebaseResourcesUnsettled } from '../runner/rebase.ts'; +import { BranchPushRefused } from '../runner/branch-push.ts'; const roots: string[] = [], services: ReviewService[] = []; +const BRANCH = 'codeboost/task-1'; +// A remote fake for tests whose path must never push or read the branch. +const unusedPush = { push: async () => { throw new Error('No push expected.'); }, + readBranch: async () => { throw new Error('No branch read expected.'); } }; vi.setConfig({ testTimeout: 15_000 }); afterEach(() => { for (const service of services.splice(0)) service.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -57,6 +62,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: operationTimeoutMs?: number; authorize?: (signal: AbortSignal) => Promise; reserves?: { processMs?: number; commandMs?: number }; + pushFailure?: (service: ReviewService, input: { from: string; to: string }) => Error; + branchRead?: (current: string) => string | null | Promise; } = {}) { const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-base-')); roots.push(root); const repository = join(root, 'repo'); fixtureGit(root, 'init', '-q', '-b', 'main', repository); @@ -159,7 +166,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: settled: Promise.resolve({ attemptId: input.attemptId, context: input.context, exitCode: commandExit ?? 0, signal: null, stdout: '', stderr: commandExit ? 'failed' : '' }), cancel() {} } satisfies InvocationHandle; }, context.allowedCommands, 'a'.repeat(32))); - let remoteReads = 0, remotePair = { base: onto, head }; + let remoteReads = 0, remotePair: { base: string; head: string; branch?: string } = { base: onto, head, branch: BRANCH }; + const pushes: { branch: string; from: string; to: string }[] = []; let authorizationChecks = 0; const authorize = options.authorize ?? (options.duringAuthorize ? async () => ({ refresh: async () => undefined, @@ -171,7 +179,16 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: remoteReads++; if (remoteReads > 1) options.duringFinalInspect?.(service, coordinator, remoteReads); return remotePair; - }, fetch: async () => undefined }, options.operationTimeoutMs, undefined, authorize, options.reserves); + }, fetch: async () => undefined, + push: async input => { + pushes.push({ branch: input.branch, from: input.from, to: input.to }); + input.beforePush(); + if (options.pushFailure) throw options.pushFailure(service, input); + if (remotePair.head !== input.from) throw new BranchPushRefused('moved'); + remotePair = { ...remotePair, head: input.to }; + }, + readBranch: async () => options.branchRead ? options.branchRead(remotePair.head) : remotePair.head, + }, options.operationTimeoutMs, undefined, authorize, options.reserves); if (options.closeAfterCommandAdmission) { const start = runner.start.bind(runner); runner.start = ((...args: Parameters) => { @@ -183,7 +200,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: const task = service.store.getTask(identity); const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); - return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons, + return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons, pushes, remote: () => remotePair, rebaseRuns: () => rebaseRuns, rebaseAborts: () => rebaseAborts, rebaseBudgets, abortBudgets }; } @@ -339,7 +356,7 @@ it('revalidates task and review versions after the final remote read', async () it('revalidates task and review versions after final authorization', async () => { const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { - if (call !== 2) return; + if (call !== 4) return; // validations: 1 rebase, 2 push read, 3 push boundary, 4 final const view = service.load(); service.store.addReviewNote(service.config.identity, view.expected, 'P1', 'change', 'Changed during authorization.'); } }); @@ -350,7 +367,7 @@ it('revalidates task and review versions after final authorization', async () => it('refreshes a pull request head that moves during final authorization', async () => { const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { - if (call !== 2) return; + if (call !== 4) return; // validations: 1 rebase, 2 push read, 3 push boundary, 4 final writeFileSync(join(service.config.repository, 'late.txt'), 'late collaborator push\n'); fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'late push'); return { base: service.load().snapshot.base, head: fixtureGit(service.config.repository, 'rev-parse', 'HEAD') }; @@ -469,7 +486,7 @@ it('refreshes the moved head against its prior base when the PR base and head ad stop: () => false, isActive: () => false, status: () => ({ active: false, stopRequested: null, unresolved: null }), get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, - { inspect: async () => moved, fetch: async () => undefined }); + { inspect: async () => moved, fetch: async () => undefined, ...unusedPush }); const task = service.store.getTask(config.identity); const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); @@ -525,7 +542,7 @@ it('traces an unpushed rewrite back to the base of a collaborator head', async ( get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, { inspect: async () => ++reads === 1 ? { base: newBase, head: rewrittenHead } : { base: newBase, head: collaboratorHead }, - fetch: async () => undefined }); + fetch: async () => undefined, ...unusedPush }); const task = service.store.getTask(identity); const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); @@ -551,7 +568,7 @@ it('refuses preparation before review or remote work while runner cleanup is unr get unreleased() { return kind === 'resources' ? [{ kind: 'container', name: 'agent-x' }] : null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, { inspect: async () => { inspected = true; return { base: view.snapshot.base, head: view.snapshot.head }; }, - fetch: async () => undefined }); + fetch: async () => undefined, ...unusedPush }); const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); expect(result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/restart|cleanup/i) }); @@ -579,7 +596,7 @@ it('refuses preparation before review or remote work while a durable rebase mark get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, { inspect: async () => { inspected = true; return { base: view.snapshot.base, head: view.snapshot.head }; }, - fetch: async () => undefined }); + fetch: async () => undefined, ...unusedPush }); const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); expect(result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/rebase|recovery|cleanup/i) }); @@ -599,7 +616,7 @@ it('preserves an already-requested shutdown without loading the review', async ( stop: () => false, isActive: () => false, status: () => ({ active: false, stopRequested: null, unresolved: null }), get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, - { inspect: async () => { throw new Error('No remote inspection expected.'); }, fetch: async () => undefined }); + { inspect: async () => { throw new Error('No remote inspection expected.'); }, fetch: async () => undefined, ...unusedPush }); const active = coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); await coordinator.close(); @@ -621,7 +638,7 @@ it('settles an admitted action after shutdown aborts its remote refresh', async get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, { inspect: signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })), - fetch: async () => undefined }, 60_000, capability); + fetch: async () => undefined, ...unusedPush }, 60_000, capability); const active = coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, actionId }); await Promise.resolve(); @@ -642,7 +659,7 @@ it('applies one operation-wide deadline to remote work and later checks', async get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, { inspect: signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })), - fetch: async () => undefined }, 20); + fetch: async () => undefined, ...unusedPush }, 20); await expect(coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head })).resolves.toMatchObject({ state: 'failed' }); await coordinator.close(); @@ -663,7 +680,7 @@ it('aborts remote work early enough to reserve bounded subprocess settlement', a { inspect: signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => { abortedAt = performance.now(); setTimeout(() => reject(signal.reason), processReserve); - }, { once: true })), fetch: async () => undefined }, operationTimeout, undefined, undefined, + }, { once: true })), fetch: async () => undefined, ...unusedPush }, operationTimeout, undefined, undefined, { processMs: processReserve }); const startedAt = performance.now(); const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, @@ -730,7 +747,7 @@ it('makes a preparation action resendable when its terminal storage write fails' stop: () => false, isActive: () => false, status: () => ({ active: false, stopRequested: null, unresolved: null }), get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, - { inspect: async () => ({ base: view.snapshot.base, head: view.snapshot.head }), fetch: async () => undefined }); + { inspect: async () => ({ base: view.snapshot.base, head: view.snapshot.head }), fetch: async () => undefined, ...unusedPush }); const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, actionId }); expect(result).toMatchObject({ state: 'failed', reason: 'transient storage failure' }); @@ -758,7 +775,7 @@ it('settles a failed preparation even when the fallback snapshot read would fail stop: () => false, isActive: () => false, status: () => ({ active: false, stopRequested: null, unresolved: null }), get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, - { inspect: async () => { throw new Error('remote inspection failed'); }, fetch: async () => undefined }); + { inspect: async () => { throw new Error('remote inspection failed'); }, fetch: async () => undefined, ...unusedPush }); await expect(coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, actionId })) .resolves.toMatchObject({ state: 'failed', reason: 'transient snapshot read failure' }); @@ -815,7 +832,7 @@ it('rechecks the remote after local preparation and refreshes a head that moved stop: () => false, isActive: () => false, status: () => ({ active: false, stopRequested: null, unresolved: null }), get unreleased() { return null; } } as never, { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, - { inspect: async () => ++reads === 1 ? { base, head } : moved, fetch: async () => undefined }); + { inspect: async () => ++reads === 1 ? { base, head } : moved, fetch: async () => undefined, ...unusedPush }); const task = service.store.getTask(identity); const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); @@ -824,3 +841,126 @@ it('rechecks the remote after local preparation and refreshes a head that moved expect(service.load().segments.some(segment => segment.row === 'Unplanned')).toBe(true); await coordinator.close(); }); + +// F6a of #22: the reviewed, rewritten head is pushed over exactly the remote head it rewrote, bracketed by a durable marker. +const prepareAgain = (fixture: Awaited>) => { + const identity = fixture.service.config.identity, view = fixture.service.load(); + return fixture.coordinator.start({ stateVersion: fixture.service.store.getTask(identity).stateVersion, + reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); +}; +const pushMarker = (fixture: Awaited>) => + fixture.service.store.getTask(fixture.service.config.identity).pushInProgress; + +it('pushes the rewritten head over the exact remote head before running command checks', async () => { + const fixture = await rebaseFixture('feature\n', 0); + expect(fixture.result).toMatchObject({ state: 'ready', head: fixture.rebased, checked: ['P1'] }); + expect(fixture.pushes).toEqual([{ branch: BRANCH, from: expect.any(String), to: fixture.rebased }]); + expect(fixture.pushes[0]!.from).not.toBe(fixture.rebased); + expect(fixture.remote().head).toBe(fixture.rebased); + expect(fixture.checkedHeads).toEqual([fixture.rebased]); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('does not push a rewrite whose approval became stale', async () => { + const fixture = await rebaseFixture('changed by rebase\n'); + expect(fixture.result).toMatchObject({ state: 'review-required', head: fixture.rebased }); + expect(fixture.pushes).toEqual([]); + expect(fixture.remote().head).not.toBe(fixture.rebased); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('returns to review without pushing when the branch moved off the rewritten head', async () => { + let collaborator = ''; + const fixture = await rebaseFixture('feature\n', undefined, { pushFailure(service) { + writeFileSync(join(service.config.repository, 'late.txt'), 'collaborator\n'); + fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'collaborator'); + collaborator = fixtureGit(service.config.repository, 'rev-parse', 'HEAD'); + return new BranchPushRefused('The branch moved.'); + }, branchRead: () => collaborator }); + expect(fixture.result).toMatchObject({ state: 'review-required' }); + expect(fixture.result.reason).toMatch(/moved before the rewritten head could be pushed/); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('settles an ambiguous push by reading the branch, then prepares again without pushing twice', async () => { + let landed: string | null = null; + const fixture = await rebaseFixture('feature\n', undefined, { + pushFailure: (_service, input) => { landed = input.to; return new Error('connection reset after the push was sent'); }, + branchRead: current => landed ?? current, + }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'connection reset after the push was sent' }); + expect(pushMarker(fixture)).toBeNull(); + expect(fixture.pushes).toHaveLength(1); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('keeps the push marker when the branch cannot be read, blocks merging, and settles it on the next preparation', async () => { + let readable = false; + const fixture = await rebaseFixture('feature\n', undefined, { + pushFailure: () => new Error('timed out'), + branchRead: current => { if (!readable) throw new Error('GitHub unreachable'); return current; }, + }); + const identity = fixture.service.config.identity; + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'timed out' }); + const marker = pushMarker(fixture); + expect(marker).toMatchObject({ branch: BRANCH, to: fixture.rebased }); + const view = fixture.service.load(); + expect(() => fixture.service.store.transitionTask(identity, fixture.service.store.getTask(identity).stateVersion, 'queued')) + .toThrow(/push of the rewritten head is in progress/); + expect(() => fixture.service.store.beginMergeAttempt(identity, { ...view.expected, reviewVersion: view.expected.reviewVersion! }, + fixture.rebased, null, 'direct', randomUUID(), fixture.service.store.getTask(identity).stateVersion)) + .toThrow(/push of the rewritten head is in progress/); + await expect(prepareAgain(fixture)).resolves.toMatchObject({ state: 'failed', + reason: expect.stringMatching(/outcome of an earlier push .* is unknown.*GitHub unreachable/) }); + expect(pushMarker(fixture)).toEqual(marker); + readable = true; + await expect(prepareAgain(fixture)).resolves.toMatchObject({ state: 'failed', reason: expect.stringMatching(/was settled/) }); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('pushes nothing when the review changes at the push boundary', async () => { + const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { + if (call !== 3) return; // validations: 1 rebase, 2 push read, 3 push boundary + const view = service.load(); + service.store.addReviewNote(service.config.identity, view.expected, 'P1', 'change', 'Changed at the push boundary.'); + } }); + expect(fixture.result).toMatchObject({ state: 'failed' }); + expect(fixture.result.reason).toMatch(/changed before the rewritten head was pushed/); + expect(fixture.remote().head).not.toBe(fixture.rebased); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('closes a task cancelled during its push once the push outcome is read', async () => { + const actionId = randomUUID(); + let coordinatorRef: PreMergeCoordinator | null = null; + const fixture = await rebaseFixture('feature\n', undefined, { pushFailure(service) { + const task = service.store.getTask(service.config.identity); + expect(coordinatorRef!.cancelTask(task.stateVersion, actionId)).toBe('stopping'); + return new Error('Task cancelled.'); + }, duringRebase(_service, coordinator) { coordinatorRef = coordinator; } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'Task cancelled.' }); + expect(pushMarker(fixture)).toBeNull(); + expect(fixture.service.store.getTask(fixture.service.config.identity).status).toBe('cancelled'); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('leaves the push marker for startup when shutdown interrupts the push, and startup settles it', async () => { + let coordinatorRef: PreMergeCoordinator | null = null; + const fixture = await rebaseFixture('feature\n', undefined, { pushFailure() { + void coordinatorRef!.close(); + return new Error('Server shutdown.'); + }, duringRebase(_service, coordinator) { coordinatorRef = coordinator; } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'Server shutdown.' }); + expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); + const restarted = new PreMergeCoordinator(fixture.service, fixture.runner, {} as never, { + inspect: async () => { throw new Error('not used'); }, fetch: async () => undefined, + push: async () => { throw new Error('not used'); }, readBranch: async () => fixture.remote().head }); + await expect(restarted.settleAtStartup()).resolves.toBe('not-pushed'); + expect(pushMarker(fixture)).toBeNull(); + await restarted.close(); await fixture.runner.close(); +}); diff --git a/test/publish.test.ts b/test/publish.test.ts index 6d90569a..8322ffab 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -358,7 +358,7 @@ describe('schema v7', () => { expect(upgraded.taskPullRequests(identity)).toEqual([]); expect(upgraded.latestAlreadyFixed(identity)).toBeNull(); const db = new DatabaseSync(path, { readOnly: true }); - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); const names = db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','index') AND (name LIKE '%pull_requests%' OR name LIKE 'already_fixed_checks%') AND name NOT LIKE 'sqlite_autoindex%' ORDER BY name").all().map(row => row.name); expect(names).toEqual(['already_fixed_checks', 'already_fixed_checks_task', 'task_pull_requests', 'task_pull_requests_number', 'task_pull_requests_opening', 'task_pull_requests_task']); db.close(); diff --git a/test/runner-branch-push.test.ts b/test/runner-branch-push.test.ts index f2b6e9be..f13c4401 100644 --- a/test/runner-branch-push.test.ts +++ b/test/runner-branch-push.test.ts @@ -119,6 +119,33 @@ describe('GitBranchPusher', () => { expect(remoteRefs(s)).toBe(`${s.base} ${REF}`); }); + it('with an expected head, overwrites exactly that commit, even one codeboost did not make', async () => { + const s = await setup(), head = await runnerCommit(s, 'one'); + personPushes(s, REF); + await pusher(s).instance.push(IDENTITY, { head, branch: BRANCH, expected: s.base }); + expect(remoteRefs(s)).toBe(`${head} ${REF}`); + }); + + it('with an expected head, refuses any other value, even a commit codeboost made, and pushes nothing', async () => { + const s = await setup(), first = await runnerCommit(s, 'one'), second = await runnerCommit(s, 'two'); + await pusher(s).instance.push(IDENTITY, { head: first, branch: BRANCH }); + const p = pusher(s, { owned: () => [first] }); + await expect(p.instance.push(IDENTITY, { head: second, branch: BRANCH, expected: s.base })) + .rejects.toThrow(`holds ${first}, not the rebased head ${s.base}`); + await expect(p.instance.push(IDENTITY, { head: second, branch: 'codeboost/absent-1', expected: s.base })) + .rejects.toThrow(BranchPushRefused); + expect(remoteRefs(s)).toBe(`${first} ${REF}`); + }); + + it('reads the task branch for push settlement, and null when it does not exist', async () => { + const s = await setup(), head = await runnerCommit(s, 'one'), p = pusher(s); + await expect(p.instance.readBranch(IDENTITY, BRANCH)).resolves.toBeNull(); + await p.instance.push(IDENTITY, { head, branch: BRANCH }); + await expect(p.instance.readBranch(IDENTITY, BRANCH)).resolves.toBe(head); + await expect(p.instance.readBranch(IDENTITY, 'main')).rejects.toThrow(/codeboost\/ task branch/); + await expect(p.instance.readBranch({ ...IDENTITY, repositoryId: 'other' }, BRANCH)).rejects.toThrow(/another repository/); + }); + it('refuses when the branch moves between the read and the push: the lease is the value read', async () => { const s = await setup(), first = await runnerCommit(s, 'one'), second = await runnerCommit(s, 'two'); await pusher(s).instance.push(IDENTITY, { head: first, branch: BRANCH }); diff --git a/test/runner-lifecycle-store.test.ts b/test/runner-lifecycle-store.test.ts index 4fa406ff..6fd0b541 100644 --- a/test/runner-lifecycle-store.test.ts +++ b/test/runner-lifecycle-store.test.ts @@ -105,7 +105,7 @@ describe('schema v12 review ordering', () => { migrated.saveReview(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: migratedVersion }, [{ item: 'P1', fingerprint: 'approved-after-upgrade' }], []); expect(migrated.unapprovedExecutionItems(identity, 1)).toEqual([]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); }); }); @@ -783,7 +783,7 @@ describe('runner commits and preparation groups (#87)', () => { db.exec('ALTER TABLE attempts DROP COLUMN preparation_identity; PRAGMA user_version=15;'); db.close(); const reopened = open(path); expect(reopened.interruptedAttempts()).toEqual([expect.objectContaining({ id: attempt.id, preparationIdentity: null })]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); }); }); @@ -917,7 +917,7 @@ describe('durable safety findings (#87 item 3)', () => { db.exec('ALTER TABLE attempts DROP COLUMN safety_finding; PRAGMA user_version=7;'); db.close(); const reopened = open(path); expect(reopened.getAttempt(identity, attempt.id).safetyFinding).toBeNull(); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); }); it('adds the durable owed marker to a version 12 database', () => { @@ -927,7 +927,7 @@ describe('durable safety findings (#87 item 3)', () => { db.exec('ALTER TABLE attempts DROP COLUMN safety_owed; PRAGMA user_version=12;'); db.close(); const reopened = open(path); expect(reopened.owedSafetyFindings()).toEqual([]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); }); it('restores and clears an escalation owed behind a human gate across real store reopens', () => { @@ -967,7 +967,7 @@ describe('allocation baseline (#91)', () => { db.exec('ALTER TABLE attempts DROP COLUMN metadata_baseline; ALTER TABLE attempts DROP COLUMN storage_base; PRAGMA user_version=8;'); db.close(); const reopened = open(path); expect(reopened.interruptedAttempts()).toEqual([expect.objectContaining({ id: attempt.id, metadataBaseline: null, storageBase: null })]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); }); }); describe('publish outcomes (#103)', () => { @@ -983,7 +983,7 @@ describe('publish outcomes (#103)', () => { reopened.recordPublish(identity, { outcome: 'opened', draft: false, message: 'Pull request #1 is open.', number: 1, url: 'https://github.com/o/r/pull/1' }); expect(reopened.lastPublish(identity)).toMatchObject({ outcome: 'opened', number: 1 }); expect(reopened.getTask(identity)).toEqual(before); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); }); it('stamps an outcome with the version the publish saw, and refuses one it cannot have seen (#114)', () => { const { store } = queued(), version = store.getTask(identity).stateVersion; @@ -1002,3 +1002,65 @@ describe('publish outcomes (#103)', () => { message: expect.stringMatching(/Use the publish action/) }); }); }); + +describe('pre-merge push marker (F6a of #22)', () => { + const BRANCH = 'codeboost/task-1'; + /** The fixture's head oid(2) rewritten to oid(12) on a moved base oid(10). */ + function rewritten() { + const f = fixture(); + const reviewed = () => ({ revision: 1, snapshotId: f.store.getSnapshot(identity).id, reviewVersion: f.store.reviewVersion(identity) }); + f.store.recordHistory(identity, reviewed(), oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]); + f.store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + return { ...f, reviewed }; + } + const begin = (f: ReturnType, input: Partial<{ branch: string; from: string; to: string }> = {}) => + f.store.beginPrePush(identity, f.reviewed(), f.store.getTask(identity).stateVersion, + { branch: BRANCH, from: oid(2), to: oid(12), ...input }); + + it('migrates a v17 store and reopens it unchanged', () => { + const { store, path } = fixture(); + store.close(); stores.splice(stores.indexOf(store), 1); + const legacy = new DatabaseSync(path); + legacy.exec('ALTER TABLE tasks DROP COLUMN push_in_progress; PRAGMA user_version=17;'); legacy.close(); + expect(open(path).getTask(identity).pushInProgress).toBeNull(); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + }); + + it('claims only a push of the current head over a durable rewrite predecessor', () => { + const f = rewritten(); + expect(() => begin(f, { from: oid(5) })).toThrow(/not a rewrite predecessor/); + expect(() => begin(f, { to: oid(2), from: oid(12) })).toThrow(/reviewed head changed/); + expect(() => begin(f, { branch: 'main' })).toThrow(/codeboost\/ task branch/); + const version = f.store.getTask(identity).stateVersion; + const marker = begin(f); + expect(f.store.getTask(identity)).toMatchObject({ stateVersion: version + 1, + pushInProgress: { attemptId: marker.attemptId, branch: BRANCH, from: oid(2), to: oid(12), snapshotId: f.store.getSnapshot(identity).id } }); + expect(() => begin(f)).toThrow(/Stale task state|push of the rewritten head is in progress/); + }); + + it('blocks every other task write, rebase and merge until the push settles, and survives a restart', () => { + const f = rewritten(), marker = begin(f), version = () => f.store.getTask(identity).stateVersion; + expect(() => f.store.transitionTask(identity, version(), 'queued')).toThrow(/push of the rewritten head is in progress/); + expect(() => f.store.beginMergeAttempt(identity, f.reviewed(), oid(12), null, 'direct', randomUUID(), version())) + .toThrow(/push of the rewritten head is in progress/); + expect(() => f.store.beginRebase(identity, f.reviewed(), version(), { oldBase: oid(10), oldHead: oid(12), onto: oid(20), oldHistory: [oid(12)] })) + .toThrow(/push of the rewritten head is in progress/); + expect(() => f.store.recordRebase(identity, f.reviewed(), oid(20), oid(22), [{ oldSha: oid(12), newSha: oid(22) }])) + .toThrow(/push of the rewritten head is in progress/); + f.store.close(); stores.splice(stores.indexOf(f.store), 1); + const reopened = open(f.path); + expect(reopened.pushesInProgress()).toEqual([{ planKey: reopened.getTask(identity).planKey, marker }]); + expect(reopened.finishPrePush(reopened.getTask(identity).planKey, randomUUID())).toBe(false); + expect(reopened.finishPrePush(reopened.getTask(identity).planKey, marker.attemptId)).toBe(true); + expect(reopened.getTask(identity).pushInProgress).toBeNull(); + expect(reopened.pushesInProgress()).toEqual([]); + }); + + it('defers a cancel requested during the push until its outcome is read, then closes the task', () => { + const f = rewritten(), marker = begin(f), actionId = randomUUID(); + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, actionId)).toBe('stopping'); + expect(f.store.getTask(identity).status).toBe('in review'); + expect(f.store.finishPrePush(f.store.getTask(identity).planKey, marker.attemptId)).toBe(true); + expect(f.store.getTask(identity).status).toBe('cancelled'); + }); +}); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 6e0bdc0b..ace90a7e 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1369,7 +1369,7 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { }; let writesClosed: boolean | undefined; const { close } = await serve(w, { issueGateway, preMerge: service => ({ - get active() { return false; }, get last() { return null; }, assertStartable() {}, + get active() { return false; }, get last() { return null; }, assertStartable() {}, settleAtStartup: async () => null, start: async () => { throw new Error('not used'); }, cancelTask: () => 'closed' as const, close: async () => { writesClosed = service.store.writesClosed; }, } as unknown as PreMergeCoordinator) }); @@ -1387,7 +1387,7 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { }; let active = false, cancelId: string | undefined; const { app, identity, store } = await serve(w, { before: completeAll, issueGateway, preMerge: service => ({ - get active() { return active; }, get last() { return null; }, assertStartable() {}, + get active() { return active; }, get last() { return null; }, assertStartable() {}, settleAtStartup: async () => null, start: () => { active = true; return settle.promise.then(() => { diff --git a/test/store.test.ts b/test/store.test.ts index fcf945fb..18cbf060 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -121,7 +121,7 @@ it('scopes issue trust to repository and current author, supports revoke, and mi legacy.exec('DROP TABLE issue_trust; ALTER TABLE attempts DROP COLUMN prompt_comments; PRAGMA user_version=16;'); legacy.close(); const migrated = open(path), db = new DatabaseSync(path); try { - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 17 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); expect(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='issue_trust'").get()).toEqual({ name: 'issue_trust' }); expect(db.prepare('PRAGMA table_info(attempts)').all().some(column => column.name === 'prompt_comments')).toBe(true); expect(migrated.issueTrust('owner/a', 5)).toBeNull(); diff --git a/web/server.ts b/web/server.ts index 43f3c397..5ec62898 100644 --- a/web/server.ts +++ b/web/server.ts @@ -116,6 +116,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge validate: () => requireTrustedIssue(access), }; }); + // A push of a rewritten head left unsettled by a crash or shutdown blocks merging until a read of its branch. + void preMerge?.settleAtStartup(); executor = new ItemExecutor(service.store, runner, assembly.sources, assembly.findings, { capability }); // A demo never publishes, whatever its github block or an injected setup provides (setUpRunner refuses demos too). if (assembly.publisher && !config.demo) { const coordinator = runner; publishing = new TaskPublishing(service.store, assembly.publisher(() => coordinator.closing), runner, executor, capability, assembly.env, { From a63e5cfdd90c2113244aef0e0fa76df394790cce Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 10:07:08 -0700 Subject: [PATCH 002/163] Settle pushes cancel-safely and wait for the pushed head 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 --- docs/implementation/pre-merge-rebase.md | 5 +- runner/pre-merge.ts | 88 +++++++++++++++++-------- test/pre-merge.test.ts | 85 +++++++++++++++++++++--- test/runner-production.test.ts | 20 ++++++ test/runner-publishing.test.ts | 29 ++++++++ web/server.ts | 5 +- 6 files changed, 194 insertions(+), 38 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index ae0d2c56..38edcd83 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -42,8 +42,9 @@ This ordering is intentionally local-only. The push below extends the durable li | Last local check | Immediately before the write, trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears, the PR head is reread and reviewed, and preparation returns `review-required`. | -| Unknown outcome | A failure, timeout or cancel during the push reads the branch once (30-second bound): `to` means pushed, `from` means not pushed, anything else means moved. The marker then clears. A cancel requested during the push closes the task at that point. | -| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. The next preparation, or the tracked startup job after restart, reads the branch and clears it; that preparation then asks for a reload. | +| Visible on the PR | GitHub's pull-request API reports a pushed head asynchronously. Preparation inspects the PR with backoff for up to 30 seconds until it reports something other than the pre-push head. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | +| Unknown outcome | A failure, timeout or cancel during the push reads the branch once, bounded by 30 seconds and by what is left of the preparation's deadline: `to` means pushed, `from` means not pushed, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | +| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push left running by a crashed process cannot overwrite anything newer: its lease still names `from`. diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 9248ecc3..ec3fdef1 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -24,6 +24,8 @@ export interface PreMergeRemote { } /** One bounded read of the branch settles a push whose outcome is unknown. */ export const PUSH_SETTLEMENT_TIMEOUT_MS = 30_000; +/** GitHub's pull-request API reports a pushed head asynchronously; wait at most this long for it after a push. */ +export const PUSHED_HEAD_VISIBLE_TIMEOUT_MS = 30_000; export interface PreMergeAuthorization { /** Complete the last external authorization read before a later external operation. */ refresh(): Promise; @@ -53,10 +55,13 @@ export class PreMergeCoordinator { readonly operationTimeoutMs: number; readonly processSettlementReserveMs: number; readonly commandSettlementReserveMs: number; + readonly pushVisibleTimeoutMs: number; readonly authorize?: (signal: AbortSignal) => Promise; readonly settle: (fn: () => T) => T; #active: Promise | null = null; #abort: AbortController | null = null; + /** Aborted only by shutdown: a cancel must not stop the branch read that settles a push and closes the task. */ + readonly #shutdown = new AbortController(); #closing = false; #last: PreMergeResult | null = null; #lastBinding: { stateVersion: number; reviewVersion: number; snapshotId: string } | null = null; @@ -64,15 +69,17 @@ export class PreMergeCoordinator { constructor(service: ReviewService, runner: RunnerCoordinator, rebaser: GitRebaser, remote: PreMergeRemote, operationTimeoutMs = 10 * 60_000, capability?: ShutdownCapability, authorize?: (signal: AbortSignal) => Promise, - reserves: { processMs?: number; commandMs?: number } = {}) { + reserves: { processMs?: number; commandMs?: number; pushVisibleMs?: number } = {}) { if (!Number.isSafeInteger(operationTimeoutMs) || operationTimeoutMs < 1 || operationTimeoutMs > 60 * 60_000) throw new Error('Invalid pre-merge operation deadline.'); this.service = service; this.runner = runner; this.rebaser = rebaser; this.remote = remote; this.operationTimeoutMs = operationTimeoutMs; this.authorize = authorize; this.processSettlementReserveMs = reserves.processMs ?? PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS; this.commandSettlementReserveMs = reserves.commandMs ?? COMMAND_CHECK_SETTLEMENT_RESERVE_MS; + this.pushVisibleTimeoutMs = reserves.pushVisibleMs ?? PUSHED_HEAD_VISIBLE_TIMEOUT_MS; if (!Number.isSafeInteger(this.processSettlementReserveMs) || this.processSettlementReserveMs < 0 - || !Number.isSafeInteger(this.commandSettlementReserveMs) || this.commandSettlementReserveMs < 0) + || !Number.isSafeInteger(this.commandSettlementReserveMs) || this.commandSettlementReserveMs < 0 + || !Number.isSafeInteger(this.pushVisibleTimeoutMs) || this.pushVisibleTimeoutMs < 0) throw new Error('Invalid pre-merge settlement reserve.'); this.settle = settleWith(capability); } @@ -147,7 +154,8 @@ export class PreMergeCoordinator { return active; } async close(): Promise { - this.#closing = true; this.#abort?.abort(new Error('Server shutdown.')); await this.#active; + this.#closing = true; this.#shutdown.abort(new Error('Server shutdown.')); this.#abort?.abort(new Error('Server shutdown.')); + await this.#active; } /** Cancel the task and promptly stop whichever rebase, remote read or command check this preparation owns. */ cancelTask(expectedStateVersion: number, actionId: string): 'closed' | 'stopping' { @@ -163,25 +171,25 @@ export class PreMergeCoordinator { * Settle a push whose outcome is unknown (a crash, shutdown or failure during the push) by reading the branch, then * clear its marker. Returns null when no push is pending; throws when the branch could not be read. */ - async settleInterruptedPush(signal?: AbortSignal): Promise { + async settleInterruptedPush(): Promise { const marker = this.service.store.getTask(this.service.config.identity).pushInProgress; - return marker ? this.#settlePush(marker, signal) : null; + return marker ? this.#settlePush(marker) : null; } /** - * After startup recovery: settle a push left by a crash or shutdown, as one tracked job that shutdown aborts and awaits - * and that a preparation waits behind. A failed read leaves the marker for the next preparation. + * After startup recovery: settle a push left by a crash or shutdown, as one tracked job that shutdown aborts and awaits. + * While it runs, a preparation is refused as already running. Never throws: a failed read leaves the marker for the + * next preparation, and a server already closing or busy skips it. */ settleAtStartup(): Promise { - this.assertStartable(); - const controller = new AbortController(); this.#abort = controller; - const active = this.settleInterruptedPush(controller.signal).catch(() => null) - .finally(() => { if (this.#active === active) { this.#active = null; this.#abort = null; } }); + if (this.#closing || this.#active) return Promise.resolve(null); + const active = this.settleInterruptedPush().catch(() => null) + .finally(() => { if (this.#active === active) this.#active = null; }); this.#active = active; return active; } - async #settlePush(marker: PushMarker, signal?: AbortSignal): Promise { - const bounded = AbortSignal.timeout(PUSH_SETTLEMENT_TIMEOUT_MS); - const read = await this.remote.readBranch(marker.branch, signal ? AbortSignal.any([signal, bounded]) : bounded); + async #settlePush(marker: PushMarker, limitMs = PUSH_SETTLEMENT_TIMEOUT_MS): Promise { + const read = await this.remote.readBranch(marker.branch, + AbortSignal.any([this.#shutdown.signal, AbortSignal.timeout(Math.max(1, Math.min(PUSH_SETTLEMENT_TIMEOUT_MS, limitMs)))])); const outcome: PushOutcome = read === marker.to ? 'pushed' : read === marker.from ? 'not-pushed' : 'moved'; this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(this.service.config.identity).planKey, marker.attemptId)); return outcome; @@ -240,12 +248,31 @@ export class PreMergeCoordinator { if (!this.settle(() => this.service.store.abortRebase(this.service.store.getTask(identity).planKey, marker.attemptId))) throw new GuardRefusal('This rebase attempt no longer owns its durable marker.'); } + /** + * Inspect the PR until GitHub reports something other than the pre-push head, with backoff, bounded by + * `pushVisibleTimeoutMs` and the preparation deadline. A PR still at the pre-push head after that fails the + * preparation without refreshing the review: the branch itself already holds the pushed head. + */ + async #awaitPushedHead(before: RemotePair, pushed: string, signal: AbortSignal, remaining: () => number): Promise { + const until = performance.now() + this.pushVisibleTimeoutMs; + for (let delay = 250; ; delay = Math.min(delay * 2, 4_000)) { + const seen = await this.remote.inspect(signal); signal.throwIfAborted(); + if (seen.head !== before.head || seen.base !== before.base) return seen; + const wait = Math.min(delay, until - performance.now(), remaining() - this.processSettlementReserveMs); + if (wait < 1) throw new GuardRefusal(`GitHub has not reported the pushed head ${pushed} on the pull request yet. Prepare the merge again shortly.`); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(); }, wait); + const stop = () => { clearTimeout(timer); reject(signal.reason); }; + signal.addEventListener('abort', stop, { once: true }); + }); + } + } /** * Push the reviewed, rewritten head over exactly the remote head it rewrote. The durable marker is written before the * first external write and cleared only after a read of the branch settles the outcome; when that read is impossible * (shutdown, or GitHub unreachable) the marker stays for the next preparation or startup. */ - async #pushRewrite(remote: RemotePair, view: ReturnType, signal: AbortSignal, + async #pushRewrite(remote: RemotePair, view: ReturnType, signal: AbortSignal, deadline: number, authorize: () => Promise, bind: (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair) => void): Promise<'pushed' | 'refused'> { const identity = this.service.config.identity; @@ -274,9 +301,11 @@ export class PreMergeCoordinator { bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }); return 'refused'; } - // Shutdown: no new GitHub read may start. Startup settles the marker. - if (!this.#closing) { - try { await this.#settlePush(marker); } + // Shutdown: no new GitHub read may start; startup settles the marker. Otherwise one read, within what is left of + // the preparation's deadline (its settlement reserve included), settles it. A cancel does not stop that read. + const left = Math.floor(deadline - performance.now()); + if (!this.#closing && left >= 1) { + try { await this.#settlePush(marker, left); } catch { /* The marker stays; the next preparation or startup reads the branch again. */ } } bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }); @@ -351,11 +380,13 @@ export class PreMergeCoordinator { throw new GuardRefusal('Rebase cleanup is unresolved; restart and recover the owned rebase before preparing a merge.'); const pending = this.service.store.getTask(identity).pushInProgress; if (pending) { - try { await this.#settlePush(pending, signal); } + // Not this preparation's signal: a cancel must let the read finish, so the cleared marker can close the task. + try { await this.#settlePush(pending, remaining()); } catch (error) { signal.throwIfAborted(); throw new GuardRefusal(`The outcome of an earlier push of the rewritten head is unknown, and the branch could not be read: ${error instanceof Error ? error.message : String(error)}`); } + signal.throwIfAborted(); // Settling advanced task state. The caller's binding is stale now; it reloads and starts again. throw new GuardRefusal('An earlier push of the rewritten head was settled. Reload, then prepare the merge again.'); } @@ -427,15 +458,20 @@ export class PreMergeCoordinator { if (blocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], reason: blocker }; if (initial.head !== view.snapshot.head) { - const pushed = await this.#pushRewrite(initial, view, signal, authorize, bindRebaseFailure); - if (pushed !== 'pushed') { - // Refused: the branch holds neither the reviewed predecessor nor our head. Review whatever is there now. - const moved = await this.remote.inspect(signal); await this.remote.fetch(moved, signal); signal.throwIfAborted(); - view = track(this.#refresh(moved, reviewBudget, initial.head)); + const pushed = await this.#pushRewrite(initial, view, signal, deadline, authorize, bindRebaseFailure); + // Refused: the branch holds neither the reviewed predecessor nor our head. Otherwise wait for GitHub's PR API, + // which reports a pushed head asynchronously, so the later inspections do not mistake its lag for a move. + const seen = pushed === 'pushed' ? await this.#awaitPushedHead(initial, view.snapshot.head, signal, remaining) : null; + if (pushed !== 'pushed' || seen!.head !== view.snapshot.head || seen!.base !== initial.base) { + const moved = seen ?? await this.remote.inspect(signal); await this.remote.fetch(moved, signal); signal.throwIfAborted(); + // After a push, history is traced from the pushed head; after a refusal, from the head the push would replace. + view = track(this.#refresh(moved, reviewBudget, pushed === 'pushed' ? view.snapshot.head : initial.head)); return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], - reason: 'The pull request branch moved before the rewritten head could be pushed; attribution and approvals were refreshed.' }; + reason: pushed === 'pushed' + ? 'The pull request moved after the rewritten head was pushed; attribution and approvals were refreshed.' + : 'The pull request branch moved before the rewritten head could be pushed; attribution and approvals were refreshed.' }; } - initial = { ...initial, head: view.snapshot.head }; + initial = seen!; view = load(); } const checked: string[] = []; diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 60b84fe5..0f6835b7 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -22,7 +22,7 @@ const BRANCH = 'codeboost/task-1'; const unusedPush = { push: async () => { throw new Error('No push expected.'); }, readBranch: async () => { throw new Error('No branch read expected.'); } }; vi.setConfig({ testTimeout: 15_000 }); -afterEach(() => { for (const service of services.splice(0)) service.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +afterEach(() => { vi.restoreAllMocks(); for (const service of services.splice(0)) service.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); const markRunnerOwned = (service: ReviewService) => { const identity = service.config.identity; @@ -61,7 +61,13 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: runnerCommitted?: boolean; operationTimeoutMs?: number; authorize?: (signal: AbortSignal) => Promise; - reserves?: { processMs?: number; commandMs?: number }; + reserves?: { processMs?: number; commandMs?: number; pushVisibleMs?: number }; + /** The PR API keeps reporting the pre-push head for this many inspections after a push. */ + pushVisibleAfter?: number; + /** The push lands on the branch before it fails. */ + landBeforeFailure?: boolean; + /** Runs once the push has landed and may move the branch again, as a collaborator push would. */ + afterPush?: (service: ReviewService, pushed: string) => string; pushFailure?: (service: ReviewService, input: { from: string; to: string }) => Error; branchRead?: (current: string) => string | null | Promise; } = {}) { @@ -168,6 +174,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }, context.allowedCommands, 'a'.repeat(32))); let remoteReads = 0, remotePair: { base: string; head: string; branch?: string } = { base: onto, head, branch: BRANCH }; const pushes: { branch: string; from: string; to: string }[] = []; + let lagging = 0, lagged = remotePair; let authorizationChecks = 0; const authorize = options.authorize ?? (options.duringAuthorize ? async () => ({ refresh: async () => undefined, @@ -176,16 +183,21 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: coordinator = new PreMergeCoordinator(service, runner, rebaser, { inspect: async () => { + if (lagging > 0) { lagging--; return lagged; } remoteReads++; - if (remoteReads > 1) options.duringFinalInspect?.(service, coordinator, remoteReads); + // Read 2 is the post-push visibility check; hooks keep counting the later inspections as before the push existed. + if (remoteReads > 2) options.duringFinalInspect?.(service, coordinator, remoteReads - 1); return remotePair; }, fetch: async () => undefined, push: async input => { pushes.push({ branch: input.branch, from: input.from, to: input.to }); input.beforePush(); + if (options.landBeforeFailure && remotePair.head === input.from) { lagged = remotePair; remotePair = { ...remotePair, head: input.to }; } if (options.pushFailure) throw options.pushFailure(service, input); if (remotePair.head !== input.from) throw new BranchPushRefused('moved'); + lagged = remotePair; lagging = options.pushVisibleAfter ?? 0; remotePair = { ...remotePair, head: input.to }; + if (options.afterPush) remotePair = { ...remotePair, head: options.afterPush(service, input.to) }; }, readBranch: async () => options.branchRead ? options.branchRead(remotePair.head) : remotePair.head, }, options.operationTimeoutMs, undefined, authorize, options.reserves); @@ -886,13 +898,14 @@ it('returns to review without pushing when the branch moved off the rewritten he }); it('settles an ambiguous push by reading the branch, then prepares again without pushing twice', async () => { - let landed: string | null = null; - const fixture = await rebaseFixture('feature\n', undefined, { - pushFailure: (_service, input) => { landed = input.to; return new Error('connection reset after the push was sent'); }, - branchRead: current => landed ?? current, - }); + let fail = true; + const fixture = await rebaseFixture('feature\n', undefined, { landBeforeFailure: true, + pushFailure: () => fail ? new Error('connection reset after the push was sent') : new Error('No second push expected.') }); expect(fixture.result).toMatchObject({ state: 'failed', reason: 'connection reset after the push was sent' }); expect(pushMarker(fixture)).toBeNull(); + expect(fixture.remote().head).toBe(fixture.rebased); + fail = false; + await expect(prepareAgain(fixture)).resolves.toMatchObject({ state: 'ready', head: fixture.rebased }); expect(fixture.pushes).toHaveLength(1); await fixture.coordinator.close(); await fixture.runner.close(); }); @@ -964,3 +977,59 @@ it('leaves the push marker for startup when shutdown interrupts the push, and st expect(pushMarker(fixture)).toBeNull(); await restarted.close(); await fixture.runner.close(); }); + +it('waits for GitHub to report the pushed head before the later inspections compare against it', async () => { + const fixture = await rebaseFixture('feature\n', 0, { pushVisibleAfter: 2 }); + expect(fixture.result).toMatchObject({ state: 'ready', head: fixture.rebased, checked: ['P1'] }); + expect(fixture.service.load().snapshot.head).toBe(fixture.rebased); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('fails without refreshing the review when GitHub never reports the pushed head in time', async () => { + const fixture = await rebaseFixture('feature\n', 0, { pushVisibleAfter: 1_000, reserves: { pushVisibleMs: 600 } }); + expect(fixture.result).toMatchObject({ state: 'failed', checked: [] }); + expect(fixture.result.reason).toMatch(/has not reported the pushed head/); + expect(fixture.checkedHeads).toEqual([]); + expect(fixture.service.load().snapshot.head).toBe(fixture.rebased); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('lets a cancel during the startup push settlement finish the read and close the task', async () => { + let coordinatorRef: PreMergeCoordinator | null = null; + const fixture = await rebaseFixture('feature\n', undefined, { pushFailure() { + void coordinatorRef!.close(); + return new Error('Server shutdown.'); + }, duringRebase(_service, coordinator) { coordinatorRef = coordinator; } }); + const identity = fixture.service.config.identity; + expect(pushMarker(fixture)).not.toBeNull(); + const read = Promise.withResolvers(); + let readSignal: AbortSignal | undefined; + const restarted = new PreMergeCoordinator(fixture.service, fixture.runner, {} as never, { + inspect: async () => { throw new Error('not used'); }, fetch: async () => undefined, + push: async () => { throw new Error('not used'); }, + readBranch: async (_branch, signal) => { readSignal = signal; return read.promise; } }); + const settling = restarted.settleAtStartup(); + expect(restarted.active).toBe(true); + expect(restarted.cancelTask(fixture.service.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + expect(readSignal?.aborted).toBe(false); + read.resolve(fixture.remote().head); + await expect(settling).resolves.toBe('not-pushed'); + expect(pushMarker(fixture)).toBeNull(); + expect(fixture.service.store.getTask(identity).status).toBe('cancelled'); + await restarted.close(); await fixture.runner.close(); +}); + +it('reviews a collaborator push that lands on the rewritten head right after it was pushed', async () => { + let collaborator = ''; + const fixture = await rebaseFixture('feature\n', 0, { afterPush(service, pushed) { + const tree = fixtureGit(service.config.repository, 'rev-parse', `${pushed}^{tree}`); + collaborator = fixtureGit(service.config.repository, 'commit-tree', tree, '-p', pushed, '-m', 'collaborator on top'); + return collaborator; + } }); + expect(fixture.result).toMatchObject({ state: 'review-required', head: collaborator, checked: [] }); + expect(fixture.result.reason).toMatch(/moved after the rewritten head was pushed/); + expect(fixture.service.load().snapshot).toMatchObject({ base: fixture.remote().base, head: collaborator }); + expect(fixture.checkedHeads).toEqual([]); + await fixture.coordinator.close(); await fixture.runner.close(); +}); diff --git a/test/runner-production.test.ts b/test/runner-production.test.ts index a15f6da4..809a09d5 100644 --- a/test/runner-production.test.ts +++ b/test/runner-production.test.ts @@ -17,6 +17,7 @@ import { exportTaskDiff, removeTaskFilesystemsAsync } from '../agents/container/ import { RunnerCoordinator, type RunnerDeps } from '../runner/coordinator.ts'; import { SafetyFindings, type ExecutionSources } from '../runner/execution.ts'; import type { GitRebaser } from '../runner/rebase.ts'; +import { GitBranchPusher } from '../runner/branch-push.ts'; vi.mock('../agents/recovery.ts', async original => ({ ...await original(), recoverLeftovers: vi.fn() })); const issueReads: number[] = []; @@ -121,6 +122,25 @@ describe('runner startup', () => { expect(await assembly.sources.vendor(service.config.identity)).toBe('claude'); expect(() => assembly.sources.planContext({ ...service.config.identity, planId: 'other' })).toThrow(/only its configured plan/); }); + it('pushes a prepared rewrite only under an exact lease on the PR head it rewrote', async () => { + const { root, service } = fixture(), calls: string[] = []; + const assembly = await setUpRunner({ service, capability, config: { root: join(root, 'runner'), committer }, lock: lock(calls), env: { CLAUDE_CODE_OAUTH_TOKEN: 't' }, + buildImage: () => 'sha256:x', recovery: () => recovery(calls) }); + const push = vi.spyOn(GitBranchPusher.prototype, 'push').mockResolvedValue(); + const read = vi.spyOn(GitBranchPusher.prototype, 'readBranch').mockResolvedValue(null); + try { + const coordinator = assembly.preMerge!({} as RunnerCoordinator, async () => ({ base: 'a'.repeat(40), head: 'a'.repeat(40) }), + async () => ({ refresh: async () => undefined, validate: () => undefined })); + const beforePush = vi.fn(), from = 'c'.repeat(40), to = 'd'.repeat(40); + await coordinator.remote.push({ branch: 'codeboost/task-1', from, to, beforePush }); + expect(push).toHaveBeenCalledWith(service.config.identity, + expect.objectContaining({ head: to, branch: 'codeboost/task-1', expected: from }), undefined); + push.mock.calls[0]![1].beforePush!(); + expect(beforePush).toHaveBeenCalledOnce(); + await expect(coordinator.remote.readBranch('codeboost/task-1')).resolves.toBeNull(); + expect(read).toHaveBeenCalledWith(service.config.identity, 'codeboost/task-1', undefined); + } finally { push.mockRestore(); read.mockRestore(); } + }); it('reads the issue once for a run of items, and again once the reuse window has passed', async () => { const { root, service } = fixture(); service.store.setIssueTrust({ repository: 'owner/repo', issue: service.store.getPlan(service.config.identity).issue, diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index ace90a7e..92db9674 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1409,6 +1409,35 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { expect(w.github.prs[0]!.open).toBe(false); }); + it('settles an interrupted pre-merge push only under the verified lock, and closes the PRs of a task it closed', async () => { + const w = world(), settle = Promise.withResolvers(), calls: string[] = []; + const issueGateway: IssueTrustGateway = { + repository: REPO, + async fetch() { return { repository: REPO, retrievedAt: new Date().toISOString(), issues: [] }; }, + async issueAccess(number) { return { number, authorLogin: 'member', collaborator: true }; }, + async issueText(number) { return { number, title: '', body: '', comments: [] }; }, + }; + const { app, identity, store } = await serve(w, { before: completeAll, issueGateway, preMerge: service => ({ + get active() { return false; }, get last() { return null; }, assertStartable() {}, + settleAtStartup: () => { calls.push('settle'); return settle.promise.then(() => { + // What finishPrePush does when a cancel was requested while the push ran. + service.store.cancelTask(service.config.identity, service.store.getTask(service.config.identity).stateVersion, randomUUID()); + return 'not-pushed' as const; + }); }, + start: async () => { throw new Error('not used'); }, cancelTask: () => 'closed' as const, close: async () => undefined, + } as unknown as PreMergeCoordinator) }); + // serve() ran startup as the CLI does, after its (passing) lock check. + expect(calls).toEqual(['settle']); + expect(() => app.publishOwed(() => { throw new Error('The database path changed.'); })).toThrow(/database path changed/); + expect(calls).toEqual(['settle']); + await publishSettled(app, identity); + expect(w.github.prs[0]!.open).toBe(true); + settle.resolve(); + await vi.waitFor(() => expect(store.lastPublish(identity)).toMatchObject(closedRecord), { timeout: 20_000, interval: 20 }); + expect(store.getTask(identity).status).toBe('cancelled'); + expect(w.github.prs[0]!.open).toBe(false); + }); + it('closes a ready PR when a task in review is cancelled, and keeps the branch', async () => { const w = world(); const { app, identity, store, branch, head } = await serve(w, { before: completeAll }); diff --git a/web/server.ts b/web/server.ts index 5ec62898..5a6fe1d3 100644 --- a/web/server.ts +++ b/web/server.ts @@ -116,8 +116,6 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge validate: () => requireTrustedIssue(access), }; }); - // A push of a rewritten head left unsettled by a crash or shutdown blocks merging until a read of its branch. - void preMerge?.settleAtStartup(); executor = new ItemExecutor(service.store, runner, assembly.sources, assembly.findings, { capability }); // A demo never publishes, whatever its github block or an injected setup provides (setUpRunner refuses demos too). if (assembly.publisher && !config.demo) { const coordinator = runner; publishing = new TaskPublishing(service.store, assembly.publisher(() => coordinator.closing), runner, executor, capability, assembly.env, { @@ -715,9 +713,12 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge * Startup (#103): a publish an earlier process owed (a lost opening, a run that ended before its publish completed) * runs once, in the background; recovery finds a lost opening by its marker. A publish pushes, so `verifyLock` * (the runner lock still names the database) runs first, here: if it throws, nothing starts and its error is thrown. + * Under the same lock, a push of a rewritten head left unsettled by a crash or shutdown (F6a of #22) is settled by a + * read of its branch. That can close a task cancelled meanwhile, so it ends like a preparation: its PRs are closed. */ publishOwed: (verifyLock: () => void): void | Promise => { verifyLock(); + if (preMerge && !stopping) afterPreMerge(preMerge.settleAtStartup()); if (!publishing) return; publishing.startup(identity); }, From b1eaaae517ead684a0f01c4eded64475acc5c6cd Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 10:33:12 -0700 Subject: [PATCH 003/163] Wait for the pushed head even when the base moves 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 --- runner/pre-merge.ts | 3 ++- test/pre-merge.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index ec3fdef1..2ea67412 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -257,7 +257,8 @@ export class PreMergeCoordinator { const until = performance.now() + this.pushVisibleTimeoutMs; for (let delay = 250; ; delay = Math.min(delay * 2, 4_000)) { const seen = await this.remote.inspect(signal); signal.throwIfAborted(); - if (seen.head !== before.head || seen.base !== before.base) return seen; + // Only the head shows whether GitHub caught up: a base that moved meanwhile says nothing about the pushed head. + if (seen.head !== before.head) return seen; const wait = Math.min(delay, until - performance.now(), remaining() - this.processSettlementReserveMs); if (wait < 1) throw new GuardRefusal(`GitHub has not reported the pushed head ${pushed} on the pull request yet. Prepare the merge again shortly.`); await new Promise((resolve, reject) => { diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 0f6835b7..ae92b348 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -64,6 +64,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: reserves?: { processMs?: number; commandMs?: number; pushVisibleMs?: number }; /** The PR API keeps reporting the pre-push head for this many inspections after a push. */ pushVisibleAfter?: number; + /** The base branch moves as the push lands: lagging and later inspections report this base. */ + movedBaseAfterPush?: (service: ReviewService) => string; /** The push lands on the branch before it fails. */ landBeforeFailure?: boolean; /** Runs once the push has landed and may move the branch again, as a collaborator push would. */ @@ -197,9 +199,17 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: if (remotePair.head !== input.from) throw new BranchPushRefused('moved'); lagged = remotePair; lagging = options.pushVisibleAfter ?? 0; remotePair = { ...remotePair, head: input.to }; + if (options.movedBaseAfterPush) { + const base = options.movedBaseAfterPush(service); + lagged = { ...lagged, base }; remotePair = { ...remotePair, base }; + } if (options.afterPush) remotePair = { ...remotePair, head: options.afterPush(service, input.to) }; }, - readBranch: async () => options.branchRead ? options.branchRead(remotePair.head) : remotePair.head, + // A real branch read is aborted by its signal; so is this one, so a cancel that stopped settlement would show. + readBranch: async (_branch, signal) => { + signal?.throwIfAborted(); + return options.branchRead ? options.branchRead(remotePair.head) : remotePair.head; + }, }, options.operationTimeoutMs, undefined, authorize, options.reserves); if (options.closeAfterCommandAdmission) { const start = runner.start.bind(runner); @@ -1033,3 +1043,19 @@ it('reviews a collaborator push that lands on the rewritten head right after it expect(fixture.checkedHeads).toEqual([]); await fixture.coordinator.close(); await fixture.runner.close(); }); + +it('keeps waiting while GitHub still reports the pre-push head, even if the base moved meanwhile', async () => { + let movedBase = ''; + const fixture = await rebaseFixture('feature\n', 0, { pushVisibleAfter: 2, movedBaseAfterPush(service) { + const onto = service.store.getSnapshot(service.config.identity).base; + const tree = fixtureGit(service.config.repository, 'rev-parse', `${onto}^{tree}`); + movedBase = fixtureGit(service.config.repository, 'commit-tree', tree, '-p', onto, '-m', 'base moved again'); + return movedBase; + } }); + expect(fixture.result).toMatchObject({ state: 'review-required', head: fixture.rebased, checked: [] }); + expect(fixture.result.reason).toMatch(/moved after the rewritten head was pushed/); + // The review stays on the head the branch holds, against the base it descends from: never back on the pre-push head. + expect(fixture.service.load().snapshot.head).toBe(fixture.rebased); + expect(fixture.service.load().segments.filter(segment => segment.row === 'Unplanned')).toEqual([]); + await fixture.coordinator.close(); await fixture.runner.close(); +}); From 9e98ad7641a1751a50df64987022251089eaed2f Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 10:42:20 -0700 Subject: [PATCH 004/163] State the pushed-head wait bound exactly 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 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/pre-merge.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 38edcd83..796999eb 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -42,7 +42,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Last local check | Immediately before the write, trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears, the PR head is reread and reviewed, and preparation returns `review-required`. | -| Visible on the PR | GitHub's pull-request API reports a pushed head asynchronously. Preparation inspects the PR with backoff for up to 30 seconds until it reports something other than the pre-push head. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | +| Visible on the PR | GitHub's pull-request API reports a pushed head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | | Unknown outcome | A failure, timeout or cancel during the push reads the branch once, bounded by 30 seconds and by what is left of the preparation's deadline: `to` means pushed, `from` means not pushed, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | | Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 2ea67412..96410efe 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -24,7 +24,10 @@ export interface PreMergeRemote { } /** One bounded read of the branch settles a push whose outcome is unknown. */ export const PUSH_SETTLEMENT_TIMEOUT_MS = 30_000; -/** GitHub's pull-request API reports a pushed head asynchronously; wait at most this long for it after a push. */ +/** + * GitHub's pull-request API reports a pushed head asynchronously. After a push, no new inspection starts once this long + * has passed; the last one may still take its own timeout to answer. + */ export const PUSHED_HEAD_VISIBLE_TIMEOUT_MS = 30_000; export interface PreMergeAuthorization { /** Complete the last external authorization read before a later external operation. */ @@ -249,9 +252,10 @@ export class PreMergeCoordinator { throw new GuardRefusal('This rebase attempt no longer owns its durable marker.'); } /** - * Inspect the PR until GitHub reports something other than the pre-push head, with backoff, bounded by - * `pushVisibleTimeoutMs` and the preparation deadline. A PR still at the pre-push head after that fails the - * preparation without refreshing the review: the branch itself already holds the pushed head. + * Inspect the PR until GitHub reports a head other than the pre-push head, with backoff. No inspection starts after + * `pushVisibleTimeoutMs`, so the wait lasts at most that plus one inspection, within the preparation deadline. A PR + * still at the pre-push head after that fails the preparation without refreshing the review: the branch itself + * already holds the pushed head. */ async #awaitPushedHead(before: RemotePair, pushed: string, signal: AbortSignal, remaining: () => number): Promise { const until = performance.now() + this.pushVisibleTimeoutMs; From 50ba7655e942c690795f11b996346b0006e4a064 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 11:06:10 -0700 Subject: [PATCH 005/163] Settle a stranded push on cancel; wait for the PR head after a refusal 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 --- docs/architecture.md | 2 +- docs/implementation/pre-merge-rebase.md | 6 ++-- runner/pre-merge.ts | 27 +++++++++-------- test/pre-merge.test.ts | 34 ++++++++++++++++----- test/runner-publishing.test.ts | 40 +++++++++++++++++++++++-- web/server.ts | 17 +++++++---- 6 files changed, 94 insertions(+), 32 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7c294ce4..cfd322da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Writing standard: plain language, ISO 24495-1:2023 - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks; required-check refresh, the already-fixed rerun, and the one-click guarded merge handoff remain for F6b. - - **Not built:** screens for the runner actions, remote push/check refresh, review rounds, and the Queue and Learning screens. + - **Not built:** screens for the runner actions, the required-check refresh and one-click merge handoff (F6b), review rounds, and the Queue and Learning screens. ## Terms used diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 796999eb..04aa6626 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -41,10 +41,10 @@ This ordering is intentionally local-only. The push below extends the durable li | Admission | Issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | | Last local check | Immediately before the write, trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | -| Refused | Nothing was pushed. The marker clears, the PR head is reread and reviewed, and preparation returns `review-required`. | -| Visible on the PR | GitHub's pull-request API reports a pushed head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | +| Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | +| Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | | Unknown outcome | A failure, timeout or cancel during the push reads the branch once, bounded by 30 seconds and by what is left of the preparation's deadline: `to` means pushed, `from` means not pushed, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | -| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | +| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push left running by a crashed process cannot overwrite anything newer: its lease still names `from`. diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 96410efe..3d6a1b64 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -179,11 +179,12 @@ export class PreMergeCoordinator { return marker ? this.#settlePush(marker) : null; } /** - * After startup recovery: settle a push left by a crash or shutdown, as one tracked job that shutdown aborts and awaits. - * While it runs, a preparation is refused as already running. Never throws: a failed read leaves the marker for the - * next preparation, and a server already closing or busy skips it. + * Settle a push left by a crash, shutdown or unreadable branch, as one tracked job that shutdown aborts and awaits: + * after startup recovery, and when a task is cancelled with no preparation running. While it runs, a preparation is + * refused as already running. Never throws: a failed read leaves the marker for the next preparation, and a server + * already closing or busy skips it. */ - settleAtStartup(): Promise { + settlePendingPush(): Promise { if (this.#closing || this.#active) return Promise.resolve(null); const active = this.settleInterruptedPush().catch(() => null) .finally(() => { if (this.#active === active) this.#active = null; }); @@ -255,16 +256,16 @@ export class PreMergeCoordinator { * Inspect the PR until GitHub reports a head other than the pre-push head, with backoff. No inspection starts after * `pushVisibleTimeoutMs`, so the wait lasts at most that plus one inspection, within the preparation deadline. A PR * still at the pre-push head after that fails the preparation without refreshing the review: the branch itself - * already holds the pushed head. + * already holds another head. */ - async #awaitPushedHead(before: RemotePair, pushed: string, signal: AbortSignal, remaining: () => number): Promise { + async #awaitHeadChange(before: RemotePair, signal: AbortSignal, remaining: () => number): Promise { const until = performance.now() + this.pushVisibleTimeoutMs; for (let delay = 250; ; delay = Math.min(delay * 2, 4_000)) { const seen = await this.remote.inspect(signal); signal.throwIfAborted(); // Only the head shows whether GitHub caught up: a base that moved meanwhile says nothing about the pushed head. if (seen.head !== before.head) return seen; const wait = Math.min(delay, until - performance.now(), remaining() - this.processSettlementReserveMs); - if (wait < 1) throw new GuardRefusal(`GitHub has not reported the pushed head ${pushed} on the pull request yet. Prepare the merge again shortly.`); + if (wait < 1) throw new GuardRefusal(`GitHub has not reported the pull request's new head yet; it still shows ${before.head}. Prepare the merge again shortly.`); await new Promise((resolve, reject) => { const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(); }, wait); const stop = () => { clearTimeout(timer); reject(signal.reason); }; @@ -464,11 +465,11 @@ export class PreMergeCoordinator { checked: [], reason: blocker }; if (initial.head !== view.snapshot.head) { const pushed = await this.#pushRewrite(initial, view, signal, deadline, authorize, bindRebaseFailure); - // Refused: the branch holds neither the reviewed predecessor nor our head. Otherwise wait for GitHub's PR API, - // which reports a pushed head asynchronously, so the later inspections do not mistake its lag for a move. - const seen = pushed === 'pushed' ? await this.#awaitPushedHead(initial, view.snapshot.head, signal, remaining) : null; - if (pushed !== 'pushed' || seen!.head !== view.snapshot.head || seen!.base !== initial.base) { - const moved = seen ?? await this.remote.inspect(signal); await this.remote.fetch(moved, signal); signal.throwIfAborted(); + // Either way the branch no longer holds the pre-push head: ours after a push, someone else's after a refusal. + // GitHub's PR API reports that asynchronously, so wait for it; its lag must not read as the old head. + const seen = await this.#awaitHeadChange(initial, signal, remaining); + if (pushed !== 'pushed' || seen.head !== view.snapshot.head || seen.base !== initial.base) { + const moved = seen; await this.remote.fetch(moved, signal); signal.throwIfAborted(); // After a push, history is traced from the pushed head; after a refusal, from the head the push would replace. view = track(this.#refresh(moved, reviewBudget, pushed === 'pushed' ? view.snapshot.head : initial.head)); return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], @@ -476,7 +477,7 @@ export class PreMergeCoordinator { ? 'The pull request moved after the rewritten head was pushed; attribution and approvals were refreshed.' : 'The pull request branch moved before the rewritten head could be pushed; attribution and approvals were refreshed.' }; } - initial = seen!; + initial = seen; view = load(); } const checked: string[] = []; diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index ae92b348..6eb6f053 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -68,6 +68,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: movedBaseAfterPush?: (service: ReviewService) => string; /** The push lands on the branch before it fails. */ landBeforeFailure?: boolean; + /** A collaborator pushes this commit to the branch just before codeboost's push, which is then refused. */ + pushedFirst?: (service: ReviewService) => string; /** Runs once the push has landed and may move the branch again, as a collaborator push would. */ afterPush?: (service: ReviewService, pushed: string) => string; pushFailure?: (service: ReviewService, input: { from: string; to: string }) => Error; @@ -195,6 +197,10 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: pushes.push({ branch: input.branch, from: input.from, to: input.to }); input.beforePush(); if (options.landBeforeFailure && remotePair.head === input.from) { lagged = remotePair; remotePair = { ...remotePair, head: input.to }; } + if (options.pushedFirst) { + lagged = remotePair; lagging = options.pushVisibleAfter ?? 0; + remotePair = { ...remotePair, head: options.pushedFirst(service) }; + } if (options.pushFailure) throw options.pushFailure(service, input); if (remotePair.head !== input.from) throw new BranchPushRefused('moved'); lagged = remotePair; lagging = options.pushVisibleAfter ?? 0; @@ -895,18 +901,32 @@ it('does not push a rewrite whose approval became stale', async () => { it('returns to review without pushing when the branch moved off the rewritten head', async () => { let collaborator = ''; - const fixture = await rebaseFixture('feature\n', undefined, { pushFailure(service) { + const fixture = await rebaseFixture('feature\n', undefined, { pushedFirst(service) { writeFileSync(join(service.config.repository, 'late.txt'), 'collaborator\n'); fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'collaborator'); collaborator = fixtureGit(service.config.repository, 'rev-parse', 'HEAD'); - return new BranchPushRefused('The branch moved.'); - }, branchRead: () => collaborator }); - expect(fixture.result).toMatchObject({ state: 'review-required' }); + return collaborator; + } }); + expect(fixture.result).toMatchObject({ state: 'review-required', head: collaborator }); expect(fixture.result.reason).toMatch(/moved before the rewritten head could be pushed/); + expect(fixture.remote().head).toBe(collaborator); expect(pushMarker(fixture)).toBeNull(); await fixture.coordinator.close(); await fixture.runner.close(); }); +it('waits for GitHub to report the collaborator head that refused the push before reviewing it', async () => { + let collaborator = ''; + const fixture = await rebaseFixture('feature\n', undefined, { pushVisibleAfter: 2, pushedFirst(service) { + writeFileSync(join(service.config.repository, 'late.txt'), 'collaborator\n'); + fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'collaborator'); + collaborator = fixtureGit(service.config.repository, 'rev-parse', 'HEAD'); + return collaborator; + } }); + expect(fixture.result).toMatchObject({ state: 'review-required', head: collaborator }); + expect(fixture.service.load().snapshot.head).toBe(collaborator); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('settles an ambiguous push by reading the branch, then prepares again without pushing twice', async () => { let fail = true; const fixture = await rebaseFixture('feature\n', undefined, { landBeforeFailure: true, @@ -983,7 +1003,7 @@ it('leaves the push marker for startup when shutdown interrupts the push, and st const restarted = new PreMergeCoordinator(fixture.service, fixture.runner, {} as never, { inspect: async () => { throw new Error('not used'); }, fetch: async () => undefined, push: async () => { throw new Error('not used'); }, readBranch: async () => fixture.remote().head }); - await expect(restarted.settleAtStartup()).resolves.toBe('not-pushed'); + await expect(restarted.settlePendingPush()).resolves.toBe('not-pushed'); expect(pushMarker(fixture)).toBeNull(); await restarted.close(); await fixture.runner.close(); }); @@ -998,7 +1018,7 @@ it('waits for GitHub to report the pushed head before the later inspections comp it('fails without refreshing the review when GitHub never reports the pushed head in time', async () => { const fixture = await rebaseFixture('feature\n', 0, { pushVisibleAfter: 1_000, reserves: { pushVisibleMs: 600 } }); expect(fixture.result).toMatchObject({ state: 'failed', checked: [] }); - expect(fixture.result.reason).toMatch(/has not reported the pushed head/); + expect(fixture.result.reason).toMatch(/has not reported the pull request's new head yet/); expect(fixture.checkedHeads).toEqual([]); expect(fixture.service.load().snapshot.head).toBe(fixture.rebased); expect(pushMarker(fixture)).toBeNull(); @@ -1019,7 +1039,7 @@ it('lets a cancel during the startup push settlement finish the read and close t inspect: async () => { throw new Error('not used'); }, fetch: async () => undefined, push: async () => { throw new Error('not used'); }, readBranch: async (_branch, signal) => { readSignal = signal; return read.promise; } }); - const settling = restarted.settleAtStartup(); + const settling = restarted.settlePendingPush(); expect(restarted.active).toBe(true); expect(restarted.cancelTask(fixture.service.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); expect(readSignal?.aborted).toBe(false); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 92db9674..43e6bbbb 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1369,7 +1369,7 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { }; let writesClosed: boolean | undefined; const { close } = await serve(w, { issueGateway, preMerge: service => ({ - get active() { return false; }, get last() { return null; }, assertStartable() {}, settleAtStartup: async () => null, + get active() { return false; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, start: async () => { throw new Error('not used'); }, cancelTask: () => 'closed' as const, close: async () => { writesClosed = service.store.writesClosed; }, } as unknown as PreMergeCoordinator) }); @@ -1387,7 +1387,7 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { }; let active = false, cancelId: string | undefined; const { app, identity, store } = await serve(w, { before: completeAll, issueGateway, preMerge: service => ({ - get active() { return active; }, get last() { return null; }, assertStartable() {}, settleAtStartup: async () => null, + get active() { return active; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, start: () => { active = true; return settle.promise.then(() => { @@ -1419,7 +1419,7 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { }; const { app, identity, store } = await serve(w, { before: completeAll, issueGateway, preMerge: service => ({ get active() { return false; }, get last() { return null; }, assertStartable() {}, - settleAtStartup: () => { calls.push('settle'); return settle.promise.then(() => { + settlePendingPush: () => { calls.push('settle'); return settle.promise.then(() => { // What finishPrePush does when a cancel was requested while the push ran. service.store.cancelTask(service.config.identity, service.store.getTask(service.config.identity).stateVersion, randomUUID()); return 'not-pushed' as const; @@ -1438,6 +1438,40 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { expect(w.github.prs[0]!.open).toBe(false); }); + it('settles a leftover pre-merge push when the task is cancelled with no preparation running, then closes its PRs', async () => { + const w = world(), settled: string[] = []; + const issueGateway: IssueTrustGateway = { + repository: REPO, + async fetch() { return { repository: REPO, retrievedAt: new Date().toISOString(), issues: [] }; }, + async issueAccess(number) { return { number, authorLogin: 'member', collaborator: true }; }, + async issueText(number) { return { number, title: '', body: '', comments: [] }; }, + }; + const { app, identity, store, branch } = await serve(w, { before: completeAll, issueGateway, preMerge: service => ({ + get active() { return false; }, get last() { return null; }, assertStartable() {}, + // Startup finds no marker yet; the cancel below must start a settlement of its own. + settlePendingPush: async () => { + const marker = service.store.getTask(service.config.identity).pushInProgress; + if (!marker) return null; + settled.push(marker.attemptId); + service.store.finishPrePush(service.store.getTask(service.config.identity).planKey, marker.attemptId); + return 'not-pushed' as const; + }, + start: async () => { throw new Error('not used'); }, cancelTask: () => { throw new Error('not routed while inactive'); }, + close: async () => undefined, + } as unknown as PreMergeCoordinator) }); + await publishSettled(app, identity); + // An earlier preparation rewrote the head and could not read the branch after its push: the marker stayed. + const reviewed = () => ({ revision: store.getPlan(identity).revision, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + const head = store.getSnapshot(identity).head, rewritten = 'e'.repeat(40); + store.recordRebase(identity, reviewed(), 'f'.repeat(40), rewritten, [{ oldSha: head, newSha: rewritten }]); + const marker = store.beginPrePush(identity, reviewed(), store.getTask(identity).stateVersion, { branch, from: head, to: rewritten }); + expect((await act(app, 'cancel-task')).body.result).toEqual({ outcome: 'stopping' }); + await vi.waitFor(() => expect(store.lastPublish(identity)).toMatchObject(closedRecord), { timeout: 20_000, interval: 20 }); + expect(settled).toEqual([marker.attemptId]); + expect(store.getTask(identity)).toMatchObject({ status: 'cancelled', pushInProgress: null }); + expect(w.github.prs[0]!.open).toBe(false); + }); + it('closes a ready PR when a task in review is cancelled, and keeps the branch', async () => { const w = world(); const { app, identity, store, branch, head } = await serve(w, { before: completeAll }); diff --git a/web/server.ts b/web/server.ts index 5a6fe1d3..d97e6875 100644 --- a/web/server.ts +++ b/web/server.ts @@ -389,10 +389,17 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if (service.store.getTask(identity).stateVersion !== expectedStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); if ((action === 'start' || action === 'resume' || action === 'approve-continuation' || action === 'prepare-merge') && service.store.reviewVersion(identity) !== expectedReviewVersion) throw new GuardRefusal('Stale review state. Reload before writing.'); - if (action === 'cancel-task') return { outcome: preMerge?.active - ? preMerge.cancelTask(expectedStateVersion as number, actionId as string) - : runner ? runner.cancelTask(identity, expectedStateVersion as number, actionId as string) - : service.store.cancelTask(identity, expectedStateVersion as number, actionId as string) }; + if (action === 'cancel-task') { + const outcome = preMerge?.active + ? preMerge.cancelTask(expectedStateVersion as number, actionId as string) + : runner ? runner.cancelTask(identity, expectedStateVersion as number, actionId as string) + : service.store.cancelTask(identity, expectedStateVersion as number, actionId as string); + // A push of a rewritten head whose outcome is still unknown holds this cancel at 'stopping'. With no preparation + // to settle it, read its branch now; clearing the marker closes the task, and then its PRs. + if (outcome === 'stopping' && preMerge && !preMerge.active && !stopping && service.store.getTask(identity).pushInProgress !== null) + service.store.afterCommit(() => afterPreMerge(preMerge!.settlePendingPush())); + return { outcome }; + } if (!runner) throw new GuardRefusal(config.demo ? RUNNER_NOT_IN_DEMO : RUNNER_NOT_CONFIGURED); if (action === 'prepare-merge') { if (access) requireTrustedIssue(access); @@ -718,7 +725,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge */ publishOwed: (verifyLock: () => void): void | Promise => { verifyLock(); - if (preMerge && !stopping) afterPreMerge(preMerge.settleAtStartup()); + if (preMerge && !stopping) afterPreMerge(preMerge.settlePendingPush()); if (!publishing) return; publishing.startup(identity); }, From e5ce306a15a1660fe256e1193b240a701bf2b648 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 11:26:01 -0700 Subject: [PATCH 006/163] Bind a post-push visibility failure; test startup settlement faithfully 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 --- runner/pre-merge.ts | 9 ++++++++- test/pre-merge.test.ts | 1 + test/runner-publishing.test.ts | 34 ++++++++++++++++++++++------------ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 3d6a1b64..b7876025 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -467,7 +467,14 @@ export class PreMergeCoordinator { const pushed = await this.#pushRewrite(initial, view, signal, deadline, authorize, bindRebaseFailure); // Either way the branch no longer holds the pre-push head: ours after a push, someone else's after a refusal. // GitHub's PR API reports that asynchronously, so wait for it; its lag must not read as the old head. - const seen = await this.#awaitHeadChange(initial, signal, remaining); + let seen: RemotePair; + try { seen = await this.#awaitHeadChange(initial, signal, remaining); } + catch (error) { + // The push (or refusal) advanced task state; bind this failure to it so it is reported as current, not stale. + bindRebaseFailure({ reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id }, + { base: view.snapshot.base, head: view.snapshot.head }); + throw error; + } if (pushed !== 'pushed' || seen.head !== view.snapshot.head || seen.base !== initial.base) { const moved = seen; await this.remote.fetch(moved, signal); signal.throwIfAborted(); // After a push, history is traced from the pushed head; after a refusal, from the head the push would replace. diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 6eb6f053..d26595e5 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1019,6 +1019,7 @@ it('fails without refreshing the review when GitHub never reports the pushed hea const fixture = await rebaseFixture('feature\n', 0, { pushVisibleAfter: 1_000, reserves: { pushVisibleMs: 600 } }); expect(fixture.result).toMatchObject({ state: 'failed', checked: [] }); expect(fixture.result.reason).toMatch(/has not reported the pull request's new head yet/); + expect(fixture.coordinator.last).toMatchObject({ state: 'failed', stale: false }); expect(fixture.checkedHeads).toEqual([]); expect(fixture.service.load().snapshot.head).toBe(fixture.rebased); expect(pushMarker(fixture)).toBeNull(); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 43e6bbbb..c66125a3 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1410,31 +1410,41 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { }); it('settles an interrupted pre-merge push only under the verified lock, and closes the PRs of a task it closed', async () => { - const w = world(), settle = Promise.withResolvers(), calls: string[] = []; + const w = world(), calls: string[] = []; const issueGateway: IssueTrustGateway = { repository: REPO, async fetch() { return { repository: REPO, retrievedAt: new Date().toISOString(), issues: [] }; }, async issueAccess(number) { return { number, authorLogin: 'member', collaborator: true }; }, async issueText(number) { return { number, title: '', body: '', comments: [] }; }, }; - const { app, identity, store } = await serve(w, { before: completeAll, issueGateway, preMerge: service => ({ + const { app, identity, store, branch } = await serve(w, { before: completeAll, issueGateway, preMerge: service => ({ get active() { return false; }, get last() { return null; }, assertStartable() {}, - settlePendingPush: () => { calls.push('settle'); return settle.promise.then(() => { - // What finishPrePush does when a cancel was requested while the push ran. - service.store.cancelTask(service.config.identity, service.store.getTask(service.config.identity).stateVersion, randomUUID()); + // As the real job does: read the branch, then clear the marker it finds. + settlePendingPush: async () => { + calls.push('settle'); + const marker = service.store.getTask(service.config.identity).pushInProgress; + if (!marker) return null; + service.store.finishPrePush(service.store.getTask(service.config.identity).planKey, marker.attemptId); return 'not-pushed' as const; - }); }, - start: async () => { throw new Error('not used'); }, cancelTask: () => 'closed' as const, close: async () => undefined, + }, + start: async () => { throw new Error('not used'); }, cancelTask: () => { throw new Error('not used'); }, close: async () => undefined, } as unknown as PreMergeCoordinator) }); - // serve() ran startup as the CLI does, after its (passing) lock check. + // serve() ran startup as the CLI does, after its (passing) lock check; nothing was pending then. expect(calls).toEqual(['settle']); + await publishSettled(app, identity); + // The state a crash leaves: a push of the rewritten head whose outcome is unknown, and a cancel requested meanwhile. + const reviewed = () => ({ revision: store.getPlan(identity).revision, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + const head = store.getSnapshot(identity).head, rewritten = 'e'.repeat(40); + store.recordRebase(identity, reviewed(), 'f'.repeat(40), rewritten, [{ oldSha: head, newSha: rewritten }]); + store.beginPrePush(identity, reviewed(), store.getTask(identity).stateVersion, { branch, from: head, to: rewritten }); + expect(store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + // A failed lock check starts nothing. expect(() => app.publishOwed(() => { throw new Error('The database path changed.'); })).toThrow(/database path changed/); expect(calls).toEqual(['settle']); - await publishSettled(app, identity); - expect(w.github.prs[0]!.open).toBe(true); - settle.resolve(); + app.publishOwed(() => { calls.push('verify'); }); + expect(calls).toEqual(['settle', 'verify', 'settle']); await vi.waitFor(() => expect(store.lastPublish(identity)).toMatchObject(closedRecord), { timeout: 20_000, interval: 20 }); - expect(store.getTask(identity).status).toBe('cancelled'); + expect(store.getTask(identity)).toMatchObject({ status: 'cancelled', pushInProgress: null }); expect(w.github.prs[0]!.open).toBe(false); }); From 496d4b3cd27a3b4f1fb3502349d8830e1807e0cb Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 11:56:05 -0700 Subject: [PATCH 007/163] Retry push settlement on a repeated cancel; test the push boundary 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 --- docs/implementation/pre-merge-rebase.md | 2 +- test/pre-merge.test.ts | 29 +++++++++++++++++++++++++ test/runner-publishing.test.ts | 12 ++++++++-- web/server.ts | 7 ++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 04aa6626..f39421f3 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -44,7 +44,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | | Unknown outcome | A failure, timeout or cancel during the push reads the branch once, bounded by 30 seconds and by what is left of the preparation's deadline: `to` means pushed, `from` means not pushed, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | -| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | +| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push left running by a crashed process cannot overwrite anything newer: its lease still names `from`. diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index d26595e5..11d7d410 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1080,3 +1080,32 @@ it('keeps waiting while GitHub still reports the pre-push head, even if the base expect(fixture.service.load().segments.filter(segment => segment.row === 'Unplanned')).toEqual([]); await fixture.coordinator.close(); await fixture.runner.close(); }); + +it('pushes nothing when a cancel is requested at the push boundary, and closes the task once the outcome is read', async () => { + const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { + if (call !== 3) return; // validations: 1 rebase, 2 push read, 3 push boundary + const identity = service.config.identity; + // Store-level, as a cancel committed by another request would be: this preparation's signal is not aborted. + expect(service.store.cancelTask(identity, service.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + } }); + expect(fixture.result).toMatchObject({ state: 'failed' }); + expect(fixture.result.reason).toMatch(/changed before the rewritten head was pushed/); + expect(fixture.remote().head).not.toBe(fixture.rebased); + expect(fixture.service.store.getTask(fixture.service.config.identity)).toMatchObject({ status: 'cancelled', pushInProgress: null }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('pushes nothing when the reviewed snapshot changes at the push boundary', async () => { + const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { + if (call !== 3) return; // validations: 1 rebase, 2 push read, 3 push boundary + const identity = service.config.identity, view = service.load(), snapshot = service.store.getSnapshot(identity); + const tree = fixtureGit(service.config.repository, 'rev-parse', `${snapshot.head}^{tree}`); + const later = fixtureGit(service.config.repository, 'commit-tree', tree, '-p', snapshot.head, '-m', 'later'); + service.store.recordHistory(identity, view.expected, snapshot.base, later, []); + } }); + expect(fixture.result).toMatchObject({ state: 'failed' }); + expect(fixture.result.reason).toMatch(/changed before the rewritten head was pushed/); + expect(fixture.remote().head).not.toBe(fixture.rebased); + expect(fixture.service.store.getTask(fixture.service.config.identity).pushInProgress).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index c66125a3..2939ce91 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1448,8 +1448,9 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { expect(w.github.prs[0]!.open).toBe(false); }); - it('settles a leftover pre-merge push when the task is cancelled with no preparation running, then closes its PRs', async () => { + it('settles a leftover pre-merge push when the task is cancelled with no preparation running, retrying on a repeated cancel', async () => { const w = world(), settled: string[] = []; + let readable = false; const issueGateway: IssueTrustGateway = { repository: REPO, async fetch() { return { repository: REPO, retrievedAt: new Date().toISOString(), issues: [] }; }, @@ -1463,6 +1464,8 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { const marker = service.store.getTask(service.config.identity).pushInProgress; if (!marker) return null; settled.push(marker.attemptId); + // As the real job does when GitHub cannot be read: the marker stays. + if (!readable) return null; service.store.finishPrePush(service.store.getTask(service.config.identity).planKey, marker.attemptId); return 'not-pushed' as const; }, @@ -1476,8 +1479,13 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { store.recordRebase(identity, reviewed(), 'f'.repeat(40), rewritten, [{ oldSha: head, newSha: rewritten }]); const marker = store.beginPrePush(identity, reviewed(), store.getTask(identity).stateVersion, { branch, from: head, to: rewritten }); expect((await act(app, 'cancel-task')).body.result).toEqual({ outcome: 'stopping' }); + await vi.waitFor(() => expect(settled).toEqual([marker.attemptId]), { timeout: 20_000, interval: 20 }); + expect(store.getTask(identity)).toMatchObject({ status: 'in review', pushInProgress: marker }); + // GitHub is reachable again: repeating the cancel reads the branch again instead of refusing. + readable = true; + expect((await act(app, 'cancel-task')).body.result).toEqual({ outcome: 'stopping' }); await vi.waitFor(() => expect(store.lastPublish(identity)).toMatchObject(closedRecord), { timeout: 20_000, interval: 20 }); - expect(settled).toEqual([marker.attemptId]); + expect(settled).toEqual([marker.attemptId, marker.attemptId]); expect(store.getTask(identity)).toMatchObject({ status: 'cancelled', pushInProgress: null }); expect(w.github.prs[0]!.open).toBe(false); }); diff --git a/web/server.ts b/web/server.ts index d97e6875..55800ba1 100644 --- a/web/server.ts +++ b/web/server.ts @@ -390,6 +390,13 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if ((action === 'start' || action === 'resume' || action === 'approve-continuation' || action === 'prepare-merge') && service.store.reviewVersion(identity) !== expectedReviewVersion) throw new GuardRefusal('Stale review state. Reload before writing.'); if (action === 'cancel-task') { + // A cancel already waits on a push whose outcome is unknown, and the read that would settle it failed. Read the + // branch again instead of refusing, so repeating the cancel can still close the task without a restart. + const pending = service.store.getTask(identity); + if (preMerge && !preMerge.active && !stopping && pending.pushInProgress !== null && pending.cancelRequested !== null) { + service.store.afterCommit(() => afterPreMerge(preMerge!.settlePendingPush())); + return { outcome: 'stopping' as const }; + } const outcome = preMerge?.active ? preMerge.cancelTask(expectedStateVersion as number, actionId as string) : runner ? runner.cancelTask(identity, expectedStateVersion as number, actionId as string) From d0639c9f807815527d10c4ea3f7683e14fb620ba Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 12:24:05 -0700 Subject: [PATCH 008/163] Trace a moved PR head through the whole rewrite lineage 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 --- runner/pre-merge.ts | 17 +++++------ test/pre-merge.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index b7876025..ff78d114 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -465,20 +465,17 @@ export class PreMergeCoordinator { checked: [], reason: blocker }; if (initial.head !== view.snapshot.head) { const pushed = await this.#pushRewrite(initial, view, signal, deadline, authorize, bindRebaseFailure); + // The push (or refusal) advanced task state; bind any later failure to it so it is reported as current, not stale. + bindRebaseFailure({ reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id }, + { base: view.snapshot.base, head: view.snapshot.head }); // Either way the branch no longer holds the pre-push head: ours after a push, someone else's after a refusal. // GitHub's PR API reports that asynchronously, so wait for it; its lag must not read as the old head. - let seen: RemotePair; - try { seen = await this.#awaitHeadChange(initial, signal, remaining); } - catch (error) { - // The push (or refusal) advanced task state; bind this failure to it so it is reported as current, not stale. - bindRebaseFailure({ reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id }, - { base: view.snapshot.base, head: view.snapshot.head }); - throw error; - } + const seen = await this.#awaitHeadChange(initial, signal, remaining); if (pushed !== 'pushed' || seen.head !== view.snapshot.head || seen.base !== initial.base) { const moved = seen; await this.remote.fetch(moved, signal); signal.throwIfAborted(); - // After a push, history is traced from the pushed head; after a refusal, from the head the push would replace. - view = track(this.#refresh(moved, reviewBudget, pushed === 'pushed' ? view.snapshot.head : initial.head)); + // Traced from the reviewed head and its rewrite lineage, nearest first. That lineage holds the pushed head, every + // earlier rewrite codeboost may already have pushed, and the original head a collaborator may have built on. + view = track(this.#refresh(moved, reviewBudget, view.snapshot.head)); return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], reason: pushed === 'pushed' ? 'The pull request moved after the rewritten head was pushed; attribution and approvals were refreshed.' diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 11d7d410..afdd6c21 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -68,6 +68,10 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: movedBaseAfterPush?: (service: ReviewService) => string; /** The push lands on the branch before it fails. */ landBeforeFailure?: boolean; + /** A later rebase's result (the first rebase always yields the fixture's `rebased`); `undefined` keeps the default. */ + rebaseResult?: (service: ReviewService, input: { oldHead: string; onto: string }, run: number) => string | undefined; + /** Remote fetches fail once codeboost has pushed. */ + fetchFailsAfterPush?: boolean; /** A collaborator pushes this commit to the branch just before codeboost's push, which is then refused. */ pushedFirst?: (service: ReviewService) => string; /** Runs once the push has landed and may move the branch again, as a collaborator push would. */ @@ -107,7 +111,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: let coordinator!: PreMergeCoordinator, rebaseRuns = 0; let rebaseAborts = 0; const rebaseBudgets: Array = [], abortBudgets: Array = []; - const rebaser = { run: async (input: { attemptId: string; signal: AbortSignal; timeoutMs?: number }) => { + const rebaser = { run: async (input: { attemptId: string; signal: AbortSignal; timeoutMs?: number; oldHead: string; onto: string }) => { rebaseRuns++; rebaseBudgets.push(input.timeoutMs); options.duringRebase?.(service, coordinator, input.timeoutMs); @@ -115,6 +119,12 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: if (options.unsettledRebase) throw new RebaseResourcesUnsettled('Conflict resources remain owned.'); if (options.rebaseFailure) throw options.rebaseFailure; const planKey = service.store.getTask(identity).planKey; + const custom = options.rebaseResult?.(service, input, rebaseRuns); + if (custom) { + service.store.prepareRebaseResult(planKey, input.attemptId, custom, [custom]); + service.store.completeRebaseResult(planKey, input.attemptId); + return { oldHead: input.oldHead, base: input.onto, head: custom, mappings: [{ oldSha: input.oldHead, newSha: custom }], resolvedConflicts: [] }; + } service.store.prepareRebaseResult(planKey, input.attemptId, rebased, [rebased]); service.store.completeRebaseResult(planKey, input.attemptId); return { oldHead: head, base: onto, head: rebased, @@ -192,7 +202,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: // Read 2 is the post-push visibility check; hooks keep counting the later inspections as before the push existed. if (remoteReads > 2) options.duringFinalInspect?.(service, coordinator, remoteReads - 1); return remotePair; - }, fetch: async () => undefined, + }, fetch: async () => { if (options.fetchFailsAfterPush && pushes.length) throw new Error('fetch failed after the push'); }, push: async input => { pushes.push({ branch: input.branch, from: input.from, to: input.to }); input.beforePush(); @@ -229,6 +239,11 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons, pushes, remote: () => remotePair, + /** The branch moves (as GitHub's Git data shows it); the PR API reports `shown` for the next `reads` inspections. */ + moveRemote: (pair: { base: string; head: string }, shown?: { base: string; head: string }, reads = 0) => { + remotePair = { ...remotePair, ...pair }; + if (shown) { lagged = { ...remotePair, ...shown }; lagging = reads; } else lagging = 0; + }, rebaseRuns: () => rebaseRuns, rebaseAborts: () => rebaseAborts, rebaseBudgets, abortBudgets }; } @@ -1109,3 +1124,48 @@ it('pushes nothing when the reviewed snapshot changes at the push boundary', asy expect(fixture.service.store.getTask(fixture.service.config.identity).pushInProgress).toBeNull(); await fixture.coordinator.close(); await fixture.runner.close(); }); + +it('after a refused push, traces a lagging earlier push of codeboost\'s own rewrite from its own base, not the original one', async () => { + // 1. The push of R1 lands, but the PR API keeps showing the original head, so preparation fails. + let r2 = ''; + const fixture = await rebaseFixture('feature\n', undefined, { pushVisibleAfter: 1_000, reserves: { pushVisibleMs: 300 }, + rebaseResult(service, input, run) { + if (run !== 2) return undefined; + // 3. The second rebase rewrites R1 onto the moved base as R2. + const repository = service.config.repository; + fixtureGit(repository, 'switch', '-qc', 'second-rebase', input.onto); + writeFileSync(join(repository, 'a.txt'), 'feature\n'); fixtureGit(repository, 'commit', '-qam', 'rebased feature again'); + r2 = fixtureGit(repository, 'rev-parse', 'HEAD'); + fixtureGit(repository, 'switch', '-q', 'feature'); + return r2; + } }); + expect(fixture.result).toMatchObject({ state: 'failed' }); + const identity = fixture.service.config.identity, r1 = fixture.rebased, b1 = fixture.service.store.getSnapshot(identity).base; + expect(fixture.remote().head).toBe(r1); + // 2. The base branch moves again; the PR API still shows the original head for two more reads. + const repository = fixture.service.config.repository; + fixtureGit(repository, 'switch', '-qc', 'moved-again', b1); + writeFileSync(join(repository, 'c.txt'), 'later base\n'); fixtureGit(repository, 'add', 'c.txt'); fixtureGit(repository, 'commit', '-qm', 'move base again'); + const b2 = fixtureGit(repository, 'rev-parse', 'HEAD'); + fixtureGit(repository, 'switch', '-q', 'feature'); + const original = fixture.pushes[0]!.from; + fixture.moveRemote({ base: b2, head: r1 }, { base: b2, head: original }, 2); + // 4. The retry rebases R1 onto B2 and pushes R2 leased to the original head; the branch holds R1, so it is refused. + const retried = await prepareAgain(fixture); + expect(retried).toMatchObject({ state: 'review-required', head: r1 }); + expect(fixture.pushes.at(-1)).toMatchObject({ from: original, to: r2 }); + // R1 is reviewed against B1, the base it was rewritten onto: the upstream base commit is not attributed to the PR. + expect(fixture.service.store.getSnapshot(identity)).toMatchObject({ base: b1, head: r1 }); + expect(fixture.service.load().segments.filter(segment => segment.row === 'Unplanned')).toEqual([]); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('reports a failure after a successful push as current, not stale', async () => { + const fixture = await rebaseFixture('feature\n', 0, { fetchFailsAfterPush: true, afterPush(service, pushed) { + const tree = fixtureGit(service.config.repository, 'rev-parse', `${pushed}^{tree}`); + return fixtureGit(service.config.repository, 'commit-tree', tree, '-p', pushed, '-m', 'collaborator on top'); + } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'fetch failed after the push' }); + expect(fixture.coordinator.last).toMatchObject({ state: 'failed', stale: false }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); From 6ab346d61b368760c8961fd83a79e8f3ecde288a Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 13:04:47 -0700 Subject: [PATCH 009/163] Test the push boundary's state check and every settlement bound 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 --- docs/implementation/pre-merge-rebase.md | 4 +- test/pre-merge.test.ts | 71 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index f39421f3..2d3dbff8 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -43,10 +43,10 @@ This ordering is intentionally local-only. The push below extends the durable li | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | -| Unknown outcome | A failure, timeout or cancel during the push reads the branch once, bounded by 30 seconds and by what is left of the preparation's deadline: `to` means pushed, `from` means not pushed, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | +| Unknown outcome | A failure, timeout or cancel during the push reads the branch once, bounded by 30 seconds and by what is left of the preparation's deadline: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | | Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | -A push left running by a crashed process cannot overwrite anything newer: its lease still names `from`. +A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. ## Regression evidence diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index afdd6c21..264fc8a9 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -70,6 +70,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: landBeforeFailure?: boolean; /** A later rebase's result (the first rebase always yields the fixture's `rebased`); `undefined` keeps the default. */ rebaseResult?: (service: ReviewService, input: { oldHead: string; onto: string }, run: number) => string | undefined; + /** Branch reads never answer; only their abort signal ends them. */ + branchReadHangs?: boolean; /** Remote fetches fail once codeboost has pushed. */ fetchFailsAfterPush?: boolean; /** A collaborator pushes this commit to the branch just before codeboost's push, which is then refused. */ @@ -224,6 +226,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: // A real branch read is aborted by its signal; so is this one, so a cancel that stopped settlement would show. readBranch: async (_branch, signal) => { signal?.throwIfAborted(); + if (options.branchReadHangs) return new Promise((_resolve, reject) => + signal?.addEventListener('abort', () => reject(signal.reason), { once: true })); return options.branchRead ? options.branchRead(remotePair.head) : remotePair.head; }, }, options.operationTimeoutMs, undefined, authorize, options.reserves); @@ -1169,3 +1173,70 @@ it('reports a failure after a successful push as current, not stale', async () = expect(fixture.coordinator.last).toMatchObject({ state: 'failed', stale: false }); await fixture.coordinator.close(); await fixture.runner.close(); }); + +/** Make the monotonic clock jump so only `leftMs` of a default ten-minute preparation deadline remains. */ +const leaveOfDeadline = (startedAt: number, leftMs: number) => { + const real = performance.now.bind(performance), offset = startedAt + 10 * 60_000 - leftMs - real(); + vi.spyOn(performance, 'now').mockImplementation(() => real() + offset); +}; + +it('pushes nothing when a plan revision is applied at the push boundary', async () => { + const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { + if (call !== 3) return; // validations: 1 rebase, 2 push read, 3 push boundary + const plan = service.store.getPlan(service.config.identity); + // A plan revision advances only the task state version: neither the review version nor the snapshot changes. + service.store.importRevision(JSON.stringify({ ...plan, summary: 'Amended at the push boundary' }), 'json', + service.planContext(), plan.revision); + } }); + expect(fixture.result).toMatchObject({ state: 'failed' }); + expect(fixture.result.reason).toMatch(/changed before the rewritten head was pushed/); + expect(fixture.remote().head).not.toBe(fixture.rebased); + expect(fixture.pushes).toHaveLength(1); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('ends the wait for the pushed head when the preparation deadline, not the visibility limit, runs out', async () => { + const startedAt = performance.now(), waitStarted: number[] = []; + const fixture = await rebaseFixture('feature\n', undefined, { pushVisibleAfter: 1_000_000, afterPush(_service, pushed) { + // The process-settlement reserve plus 1.5 s remain once the push lands; the 30 s visibility limit would outlast it. + leaveOfDeadline(startedAt, PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS + 1_500); + waitStarted.push(Date.now()); + return pushed; + } }); + expect(fixture.result).toMatchObject({ state: 'failed' }); + expect(fixture.result.reason).toMatch(/has not reported the pull request's new head yet/); + expect(Date.now() - waitStarted[0]!).toBeLessThan(10_000); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('bounds the settlement read after a failed push by what is left of the preparation deadline', async () => { + const startedAt = performance.now(); + const fixture = await rebaseFixture('feature\n', undefined, { branchReadHangs: true, pushFailure() { + leaveOfDeadline(startedAt, 1_000); + return new Error('connection reset'); + } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'connection reset' }); + // The read was cut off by the deadline, so the outcome is still unknown and the marker stays. + expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('lets shutdown abort a startup push settlement whose branch read does not answer', async () => { + let coordinatorRef: PreMergeCoordinator | null = null; + const fixture = await rebaseFixture('feature\n', undefined, { pushFailure() { + void coordinatorRef!.close(); + return new Error('Server shutdown.'); + }, duringRebase(_service, coordinator) { coordinatorRef = coordinator; } }); + expect(pushMarker(fixture)).not.toBeNull(); + const restarted = new PreMergeCoordinator(fixture.service, fixture.runner, {} as never, { + inspect: async () => { throw new Error('not used'); }, fetch: async () => undefined, + push: async () => { throw new Error('not used'); }, + readBranch: async (_branch, signal) => new Promise((_resolve, reject) => + signal?.addEventListener('abort', () => reject(signal.reason), { once: true })) }); + const settling = restarted.settlePendingPush(), closedAt = Date.now(); + await restarted.close(); + expect(Date.now() - closedAt).toBeLessThan(5_000); + await expect(settling).resolves.toBeNull(); + expect(pushMarker(fixture)).not.toBeNull(); + await fixture.runner.close(); +}); From 0be746274fd9a325cdcbd62dfd3dfbea288af06d Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 13:32:01 -0700 Subject: [PATCH 010/163] Cover cancel-safe settlement, admission and shutdown reads 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 --- test/pre-merge.test.ts | 48 +++++++++++++++++++++++------ test/runner-lifecycle-store.test.ts | 3 ++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 264fc8a9..f7b80db7 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -79,7 +79,9 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: /** Runs once the push has landed and may move the branch again, as a collaborator push would. */ afterPush?: (service: ReviewService, pushed: string) => string; pushFailure?: (service: ReviewService, input: { from: string; to: string }) => Error; - branchRead?: (current: string) => string | null | Promise; + branchRead?: (current: string, signal?: AbortSignal) => string | null | Promise; + /** Runs while the push reads the remote, before its final local checks (the pusher's `beforePush`). */ + duringPushRead?: (service: ReviewService, coordinator: PreMergeCoordinator) => void; } = {}) { const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-base-')); roots.push(root); const repository = join(root, 'repo'); fixtureGit(root, 'init', '-q', '-b', 'main', repository); @@ -190,7 +192,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }, context.allowedCommands, 'a'.repeat(32))); let remoteReads = 0, remotePair: { base: string; head: string; branch?: string } = { base: onto, head, branch: BRANCH }; const pushes: { branch: string; from: string; to: string }[] = []; - let lagging = 0, lagged = remotePair; + let lagging = 0, lagged = remotePair, branchReads = 0; let authorizationChecks = 0; const authorize = options.authorize ?? (options.duringAuthorize ? async () => ({ refresh: async () => undefined, @@ -207,6 +209,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }, fetch: async () => { if (options.fetchFailsAfterPush && pushes.length) throw new Error('fetch failed after the push'); }, push: async input => { pushes.push({ branch: input.branch, from: input.from, to: input.to }); + options.duringPushRead?.(service, coordinator); input.beforePush(); if (options.landBeforeFailure && remotePair.head === input.from) { lagged = remotePair; remotePair = { ...remotePair, head: input.to }; } if (options.pushedFirst) { @@ -225,10 +228,11 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }, // A real branch read is aborted by its signal; so is this one, so a cancel that stopped settlement would show. readBranch: async (_branch, signal) => { + branchReads++; signal?.throwIfAborted(); if (options.branchReadHangs) return new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })); - return options.branchRead ? options.branchRead(remotePair.head) : remotePair.head; + return options.branchRead ? options.branchRead(remotePair.head, signal) : remotePair.head; }, }, options.operationTimeoutMs, undefined, authorize, options.reserves); if (options.closeAfterCommandAdmission) { @@ -243,6 +247,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons, pushes, remote: () => remotePair, + branchReads: () => branchReads, /** The branch moves (as GitHub's Git data shows it); the PR API reports `shown` for the next `reads` inspections. */ moveRemote: (pair: { base: string; head: string }, shown?: { base: string; head: string }, reads = 0) => { remotePair = { ...remotePair, ...pair }; @@ -1019,6 +1024,8 @@ it('leaves the push marker for startup when shutdown interrupts the push, and st }, duringRebase(_service, coordinator) { coordinatorRef = coordinator; } }); expect(fixture.result).toMatchObject({ state: 'failed', reason: 'Server shutdown.' }); expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); + // Shutdown starts no new GitHub read: the marker waits for startup. + expect(fixture.branchReads()).toBe(0); const restarted = new PreMergeCoordinator(fixture.service, fixture.runner, {} as never, { inspect: async () => { throw new Error('not used'); }, fetch: async () => undefined, push: async () => { throw new Error('not used'); }, readBranch: async () => fixture.remote().head }); @@ -1100,15 +1107,12 @@ it('keeps waiting while GitHub still reports the pre-push head, even if the base await fixture.coordinator.close(); await fixture.runner.close(); }); -it('pushes nothing when a cancel is requested at the push boundary, and closes the task once the outcome is read', async () => { - const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { - if (call !== 3) return; // validations: 1 rebase, 2 push read, 3 push boundary - const identity = service.config.identity; - // Store-level, as a cancel committed by another request would be: this preparation's signal is not aborted. - expect(service.store.cancelTask(identity, service.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); +it('pushes nothing when a cancel is committed while the push reads the remote, and closes the task once the outcome is read', async () => { + const fixture = await rebaseFixture('feature\n', undefined, { duringPushRead(service, coordinator) { + // The production route: the coordinator cancels the task and aborts this preparation after the cancel commits. + expect(coordinator.cancelTask(service.store.getTask(service.config.identity).stateVersion, randomUUID())).toBe('stopping'); } }); expect(fixture.result).toMatchObject({ state: 'failed' }); - expect(fixture.result.reason).toMatch(/changed before the rewritten head was pushed/); expect(fixture.remote().head).not.toBe(fixture.rebased); expect(fixture.service.store.getTask(fixture.service.config.identity)).toMatchObject({ status: 'cancelled', pushInProgress: null }); await fixture.coordinator.close(); await fixture.runner.close(); @@ -1240,3 +1244,27 @@ it('lets shutdown abort a startup push settlement whose branch read does not ans expect(pushMarker(fixture)).not.toBeNull(); await fixture.runner.close(); }); + +it('lets a cancel during the next preparation\'s push settlement finish the read and close the task', async () => { + let readable = false; + const read = Promise.withResolvers(); + const fixture = await rebaseFixture('feature\n', undefined, { + pushFailure: () => new Error('timed out'), + branchRead: (current, signal) => { + if (!readable) throw new Error('GitHub unreachable'); + // A real read is aborted by its signal; a cancel must not be able to abort this one. + signal?.addEventListener('abort', () => read.reject(signal.reason), { once: true }); + return read.promise; + }, + }); + const identity = fixture.service.config.identity; + expect(pushMarker(fixture)).not.toBeNull(); + readable = true; + const preparing = prepareAgain(fixture); + await vi.waitFor(() => expect(fixture.branchReads()).toBe(2)); + expect(fixture.coordinator.cancelTask(fixture.service.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + read.resolve(fixture.remote().head); + await expect(preparing).resolves.toMatchObject({ state: 'failed', reason: 'Task cancelled.' }); + expect(fixture.service.store.getTask(identity)).toMatchObject({ status: 'cancelled', pushInProgress: null }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); diff --git a/test/runner-lifecycle-store.test.ts b/test/runner-lifecycle-store.test.ts index 6fd0b541..c2239e7f 100644 --- a/test/runner-lifecycle-store.test.ts +++ b/test/runner-lifecycle-store.test.ts @@ -1047,6 +1047,9 @@ describe('pre-merge push marker (F6a of #22)', () => { .toThrow(/push of the rewritten head is in progress/); expect(() => f.store.recordRebase(identity, f.reviewed(), oid(20), oid(22), [{ oldSha: oid(12), newSha: oid(22) }])) .toThrow(/push of the rewritten head is in progress/); + expect(() => f.store.admitAttempt(identity, { expectedStateVersion: version(), kind: 'check', item: 'P1', + expectedContext: f.store.currentContext(identity), deadline: later() })).toThrow(/push of the rewritten head is in progress/); + expect(() => f.store.setAssignment(identity, version(), 'someone', 'b'.repeat(40))).toThrow(/push of the rewritten head is in progress/); f.store.close(); stores.splice(stores.indexOf(f.store), 1); const reopened = open(f.path); expect(reopened.pushesInProgress()).toEqual([{ planKey: reopened.getTask(identity).planKey, marker }]); From 7cc5477090736b6a2e6ee1ac4ed9d1282be4c5f7 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 13:49:19 -0700 Subject: [PATCH 011/163] Test that a failed push is reported as current Co-Authored-By: Claude Opus 5.5 --- test/pre-merge.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index f7b80db7..9e90e213 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -956,6 +956,8 @@ it('settles an ambiguous push by reading the branch, then prepares again without const fixture = await rebaseFixture('feature\n', undefined, { landBeforeFailure: true, pushFailure: () => fail ? new Error('connection reset after the push was sent') : new Error('No second push expected.') }); expect(fixture.result).toMatchObject({ state: 'failed', reason: 'connection reset after the push was sent' }); + // The push claim and its settlement advanced task state; the failure is bound to that state, so it reads as current. + expect(fixture.coordinator.last).toMatchObject({ state: 'failed', stale: false }); expect(pushMarker(fixture)).toBeNull(); expect(fixture.remote().head).toBe(fixture.rebased); fail = false; From cedd594e53edf349a6aa9de61384594b7c92bdf4 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 14:33:23 -0700 Subject: [PATCH 012/163] Bind a failed push to the state its own settlement left 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 --- runner/pre-merge.ts | 21 +++++++++++++-------- test/pre-merge.test.ts | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index ff78d114..d6e8dc2e 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -176,7 +176,7 @@ export class PreMergeCoordinator { */ async settleInterruptedPush(): Promise { const marker = this.service.store.getTask(this.service.config.identity).pushInProgress; - return marker ? this.#settlePush(marker) : null; + return marker ? (await this.#settlePush(marker)).outcome : null; } /** * Settle a push left by a crash, shutdown or unreadable branch, as one tracked job that shutdown aborts and awaits: @@ -191,12 +191,14 @@ export class PreMergeCoordinator { this.#active = active; return active; } - async #settlePush(marker: PushMarker, limitMs = PUSH_SETTLEMENT_TIMEOUT_MS): Promise { + /** Also returns the task state version just before and just after clearing the marker, read in that same turn. */ + async #settlePush(marker: PushMarker, limitMs = PUSH_SETTLEMENT_TIMEOUT_MS): Promise<{ outcome: PushOutcome; before: number; after: number }> { const read = await this.remote.readBranch(marker.branch, AbortSignal.any([this.#shutdown.signal, AbortSignal.timeout(Math.max(1, Math.min(PUSH_SETTLEMENT_TIMEOUT_MS, limitMs)))])); const outcome: PushOutcome = read === marker.to ? 'pushed' : read === marker.from ? 'not-pushed' : 'moved'; + const before = this.service.store.getTask(this.service.config.identity).stateVersion; this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(this.service.config.identity).planKey, marker.attemptId)); - return outcome; + return { outcome, before, after: this.service.store.getTask(this.service.config.identity).stateVersion }; } #refresh(pair: RemotePair, remaining: () => number, priorHead?: string) { @@ -280,7 +282,7 @@ export class PreMergeCoordinator { */ async #pushRewrite(remote: RemotePair, view: ReturnType, signal: AbortSignal, deadline: number, authorize: () => Promise, - bind: (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair) => void): Promise<'pushed' | 'refused'> { + bind: (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair, stateVersion?: number) => void): Promise<'pushed' | 'refused'> { const identity = this.service.config.identity; if (!remote.branch) throw new GuardRefusal('The pull request was not published by the runner, so its rewritten head cannot be pushed.'); const reviewed = { revision: view.expected.revision, snapshotId: view.expected.snapshotId, @@ -309,12 +311,15 @@ export class PreMergeCoordinator { } // Shutdown: no new GitHub read may start; startup settles the marker. Otherwise one read, within what is left of // the preparation's deadline (its settlement reserve included), settles it. A cancel does not stop that read. + // The failure is bound to the state this push left: the claim's version, or the one clearing the marker produced + // when nothing else wrote meanwhile. A write during the awaited read, such as a plan revision, must leave it stale. + let settled = binding; const left = Math.floor(deadline - performance.now()); if (!this.#closing && left >= 1) { - try { await this.#settlePush(marker, left); } + try { const read = await this.#settlePush(marker, left); if (read.before === binding) settled = read.after; } catch { /* The marker stays; the next preparation or startup reads the branch again. */ } } - bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }); + bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }, settled); throw error; } this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(identity).planKey, marker.attemptId)); @@ -368,10 +373,10 @@ export class PreMergeCoordinator { // Rebase lifecycle writes advance task state without changing the review that admitted them. Bind a resulting // failure to that original review/snapshot while adopting only the task-state transition owned by the rebase. If a // read fails, the older binding remains safely stale. - const bindRebaseFailure = (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair) => { + const bindRebaseFailure = (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair, stateVersion?: number) => { try { onObserved({ pair, binding: { - stateVersion: this.service.store.getTask(identity).stateVersion, + stateVersion: stateVersion ?? this.service.store.getTask(identity).stateVersion, reviewVersion: reviewed.reviewVersion, snapshotId: reviewed.snapshotId, } }); } catch { /* An unbound failure is conservatively historical. */ } diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 9e90e213..84000e08 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1270,3 +1270,23 @@ it('lets a cancel during the next preparation\'s push settlement finish the read expect(fixture.service.store.getTask(identity)).toMatchObject({ status: 'cancelled', pushInProgress: null }); await fixture.coordinator.close(); await fixture.runner.close(); }); + +it('reports a failed push as stale when a plan revision lands while its outcome is read', async () => { + let serviceRef: ReviewService | null = null, revised = false; + const fixture = await rebaseFixture('feature\n', undefined, { landBeforeFailure: true, + duringRebase(service) { serviceRef = service; }, + pushFailure: () => new Error('connection reset'), + branchRead: (current) => { + // Another request applies a plan revision while the settlement read is in flight. + const service = serviceRef!, plan = service.store.getPlan(service.config.identity); + service.store.importRevision(JSON.stringify({ ...plan, summary: 'Amended during the read' }), 'json', + service.planContext(), plan.revision); + revised = true; + return current; + } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'connection reset' }); + expect(revised).toBe(true); + expect(pushMarker(fixture)).toBeNull(); + expect(fixture.coordinator.last).toMatchObject({ state: 'failed', stale: true }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); From a0ecaf05b241dca198ce2a58e9b0379168579549 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 14:52:53 -0700 Subject: [PATCH 013/163] Bind every push exit to the state that push left 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 --- runner/pre-merge.ts | 38 ++++++++++++++++++++------------------ test/pre-merge.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index d6e8dc2e..929e6da7 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -191,14 +191,17 @@ export class PreMergeCoordinator { this.#active = active; return active; } - /** Also returns the task state version just before and just after clearing the marker, read in that same turn. */ + /** Clear the marker; returns the task state version just before and just after, read in the same turn. */ + #finishPush(marker: PushMarker): { before: number; after: number } { + const identity = this.service.config.identity, before = this.service.store.getTask(identity).stateVersion; + this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(identity).planKey, marker.attemptId)); + return { before, after: this.service.store.getTask(identity).stateVersion }; + } async #settlePush(marker: PushMarker, limitMs = PUSH_SETTLEMENT_TIMEOUT_MS): Promise<{ outcome: PushOutcome; before: number; after: number }> { const read = await this.remote.readBranch(marker.branch, AbortSignal.any([this.#shutdown.signal, AbortSignal.timeout(Math.max(1, Math.min(PUSH_SETTLEMENT_TIMEOUT_MS, limitMs)))])); const outcome: PushOutcome = read === marker.to ? 'pushed' : read === marker.from ? 'not-pushed' : 'moved'; - const before = this.service.store.getTask(this.service.config.identity).stateVersion; - this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(this.service.config.identity).planKey, marker.attemptId)); - return { outcome, before, after: this.service.store.getTask(this.service.config.identity).stateVersion }; + return { outcome, ...this.#finishPush(marker) }; } #refresh(pair: RemotePair, remaining: () => number, priorHead?: string) { @@ -282,7 +285,8 @@ export class PreMergeCoordinator { */ async #pushRewrite(remote: RemotePair, view: ReturnType, signal: AbortSignal, deadline: number, authorize: () => Promise, - bind: (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair, stateVersion?: number) => void): Promise<'pushed' | 'refused'> { + bind: (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair, stateVersion?: number) => void): + Promise<{ result: 'pushed' | 'refused'; stateVersion: number }> { const identity = this.service.config.identity; if (!remote.branch) throw new GuardRefusal('The pull request was not published by the runner, so its rewritten head cannot be pushed.'); const reviewed = { revision: view.expected.revision, snapshotId: view.expected.snapshotId, @@ -292,6 +296,10 @@ export class PreMergeCoordinator { const marker = this.service.store.beginPrePush(identity, reviewed, this.service.store.getTask(identity).stateVersion, { branch: remote.branch, from: remote.head, to: view.snapshot.head }); const binding = this.service.store.getTask(identity).stateVersion; + // The task state this push left, for binding a later failure: the version clearing the marker produced when nothing + // else wrote since the claim, else the claim's own. A write while the push or its read was in flight, such as a plan + // revision, must leave a later failure stale. + const owned = (cleared: { before: number; after: number }) => cleared.before === binding ? cleared.after : binding; try { await this.remote.push({ branch: marker.branch, from: marker.from, to: marker.to, beforePush: () => { // The last local checks before the external write: trust, and no task or review change since the claim. @@ -304,26 +312,19 @@ export class PreMergeCoordinator { signal.throwIfAborted(); } }, signal); } catch (error) { - if (error instanceof BranchPushRefused) { - this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(identity).planKey, marker.attemptId)); - bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }); - return 'refused'; - } + if (error instanceof BranchPushRefused) return { result: 'refused', stateVersion: owned(this.#finishPush(marker)) }; // Shutdown: no new GitHub read may start; startup settles the marker. Otherwise one read, within what is left of // the preparation's deadline (its settlement reserve included), settles it. A cancel does not stop that read. - // The failure is bound to the state this push left: the claim's version, or the one clearing the marker produced - // when nothing else wrote meanwhile. A write during the awaited read, such as a plan revision, must leave it stale. let settled = binding; const left = Math.floor(deadline - performance.now()); if (!this.#closing && left >= 1) { - try { const read = await this.#settlePush(marker, left); if (read.before === binding) settled = read.after; } + try { settled = owned(await this.#settlePush(marker, left)); } catch { /* The marker stays; the next preparation or startup reads the branch again. */ } } bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }, settled); throw error; } - this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(identity).planKey, marker.attemptId)); - return 'pushed'; + return { result: 'pushed', stateVersion: owned(this.#finishPush(marker)) }; } async #run(expected: { stateVersion: number; reviewVersion: number; snapshotId: string; actionId?: string }, signal: AbortSignal, deadline: number, onObserved: (value: { pair: RemotePair; binding: { stateVersion: number; reviewVersion: number; @@ -469,10 +470,11 @@ export class PreMergeCoordinator { if (blocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], reason: blocker }; if (initial.head !== view.snapshot.head) { - const pushed = await this.#pushRewrite(initial, view, signal, deadline, authorize, bindRebaseFailure); - // The push (or refusal) advanced task state; bind any later failure to it so it is reported as current, not stale. + const { result: pushed, stateVersion: pushState } = await this.#pushRewrite(initial, view, signal, deadline, authorize, + bindRebaseFailure); + // Bind any later failure to the state the push (or refusal) left, so it is reported as current, not stale. bindRebaseFailure({ reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id }, - { base: view.snapshot.base, head: view.snapshot.head }); + { base: view.snapshot.base, head: view.snapshot.head }, pushState); // Either way the branch no longer holds the pre-push head: ours after a push, someone else's after a refusal. // GitHub's PR API reports that asynchronously, so wait for it; its lag must not read as the old head. const seen = await this.#awaitHeadChange(initial, signal, remaining); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 84000e08..dea49bd4 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1290,3 +1290,30 @@ it('reports a failed push as stale when a plan revision lands while its outcome expect(fixture.coordinator.last).toMatchObject({ state: 'failed', stale: true }); await fixture.coordinator.close(); await fixture.runner.close(); }); + +it('reports a failure after a push as stale when a plan revision landed while the push was on the wire', async () => { + const revise = (service: ReviewService) => { + const plan = service.store.getPlan(service.config.identity); + service.store.importRevision(JSON.stringify({ ...plan, summary: 'Amended during the push' }), 'json', + service.planContext(), plan.revision); + }; + // A successful push, then a failure while reviewing the head a collaborator pushed on top of it. + const pushed = await rebaseFixture('feature\n', 0, { fetchFailsAfterPush: true, afterPush(service, head) { + revise(service); + const tree = fixtureGit(service.config.repository, 'rev-parse', `${head}^{tree}`); + return fixtureGit(service.config.repository, 'commit-tree', tree, '-p', head, '-m', 'collaborator on top'); + } }); + expect(pushed.result).toMatchObject({ state: 'failed', reason: 'fetch failed after the push' }); + expect(pushed.coordinator.last).toMatchObject({ state: 'failed', stale: true }); + await pushed.coordinator.close(); await pushed.runner.close(); + // A refused push, then a failure while reviewing the collaborator head that refused it. + const refused = await rebaseFixture('feature\n', undefined, { fetchFailsAfterPush: true, pushedFirst(service) { + revise(service); + writeFileSync(join(service.config.repository, 'late.txt'), 'collaborator\n'); + fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'collaborator'); + return fixtureGit(service.config.repository, 'rev-parse', 'HEAD'); + } }); + expect(refused.result).toMatchObject({ state: 'failed', reason: 'fetch failed after the push' }); + expect(refused.coordinator.last).toMatchObject({ state: 'failed', stale: true }); + await refused.coordinator.close(); await refused.runner.close(); +}); From fc266272dcd37a1856c9d0e3672069acce37bbda Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 15:56:34 -0700 Subject: [PATCH 014/163] Refuse checks after a push if the task changed while it was in flight 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 --- docs/implementation/pre-merge-rebase.md | 4 ++-- runner/pre-merge.ts | 3 +++ runner/store.ts | 2 +- test/pre-merge.test.ts | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 2d3dbff8..058a5874 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -39,12 +39,12 @@ This ordering is intentionally local-only. The push below extends the durable li | Step | What happens | |---|---| | Admission | Issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | -| Last local check | Immediately before the write, trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. | +| Last local check | Immediately before the write, trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | | Unknown outcome | A failure, timeout or cancel during the push reads the branch once, bounded by 30 seconds and by what is left of the preparation's deadline: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | -| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, another rebase and another push all refuse while it exists, and merge status names it. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | +| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 929e6da7..7f50b7f8 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -488,6 +488,9 @@ export class PreMergeCoordinator { ? 'The pull request moved after the rewritten head was pushed; attribution and approvals were refreshed.' : 'The pull request branch moved before the rewritten head could be pushed; attribution and approvals were refreshed.' }; } + // Nothing but this push may have changed the task meanwhile: a plan revision or review edit applied while it was in + // flight would otherwise be adopted by the reload below and run checks under approvals it made stale. + assertCurrent({ stateVersion: pushState, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id }); initial = seen; view = load(); } diff --git a/runner/store.ts b/runner/store.ts index 56a9aa3f..632801ef 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2170,7 +2170,7 @@ export class Store { if (this.#activeMerge(key)) throw new GuardRefusal('A merge is in progress; wait for its outcome.'); // Interrupted work waiting to be requeued, or a rebase in progress, is not finished work to publish. if (task.requeue_pending === 1) throw new GuardRefusal('The task has interrupted work waiting to be requeued.'); - this.#assertNoRewriteInFlight(task); + if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is in progress for this task.'); // A ready PR is finished work: every item of the current plan revision has a completed execute attempt. Checked here, // at every guarded publish write, so a plan revision that adds an item while a publish awaits GitHub refuses it. if (!draft) { diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index dea49bd4..79b27da2 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1317,3 +1317,17 @@ it('reports a failure after a push as stale when a plan revision landed while th expect(refused.coordinator.last).toMatchObject({ state: 'failed', stale: true }); await refused.coordinator.close(); await refused.runner.close(); }); + +it('runs no command check when a plan revision landed while the push was on the wire', async () => { + const fixture = await rebaseFixture('feature\n', 0, { afterPush(service, head) { + const plan = service.store.getPlan(service.config.identity); + service.store.importRevision(JSON.stringify({ ...plan, summary: 'Amended during the push' }), 'json', + service.planContext(), plan.revision); + return head; + } }); + expect(fixture.result).toMatchObject({ state: 'failed', checked: [] }); + expect(fixture.result.reason).toMatch(/changed during preparation/); + expect(fixture.checkedHeads).toEqual([]); + expect(fixture.coordinator.last).toMatchObject({ state: 'failed', stale: true }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); From 365cda00b5fb1a097543fd5c427c3f15d2dba097 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 16:41:04 -0700 Subject: [PATCH 015/163] Describe the pusher's expected-head rule in its doc comments Co-Authored-By: Claude Opus 5.5 --- runner/branch-push.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/runner/branch-push.ts b/runner/branch-push.ts index 84272e2f..8bfffefc 100644 --- a/runner/branch-push.ts +++ b/runner/branch-push.ts @@ -10,7 +10,10 @@ import type { GitCallOptions, RunnerRepository } from './runner-repository.ts'; * user's checkout is never read or written, and neither the user's Git config nor their credential helpers are used. */ -/** The branch holds a commit codeboost did not make, or moved while the push ran. Nothing was pushed; a person decides. */ +/** + * Nothing was pushed: the branch holds a commit the push may not overwrite (one codeboost did not make, or, with an + * `expected` head, anything else), or it moved while the push ran. A person, or a fresh review, decides. + */ export class BranchPushRefused extends Error {} export interface GitBranchPusherConfig extends Pick { @@ -21,8 +24,8 @@ export interface GitBranchPusherConfig extends Pick Iterable; /** The push URL. Default `https:///.git`. A local path is allowed, for tests. */ From 87dd3bf1044eb12080ac3511eb14d4aa854b5ba8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 17:01:17 -0700 Subject: [PATCH 016/163] Test the push claim's admission guards and the busy settlement skip Co-Authored-By: Claude Opus 5.5 --- test/pre-merge.test.ts | 14 ++++++++++++++ test/runner-lifecycle-store.test.ts | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 79b27da2..bc9c85eb 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1331,3 +1331,17 @@ it('runs no command check when a plan revision landed while the push was on the expect(fixture.coordinator.last).toMatchObject({ state: 'failed', stale: true }); await fixture.coordinator.close(); await fixture.runner.close(); }); + +it('leaves a running preparation in charge when a push settlement is requested meanwhile', async () => { + let requested: Promise | null = null, stillActive: boolean | null = null; + const fixture = await rebaseFixture('feature\n', undefined, { + duringRebase(_service, coordinator) { requested = coordinator.settlePendingPush(); }, + // Later, after several awaits: the settlement request must not have taken the preparation's place. + duringPushRead(_service, coordinator) { stillActive = coordinator.active; }, + }); + await expect(requested).resolves.toBeNull(); + expect(stillActive).toBe(true); + expect(fixture.result).toMatchObject({ state: 'ready', head: fixture.rebased }); + expect(fixture.branchReads()).toBe(0); + await fixture.coordinator.close(); await fixture.runner.close(); +}); diff --git a/test/runner-lifecycle-store.test.ts b/test/runner-lifecycle-store.test.ts index c2239e7f..21a4e9c0 100644 --- a/test/runner-lifecycle-store.test.ts +++ b/test/runner-lifecycle-store.test.ts @@ -1038,6 +1038,25 @@ describe('pre-merge push marker (F6a of #22)', () => { expect(() => begin(f)).toThrow(/Stale task state|push of the rewritten head is in progress/); }); + it('refuses a push claim for a task that is not mergeable, is being cancelled, or has an attempt or merge in flight', () => { + const queuedTask = rewritten(); + queuedTask.store.transitionTask(identity, queuedTask.store.getTask(identity).stateVersion, 'queued'); + expect(() => begin(queuedTask)).toThrow(/The task is queued; push it from review/); + + const attempting = rewritten(); + admit(attempting.store, { kind: 'check' }); + expect(() => begin(attempting)).toThrow(/attempt is still active/); + + const cancelling = rewritten(); + admit(cancelling.store, { kind: 'check' }); + expect(cancelling.store.cancelTask(identity, cancelling.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + expect(() => begin(cancelling)).toThrow(/being cancelled/); + + const merging = rewritten(); + merging.store.beginMergeAttempt(identity, merging.reviewed(), oid(12), null, 'direct', randomUUID(), merging.store.getTask(identity).stateVersion); + expect(() => begin(merging)).toThrow(/merge is in progress/); + }); + it('blocks every other task write, rebase and merge until the push settles, and survives a restart', () => { const f = rewritten(), marker = begin(f), version = () => f.store.getTask(identity).stateVersion; expect(() => f.store.transitionTask(identity, version(), 'queued')).toThrow(/push of the rewritten head is in progress/); From 58e63e0179526ca10a38ad7e4e3ed8cb8df68434 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 23:48:07 -0700 Subject: [PATCH 017/163] Address Copilot: capture the claim version, finalize at Git start, reserve 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 --- docs/implementation/pre-merge-rebase.md | 6 ++-- runner/pre-merge.ts | 15 ++++++-- runner/production.ts | 4 ++- test/pre-merge.test.ts | 46 +++++++++++++++++++++++++ test/runner-production.test.ts | 6 +++- 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 058a5874..4458d1f4 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -38,12 +38,12 @@ This ordering is intentionally local-only. The push below extends the durable li | Step | What happens | |---|---| -| Admission | Issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | -| Last local check | Immediately before the write, trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. | +| Admission | The task state version is captured, then issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | +| Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | -| Unknown outcome | A failure, timeout or cancel during the push reads the branch once, bounded by 30 seconds and by what is left of the preparation's deadline: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | +| Unknown outcome | A failure, timeout or cancel during the push reads the branch once. The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | | Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 7f50b7f8..e9932a0f 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -197,9 +197,15 @@ export class PreMergeCoordinator { this.settle(() => this.service.store.finishPrePush(this.service.store.getTask(identity).planKey, marker.attemptId)); return { before, after: this.service.store.getTask(identity).stateVersion }; } + /** + * One branch read, all of it, Git's settlement after an abort included, within `limitMs` (at most + * PUSH_SETTLEMENT_TIMEOUT_MS): the read is aborted the process-settlement reserve before that, and does not start + * when less than the reserve is left. + */ async #settlePush(marker: PushMarker, limitMs = PUSH_SETTLEMENT_TIMEOUT_MS): Promise<{ outcome: PushOutcome; before: number; after: number }> { - const read = await this.remote.readBranch(marker.branch, - AbortSignal.any([this.#shutdown.signal, AbortSignal.timeout(Math.max(1, Math.min(PUSH_SETTLEMENT_TIMEOUT_MS, limitMs)))])); + const readMs = Math.min(PUSH_SETTLEMENT_TIMEOUT_MS, limitMs) - this.processSettlementReserveMs; + if (readMs < 1) throw Object.assign(new Error('Too little time is left to read the branch and settle Git.'), { code: 'ETIMEDOUT' }); + const read = await this.remote.readBranch(marker.branch, AbortSignal.any([this.#shutdown.signal, AbortSignal.timeout(readMs)])); const outcome: PushOutcome = read === marker.to ? 'pushed' : read === marker.from ? 'not-pushed' : 'moved'; return { outcome, ...this.#finishPush(marker) }; } @@ -291,9 +297,12 @@ export class PreMergeCoordinator { if (!remote.branch) throw new GuardRefusal('The pull request was not published by the runner, so its rewritten head cannot be pushed.'); const reviewed = { revision: view.expected.revision, snapshotId: view.expected.snapshotId, reviewVersion: view.expected.reviewVersion! }; + // Captured before the asynchronous authorization read: any task write that lands during it (a reassignment, a + // referenced-code change) must refuse the claim, not be adopted by it. + const admitted = this.service.store.getTask(identity).stateVersion; const authorization = await authorize(); signal.throwIfAborted(); - const marker = this.service.store.beginPrePush(identity, reviewed, this.service.store.getTask(identity).stateVersion, + const marker = this.service.store.beginPrePush(identity, reviewed, admitted, { branch: remote.branch, from: remote.head, to: view.snapshot.head }); const binding = this.service.store.getTask(identity).stateVersion; // The task state this push left, for binding a later failure: the version clearing the marker produced when nothing diff --git a/runner/production.ts b/runner/production.ts index 001e0112..2d307e60 100644 --- a/runner/production.ts +++ b/runner/production.ts @@ -334,8 +334,10 @@ export async function setUpRunner(o: { service: ReviewService; capability: Shutd inspect, fetch: (pair, signal) => pusher.fetchCommits([pair.base, pair.head], signal), // Leased to exactly the inspected head the rebase rewrote; the coordinator's durable marker brackets the write. + // The guard is returned as the boundary's finalizer, which the pusher runs synchronously as Git starts: nothing can + // land between the last check and the write. push: (input, signal) => pusher.push(identity, { head: input.to, branch: input.branch, expected: input.from, - beforePush: () => { input.beforePush(); } }, signal), + beforePush: async () => input.beforePush }, signal), readBranch: (branch, signal) => pusher.readBranch(identity, branch, signal), }, undefined, o.capability, authorize); }; diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index bc9c85eb..f9ac1e09 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1345,3 +1345,49 @@ it('leaves a running preparation in charge when a push settlement is requested m expect(fixture.branchReads()).toBe(0); await fixture.coordinator.close(); await fixture.runner.close(); }); + +it('pushes nothing when the task is reassigned while the push authorization is read', async () => { + const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { + if (call !== 2) return; // validations: 1 rebase, 2 push authorization read + const identity = service.config.identity; + service.store.setAssignment(identity, service.store.getTask(identity).stateVersion, 'someone-else', 'c'.repeat(40)); + } }); + expect(fixture.result).toMatchObject({ state: 'failed' }); + expect(fixture.pushes).toEqual([]); + expect(fixture.remote().head).not.toBe(fixture.rebased); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('keeps the process-settlement reserve inside a settlement read\'s time limit', async () => { + // Reserve plus 2 s left: the read may run for at most about 2 s, leaving the reserve for Git to settle. + let startedAt = 0, failedAt = 0, pushedAt = 0; + const fixture = await rebaseFixture('feature\n', undefined, { branchReadHangs: true, + // Just after the preparation started, so `leaveOfDeadline` measures from (almost) its own start. + duringRebase() { startedAt = performance.now(); }, + pushFailure() { + leaveOfDeadline(startedAt, PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS + 2_000); + pushedAt = Date.now(); + return new Error('connection reset'); + } }); + failedAt = Date.now(); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'connection reset' }); + expect(fixture.branchReads()).toBe(1); + expect(failedAt - pushedAt).toBeLessThan(8_000); + expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('starts no settlement read when less than the process-settlement reserve is left', async () => { + let startedAt = 0; + const fixture = await rebaseFixture('feature\n', undefined, { branchReadHangs: true, + duringRebase() { startedAt = performance.now(); }, + pushFailure() { + leaveOfDeadline(startedAt, PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS - 1_000); + return new Error('connection reset'); + } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'connection reset' }); + expect(fixture.branchReads()).toBe(0); + expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); diff --git a/test/runner-production.test.ts b/test/runner-production.test.ts index 809a09d5..bd5502d0 100644 --- a/test/runner-production.test.ts +++ b/test/runner-production.test.ts @@ -135,7 +135,11 @@ describe('runner startup', () => { await coordinator.remote.push({ branch: 'codeboost/task-1', from, to, beforePush }); expect(push).toHaveBeenCalledWith(service.config.identity, expect.objectContaining({ head: to, branch: 'codeboost/task-1', expected: from }), undefined); - push.mock.calls[0]![1].beforePush!(); + // The boundary only hands back the guard; the pusher runs it as its finalizer, synchronously, as Git starts. + const finalize = await push.mock.calls[0]![1].beforePush!(); + expect(beforePush).not.toHaveBeenCalled(); + expect(finalize).toBeTypeOf('function'); + (finalize as () => void)(); expect(beforePush).toHaveBeenCalledOnce(); await expect(coordinator.remote.readBranch('codeboost/task-1')).resolves.toBeNull(); expect(read).toHaveBeenCalledWith(service.config.identity, 'codeboost/task-1', undefined); From 9e1f69572d32819a332959a5bdda55581100ca26 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sat, 10 Oct 2026 00:09:52 -0700 Subject: [PATCH 018/163] State that a deadline-ended push leaves its marker unread 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 --- docs/implementation/pre-merge-rebase.md | 4 ++-- runner/pre-merge.ts | 4 +++- test/pre-merge.test.ts | 20 ++++++++++++-------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 4458d1f4..7f8e1a53 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -43,8 +43,8 @@ This ordering is intentionally local-only. The push below extends the durable li | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | -| Unknown outcome | A failure, timeout or cancel during the push reads the branch once. The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | -| Unreadable or shutdown | The marker stays. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | +| Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | +| Unreadable, deadline or shutdown | The marker stays when the branch cannot be read, when the preparation's own deadline ended the push (less than the settlement reserve is left, so no read starts), or on shutdown. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index e9932a0f..211069b9 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -323,7 +323,9 @@ export class PreMergeCoordinator { } catch (error) { if (error instanceof BranchPushRefused) return { result: 'refused', stateVersion: owned(this.#finishPush(marker)) }; // Shutdown: no new GitHub read may start; startup settles the marker. Otherwise one read, within what is left of - // the preparation's deadline (its settlement reserve included), settles it. A cancel does not stop that read. + // the preparation's deadline with Git's settlement reserve kept inside it, settles it; a cancel does not stop that + // read. When the preparation's own deadline ended the push, less than that reserve is left, so no read starts and + // the marker stays, as for an unreadable branch. let settled = binding; const left = Math.floor(deadline - performance.now()); if (!this.#closing && left >= 1) { diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index f9ac1e09..e215ad4f 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1215,14 +1215,18 @@ it('ends the wait for the pushed head when the preparation deadline, not the vis await fixture.coordinator.close(); await fixture.runner.close(); }); -it('bounds the settlement read after a failed push by what is left of the preparation deadline', async () => { - const startedAt = performance.now(); - const fixture = await rebaseFixture('feature\n', undefined, { branchReadHangs: true, pushFailure() { - leaveOfDeadline(startedAt, 1_000); - return new Error('connection reset'); - } }); - expect(fixture.result).toMatchObject({ state: 'failed', reason: 'connection reset' }); - // The read was cut off by the deadline, so the outcome is still unknown and the marker stays. +it('leaves the push marker without a branch read when the preparation deadline ends the push', async () => { + let startedAt = 0; + const fixture = await rebaseFixture('feature\n', undefined, { branchReadHangs: true, + duringRebase() { startedAt = performance.now(); }, + pushFailure() { + // The preparation's timer aborts its work the settlement reserve before the deadline; so does this push. + leaveOfDeadline(startedAt, PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS - 50); + return Object.assign(new Error('Pre-merge preparation deadline exceeded.'), { code: 'ETIMEDOUT' }); + } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'Pre-merge preparation deadline exceeded.' }); + // Less than Git's settlement reserve is left, so no read starts: the outcome stays unknown and the marker stays. + expect(fixture.branchReads()).toBe(0); expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); await fixture.coordinator.close(); await fixture.runner.close(); }); From 3ade4213c19459697eb02c60fed6653c8cce8c88 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 17:15:09 -0700 Subject: [PATCH 019/163] F6b: scan for an earlier fix from the last clear check's base 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 --- docs/implementation/guarded-merge.md | 4 +- docs/implementation/merge-handoff.md | 83 ++++++++++++++++++++++++++++ github/merge.ts | 22 ++++++-- runner/merge.ts | 16 +++++- test/merge-task-pr.test.ts | 15 ++++- test/merge.test.ts | 11 ++++ 6 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 docs/implementation/merge-handoff.md diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index fc56f535..4168704b 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. The commit scan starts at the PR's base commit on its base branch. Merging requires that commit to equal the reviewed snapshot's base, which is the base the pre-PR check scanned from. The merge passes no own commits, because the task's commits reach the base branch only by this merge. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base the task's last clear already-fixed check (the pre-PR check) scanned from, so base commits that a pre-merge rebase brought under the PR are covered too (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. @@ -35,7 +35,7 @@ Shutdown sets a terminal admission flag before inspecting active work and checks ## Deferred runner work -This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. The already-fixed commit scan starts at the PR's current base commit; once a rebase moves that base, the scan must still cover the base-branch commits the rebase brought under the PR since the last check. +This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts at the base of the last clear check. ## Validation diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md new file mode 100644 index 00000000..8b8d3183 --- /dev/null +++ b/docs/implementation/merge-handoff.md @@ -0,0 +1,83 @@ +# One-click merge handoff (F6b of #22) + +**Audience:** people who build or review codeboost's runner and merge code. +**Purpose:** say exactly what happens between a click on **Merge PR** and GitHub merging the task's pull request, and what stops it. + +## Summary + +With a runner, **Merge PR** no longer merges at once. It starts one **merge job** and returns. The job: + +1. prepares the merge (refresh, rebase, review check, push, `cmd:` checks), as `prepare-merge` does; +2. waits for GitHub's required checks on the pushed head, for up to 30 minutes; +3. merges through the existing guarded merge, which reruns the already-fixed check and pins the exact head. + +Any change that needs a person (a stale approval, a moved pull request, a failing check) stops the job and says why. A failing or slow required check, or a refusal from GitHub, moves the task to **approved, merge blocked**; **Retry merge** starts the whole job again. The browser follows the job by polling. + +Without a runner, **Merge PR** works as before. + +## Terms + +| Term | Meaning | +|---|---| +| Merge job | The background work one click starts. One per task at a time. | +| Preparation | `PreMergeCoordinator.start`: refresh, rebase, review check, push of the rewritten head, `cmd:` checks. | +| Readiness | The record preparation leaves when it succeeds. It binds the task state version, review version, snapshot, base, head and command policy. The guarded merge refuses without it. | +| Required checks | The union of the checks GitHub's rulesets and classic branch protection require. | +| Guarded merge | `MergeCoordinator.merge`: two or more fresh inspections, then `gh pr merge --match-head-commit`. | +| Already-fixed check | The check that the issue is not fixed elsewhere: closed, another PR closes it, or a base-branch commit mentions it. | + +## The job, step by step + +| Step | What happens | It continues when | It stops when | +|---|---|---|---| +| Admission | The click's review token must be current, the task mergeable, and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns `merging`. | always | the token is stale, or something is busy (nothing is saved; the click may be sent again) | +| Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | +| Wait for checks | The job inspects the pull request (fresh), first after 10 s, then with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | +| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | + +While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. Unreadable branch rules never pass. + +## Outcomes + +| Job state | Task status afterwards | What the person does | +|---|---|---| +| `merged`, `queued` | merged, or in review while queued | nothing | +| `submitted` | unchanged | refresh; the merge attempt settles from GitHub | +| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge** | +| `review-required` | unchanged | review the refreshed pull request, then merge again | +| `failed` | unchanged | read the reason, then merge again | +| `cancelled` | cancelled | nothing | + +Moving the task to **approved, merge blocked** advances its state version, so the next job prepares again from the start. + +## The already-fixed range + +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base of the snapshot on which the last clear already-fixed check ran (the pre-PR check) to the base branch's tip. The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. + +## Cancel, shutdown and restart + +| Event | Effect | +|---|---| +| Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | +| Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | +| Cancel during the guarded merge | as before: refused while a merge attempt is submitting | +| Shutdown | the job is aborted and awaited before the store closes; it stays `running` on disk | +| Restart | a `running` job becomes `failed` ("The server restarted before the merge finished"); a `prepare-merge` action and a push marker are settled as before | + +## Storage + +Schema v19 adds `merge_jobs`: one row per job (`action_id`, task key, `state`, `step`, `reason`, required-check names and states, the merge result URL, times). Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. + +## API and browser + +- `POST /api/action` with `merge` returns `{ job }` at once when a runner is configured. +- `GET /api/merge` returns `{ queue, job }`. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, lists waiting checks in the blocker dialog, and offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. + +## Tests + +Each row of the step and outcome tables has a regression. The #22 acceptance cases run through the job: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancelled or interrupted job leaves the task as stated above. The required-check wait has the three cases the design names: zero required checks, one check pending then passing, and a timeout. + +## Check this document with a reader + +Before merging, ask someone who did not write it to answer, from this page alone: what one click does, when the task becomes approved, merge blocked, and why a job survives a review edit only by failing. diff --git a/github/merge.ts b/github/merge.ts index 95893a34..5b470e59 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -60,6 +60,13 @@ export interface MergeTarget { ownPullRequests?: readonly number[]; /** The task branch of a published target (#121). The inspection then also reads `RemoteMergeState.published`. */ headBranch?: string; + /** + * Where the already-fixed commit scan starts: the base the task's last clear check scanned from. A rebase moves the + * PR's base past base commits that check never saw (F6b of #22). Default: the PR's base commit. + */ + alreadyFixedSince?: string; + /** Commits the task made; the already-fixed scan does not count them. Default: none. */ + ownCommits?: readonly string[]; } export interface MergeResult { url: string; } @@ -100,7 +107,7 @@ export interface GhMergeConfig { baseBranch?: string; } -type ResolvedTarget = { pullRequest: number; ownPullRequests: readonly number[]; headBranch?: string }; +type ResolvedTarget = { pullRequest: number; ownPullRequests: readonly number[]; headBranch?: string; alreadyFixedSince?: string; ownCommits?: readonly string[] }; type BranchRules = Pick; export type RunGh = (args: readonly string[], options?: { signal?: AbortSignal }) => Promise; @@ -172,7 +179,11 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { const own = target?.ownPullRequests ?? []; if (own.some(number => !validNumber(number))) throw new Error('Invalid pull request number.'); if (target?.headBranch !== undefined && (typeof target.headBranch !== 'string' || !BRANCH.test(target.headBranch))) throw new Error('Invalid pull request branch.'); - return { pullRequest, ownPullRequests: [...new Set([pullRequest, ...own])], ...(target?.headBranch !== undefined ? { headBranch: target.headBranch } : {}) }; + if (target?.alreadyFixedSince !== undefined && (typeof target.alreadyFixedSince !== 'string' || !SHA.test(target.alreadyFixedSince))) throw new Error('Invalid already-fixed scan start.'); + if (target?.ownCommits !== undefined && (!Array.isArray(target.ownCommits) || target.ownCommits.some(sha => typeof sha !== 'string' || !SHA.test(sha)))) throw new Error('Invalid own commit.'); + return { pullRequest, ownPullRequests: [...new Set([pullRequest, ...own])], ...(target?.headBranch !== undefined ? { headBranch: target.headBranch } : {}), + ...(target?.alreadyFixedSince !== undefined ? { alreadyFixedSince: target.alreadyFixedSince } : {}), + ...(target?.ownCommits !== undefined ? { ownCommits: [...new Set(target.ownCommits)].sort() } : {}) }; } async #json(args: readonly string[], signal?: AbortSignal): Promise { @@ -197,14 +208,15 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { * The check stops early enough that its `gh` processes, which may need both grace periods after an abort, settle before * the inspection's deadline; stopped that way it is `unknown`. */ - async #alreadyFixed(ownPullRequests: readonly number[], baseBranch: string, base: string, deadlineAt: number, signal?: AbortSignal): Promise> { + async #alreadyFixed(target: ResolvedTarget, baseBranch: string, base: string, deadlineAt: number, signal?: AbortSignal): Promise> { // A check started with no settle time left could leave processes running past the inspection's deadline. const runFor = deadlineAt - MERGE_CHECK_SETTLE_MS - Date.now(); if (runFor <= 0) return { alreadyFixed: 'unknown', alreadyFixedDetail: 'No time was left to run the check.' }; const stop = new AbortController(); const timer = setTimeout(() => stop.abort(new Error('The already-fixed check did not finish in time.')), runFor); try { - const result = await this.checks.check({ issue: this.config.issue, taskBase: base, baseBranch, ownPullRequests: [...ownPullRequests], ownCommits: new Set() }, + const result = await this.checks.check({ issue: this.config.issue, taskBase: target.alreadyFixedSince ?? base, baseBranch, + ownPullRequests: [...target.ownPullRequests], ownCommits: new Set(target.ownCommits ?? []) }, signal ? AbortSignal.any([signal, stop.signal]) : stop.signal); // An answer that arrives after the check was stopped is not used. (One after a caller's abort is refused by the // inspection itself, once the rule reads have ended.) @@ -234,7 +246,7 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { // process they started outlives the inspection. const failed = new AbortController(), reads = signal ? AbortSignal.any([signal, failed.signal]) : failed.signal; const stopOthers = (error: unknown) => { failed.abort(error); throw error; }; - const alreadyFixed = this.#alreadyFixed(target.ownPullRequests, pr.baseRefName, base, deadlineAt, reads); + const alreadyFixed = this.#alreadyFixed(target, pr.baseRefName, base, deadlineAt, reads); const [ruleRead, listRead] = await Promise.allSettled([ this.#rules(pr.baseRefName, pr.statusCheckRollup as Array>, reads).catch(stopOthers), published ? this.#branchOpen(target.headBranch!, reads).catch(stopOthers) : undefined, diff --git a/runner/merge.ts b/runner/merge.ts index e1eae8d6..5ed96c5b 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -112,8 +112,20 @@ export class MergeCoordinator { if (!latest || latest.number === null) throw new MergeTargetUnavailable(`The task has no published pull request into ${published.baseBranch}.`); if (published.configured !== undefined && published.configured !== latest.number) throw new MergeTargetUnavailable(`github.pullRequest is #${published.configured}, but the task's pull request is #${latest.number}. Remove github.pullRequest from the review configuration.`); - return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch }, openingId: latest.openingId, - marker: openingMarker(latest.openingId), headBranch: latest.headBranch }; + return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch, ...this.#alreadyFixedRange() }, + openingId: latest.openingId, marker: openingMarker(latest.openingId), headBranch: latest.headBranch }; + } + + /** + * The already-fixed scan of a runner-published PR (F6b of #22): from the base the task's last clear check scanned (the + * pre-PR check), so base commits a pre-merge rebase brought under the PR are covered, excluding the task's own commits. + */ + #alreadyFixedRange(): { alreadyFixedSince?: string; ownCommits: string[] } { + const { store, config } = this.service, identity = config.identity; + const check = store.latestAlreadyFixed(identity); + const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); + if (!check || check.result.outcome !== 'clear') return { ownCommits }; + return { alreadyFixedSince: store.getSnapshot(identity, check.snapshotId).base, ownCommits }; } /** Whether GitHub shows the task's published PR where it was opened, as the only open PR from its branch. */ diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index 4fc4bed5..2ecbbd66 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -252,7 +252,7 @@ describe('the merge gate with a runner block (#121)', () => { const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); const status = await merges.status(); expect(status.blockers).toEqual([]); - expect(gh.targets[0]).toEqual({ pullRequest: 7, ownPullRequests: [7], headBranch: BRANCH }); + expect(gh.targets[0]).toEqual({ pullRequest: 7, ownPullRequests: [7], headBranch: BRANCH, alreadyFixedSince: oid(1), ownCommits: [oid(2)] }); await merges.merge('review-token'); expect(gh.merged).toEqual([7]); expect(store.getMergeAttempt(identity)).toMatchObject({ pullRequest: 7, state: 'merged' }); @@ -332,6 +332,17 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.merged).toEqual([]); }); + it('scans for an earlier fix from the pre-PR check\'s base after a rebase, skipping the task\'s own commits', async () => { + const { store } = published(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // A pre-merge rebase moved the reviewed base from oid(1) to oid(10) and rewrote the owned head oid(2) as oid(12). + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 7, alreadyFixedSince: oid(1) }); + expect([...gh.targets.at(-1)!.ownCommits!].sort()).toEqual([oid(12), oid(2)].sort()); + }); + it('refuses pre-merge preparation for a pull request that is no longer open', async () => { const { store } = published(); const gh = github(store, () => ({ pullRequestState: 'CLOSED' })); @@ -358,7 +369,7 @@ describe('the merge gate with a runner block (#121)', () => { toReview(store); const gh = github(store); await new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED).status(); - expect(gh.targets[0]).toEqual({ pullRequest: 8, ownPullRequests: [7, 8], headBranch: BRANCH }); + expect(gh.targets[0]).toEqual({ pullRequest: 8, ownPullRequests: [7, 8], headBranch: BRANCH, alreadyFixedSince: oid(1), ownCommits: [oid(2)] }); }); it('refuses when the task\'s PR changes between the validation passes', async () => { diff --git a/test/merge.test.ts b/test/merge.test.ts index 336c04c0..03cca720 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -1297,6 +1297,17 @@ it('gives an injected check the merge inputs and fails closed on an unknown outc expect(() => new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21 }, run, { check: checks.check })).toThrow(/merge repository/); }); +it('scans for an earlier fix from the target\'s scan start and skips the task\'s own commits (F6b)', async () => { + const inputs: AlreadyFixedInput[] = []; + const checks: AlreadyFixedGateway = { repository: 'Owner/Repo', check: async input => { inputs.push(input); return { outcome: 'clear', baseHead: sha('9') }; } }; + const run = async (args: readonly string[]) => mergeReads(args, { baseRefName: 'release/1', baseRefOid: sha('c') }); + const client = new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21 }, run, checks); + await client.inspect({ fresh: true, target: { pullRequest: 7, alreadyFixedSince: sha('a'), ownCommits: [sha('e'), sha('d'), sha('e')] } }); + expect(inputs[0]).toEqual({ issue: 21, taskBase: sha('a'), baseBranch: 'release/1', ownPullRequests: [7], ownCommits: new Set([sha('d'), sha('e')]) }); + await expect(client.inspect({ fresh: true, target: { pullRequest: 7, alreadyFixedSince: 'main' } })).rejects.toThrow(/scan start/); + await expect(client.inspect({ fresh: true, target: { pullRequest: 7, ownCommits: ['x'] } })).rejects.toThrow(/own commit/); +}); + it('keeps the caller cancellation when the merge check is aborted', async () => { const controller = new AbortController(); let started!: () => void; From 4a3d23e1e24898ee6cb6d67854815f510f0e2c11 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 17:40:08 -0700 Subject: [PATCH 020/163] F6b: one-click merge job with a required-check wait 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 --- docs/architecture.md | 8 +- docs/implementation/merge-handoff.md | 2 +- runner/merge-job.ts | 224 +++++++++++++++++++++++++++ runner/production.ts | 1 + runner/store.ts | 94 ++++++++++- test/browser/review.spec.ts | 28 ++++ test/merge-job.test.ts | 217 ++++++++++++++++++++++++++ test/planning-drafts.test.ts | 4 +- test/pre-merge.test.ts | 78 +++++++++- test/publish.test.ts | 2 +- test/runner-lifecycle-store.test.ts | 14 +- test/runner-publishing.test.ts | 109 ++++++++++++- test/store.test.ts | 2 +- web/public/app.js | 55 ++++++- web/server.ts | 32 +++- 15 files changed, 839 insertions(+), 31 deletions(-) create mode 100644 runner/merge-job.ts create mode 100644 test/merge-job.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index cfd322da..ee173603 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,8 +43,8 @@ Writing standard: plain language, ISO 24495-1:2023 - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks; required-check refresh, the already-fixed rerun, and the one-click guarded merge handoff remain for F6b. - - **Not built:** screens for the runner actions, the required-check refresh and one-click merge handoff (F6b), review rounds, and the Queue and Learning screens. + - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from the last clear check's base (F6b, `docs/implementation/merge-handoff.md`). + - **Not built:** screens for the runner actions, the merge job's full step list and Cancel button, review rounds, and the Queue and Learning screens. ## Terms used @@ -410,7 +410,7 @@ These are the decisions that shape the structure. The full list, with the reason | C, K | Guarded merge, merge queue | Done | | D | Agent isolation boundary | Done. Since the isolation gate: bounded diff export (#76), task-change inspection and the runner's commit as a bounded `git bundle` (#66, PRs #85 and #90), owner-scoped recovery for Ask (#95), the plan schema for Claude (#97), gitlink mounts and the pre-launch tree check (#81, #99, PR #100), and Codex refused in every phase (#93, PR #106). The runner-contract follow-ups (#51) are closed: the last one tests export and removal through a recovery handle after a real restart (PR #109). | | E | Planning library and suggestions | Done. E4 (PR #45) added the acceptance fixtures and recorded Claude output that replays through validation and the Store. | -| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. This branch adds F6a: the durable push of the rewritten head. F6b owns fresh required-check refresh, the already-fixed rerun, and the one-click exact-pair guarded-merge handoff; F6c owns common CI. | +| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from the last clear check's base; F6c owns common CI. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | | G | Planning screen | Done (#145). The screen displays/imports the current plan, requests and renders a next-revision draft or suggestion cards, and applies results with revision-bound exact ambiguous replay. G4 composes the browser with the production planning setup, D-backed provider boundary, F endpoints and SQLite persistence; focused adapter, recorded-output and exact-head real-Docker planning suites cover the lower boundary. | @@ -419,7 +419,7 @@ These are the decisions that shape the structure. The full list, with the reason ## Known limits -- `prepare-merge` invokes the trusted rebase and sandboxed conflict machinery, refreshes the review, pushes the rewritten head, and runs head-bound `cmd:` checks. Until F6b of #22 lands, it does not wait for fresh required checks, rerun the already-fixed check, or hand the exact pair to guarded merge; a person clicks Merge once GitHub's checks on the pushed head pass. +- With a runner, Approve & merge runs one merge job in the background: preparation, a wait of up to 30 minutes for GitHub's required checks, then the guarded merge. A failing or slow check, or GitHub's refusal, leaves the task approved, merge blocked; the browser shows only the job's current step, not the design's full step list, and has no Cancel button for the job yet (cancel the task instead). - Linking is line-based. It cannot see an unrelated edit inside a declared file; only the review agent and you can catch that. - The container's guarantees are those of Docker. The agent can always reach its own vendor account. - `cmd:` acceptance checks run during `prepare-merge`, and their passing evidence is bound to the exact reviewed head. A plan item without current passing evidence still blocks merging. diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 8b8d3183..4af4c645 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -32,7 +32,7 @@ Without a runner, **Merge PR** works as before. |---|---|---|---| | Admission | The click's review token must be current, the task mergeable, and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns `merging`. | always | the token is stale, or something is busy (nothing is saved; the click may be sent again) | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | -| Wait for checks | The job inspects the pull request (fresh), first after 10 s, then with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | +| Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. Unreadable branch rules never pass. diff --git a/runner/merge-job.ts b/runner/merge-job.ts new file mode 100644 index 00000000..dced4b5d --- /dev/null +++ b/runner/merge-job.ts @@ -0,0 +1,224 @@ +import { randomUUID } from 'node:crypto'; +import { GuardRefusal, MERGEABLE_STATUSES, settleWith, type ShutdownCapability } from './lifecycle.ts'; +import { MergeNotApplied, MergeOutcomeUnknown, type MergeCoordinator, type MergeStatus } from './merge.ts'; +import type { PreMergeCoordinator } from './pre-merge.ts'; +import type { ReviewService } from './review.ts'; +import type { MergeJob, MergeJobState } from './store.ts'; + +/** How long a job waits for GitHub's required checks on the pushed head (docs/implementation/merge-handoff.md). */ +export const REQUIRED_CHECK_WAIT_MS = 30 * 60_000; +/** The wait between required-check inspections starts here and doubles up to the maximum. */ +export const REQUIRED_CHECK_FIRST_DELAY_MS = 10_000; +export const REQUIRED_CHECK_MAX_DELAY_MS = 60_000; + +/** A job's answer to the browser: everything but the click's review token. */ +export type MergeJobView = Omit; +export const mergeJobView = ({ token: _token, ...job }: MergeJob): MergeJobView => job; + +export interface MergeJobTiming { checkWaitMs?: number; firstDelayMs?: number; maxDelayMs?: number } + +/** Why the wait for required checks ended. */ +type CheckVerdict = { kind: 'ready' } | { kind: 'wait'; checks: { context: string; state: string }[] } + | { kind: 'blocked'; reason: string } | { kind: 'failed'; reason: string } | { kind: 'cancelled' }; + +/** + * The one-click merge (F6b of #22): preparation, the wait for GitHub's required checks on the pushed head, then the + * guarded merge. One job per task at a time; its progress is saved in `merge_jobs`, never on the task, so the readiness + * preparation records stays bound while the job waits. + */ +export class MergeJobCoordinator { + readonly service: ReviewService; + readonly preMerge: PreMergeCoordinator; + readonly merges: MergeCoordinator; + readonly checkWaitMs: number; readonly firstDelayMs: number; readonly maxDelayMs: number; + readonly settle: (fn: () => T) => T; + #active: Promise | null = null; + #abort: AbortController | null = null; + #closing = false; + + constructor(service: ReviewService, preMerge: PreMergeCoordinator, merges: MergeCoordinator, capability?: ShutdownCapability, + timing: MergeJobTiming = {}) { + this.service = service; this.preMerge = preMerge; this.merges = merges; + this.checkWaitMs = timing.checkWaitMs ?? REQUIRED_CHECK_WAIT_MS; + this.firstDelayMs = timing.firstDelayMs ?? REQUIRED_CHECK_FIRST_DELAY_MS; + this.maxDelayMs = timing.maxDelayMs ?? REQUIRED_CHECK_MAX_DELAY_MS; + for (const value of [this.checkWaitMs, this.firstDelayMs, this.maxDelayMs]) + if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid merge job timing.'); + this.settle = settleWith(capability); + } + + get active(): boolean { return this.#active !== null; } + /** The running job's settlement, for the server's after-work hooks; null when none runs. */ + get current(): Promise | null { return this.#active; } + latest(): MergeJobView | null { + const job = this.service.store.latestMergeJob(this.service.config.identity); + return job ? mergeJobView(job) : null; + } + + /** + * Admit the click and start its job after the caller's transaction commits. A resend of the same click returns its + * job without starting another. Refusals save nothing, so the same click may be sent again. + */ + start(token: unknown, actionId: string, busy: () => string | null = () => null): MergeJobView { + const { store, config } = this.service, identity = config.identity; + const saved = store.getMergeJob(identity, actionId); + if (saved) return mergeJobView(store.beginMergeJob(identity, { actionId, token: token as string }).job); + if (this.#closing) throw new GuardRefusal('The server is shutting down.'); + if (this.#active) throw new GuardRefusal('A merge is already running for this task.'); + if (typeof token !== 'string' || this.service.load().token !== token) throw new GuardRefusal('Stale review state. Refresh before merging.'); + const task = store.getTask(identity); + if (!MERGEABLE_STATUSES.includes(task.status)) throw new GuardRefusal(`The task is ${task.status}; merge it from review.`); + if (task.cancelRequested !== null) throw new GuardRefusal('The task is being cancelled.'); + const reason = busy(); + if (reason) throw new GuardRefusal(reason); + this.preMerge.assertStartable(); + const { job } = store.beginMergeJob(identity, { actionId, token }); + const abort = new AbortController(); this.#abort = abort; + const run = Promise.resolve().then(() => this.#run(job, abort.signal)).catch(error => { + // Anything unexpected ends the job, unless shutdown stopped it: startup then settles it. + if (!abort.signal.aborted || !this.#closing) this.#finish(job.id, 'failed', error instanceof Error ? error.message : String(error)); + }).finally(() => { if (this.#active === run) { this.#active = null; this.#abort = null; } }); + this.#active = run; + return mergeJobView(job); + } + + /** The task was cancelled: stop waiting at once. Preparation and the guarded merge stop through their own routes. */ + cancel(): void { this.#abort?.abort(Object.assign(new Error('Task cancelled.'), { code: 'CANCELLED' })); } + + async close(): Promise { + this.#closing = true; + this.#abort?.abort(new Error('Server shutdown.')); + await this.#active; + } + + #finish(id: string, state: Exclude, reason: string | null = null, url: string | null = null): void { + try { this.settle(() => this.service.store.finishMergeJob(this.service.config.identity, id, { state, reason, url })); } + catch { /* Startup settles a job that could not be ended now. */ } + } + + async #run(job: MergeJob, signal: AbortSignal): Promise { + const { store, config } = this.service, identity = config.identity; + // 1. Prepare: refresh, rebase, review check, push, cmd: checks. + let view = this.service.load(); + const prepareId = randomUUID(), task = store.getTask(identity); + store.userAction(identity, { actionId: prepareId, kind: 'prepare-merge', + request: { expectedStateVersion: task.stateVersion, expectedReviewVersion: view.expected.reviewVersion, mergeJob: job.id } }, + () => ({ outcome: 'preparing' })); + const prepared = await this.preMerge.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, + snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, actionId: prepareId }); + if (signal.aborted) return this.#end(job, signal); + if (prepared.state !== 'ready') + return this.#finish(job.id, prepared.state === 'review-required' ? 'review-required' : 'failed', prepared.reason); + // 2. Wait for GitHub's required checks on the pushed head. + this.settle(() => store.updateMergeJob(identity, job.id, { step: 'checks' })); + const verdict = await this.#awaitChecks(job, signal); + if (verdict.kind === 'cancelled') return this.#end(job, signal); + if (verdict.kind === 'blocked') return this.#block(job, verdict.reason); + if (verdict.kind === 'failed') return this.#finish(job.id, 'failed', verdict.reason); + // 3. Merge through the guarded merge, which re-validates everything and reruns the already-fixed check. + this.settle(() => store.updateMergeJob(identity, job.id, { step: 'merging', checks: [] })); + view = this.service.load(); + const mergeId = randomUUID(); + try { + const merged = await this.merges.merge(view.token, mergeId); + const attempt = store.getMergeAttempt(identity); + this.#finish(job.id, attempt?.kind === 'queue' && attempt.state !== 'merged' ? 'queued' : 'merged', null, merged.result.url || null); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + if (error instanceof MergeOutcomeUnknown) return this.#finish(job.id, 'submitted', reason); + if (error instanceof MergeNotApplied) return signal.aborted ? this.#end(job, signal) : this.#finish(job.id, 'failed', reason); + // GitHub refused the attempt this job admitted: approved, merge blocked (design: "GitHub refused the merge"). + const attempt = store.getMergeAttempt(identity); + if (attempt?.actionId === mergeId && (attempt.state === 'failed' || attempt.state === 'removed')) + return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`); + this.#finish(job.id, 'failed', reason); + } + } + + /** Cancel ends the job; shutdown leaves it running on disk for startup to settle. */ + #end(job: MergeJob, signal: AbortSignal): void { + if ((signal.reason as { code?: string } | undefined)?.code === 'CANCELLED') this.#finish(job.id, 'cancelled', 'Task cancelled.'); + } + + /** Approved, merge blocked: the task's next merge prepares again from the start. */ + #block(job: MergeJob, reason: string): void { + const { store, config } = this.service, identity = config.identity; + try { + this.settle(() => { + const task = store.getTask(identity); + if (task.status === 'in review') store.transitionTask(identity, task.stateVersion, 'approved but merge blocked'); + }); + } catch { /* The task moved meanwhile (for example, cancelled); the job still ends blocked. */ } + this.#finish(job.id, 'blocked', reason); + } + + async #awaitChecks(job: MergeJob, signal: AbortSignal): Promise { + const { store, config } = this.service, identity = config.identity; + const until = performance.now() + this.checkWaitMs; + let delay = this.firstDelayMs, waiting: { context: string; state: string }[] = []; + for (;;) { + if (signal.aborted) return { kind: 'cancelled' }; + const task = store.getTask(identity); + if (task.status === 'cancelled' || task.cancelRequested !== null) return { kind: 'cancelled' }; + let status: MergeStatus; + try { status = await this.merges.status(this.service.load(), true, signal); } + catch (error) { + if (signal.aborted) return { kind: 'cancelled' }; + return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }; + } + const verdict = classify(status); + if (verdict.kind !== 'wait') return verdict; + waiting = verdict.checks; + this.settle(() => store.updateMergeJob(identity, job.id, { checks: waiting })); + const left = until - performance.now(); + if (left <= 0) return { kind: 'blocked', reason: `Required checks did not pass within ${Math.round(this.checkWaitMs / 60_000)} minutes: ${ + waiting.length ? waiting.map(check => `${check.context} is ${check.state}`).join(', ') : 'GitHub has not determined mergeability'}.` }; + await sleep(Math.min(delay, left), signal).catch(() => undefined); + delay = Math.min(delay * 2, this.maxDelayMs); + } + } +} + +/** Blockers the merge job resolves itself: it prepares (rebase, push, cmd: checks) and waits for required checks. */ +const JOB_RESOLVES = new Set(['preparation', 'base', 'acceptance', 'check']); + +/** + * The merge status the browser sees when merging runs as a job: ready when only blockers the job resolves remain (or + * GitHub is still computing mergeability), and never while a job or merge attempt is in flight. After a blocked job the + * action is a retry. The latest job rides along. + */ +export function withMergeJob(status: T, job: MergeJobView | null, taskStatus: string): T & { job: MergeJobView | null } { + const running = job?.state === 'running'; + const attempt = status.queue?.state === 'submitting' || status.queue?.state === 'queued' || status.queue?.state === 'merged'; + const computing = status.remote?.mergeable === 'UNKNOWN'; + const personal = status.blockers.filter(blocker => !JOB_RESOLVES.has(blocker.code) && !(blocker.code === 'mergeable' && computing)); + const ready = !running && !attempt && status.remote !== null && personal.length === 0; + return { ...status, ready, action: ready ? (taskStatus === 'approved but merge blocked' || status.action === 'retry' ? 'retry' : 'merge') : null, job }; +} + +/** + * One fresh inspection's answer for the wait: a failing required check blocks; pending or missing ones, and GitHub + * still computing mergeability, wait; any other blocker (the PR moved, readiness lost, the already-fixed check, rules) + * ends the job for a person to act. + */ +export function classify(status: MergeStatus): CheckVerdict { + const failing = status.remote.requiredChecks.filter(check => check.state === 'failure'); + if (failing.length) return { kind: 'blocked', reason: `Required check${failing.length === 1 ? '' : 's'} failed: ${failing.map(check => check.context).join(', ')}.` }; + const waiting = status.remote.requiredChecks.filter(check => check.state !== 'success') + .map(check => ({ context: check.context, state: check.state })); + const computing = status.remote.mergeable === 'UNKNOWN'; + const other = status.blockers.filter(blocker => blocker.code !== 'check' && !(blocker.code === 'mergeable' && computing)); + if (other.length) return { kind: 'failed', reason: other[0]!.message }; + if (waiting.length || computing) return { kind: 'wait', checks: waiting }; + return status.ready ? { kind: 'ready' } : { kind: 'failed', reason: status.blockers[0]?.message ?? 'Merge is blocked.' }; +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(signal.reason); return; } + const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(); }, ms); + const stop = () => { clearTimeout(timer); reject(signal.reason); }; + signal.addEventListener('abort', stop, { once: true }); + }); +} diff --git a/runner/production.ts b/runner/production.ts index 2d307e60..cf015259 100644 --- a/runner/production.ts +++ b/runner/production.ts @@ -264,6 +264,7 @@ export async function setUpRunner(o: { service: ReviewService; capability: Shutd deps: o.recovery ? o.recovery(image) : dRecoveryDeps(image, { abort: async (attemptId, resultHead, state) => (await getRebaser()).abort(attemptId, resultHead, state) }, rebasePlanKey) }); service.store.settleInterruptedPreMergeActions(identity); + service.store.settleInterruptedMergeJobs(identity); const repository = await getRepository(); const imageId = image(); const workspace = createTaskWorkspace({ store: service.store, runnerRoot: config.root, runnerOwner, repository, imageId, diff --git a/runner/store.ts b/runner/store.ts index 632801ef..83e939f8 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -129,6 +129,23 @@ export interface PushMarker { } /** What a read of the branch showed after a push: `to`, still `from`, or anything else. */ export type PushOutcome = 'pushed' | 'not-pushed' | 'moved'; +/** A one-click merge job (F6b of #22; docs/implementation/merge-handoff.md). */ +export type MergeJobState = 'running' | 'merged' | 'queued' | 'submitted' | 'blocked' | 'review-required' | 'failed' | 'cancelled'; +export type MergeJobStep = 'preparing' | 'checks' | 'merging'; +export interface MergeJob { + /** The click's action ID. */ + id: string; + /** The review token the click was made with; a resend must carry the same one. */ + token: string; + state: MergeJobState; + step: MergeJobStep | null; + reason: string | null; + /** Required checks that have not succeeded yet, as the last inspection read them. */ + checks: { context: string; state: string }[]; + url: string | null; + startedAt: string; + updatedAt: string; +} export interface AttemptRecord { id: string; kind: AttemptKind; phase: string; item: string | null; state: AttemptState; context: InvocationContext; deadline: number; firstReason: FirstReason | null; stopReason: StopReason | null; exitCode: number | null; signal: string | null; result: unknown; @@ -206,8 +223,8 @@ export class Store { this.#db.exec('PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;'); this.#transaction(() => { const version = this.#get('PRAGMA user_version')!.user_version as number; - if (![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].includes(version)) throw new Error('Unsupported store schema version.'); - if (version === 18) return; + if (![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19].includes(version)) throw new Error('Unsupported store schema version.'); + if (version === 19) return; if (version === 0) this.#db.exec(` CREATE TABLE plans (key TEXT PRIMARY KEY, issue INTEGER NOT NULL, revision INTEGER NOT NULL, snapshot_id TEXT); CREATE TABLE revisions (key TEXT NOT NULL REFERENCES plans(key), revision INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(key,revision)); @@ -329,6 +346,13 @@ export class Store { this.#db.exec('ALTER TABLE tasks ADD COLUMN push_in_progress TEXT'); this.#db.exec('PRAGMA user_version=18'); } + // One-click merge jobs (F6b of #22). Kept apart from tasks, so a job's progress never changes the task state version. + if (version < 19) { + this.#db.exec(`CREATE TABLE IF NOT EXISTS merge_jobs ( + id TEXT PRIMARY KEY, plan_key TEXT NOT NULL REFERENCES plans(key), data TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS merge_jobs_plan ON merge_jobs(plan_key);`); + this.#db.exec('PRAGMA user_version=19'); + } }); } catch (error) { this.#db.close(); throw error; } } @@ -2081,6 +2105,72 @@ export class Store { return saved.ok === true && saved.value?.outcome === 'ready' && stable(saved.preMergeReadiness) === stable(readiness); } /** Startup recovery makes an interrupted preparation definite and replayable before new work is admitted. */ + /** + * Admit a merge job for this click. A resend of the same click returns its job; another click while a job runs, or a + * reused action ID with another token, is refused. Never touches the task. + */ + beginMergeJob(identity: PlanIdentity, input: { actionId: string; token: string; now?: string }): { job: MergeJob; resent: boolean } { + assertUuidV4(input.actionId, 'Action ID'); + if (typeof input.token !== 'string' || !input.token || input.token.length > 512) throw new Error('A review token is required.'); + const key = identityKey(identity); + return this.#transaction(() => { + const task = this.#task(key); + const existing = this.#get('SELECT data FROM merge_jobs WHERE id=?', input.actionId); + if (existing) { + const job = decode(existing.data); + if (job.token !== input.token || this.#get('SELECT plan_key FROM merge_jobs WHERE id=?', input.actionId)!.plan_key !== key) + throw new ActionIdReused('Action ID already used for a different request.'); + return { job, resent: true }; + } + if (this.#closed(task.status as TaskStatus)) throw new GuardRefusal(`The task is ${task.status}; it cannot be merged.`); + if (this.runningMergeJob(identity)) throw new GuardRefusal('A merge is already running for this task.'); + const now = input.now ?? new Date().toISOString(); + const job: MergeJob = { id: input.actionId, token: input.token, state: 'running', step: 'preparing', reason: null, checks: [], + url: null, startedAt: now, updatedAt: now }; + this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); + return { job, resent: false }; + }); + } + /** Move a running job to another step or record what it waits for. A job no longer running is left unchanged. */ + updateMergeJob(identity: PlanIdentity, id: string, patch: { step?: MergeJobStep; reason?: string | null; checks?: { context: string; state: string }[] }): MergeJob | null { + return this.#changeMergeJob(identity, id, job => job.state === 'running' ? { ...job, ...patch } : null); + } + /** End a running job. Returns false when it had already ended. */ + finishMergeJob(identity: PlanIdentity, id: string, outcome: { state: Exclude; reason?: string | null; url?: string | null }): boolean { + return this.#changeMergeJob(identity, id, job => job.state === 'running' + ? { ...job, state: outcome.state, step: null, reason: outcome.reason ?? null, url: outcome.url ?? job.url } : null) !== null; + } + #changeMergeJob(identity: PlanIdentity, id: string, change: (job: MergeJob) => MergeJob | null): MergeJob | null { + const key = identityKey(identity); + return this.#transaction(() => { + const row = this.#get('SELECT data FROM merge_jobs WHERE id=? AND plan_key=?', id, key); + if (!row) throw new Error('Unknown merge job.'); + const next = change(decode(row.data)); + if (!next) return null; + const job = { ...next, updatedAt: new Date().toISOString() }; + this.#run('UPDATE merge_jobs SET data=? WHERE id=?', encode(job), id); + return job; + }); + } + getMergeJob(identity: PlanIdentity, id: string): MergeJob | null { + const row = this.#get('SELECT data FROM merge_jobs WHERE id=? AND plan_key=?', id, identityKey(identity)); + return row ? decode(row.data) : null; + } + latestMergeJob(identity: PlanIdentity): MergeJob | null { + const row = this.#get('SELECT data FROM merge_jobs WHERE plan_key=? ORDER BY rowid DESC LIMIT 1', identityKey(identity)); + return row ? decode(row.data) : null; + } + runningMergeJob(identity: PlanIdentity): MergeJob | null { + const row = this.#get(`SELECT data FROM merge_jobs WHERE plan_key=? AND json_extract(data,'$.state')='running' ORDER BY rowid DESC LIMIT 1`, + identityKey(identity)); + return row ? decode(row.data) : null; + } + /** At startup: a job the server stopped running cannot continue; it fails, and the person merges again. */ + settleInterruptedMergeJobs(identity: PlanIdentity): void { + const running = this.runningMergeJob(identity); + if (running) this.finishMergeJob(identity, running.id, { state: 'failed', + reason: 'The server restarted before the merge finished. Merge again.' }); + } settleInterruptedPreMergeActions(identity: PlanIdentity): void { const snapshot = this.getSnapshot(identity); const result: PreMergeActionResult = { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index c028ced0..3794ad53 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -629,3 +629,31 @@ test('hides Retry until a timed-out invocation has actually settled',async({page settle('Late answer'); await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible({timeout:10000}); }); +test('follows a one-click merge job from preparation through the required-check wait to merged (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('running','merging'),job('merged',null)]; + let polls=0,reviews=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing'); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{reviews++;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:'Preparing…',exact:true})).toBeDisabled(); + await expect(page.locator('#banner')).toContainText('Preparing the merge'); + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toBeDisabled(); + await expect(page.locator('#banner')).toContainText('Waiting for required checks: ci is pending.'); + await page.getByRole('button',{name:'Merge status',exact:true}).click(); + await expect(page.locator('dialog')).toContainText('ci: pending'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('button',{name:'Merging…',exact:true})).toBeDisabled(); + const reloadsBeforeEnd=reviews; + await expect(page.locator('#banner')).toHaveText('Merged.'); + expect(reviews).toBeGreaterThan(reloadsBeforeEnd); + expect(polls).toBe(3); +}); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts new file mode 100644 index 00000000..36b29e4c --- /dev/null +++ b/test/merge-job.test.ts @@ -0,0 +1,217 @@ +import { randomUUID } from 'node:crypto'; +import { afterEach, expect, it, vi } from 'vitest'; +import { Store } from '../runner/store.ts'; +import { GuardRefusal } from '../runner/lifecycle.ts'; +import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; +import { MergeJobCoordinator } from '../runner/merge-job.ts'; +import type { PreMergeCoordinator, PreMergeResult } from '../runner/pre-merge.ts'; +import type { ReviewService } from '../runner/review.ts'; +import { MergeSubmissionError, type MergeGateway, type RemoteMergeState, type RequiredCheck } from '../github/merge.ts'; +import { openingMarker } from '../github/pull-requests.ts'; +import type { Plan, PlanContext } from '../core/plan.ts'; +import { commandDigest } from '../runner/checks.ts'; + +// F6b of #22: the one-click merge job (docs/implementation/merge-handoff.md). +const oid = (n: number) => n.toString(16).padStart(40, '0'); +const identity = { repositoryId: 'repo', taskId: 'Task_42', planId: 'plan' }; +const plan: Plan = { schema_version: 1, issue: 12, revision: 1, summary: 'Stop the crash', questions: [], items: [ + { id: 'P1', title: 'Guard input', intent: 'Reject empty input', files: [{ path: 'a.ts', kind: 'edit', renamed_from: null, change: 'Check it' }], acceptance: [{ type: 'check', text: 'Works' }], depends_on: [] }] }; +const context: PlanContext = { identity, issue: 12, baseEntries: [{ path: 'a.ts', kind: 'file' }], pathKey: p => p, allowedCommands: [] }; +const BRANCH = 'codeboost/issue-12-task-42-0123456789abcdef'; +const PUBLISHED: PublishedTarget = { repository: 'owner/repo', baseBranch: 'main', requiresPreparation: true }; +const TIMING = { checkWaitMs: 2_000, firstDelayMs: 5, maxDelayMs: 20 }; +const stores: Store[] = [], jobs: MergeJobCoordinator[] = []; +afterEach(async () => { for (const job of jobs.splice(0)) await job.close(); for (const store of stores.splice(0)) store.close(); }); + +/** A task in review whose runner published PR 7 with head oid(2) over oid(1). */ +function publishedTask(): Store { + const store = new Store(':memory:'); stores.push(store); + store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); + const opening = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'main', headBranch: BRANCH, headSha: oid(2), draft: false }); + expect(store.recordPullRequestOpened(identity, opening.openingId, { number: 7, url: 'https://github.com/owner/repo/pull/7', headSha: oid(2), draft: false }, true)).toBe('in review'); + return store; +} +function service(store: Store) { + const view = () => { + const snapshot = store.getSnapshot(identity); + return { + items: [{ id: 'P1', state: 'approved', outside: [], acceptance: [{ type: 'check', text: 'Works' }], checks: { tests: '– No tests defined' } }], + plan: { revision: 1 }, segments: [], notes: [], snapshot: { id: snapshot.id, base: snapshot.base, head: snapshot.head }, token: 'review-token', + expected: { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, + }; + }; + return { store, config: { identity }, load: vi.fn(view), commandPolicyDigest: () => commandDigest([]) } as unknown as ReviewService; +} +/** Preparation as F5/F6a end it: `ready` records exact readiness on the job's own prepare-merge action. */ +function preparation(store: Store, result: (call: number) => PreMergeResult['state'] | Error = () => 'ready') { + const calls: string[] = []; + const fake = { + assertStartable: () => undefined, + start: vi.fn(async (expected: { actionId: string }) => { + calls.push(expected.actionId); + const outcome = result(calls.length), snapshot = store.getSnapshot(identity); + if (outcome instanceof Error) throw outcome; + const readiness = { stateVersion: store.getTask(identity).stateVersion, reviewVersion: store.reviewVersion(identity), + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, commandPolicyDigest: commandDigest([]) }; + const settled = { state: outcome, base: snapshot.base, head: snapshot.head, checked: [], reason: outcome === 'ready' ? null : `preparation ${outcome}` }; + return store.settlePreMergeAction(identity, expected.actionId, settled, outcome === 'ready' ? readiness : null); + }), + }; + return { fake: fake as unknown as PreMergeCoordinator, calls }; +} +/** GitHub showing PR 7 as published; `checks(n)` gives the required checks of the n-th inspection. */ +function github(store: Store, options: { checks?: (n: number) => RequiredCheck[]; change?: (n: number) => Partial; + merge?: () => Promise<{ url: string }> } = {}) { + let inspections = 0; + const merged: string[] = []; + const client: MergeGateway = { + inspect: vi.fn(async request => { + inspections++; + const record = store.taskPullRequests(identity).find(pr => pr.number === 7)!; + const snapshot = store.getSnapshot(identity); + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, + mergeQueue: false, requiredChecks: options.checks?.(inspections) ?? [], alreadyFixed: 'clear', pullRequest: 7, draft: false, + ...(request?.target?.headBranch ? { published: { headBranch: request.target.headBranch, baseBranch: 'main', crossRepository: false, + marker: openingMarker(record.openingId), branchOpen: [{ number: 7, base: 'main' }] } } : {}), + ...options.change?.(inspections) } as RemoteMergeState; + }), + merge: vi.fn(async head => { merged.push(head); return options.merge ? options.merge() : { url: 'https://github.com/owner/repo/pull/7' }; }), + }; + return { client, merged, inspections: () => inspections }; +} +function setup(options: Parameters[1] & { prepare?: Parameters[1] } = {}) { + const store = publishedTask(), review = service(store), gh = github(store, options); + const merges = new MergeCoordinator(review, gh.client, undefined, undefined, PUBLISHED); + const prep = preparation(store, options.prepare); + const coordinator = new MergeJobCoordinator(review, prep.fake, merges, undefined, TIMING); jobs.push(coordinator); + return { store, review, gh, merges, prep, coordinator }; +} +const settled = async (coordinator: MergeJobCoordinator) => { await coordinator.current; return coordinator.latest()!; }; +const pending = (context = 'ci'): RequiredCheck => ({ context, appId: null, state: 'pending' }); +const passing = (context = 'ci'): RequiredCheck => ({ context, appId: null, state: 'success' }); + +it('merges at once when the required set was read and is empty', async () => { + const f = setup(); + const started = f.coordinator.start('review-token', randomUUID()); + expect(started).toMatchObject({ state: 'running', step: 'preparing' }); + expect(started).not.toHaveProperty('token'); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged', step: null, url: 'https://github.com/owner/repo/pull/7' }); + expect(f.gh.merged).toEqual([oid(2)]); + expect(f.prep.calls).toHaveLength(1); + expect(f.store.getTask(identity).status).toBe('merged'); +}); + +it('waits while a required check is pending, then merges once it passes', async () => { + const seen: string[] = []; + const f = setup({ checks: n => (n <= 2 ? [pending()] : [passing()]) }); + const job = f.coordinator.start('review-token', randomUUID()); + const poll = setInterval(() => { const latest = f.coordinator.latest(); if (latest?.checks.length) seen.push(`${latest.step}:${latest.checks[0]!.state}`); }, 1); + const done = await settled(f.coordinator); clearInterval(poll); + expect(done).toMatchObject({ id: job.id, state: 'merged' }); + expect(seen).toContain('checks:pending'); + expect(f.gh.merged).toEqual([oid(2)]); +}); + +it('ends approved, merge blocked when required checks do not pass in time', async () => { + const f = setup({ checks: () => [pending('build'), { context: 'deploy', appId: null, state: 'missing' }] }); + f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + expect(done.state).toBe('blocked'); + expect(done.reason).toMatch(/did not pass within .*build is pending, deploy is missing/); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('stops at once when a required check fails, without waiting for the time limit', async () => { + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }] }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: 'Required check failed: build.' }); + expect(f.gh.inspections()).toBe(1); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('waits while GitHub is still computing mergeability', async () => { + const f = setup({ change: n => (n <= 2 ? { mergeable: 'UNKNOWN' } : {}) }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); +}); + +it('ends for review or as failed when preparation does, and merges nothing', async () => { + for (const [outcome, state] of [['review-required', 'review-required'], ['failed', 'failed']] as const) { + const f = setup({ prepare: () => outcome }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state, reason: `preparation ${outcome}` }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); + } +}); + +it('ends as failed, for a person, when another blocker appears while waiting', async () => { + const f = setup({ change: () => ({ base: oid(30) }) }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/base branch moved/) }); + expect(f.gh.merged).toEqual([]); +}); + +it('ends approved, merge blocked when GitHub refuses the merge', async () => { + const f = setup({ merge: async () => { throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); } }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge: .*expected/) }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('ends submitted when GitHub\'s merge outcome is unknown; the attempt stays in flight', async () => { + const f = setup({ merge: async () => { throw new MergeSubmissionError('connection reset', 'unknown'); } }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'submitted' }); + expect(f.store.getMergeAttempt(identity)?.state).toBe('submitting'); +}); + +it('stops waiting at once when the task is cancelled', async () => { + const f = setup({ checks: () => [pending()] }); + f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + f.coordinator.cancel(); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled' }); + expect(f.gh.merged).toEqual([]); +}); + +it('keeps the readiness preparation recorded valid while the job records its progress', async () => { + let readyAt: number | null = null; + const f = setup({ checks: n => { if (n === 1) readyAt = f.store.getTask(identity).stateVersion; return n <= 3 ? [pending()] : [passing()]; } }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); + // The guarded merge admitted the readiness bound at preparation: the job's own writes never advanced the task. + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'merged' }); + expect(readyAt).not.toBeNull(); +}); + +it('replays a resent click, refuses a second click while a job runs, and saves nothing for a stale token', async () => { + const f = setup({ checks: () => [pending()] }); + const id = randomUUID(); + expect(() => f.coordinator.start('stale-token', randomUUID())).toThrow(/Stale review state/); + expect(f.coordinator.latest()).toBeNull(); + const first = f.coordinator.start('review-token', id); + expect(f.coordinator.start('review-token', id)).toMatchObject({ id: first.id, state: 'running' }); + expect(() => f.coordinator.start('review-token', randomUUID())).toThrow(GuardRefusal); + expect(() => f.coordinator.start('other-token', id)).toThrow(/already used/); + f.coordinator.cancel(); await settled(f.coordinator); + expect(f.prep.calls).toHaveLength(1); +}); + +it('leaves a job running on shutdown, and startup ends it as failed', async () => { + const f = setup({ checks: () => [pending()] }); + f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + await f.coordinator.close(); + expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); + f.store.settleInterruptedMergeJobs(identity); + expect(f.coordinator.latest()).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); + expect(() => f.coordinator.start('review-token', randomUUID())).toThrow(/shutting down/); +}); diff --git a/test/planning-drafts.test.ts b/test/planning-drafts.test.ts index 56290fd1..9f57ce3e 100644 --- a/test/planning-drafts.test.ts +++ b/test/planning-drafts.test.ts @@ -170,7 +170,7 @@ it.each([9, 10])('migrates a v%i database to the current schema, reading its exi const migrated = open(); expect(migrated.getSuggestions(identity, id)).toMatchObject({ mode: 'suggest', state: 'pending' }); const db = new DatabaseSync(path); - try { expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); } finally { db.close(); } + try { expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); } finally { db.close(); } }); it('adds checkpoint bindings to v14 planning requests without losing their state', () => { const { store, path, open } = fixture(), id = store.beginSuggestions(identity, state(store), 'draft'); @@ -181,7 +181,7 @@ it('adds checkpoint bindings to v14 planning requests without losing their state expect(migrated.getDraft(identity, id).state).toBe('pending'); const db = new DatabaseSync(path); try { - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); expect(db.prepare('PRAGMA table_info(requests)').all().some(column => column.name === 'continuation')).toBe(true); } finally { db.close(); } }); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index e215ad4f..4865f58c 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -4,7 +4,10 @@ import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { afterEach, expect, it, vi } from 'vitest'; import { COMMAND_CHECK_SETTLEMENT_RESERVE_MS, PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS, PreMergeCoordinator, - type PreMergeAuthorization } from '../runner/pre-merge.ts'; + type PreMergeAuthorization, type PreMergeResult } from '../runner/pre-merge.ts'; +import { MergeCoordinator } from '../runner/merge.ts'; +import { MergeJobCoordinator } from '../runner/merge-job.ts'; +import type { MergeGateway } from '../github/merge.ts'; import { ReviewService } from '../runner/review.ts'; import { createDemo } from '../scripts/demo.ts'; import { fixtureGit } from './fixtures/git.ts'; @@ -72,6 +75,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: rebaseResult?: (service: ReviewService, input: { oldHead: string; onto: string }, run: number) => string | undefined; /** Branch reads never answer; only their abort signal ends them. */ branchReadHangs?: boolean; + /** Set up everything but start no preparation; `result` is then null. */ + deferStart?: boolean; /** Remote fetches fail once codeboost has pushed. */ fetchFailsAfterPush?: boolean; /** A collaborator pushes this commit to the branch just before codeboost's push, which is then refused. */ @@ -244,7 +249,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }) as RunnerCoordinator['start']; } const task = service.store.getTask(identity); - const result = await coordinator.start({ stateVersion: task.stateVersion, + const result = options.deferStart ? null as unknown as PreMergeResult : await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons, pushes, remote: () => remotePair, branchReads: () => branchReads, @@ -1395,3 +1400,72 @@ it('starts no settlement read when less than the process-settlement reserve is l expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); await fixture.coordinator.close(); await fixture.runner.close(); }); + +// F6b of #22: the acceptance cases, run through the one-click merge job with real preparation. +async function mergeJob(rebasedText: string, commandExit?: number, options: Parameters[2] = {}) { + const fixture = await rebaseFixture(rebasedText, commandExit, { ...options, deferStart: true }); + const merged: string[] = []; + const gateway: MergeGateway = { + inspect: async () => ({ base: fixture.remote().base, head: fixture.remote().head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', + rulesKnown: true, atomicBaseGuard: true, mergeQueue: false, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }), + merge: async head => { merged.push(head); return { url: 'https://github.com/acme/app/pull/7' }; }, + }; + const merges = new MergeCoordinator(fixture.service, gateway); + const jobs = new MergeJobCoordinator(fixture.service, fixture.coordinator, merges, undefined, { checkWaitMs: 2_000, firstDelayMs: 5, maxDelayMs: 20 }); + const start = () => jobs.start(fixture.service.load().token, randomUUID()); + const settled = async () => { await jobs.current; return jobs.latest()!; }; + const close = async () => { await jobs.close(); await fixture.coordinator.close(); await fixture.runner.close(); }; + return { ...fixture, merged, jobs, start, settled, close }; +} + +it('merges through the job when a moved base leaves approvals valid, pushing and checking the rewritten head', async () => { + const f = await mergeJob('feature\n', 0); + f.start(); + expect(await f.settled()).toMatchObject({ state: 'merged' }); + expect(f.pushes).toHaveLength(1); + expect(f.checkedHeads).toEqual([f.rebased]); + expect(f.merged).toEqual([f.rebased]); + await f.close(); +}); + +it('stops the job for review when the rebase makes an approval stale, and merges nothing', async () => { + const f = await mergeJob('changed by rebase\n'); + f.start(); + expect(await f.settled()).toMatchObject({ state: 'review-required', reason: expect.stringMatching(/requires refreshed attribution or approval/) }); + expect(f.merged).toEqual([]); + await f.close(); +}); + +it('fails the job when a command check breaks on the rewritten head', async () => { + const f = await mergeJob('feature\n', 1); + f.start(); + expect(await f.settled()).toMatchObject({ state: 'failed', reason: expect.stringMatching(/command checks did not pass/) }); + expect(f.merged).toEqual([]); + await f.close(); +}); + +it('stops the job for review when a collaborator pushed to the pull request', async () => { + const f = await mergeJob('feature\n'); + const repository = f.service.config.repository; + writeFileSync(join(repository, 'late.txt'), 'collaborator\n'); fixtureGit(repository, 'add', 'late.txt'); fixtureGit(repository, 'commit', '-qm', 'collaborator'); + f.moveRemote({ base: f.remote().base, head: fixtureGit(repository, 'rev-parse', 'HEAD') }); + f.start(); + expect(await f.settled()).toMatchObject({ state: 'review-required', reason: expect.stringMatching(/head moved/) }); + expect(f.pushes).toEqual([]); + expect(f.merged).toEqual([]); + await f.close(); +}); + +it('ends the job cancelled when the task is cancelled while a command check runs', async () => { + const f = await mergeJob('feature\n', 0, { commandWaitsForCancellation: true }); + f.start(); + await vi.waitFor(() => expect(f.runner.isActive(f.service.config.identity)).toBe(true)); + const identity = f.service.config.identity; + f.jobs.cancel(); + f.coordinator.cancelTask(f.service.store.getTask(identity).stateVersion, randomUUID()); + expect(await f.settled()).toMatchObject({ state: 'cancelled' }); + expect(new Set(f.cancelReasons)).toEqual(new Set(['cancelled'])); + expect(f.merged).toEqual([]); + await vi.waitFor(() => expect(f.service.store.getTask(identity).status).toBe('cancelled')); + await f.close(); +}); diff --git a/test/publish.test.ts b/test/publish.test.ts index 8322ffab..3f347e5f 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -358,7 +358,7 @@ describe('schema v7', () => { expect(upgraded.taskPullRequests(identity)).toEqual([]); expect(upgraded.latestAlreadyFixed(identity)).toBeNull(); const db = new DatabaseSync(path, { readOnly: true }); - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); const names = db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','index') AND (name LIKE '%pull_requests%' OR name LIKE 'already_fixed_checks%') AND name NOT LIKE 'sqlite_autoindex%' ORDER BY name").all().map(row => row.name); expect(names).toEqual(['already_fixed_checks', 'already_fixed_checks_task', 'task_pull_requests', 'task_pull_requests_number', 'task_pull_requests_opening', 'task_pull_requests_task']); db.close(); diff --git a/test/runner-lifecycle-store.test.ts b/test/runner-lifecycle-store.test.ts index 21a4e9c0..b22d5772 100644 --- a/test/runner-lifecycle-store.test.ts +++ b/test/runner-lifecycle-store.test.ts @@ -105,7 +105,7 @@ describe('schema v12 review ordering', () => { migrated.saveReview(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: migratedVersion }, [{ item: 'P1', fingerprint: 'approved-after-upgrade' }], []); expect(migrated.unapprovedExecutionItems(identity, 1)).toEqual([]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); }); @@ -783,7 +783,7 @@ describe('runner commits and preparation groups (#87)', () => { db.exec('ALTER TABLE attempts DROP COLUMN preparation_identity; PRAGMA user_version=15;'); db.close(); const reopened = open(path); expect(reopened.interruptedAttempts()).toEqual([expect.objectContaining({ id: attempt.id, preparationIdentity: null })]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); }); @@ -917,7 +917,7 @@ describe('durable safety findings (#87 item 3)', () => { db.exec('ALTER TABLE attempts DROP COLUMN safety_finding; PRAGMA user_version=7;'); db.close(); const reopened = open(path); expect(reopened.getAttempt(identity, attempt.id).safetyFinding).toBeNull(); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); it('adds the durable owed marker to a version 12 database', () => { @@ -927,7 +927,7 @@ describe('durable safety findings (#87 item 3)', () => { db.exec('ALTER TABLE attempts DROP COLUMN safety_owed; PRAGMA user_version=12;'); db.close(); const reopened = open(path); expect(reopened.owedSafetyFindings()).toEqual([]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); it('restores and clears an escalation owed behind a human gate across real store reopens', () => { @@ -967,7 +967,7 @@ describe('allocation baseline (#91)', () => { db.exec('ALTER TABLE attempts DROP COLUMN metadata_baseline; ALTER TABLE attempts DROP COLUMN storage_base; PRAGMA user_version=8;'); db.close(); const reopened = open(path); expect(reopened.interruptedAttempts()).toEqual([expect.objectContaining({ id: attempt.id, metadataBaseline: null, storageBase: null })]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); }); describe('publish outcomes (#103)', () => { @@ -983,7 +983,7 @@ describe('publish outcomes (#103)', () => { reopened.recordPublish(identity, { outcome: 'opened', draft: false, message: 'Pull request #1 is open.', number: 1, url: 'https://github.com/o/r/pull/1' }); expect(reopened.lastPublish(identity)).toMatchObject({ outcome: 'opened', number: 1 }); expect(reopened.getTask(identity)).toEqual(before); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); it('stamps an outcome with the version the publish saw, and refuses one it cannot have seen (#114)', () => { const { store } = queued(), version = store.getTask(identity).stateVersion; @@ -1023,7 +1023,7 @@ describe('pre-merge push marker (F6a of #22)', () => { const legacy = new DatabaseSync(path); legacy.exec('ALTER TABLE tasks DROP COLUMN push_in_progress; PRAGMA user_version=17;'); legacy.close(); expect(open(path).getTask(identity).pushInProgress).toBeNull(); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); it('claims only a push of the current head over a durable rewrite predecessor', () => { diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 2939ce91..5130ad17 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { chmodSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -24,6 +24,8 @@ import type { PlanIdentity } from '../core/identity.ts'; import { approveItem } from '../core/approvals.ts'; import { GhIssueGateway, type IssueTrustGateway } from '../github/issues.ts'; import type { PreMergeCoordinator } from '../runner/pre-merge.ts'; +import type { MergeJobTiming } from '../runner/merge-job.ts'; +import type { RemoteMergeState, RequiredCheck } from '../github/merge.ts'; vi.setConfig({ testTimeout: 30_000 }); const roots: string[] = [], cleanups: (() => Promise | void)[] = []; @@ -232,13 +234,16 @@ function world(): World { * earlier process would have left it. */ async function serve(w: World, options: { before?: (service: ReviewService) => void; onPushSpawn?: (n: number, app: () => App, close: () => Promise) => void; demo?: boolean; startup?: boolean; settleMs?: number; env?: NodeJS.ProcessEnv; hold?: Promise; shortRetryMs?: number; findingSource?: FindingSource; - issueGateway?: IssueTrustGateway; preMerge?: (service: ReviewService) => PreMergeCoordinator } = {}) { + issueGateway?: IssueTrustGateway; preMerge?: (service: ReviewService) => PreMergeCoordinator; mergeGateway?: MergeGateway; mergeJobTiming?: MergeJobTiming; + /** A runner block and no github.pullRequest: merging targets the PR the runner published, after preparation. */ + runnerPublished?: boolean } = {}) { let app: App | undefined, spawns = 0, closing: Promise | undefined; const close = () => closing ??= app!.close(); let branchOf: (identity: PlanIdentity) => string = () => ''; let findings: SafetyFindings | undefined; - const config = { ...w.demo, demo: options.demo ?? false, ...(options.issueGateway ? { github: { repository: options.issueGateway.repository, issue: 3, pullRequest: 1, baseBranch: 'main' } } : {}) }; - app = await startServer(config, 0, undefined, undefined, 2_000, options.issueGateway, undefined, undefined, async service => { + const config = { ...w.demo, demo: options.demo ?? false, ...(options.issueGateway ? { github: { repository: options.issueGateway.repository, issue: 3, + ...(options.runnerPublished ? {} : { pullRequest: 1 }), baseBranch: 'main' } } : {}), ...(options.runnerPublished ? { runner: {} } : {}) }; + app = await startServer(config, 0, undefined, options.mergeGateway, 2_000, options.issueGateway, undefined, undefined, async service => { const identity = service.config.identity, task = service.store.getTask(identity), plan = service.store.getPlan(identity); if (task.status !== 'merged' && task.status !== 'cancelled' && service.store.unapprovedExecutionItems(identity, plan.revision).length) { const review = service.load(); @@ -284,7 +289,7 @@ async function serve(w: World, options: { before?: (service: ReviewService) => v return { deps, sources, findings, publisher, ...(options.preMerge ? { preMerge: () => options.preMerge!(service) } : {}), ...(options.env ? { env: options.env } : {}), ...(options.shortRetryMs ? { shortRetryMs: options.shortRetryMs } : {}), recovery: { finalized: [], requeue: [], removedDirectories: [], unknownEntries: [], unmatchedStorage: [], repairedMerges: [] } }; - }); + }, options.mergeJobTiming ? { mergeJobTiming: options.mergeJobTiming } : {}); cleanups.push(close); // As the CLI does once it has verified the lock. if (options.startup !== false) app.publishOwed(() => undefined); @@ -1723,3 +1728,97 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { expect(await act(app, 'publish')).toMatchObject({ status: 409, body: { error: 'The task is cancelled; its pull requests are closed, not published.' } }); }); }); + +describe('the one-click merge job (F6b of #22)', () => { + const issueGateway: IssueTrustGateway = { + repository: REPO, + async fetch() { return { repository: REPO, retrievedAt: new Date().toISOString(), issues: [] }; }, + async issueAccess(number) { return { number, authorLogin: 'member', collaborator: true }; }, + async issueText(number) { return { number, title: '', body: '', comments: [] }; }, + }; + /** Preparation as F5/F6a end it: readiness recorded exactly on its own action. */ + const preparation = (service: ReviewService) => ({ + get active() { return false; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, + cancelTask: () => 'closed' as const, close: async () => undefined, + start: async (expected: { actionId: string }) => { + const identity = service.config.identity, snapshot = service.store.getSnapshot(identity); + const readiness = { stateVersion: service.store.getTask(identity).stateVersion, reviewVersion: service.store.reviewVersion(identity), + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, commandPolicyDigest: service.commandPolicyDigest() }; + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'ready', base: snapshot.base, head: snapshot.head, checked: [], reason: null }, readiness); + }, + } as unknown as PreMergeCoordinator); + /** GitHub showing the task's published PR at the reviewed pair; `checks()` gives its required checks now. */ + const gateway = (store: () => Store, checks: () => RequiredCheck[]): MergeGateway & { merged: string[] } => { + const merged: string[] = []; + return { merged, + inspect: async request => { + const identity = createIdentity(), pr = store().taskPullRequests(identity).find(row => row.number === request?.target?.pullRequest); + const snapshot = store().getSnapshot(identity); + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, + mergeQueue: false, requiredChecks: checks(), alreadyFixed: 'clear', pullRequest: request!.target!.pullRequest, draft: false, + published: { headBranch: request!.target!.headBranch!, baseBranch: 'main', crossRepository: false, marker: openingMarker(pr!.openingId), + branchOpen: [{ number: request!.target!.pullRequest, base: 'main' }] } } as RemoteMergeState; + }, + merge: async head => { merged.push(head); return { url: 'https://github.com/acme/app/pull/100' }; } }; + }; + let createIdentity: () => PlanIdentity = () => { throw new Error('not set'); }; + /** A review a person could merge: the demo's out-of-scope mode change undone, its unplanned changes accepted, all approved. */ + const mergeable = (w: World) => { + chmodSync(join(w.demo.repository, 'run.sh'), 0o644); git(w.demo.repository, 'commit', '-qam', 'Restore declared scope'); + }; + const acceptAndApprove = (service: ReviewService) => { + let view = service.load(); + for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + view = service.act({ action: 'accept', key: segment.key, token: view.token }); + for (const item of view.items) if (item.state !== 'approved') + view = service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + }; + const review = async (app: App) => (await fetch(`${new URL(app.url).origin}/api/review`, { headers: { 'x-codeboost-token': app.token } })).json() as Promise>; + const merge = async (app: App, token: string, actionId = randomUUID()) => { + const response = await fetch(`${new URL(app.url).origin}/api/action`, { method: 'POST', headers: { 'x-codeboost-token': app.token, 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'merge', token, actionId }) }); + return { status: response.status, body: await response.json() as Record }; + }; + const mergeState = async (app: App) => (await fetch(`${new URL(app.url).origin}/api/merge`, { headers: { 'x-codeboost-token': app.token } })).json() as Promise>; + + it('offers Merge PR while only blockers the job resolves remain, then waits for checks and merges, refusing prepare-merge meanwhile', async () => { + const w = world(); mergeable(w); + let checks: RequiredCheck[] = [{ context: 'ci', appId: null, state: 'pending' }], storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => checks); + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const before = await review(served.app); + // Not prepared yet, and a required check pends: both are the job's to resolve. + expect(before.merge.blockers.map((blocker: { code: string }) => blocker.code)).toEqual(expect.arrayContaining(['preparation', 'check'])); + expect(before.merge).toMatchObject({ ready: true, action: 'merge', job: null }); + const started = await merge(served.app, before.token); + expect(started.status).toBe(200); + expect(started.body.job).toMatchObject({ state: 'running' }); + expect(started.body.merge).toMatchObject({ ready: false, job: { state: 'running' } }); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks', checks: [{ context: 'ci', state: 'pending' }] }), { timeout: 10_000 }); + expect(await act(served.app, 'prepare-merge')).toMatchObject({ status: 409, body: { error: expect.stringMatching(/merge is running/) } }); + checks = [{ context: 'ci', appId: null, state: 'success' }]; + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); + expect(gh.merged).toEqual([served.store.getSnapshot(served.identity).head]); + expect(served.store.getTask(served.identity).status).toBe('merged'); + }, 60_000); + + it('stops a waiting merge job when the task is cancelled, and closes the task\'s pull requests', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }]); + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks' }), { timeout: 10_000 }); + expect((await act(served.app, 'cancel-task')).body.result).toEqual({ outcome: 'closed' }); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'cancelled' }), { timeout: 10_000 }); + await vi.waitFor(() => expect(w.github.prs[0]!.open).toBe(false), { timeout: 20_000 }); + expect(gh.merged).toEqual([]); + }, 60_000); +}); diff --git a/test/store.test.ts b/test/store.test.ts index 18cbf060..4ea407eb 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -121,7 +121,7 @@ it('scopes issue trust to repository and current author, supports revoke, and mi legacy.exec('DROP TABLE issue_trust; ALTER TABLE attempts DROP COLUMN prompt_comments; PRAGMA user_version=16;'); legacy.close(); const migrated = open(path), db = new DatabaseSync(path); try { - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); expect(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='issue_trust'").get()).toEqual({ name: 'issue_trust' }); expect(db.prepare('PRAGMA table_info(attempts)').all().some(column => column.name === 'prompt_comments')).toBe(true); expect(migrated.issueTrust('owner/a', 5)).toBeNull(); diff --git a/web/public/app.js b/web/public/app.js index a79f8c54..6003cde9 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -159,6 +159,15 @@ function renderMerge() { // Once the attempt this retained key started has ended, a retry is a new action and needs a new key. if (mergeAction && merge.queue?.actionId === mergeAction.actionId && ["merged", "removed", "failed"].includes(merge.queue.state)) mergeAction = null; $("merge").disabled = !merge.ready; + const job = merge.job; + if (job?.state === "running") { + $("merge").disabled = true; + $("merge").textContent = { preparing: "Preparing…", checks: "Waiting for checks…", merging: "Merging…" }[job.step] ?? "Merging…"; + $("merge-details").hidden = false; + $("merge-details").textContent = "Merge status"; + scheduleMergePoll(); + return; + } $("merge").textContent = merge.action === "retry" ? "Retry merge" : merge.queue?.state === "submitting" @@ -177,8 +186,9 @@ function scheduleMergePoll() { if (mergePollTimer) clearTimeout(mergePollTimer); mergePollTimer = null; const queue = data?.merge?.queue; - const state = queue?.state; - if (queue?.kind === "queue" && ["submitting", "queued"].includes(state)) { + const job = data?.merge?.job; + const state = job?.state === "running" ? `job:${job.step}` : queue?.state; + if (job?.state === "running" || (queue?.kind === "queue" && ["submitting", "queued"].includes(state))) { if (state !== mergePollState) { mergePollState = state; mergePollDelay = 2000; @@ -197,6 +207,21 @@ async function pollMergeQueue(generation) { try { const update = await api("/api/merge"); if (generation !== mergeGeneration || !data?.merge?.available) return; + if (data.merge.job?.state === "running") { + // A merge job (F6b): follow its step; once it ends, reload the review, which shows the outcome. + const job = update.job; + if (job?.state === "running") { + const changed = job.step !== data.merge.job.step || JSON.stringify(job.checks) !== JSON.stringify(data.merge.job.checks); + data = { ...data, merge: { ...data.merge, job } }; + $("banner").textContent = mergeJobText(job); + if (!changed) backOffMergePoll(); + renderMerge(); + return; + } + await refresh(); + if (job) $("banner").textContent = mergeJobText(job); + return; + } const queue = update.queue; mergeObservationGeneration++; data = withMergeQueue(data, queue); @@ -210,6 +235,18 @@ async function pollMergeQueue(generation) { scheduleMergePoll(); } } +function mergeJobText(job) { + if (job.state === "running") return job.step === "checks" + ? `Waiting for required checks${job.checks.length ? `: ${job.checks.map(check => `${check.context} is ${check.state}`).join(", ")}` : ""}.` + : job.step === "merging" ? "Merging the reviewed head." : "Preparing the merge: refresh, rebase, push and command checks."; + return { + merged: "Merged.", queued: "Merge queued. Waiting for GitHub to confirm the outcome.", + submitted: `Merge submitted; GitHub's outcome is not confirmed yet. ${job.reason ?? ""}`.trim(), + blocked: `Approved, merge blocked. ${job.reason ?? ""} Fix it, then Retry merge.`.trim(), + "review-required": `Review needed before merging. ${job.reason ?? ""}`.trim(), + failed: `Merge stopped. ${job.reason ?? ""} Merge again when ready.`.trim(), cancelled: "Merge cancelled.", + }[job.state] ?? job.state; +} const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge"]); function withMergeQueue(view, queue) { const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); @@ -522,7 +559,12 @@ $("approve").onclick = () => { $("reload").onclick = refresh; $("merge-details").onclick = () => { if (!data?.merge?.available) return; - const queue = data.merge.queue; + const queue = data.merge.queue, job = data.merge.job; + if (job?.state === "running") { + showDialog(`

Merge status

${esc(job.step ?? job.state)}

${esc(mergeJobText(job))}

${job.checks.length + ? `
    ${job.checks.map(check => `
  • ${esc(check.context)}: ${esc(check.state)}
  • `).join("")}
` : ""}`); + return; + } showDialog( `

${queue ? "Merge status" : "Merge blockers"}

${queue ? `

${esc(queue.state)} · reviewed head ${esc(queue.reviewedHead.slice(0, 12))}

${queue.phase ? `

GitHub phase: ${esc(queue.phase)}${queue.position === null ? "" : ` · position ${queue.position}`}

` : ""}${queue.reason ? `

${esc(queue.reason)}

` : ""}${queue.observationError ? `

${esc(queue.observationError)}

` : ""}` : ""}
    ${data.merge.blockers.map((blocker) => `
  • ${esc(blocker.message)}
  • `).join("")}
`, ); @@ -541,6 +583,13 @@ $("merge").onclick = async () => { mergeAction ??= { actionId: crypto.randomUUID(), token: data.token }; const updated = await api("/api/action", { action: "merge", token: mergeAction.token, actionId: mergeAction.actionId }); mergeAction = null; + if (updated.job) { + // A merge job runs in the background (F6b); the page follows it. + data = { ...updated, merge: { ...updated.merge, job: updated.job } }; + render(); + $("banner").textContent = mergeJobText(updated.job); + return; + } const queued = updated.mergeQueue?.state === "queued" || updated.mergeQueue?.state === "submitting"; const blocker = { code: queued ? "queue-active" : "merge-submitted", message: queued ? "The reviewed head is queued. Waiting for GitHub to confirm the outcome." : "Merge was submitted. Refresh to confirm GitHub state." }; data = updated.mergeRefreshRequired diff --git a/web/server.ts b/web/server.ts index 55800ba1..797be9d7 100644 --- a/web/server.ts +++ b/web/server.ts @@ -21,6 +21,7 @@ import { SuggestionCoordinator, type PlanningMode, type SuggestionHandle, type S import type { AuthorProvider } from '../core/planning-author.ts'; import { PLANNING_BUDGET_MS } from '../runner/planning-provider.ts'; import type { PreMergeCoordinator } from '../runner/pre-merge.ts'; +import { MergeJobCoordinator, withMergeJob, type MergeJobTiming } from '../runner/merge-job.ts'; export type PlanningDescription = Pick & { repo: { name: string; baseRef: string }; /** Revalidates any mutable authority carried by this description at the synchronous prompt-construction boundary. */ @@ -57,12 +58,16 @@ export const RUNNER_NOT_IN_DEMO = 'Demos do not run the runner. Use a review con export interface ServerTestHooks { /** Test-only gate for forcing a response lifecycle transition before the Issues wait is armed. */ beforeIssueRefreshWait?(): Promise | void; + /** Shorter required-check waits for tests of the one-click merge (F6b). */ + mergeJobTiming?: MergeJobTiming; } export async function startServer(config: ReviewConfig, port = 4318, questionAgent?: QuestionAgent, mergeGateway?: MergeGateway, shutdownDrainMs = MAX_SHUTDOWN_DRAIN_MS, issueGateway?: IssueGateway, runnerDeps?: RunnerDeps, planningInput?: PlanningDeps | PlanningSetup, runnerSetup?: RunnerSetup, testHooks: ServerTestHooks = {}) { if (!Number.isSafeInteger(shutdownDrainMs) || shutdownDrainMs < 1 || shutdownDrainMs > MAX_SHUTDOWN_DRAIN_MS) throw new Error('Invalid shutdown drain deadline.'); const service = new ReviewService(config), token = randomBytes(32).toString('hex'); let questions: Questions, merges: MergeCoordinator | null, issues: IssueBoard, runner: RunnerCoordinator | null, suggestions: SuggestionCoordinator | null; let preMerge: PreMergeCoordinator | null = null; + /** The one-click merge (F6b of #22); only with a runner-published PR, which requires preparation. */ + let mergeJobs: MergeJobCoordinator | null = null; let planning: PlanningDeps | undefined, issueSource: IssueGateway | null; /** Runs a task's plan items; one per Store, like the coordinator. Only the production runner has one. */ let executor: ItemExecutor | null = null; @@ -116,6 +121,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge validate: () => requireTrustedIssue(access), }; }); + if (preMerge && merges && merges.published?.requiresPreparation) + mergeJobs = new MergeJobCoordinator(service, preMerge, merges, capability, testHooks.mergeJobTiming); executor = new ItemExecutor(service.store, runner, assembly.sources, assembly.findings, { capability }); // A demo never publishes, whatever its github block or an injected setup provides (setUpRunner refuses demos too). if (assembly.publisher && !config.demo) { const coordinator = runner; publishing = new TaskPublishing(service.store, assembly.publisher(() => coordinator.closing), runner, executor, capability, assembly.env, { @@ -128,7 +135,13 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge } catch (error) { service.close(); throw error; } } const loadReview=()=>{const view=service.load();return {...view,notes:view.notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))};}; - const load=async(signal?:AbortSignal)=>{const view=loadReview();return {...view,merge:merges?await merges.displayStatus(view,signal):{available:false}};}; + const mergeView=async(view:ReturnType,signal?:AbortSignal)=>{ + if(!merges)return {available:false}; + const status=await merges.displayStatus(view,signal); + // With a runner, Merge PR starts a merge job, which itself prepares and waits for required checks (F6b of #22). + return mergeJobs?withMergeJob(status,mergeJobs.latest(),service.store.getTask(identity).status):status; + }; + const load=async(signal?:AbortSignal)=>{const view=loadReview();return {...view,merge:await mergeView(view,signal)};}; const answerStatuses=()=>service.store.getReviewNotes(config.identity) .filter(note=>note.kind==='question') .map(note=>({id:note.id,answer:note.answer,answerActive:questions.isRunning(note.id)})); @@ -397,6 +410,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge service.store.afterCommit(() => afterPreMerge(preMerge!.settlePendingPush())); return { outcome: 'stopping' as const }; } + // A merge job stops its wait at once; its preparation and guarded merge stop through their own routes. + if (mergeJobs?.active) service.store.afterCommit(() => mergeJobs!.cancel()); const outcome = preMerge?.active ? preMerge.cancelTask(expectedStateVersion as number, actionId as string) : runner ? runner.cancelTask(identity, expectedStateVersion as number, actionId as string) @@ -411,6 +426,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if (action === 'prepare-merge') { if (access) requireTrustedIssue(access); if (!preMerge) throw new GuardRefusal('Pre-merge preparation is not configured.'); + if (mergeJobs?.active) throw new GuardRefusal('A merge is running for this task; it prepares the merge itself.'); if (runner.isActive(identity) || executor?.busy(identity) || publishing?.busy(identity)) throw new GuardRefusal('The runner or publisher is busy; pre-merge preparation cannot start yet.'); preMerge.assertStartable(); @@ -588,7 +604,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if (stopping) { json(503, { error: 'The review server is shutting down.' }); return; } if (req.method === 'GET' && path === '/api/settings') { json(200,{questionProvider:service.store.questionProvider()});return; } if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:answerStatuses()});return; } - if (req.method === 'GET' && path === '/api/merge') { if(!merges)throw new Error('Merging is not configured for this review.');json(200,{queue:await merges.pollQueue()});return; } + if (req.method === 'GET' && path === '/api/merge') { if(!merges)throw new Error('Merging is not configured for this review.');json(200,{queue:await merges.pollQueue(),job:mergeJobs?.latest()??null});return; } if (req.method === 'GET' && path === '/api/issues') { json(200, issues.view()); return; } if (req.method === 'GET' && path === '/api/review') { json(200, await load(requestAbort.signal)); return; } if (req.method === 'GET' && path === '/api/runner') { json(200, runnerView()); return; } @@ -660,6 +676,15 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge // Every merge click carries its idempotency key; a request without one would bypass replay. if(!isUuidV4(input.actionId)) { json(400,{error:'A merge request needs a UUID v4 actionId.'}); return; } if(!merges)throw new Error('Merging is not configured for this review.'); + // With a runner, one click starts the whole merge job (F6b of #22) and returns at once; the browser follows it + // through GET /api/merge. + if(mergeJobs) { + const job=mergeJobs.start(input.token,input.actionId,()=>runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) + ?'The runner or publisher is busy; the merge cannot start yet.':null); + const running=mergeJobs.current; + if(running)void running.finally(()=>{ if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); }); + json(200,{...(await load(requestAbort.signal)),job});return; + } let merged: Awaited>; // A merge stopped for a passing reason applied nothing, so answer 503 and the browser resends the same key. try { merged=await merges.merge(input.token,input.actionId); } @@ -760,7 +785,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge // whole pre-merge lifecycle before the write gate closes, so those settlement writes cannot be refused. const failures: unknown[] = publishingFailure === undefined ? [] : [publishingFailure]; const step = async (run: () => Promise | unknown) => { try { await run(); } catch (error) { failures.push(error); } }; - await step(() => preMerge?.close()); + // A merge job awaits its preparation, so both stop together: the job is aborted while preparation is. + await step(() => Promise.all([mergeJobs?.close(), preMerge?.close()])); // Step 3: after the drain, close the Store write gate. A request-path write still pending after the abort // (for example merge reconciliation after a GitHub await) now fails with 503; settling coordinators keep the capability. service.store.closeWrites(); From 7cc6ab2cd8e8372638b1c52d6550cfd5066cfdef Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 18:51:46 -0700 Subject: [PATCH 021/163] Stop the job's guarded merge on shutdown; end cancelled at every step 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 --- docs/implementation/merge-handoff.md | 10 ++-- runner/merge-job.ts | 19 +++++-- runner/merge.ts | 6 ++- test/merge-job.test.ts | 80 ++++++++++++++++++++++++++-- test/runner-production.test.ts | 9 ++++ test/runner-publishing.test.ts | 35 ++++++++++-- 6 files changed, 140 insertions(+), 19 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 4af4c645..e3b0c7f0 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -11,7 +11,7 @@ With a runner, **Merge PR** no longer merges at once. It starts one **merge job* 2. waits for GitHub's required checks on the pushed head, for up to 30 minutes; 3. merges through the existing guarded merge, which reruns the already-fixed check and pins the exact head. -Any change that needs a person (a stale approval, a moved pull request, a failing check) stops the job and says why. A failing or slow required check, or a refusal from GitHub, moves the task to **approved, merge blocked**; **Retry merge** starts the whole job again. The browser follows the job by polling. +Any change that needs a person (a stale approval, a moved pull request, a failing check) stops the job and says why. A task cancelled while the job runs ends it `cancelled`, however the job learns of it. A failing or slow required check, or a refusal from GitHub, moves the task to **approved, merge blocked**; **Retry merge** starts the whole job again. The browser follows the job by polling. Without a runner, **Merge PR** works as before. @@ -30,7 +30,7 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns `merging`. | always | the token is stale, or something is busy (nothing is saved; the click may be sent again) | +| Admission | The click's review token must be current, the task mergeable, and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, or something is busy (nothing is saved; the click may be sent again) | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | @@ -60,8 +60,8 @@ A rebase moves the pull request's base. The guarded merge would then scan only t |---|---| | Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | -| Cancel during the guarded merge | as before: refused while a merge attempt is submitting | -| Shutdown | the job is aborted and awaited before the store closes; it stays `running` on disk | +| Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | +| Shutdown | the job is aborted and awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends `submitted` and the merge attempt reconciles as before | | Restart | a `running` job becomes `failed` ("The server restarted before the merge finished"); a `prepare-merge` action and a push marker are settled as before | ## Storage @@ -76,7 +76,7 @@ Schema v19 adds `merge_jobs`: one row per job (`action_id`, task key, `state`, ` ## Tests -Each row of the step and outcome tables has a regression. The #22 acceptance cases run through the job: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancelled or interrupted job leaves the task as stated above. The required-check wait has the three cases the design names: zero required checks, one check pending then passing, and a timeout. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, `GET /api/merge`, the `prepare-merge` refusal, cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. ## Check this document with a reader diff --git a/runner/merge-job.ts b/runner/merge-job.ts index dced4b5d..57576f54 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -106,7 +106,7 @@ export class MergeJobCoordinator { () => ({ outcome: 'preparing' })); const prepared = await this.preMerge.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, actionId: prepareId }); - if (signal.aborted) return this.#end(job, signal); + if (signal.aborted || this.#cancelled()) return this.#end(job, signal); if (prepared.state !== 'ready') return this.#finish(job.id, prepared.state === 'review-required' ? 'review-required' : 'failed', prepared.reason); // 2. Wait for GitHub's required checks on the pushed head. @@ -120,13 +120,17 @@ export class MergeJobCoordinator { view = this.service.load(); const mergeId = randomUUID(); try { - const merged = await this.merges.merge(view.token, mergeId); + // The job's signal stops the guarded merge as shutdown or a deadline stops a click: before GitHub is asked, nothing + // is applied; after, the outcome is unknown. + const merged = await this.merges.merge(view.token, mergeId, signal); const attempt = store.getMergeAttempt(identity); this.#finish(job.id, attempt?.kind === 'queue' && attempt.state !== 'merged' ? 'queued' : 'merged', null, merged.result.url || null); } catch (error) { const reason = error instanceof Error ? error.message : String(error); if (error instanceof MergeOutcomeUnknown) return this.#finish(job.id, 'submitted', reason); - if (error instanceof MergeNotApplied) return signal.aborted ? this.#end(job, signal) : this.#finish(job.id, 'failed', reason); + // A cancel or shutdown, whichever way the merge reported it (stopped, or refused because the task was cancelled). + if (signal.aborted || this.#cancelled()) return this.#end(job, signal); + if (error instanceof MergeNotApplied) return this.#finish(job.id, 'failed', reason); // GitHub refused the attempt this job admitted: approved, merge blocked (design: "GitHub refused the merge"). const attempt = store.getMergeAttempt(identity); if (attempt?.actionId === mergeId && (attempt.state === 'failed' || attempt.state === 'removed')) @@ -135,9 +139,14 @@ export class MergeJobCoordinator { } } - /** Cancel ends the job; shutdown leaves it running on disk for startup to settle. */ + #cancelled(): boolean { + const task = this.service.store.getTask(this.service.config.identity); + return task.status === 'cancelled' || task.cancelRequested !== null; + } + /** A cancelled task ends the job, however the job learned of it; shutdown leaves it running on disk for startup. */ #end(job: MergeJob, signal: AbortSignal): void { - if ((signal.reason as { code?: string } | undefined)?.code === 'CANCELLED') this.#finish(job.id, 'cancelled', 'Task cancelled.'); + if ((signal.reason as { code?: string } | undefined)?.code === 'CANCELLED' || this.#cancelled()) + this.#finish(job.id, 'cancelled', 'Task cancelled.'); } /** Approved, merge blocked: the task's next merge prepares again from the start. */ diff --git a/runner/merge.ts b/runner/merge.ts index 5ed96c5b..a4521c1e 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -291,7 +291,7 @@ export class MergeCoordinator { * `actionId` is the click's idempotency key. A resend with the same key (after a lost response) returns the saved * attempt's current outcome and never submits again. */ - async merge(token: unknown, actionId?: unknown): Promise<{ status: MergeStatus | MergeUnavailableStatus; result: MergeResult }> { + async merge(token: unknown, actionId?: unknown, caller?: AbortSignal): Promise<{ status: MergeStatus | MergeUnavailableStatus; result: MergeResult }> { if (actionId !== undefined) assertUuidV4(actionId, 'Action ID'); // A resend of the click still in progress joins it, so it gets the final result rather than a saved interim state. if (this.#active && typeof actionId === 'string' && this.#activeClick?.actionId === actionId) { @@ -311,9 +311,13 @@ export class MergeCoordinator { if (typeof token !== 'string') throw new Error('Stale review state. Refresh before merging.'); const abort = new AbortController(); const timer = setTimeout(() => abort.abort(new Error('Merge request deadline exceeded.')), this.operationTimeoutMs); + // A caller that owns the merge (the one-click merge job) stops it as shutdown or a deadline would. + const stop = () => abort.abort(caller!.reason); + if (caller?.aborted) stop(); else caller?.addEventListener('abort', stop, { once: true }); this.#abort = abort; const attempt = this.#merge(token, abort.signal, actionId as string | undefined).finally(() => { clearTimeout(timer); + caller?.removeEventListener('abort', stop); if (this.#active === attempt) { this.#active = null; this.#activeClick = null; } if (this.#abort === abort) this.#abort = null; }); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 36b29e4c..52acfed7 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -3,10 +3,10 @@ import { afterEach, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; -import { MergeJobCoordinator } from '../runner/merge-job.ts'; +import { MergeJobCoordinator, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; import type { PreMergeCoordinator, PreMergeResult } from '../runner/pre-merge.ts'; import type { ReviewService } from '../runner/review.ts'; -import { MergeSubmissionError, type MergeGateway, type RemoteMergeState, type RequiredCheck } from '../github/merge.ts'; +import { MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type RemoteMergeState, type RequiredCheck } from '../github/merge.ts'; import { openingMarker } from '../github/pull-requests.ts'; import type { Plan, PlanContext } from '../core/plan.ts'; import { commandDigest } from '../runner/checks.ts'; @@ -67,7 +67,7 @@ function preparation(store: Store, result: (call: number) => PreMergeResult['sta } /** GitHub showing PR 7 as published; `checks(n)` gives the required checks of the n-th inspection. */ function github(store: Store, options: { checks?: (n: number) => RequiredCheck[]; change?: (n: number) => Partial; - merge?: () => Promise<{ url: string }> } = {}) { + merge?: (signal?: AbortSignal) => Promise<{ url: string }>; queue?: boolean } = {}) { let inspections = 0; const merged: string[] = []; const client: MergeGateway = { @@ -76,12 +76,17 @@ function github(store: Store, options: { checks?: (n: number) => RequiredCheck[] const record = store.taskPullRequests(identity).find(pr => pr.number === 7)!; const snapshot = store.getSnapshot(identity); return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, - mergeQueue: false, requiredChecks: options.checks?.(inspections) ?? [], alreadyFixed: 'clear', pullRequest: 7, draft: false, + mergeQueue: options.queue === true, requiredChecks: options.checks?.(inspections) ?? [], alreadyFixed: 'clear', pullRequest: 7, draft: false, ...(request?.target?.headBranch ? { published: { headBranch: request.target.headBranch, baseBranch: 'main', crossRepository: false, marker: openingMarker(record.openingId), branchOpen: [{ number: 7, base: 'main' }] } } : {}), ...options.change?.(inspections) } as RemoteMergeState; }), - merge: vi.fn(async head => { merged.push(head); return options.merge ? options.merge() : { url: 'https://github.com/owner/repo/pull/7' }; }), + merge: vi.fn(async (head, request) => { merged.push(head); return options.merge ? options.merge(request?.signal) : { url: 'https://github.com/owner/repo/pull/7' }; }), + ...(options.queue ? { + queueWatermark: vi.fn(async () => 'CURSOR'), + inspectQueue: vi.fn(async (head: string) => ({ state: 'queued' as const, reviewedHead: head, entryId: 'E', phase: 'QUEUED' as const, position: 0, + enqueuedAt: '2026-10-11T00:00:00Z', queueHead: head })), + } satisfies MergeQueueGateway : {}), }; return { client, merged, inspections: () => inspections }; } @@ -215,3 +220,68 @@ it('leaves a job running on shutdown, and startup ends it as failed', async () = expect(f.coordinator.latest()).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); expect(() => f.coordinator.start('review-token', randomUUID())).toThrow(/shutting down/); }); + +it('ends queued when the merge queue accepted the head', async () => { + const f = setup({ queue: true }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'queued' }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ kind: 'queue', state: 'queued' }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('refuses a click while the runner or publisher is busy, saving nothing', () => { + const f = setup(); + expect(() => f.coordinator.start('review-token', randomUUID(), () => 'The runner is busy.')).toThrow('The runner is busy.'); + expect(f.coordinator.latest()).toBeNull(); + expect(f.prep.calls).toEqual([]); +}); + +it('ends cancelled when the task was cancelled while the job waited, even if the job was not told', async () => { + const f = setup({ checks: () => [pending()] }); + f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled' }); +}); + +it('ends cancelled when the task is cancelled during the guarded merge\'s validation', async () => { + let cancelAt = Number.POSITIVE_INFINITY, coordinatorRef: MergeJobCoordinator | null = null, storeRef: Store | null = null; + const f = setup({ change: n => { + if (n === cancelAt) { storeRef!.cancelTask(identity, storeRef!.getTask(identity).stateVersion, randomUUID()); coordinatorRef!.cancel(); } + return {}; + } }); + coordinatorRef = f.coordinator; storeRef = f.store; + // Inspection 1 is the wait; inspections 2+ are the guarded merge's validation passes. + cancelAt = 2; + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled' }); + expect(f.gh.merged).toEqual([]); +}); + +it('stops a guarded merge in flight on shutdown, so shutdown does not wait for GitHub', async () => { + const f = setup({ merge: signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })) }); + f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.gh.merged).toHaveLength(1)); + const closing = Date.now(); + await f.coordinator.close(); + expect(Date.now() - closing).toBeLessThan(2_000); + // GitHub was already asked, so its outcome is unknown: the job ends submitted and the attempt stays in flight. + expect(f.coordinator.latest()).toMatchObject({ state: 'submitted' }); + expect(f.store.getMergeAttempt(identity)?.state).toBe('submitting'); +}); + +it('offers Merge PR while only blockers the job resolves remain, and a retry after a blocked job', () => { + const remote = { mergeable: 'MERGEABLE' }; + const status = (codes: string[], extra: Partial<{ queue: { state: string } | null; remote: { mergeable: string } | null }> = {}) => + ({ ready: false, action: null, blockers: codes.map(code => ({ code, message: code })), remote, queue: null, ...extra }) as Parameters[0]; + const running = { state: 'running' } as MergeJobView, blocked = { state: 'blocked' } as MergeJobView; + expect(withMergeJob(status(['preparation', 'base', 'acceptance', 'check']), null, 'in review')).toMatchObject({ ready: true, action: 'merge', job: null }); + expect(withMergeJob(status(['mergeable'], { remote: { mergeable: 'UNKNOWN' } }), null, 'in review')).toMatchObject({ ready: true }); + expect(withMergeJob(status(['mergeable']), null, 'in review')).toMatchObject({ ready: false, action: null }); + for (const code of ['approval', 'scope', 'unplanned', 'head', 'already-fixed', 'rules', 'base-guard', 'task']) + expect(withMergeJob(status(['preparation', code]), null, 'in review')).toMatchObject({ ready: false, action: null }); + expect(withMergeJob(status([]), running, 'in review')).toMatchObject({ ready: false, job: running }); + expect(withMergeJob(status([], { queue: { state: 'submitting' } }), null, 'in review')).toMatchObject({ ready: false }); + expect(withMergeJob(status([], { remote: null }), null, 'in review')).toMatchObject({ ready: false }); + expect(withMergeJob(status(['preparation']), blocked, 'approved but merge blocked')).toMatchObject({ ready: true, action: 'retry', job: blocked }); +}); diff --git a/test/runner-production.test.ts b/test/runner-production.test.ts index bd5502d0..13265ebc 100644 --- a/test/runner-production.test.ts +++ b/test/runner-production.test.ts @@ -122,6 +122,15 @@ describe('runner startup', () => { expect(await assembly.sources.vendor(service.config.identity)).toBe('claude'); expect(() => assembly.sources.planContext({ ...service.config.identity, planId: 'other' })).toThrow(/only its configured plan/); }); + it('fails a merge job an earlier process left running before it assembles anything (F6b)', async () => { + const { root, service } = fixture(), calls: string[] = []; + const actionId = randomUUID(); + service.store.beginMergeJob(service.config.identity, { actionId, token: 'review-token' }); + await setUpRunner({ service, capability, config: { root: join(root, 'runner'), committer }, lock: lock(calls), env: { CLAUDE_CODE_OAUTH_TOKEN: 't' }, + buildImage: () => 'sha256:x', recovery: () => recovery(calls) }); + expect(service.store.getMergeJob(service.config.identity, actionId)).toMatchObject({ state: 'failed', + reason: expect.stringMatching(/server restarted/) }); + }); it('pushes a prepared rewrite only under an exact lease on the PR head it rewrote', async () => { const { root, service } = fixture(), calls: string[] = []; const assembly = await setUpRunner({ service, capability, config: { root: join(root, 'runner'), committer }, lock: lock(calls), env: { CLAUDE_CODE_OAUTH_TOKEN: 't' }, diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 5130ad17..2ba086ff 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1749,10 +1749,18 @@ describe('the one-click merge job (F6b of #22)', () => { }, } as unknown as PreMergeCoordinator); /** GitHub showing the task's published PR at the reviewed pair; `checks()` gives its required checks now. */ - const gateway = (store: () => Store, checks: () => RequiredCheck[]): MergeGateway & { merged: string[] } => { - const merged: string[] = []; - return { merged, + const gateway = (store: () => Store, checks: () => RequiredCheck[], hang: () => boolean = () => false): + MergeGateway & { merged: string[]; inspections: () => number; hung: AbortSignal[] } => { + const merged: string[] = [], hung: AbortSignal[] = []; + let inspections = 0; + return { merged, hung, inspections: () => inspections, inspect: async request => { + inspections++; + // From this inspection on, GitHub does not answer; only the caller's abort ends the read. + if (hang() && request?.signal) { + hung.push(request.signal); + await new Promise((_resolve, reject) => request.signal!.addEventListener('abort', () => reject(request.signal!.reason), { once: true })); + } const identity = createIdentity(), pr = store().taskPullRequests(identity).find(row => row.number === request?.target?.pullRequest); const snapshot = store().getSnapshot(identity); return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, @@ -1806,6 +1814,27 @@ describe('the one-click merge job (F6b of #22)', () => { expect(served.store.getTask(served.identity).status).toBe('merged'); }, 60_000); + it('stops a waiting merge job promptly on shutdown and leaves it running on disk for the next startup', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, hanging = false; + const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }], () => hanging); + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 30, maxDelayMs: 30 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks' }), { timeout: 10_000 }); + // GitHub stops answering the job's next inspection; only the job's own abort can end that read. + hanging = true; + await vi.waitFor(() => expect(gh.hung).toHaveLength(1), { timeout: 10_000 }); + const database = served.app.service.config.database, closing = Date.now(); + await served.close(); + expect(Date.now() - closing).toBeLessThan(10_000); + expect(gh.hung[0]!.aborted).toBe(true); + const reopened = new Store(database); + try { expect(reopened.latestMergeJob(served.identity)).toMatchObject({ state: 'running', step: 'checks' }); } finally { reopened.close(); } + }, 60_000); + it('stops a waiting merge job when the task is cancelled, and closes the task\'s pull requests', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null; From 79b7fd569a7c9a3ab0a9254d3d541a7f12e6aa51 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 19:18:47 -0700 Subject: [PATCH 022/163] Test the readiness hand-off for real; stop jobs as shutdown begins 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 --- docs/implementation/merge-handoff.md | 4 ++-- runner/merge-job.ts | 14 +++++++---- test/merge-job.test.ts | 18 ++++++++++++++ test/pre-merge.test.ts | 35 ++++++++++++++++++++++------ test/runner-publishing.test.ts | 6 +++-- web/server.ts | 2 ++ 6 files changed, 64 insertions(+), 15 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index e3b0c7f0..e43a7484 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. Unreadable branch rules never pass. +While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task stays in review. ## Outcomes @@ -61,7 +61,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | -| Shutdown | the job is aborted and awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends `submitted` and the merge attempt reconciles as before | +| Shutdown | the job is stopped in shutdown's first, synchronous step, before requests drain, and awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends `submitted` and the merge attempt reconciles as before | | Restart | a `running` job becomes `failed` ("The server restarted before the merge finished"); a `prepare-merge` action and a push marker are settled as before | ## Storage diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 57576f54..4d8998a1 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -85,9 +85,13 @@ export class MergeJobCoordinator { /** The task was cancelled: stop waiting at once. Preparation and the guarded merge stop through their own routes. */ cancel(): void { this.#abort?.abort(Object.assign(new Error('Task cancelled.'), { code: 'CANCELLED' })); } - async close(): Promise { + /** Shutdown's first, synchronous step: no job starts, and a running one stops before it can ask GitHub to merge. */ + stop(): void { this.#closing = true; this.#abort?.abort(new Error('Server shutdown.')); + } + async close(): Promise { + this.stop(); await this.#active; } @@ -212,13 +216,15 @@ export function withMergeJob blocker.code !== 'check' && !(blocker.code === 'mergeable' && computing)); + if (other.length) return { kind: 'failed', reason: other[0]!.message }; const failing = status.remote.requiredChecks.filter(check => check.state === 'failure'); if (failing.length) return { kind: 'blocked', reason: `Required check${failing.length === 1 ? '' : 's'} failed: ${failing.map(check => check.context).join(', ')}.` }; const waiting = status.remote.requiredChecks.filter(check => check.state !== 'success') .map(check => ({ context: check.context, state: check.state })); - const computing = status.remote.mergeable === 'UNKNOWN'; - const other = status.blockers.filter(blocker => blocker.code !== 'check' && !(blocker.code === 'mergeable' && computing)); - if (other.length) return { kind: 'failed', reason: other[0]!.message }; if (waiting.length || computing) return { kind: 'wait', checks: waiting }; return status.ready ? { kind: 'ready' } : { kind: 'failed', reason: status.blockers[0]?.message ?? 'Merge is blocked.' }; } diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 52acfed7..e80734e7 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -285,3 +285,21 @@ it('offers Merge PR while only blockers the job resolves remain, and a retry aft expect(withMergeJob(status([], { remote: null }), null, 'in review')).toMatchObject({ ready: false }); expect(withMergeJob(status(['preparation']), blocked, 'approved but merge blocked')).toMatchObject({ ready: true, action: 'retry', job: blocked }); }); + +it('ends as failed, not blocked, when checks fail on a head that moved while waiting', async () => { + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }], change: () => ({ head: oid(40) }) }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/head moved/) }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('asks GitHub to merge nothing once stopped, even if the checks pass meanwhile', async () => { + let passing = false; + const f = setup({ checks: () => [passing ? { context: 'ci', appId: null, state: 'success' } : pending()] }); + f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + f.coordinator.stop(); passing = true; + await f.coordinator.current; + expect(f.gh.merged).toEqual([]); + expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); +}); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 4865f58c..f24dbc0a 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -8,6 +8,7 @@ import { COMMAND_CHECK_SETTLEMENT_RESERVE_MS, PRE_MERGE_PROCESS_SETTLEMENT_RESER import { MergeCoordinator } from '../runner/merge.ts'; import { MergeJobCoordinator } from '../runner/merge-job.ts'; import type { MergeGateway } from '../github/merge.ts'; +import { openingMarker } from '../github/pull-requests.ts'; import { ReviewService } from '../runner/review.ts'; import { createDemo } from '../scripts/demo.ts'; import { fixtureGit } from './fixtures/git.ts'; @@ -27,7 +28,17 @@ const unusedPush = { push: async () => { throw new Error('No push expected.'); } vi.setConfig({ testTimeout: 15_000 }); afterEach(() => { vi.restoreAllMocks(); for (const service of services.splice(0)) service.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -const markRunnerOwned = (service: ReviewService) => { +/** The task's PR as the runner publishes it (F6b tests): a clear pre-PR check, then PR 7 from BRANCH into main. */ +const publishPullRequest = (service: ReviewService) => { + const identity = service.config.identity, store = service.store, snapshot = store.getSnapshot(identity); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: snapshot.id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: snapshot.base } }); + const opening = store.beginPullRequest(identity, { checkId: check.id, repository: 'acme/app', base: 'main', headBranch: BRANCH, + headSha: snapshot.head, draft: false }); + expect(store.recordPullRequestOpened(identity, opening.openingId, { number: 7, url: 'https://github.com/acme/app/pull/7', + headSha: snapshot.head, draft: false }, true)).toBe('in review'); +}; +const markRunnerOwned = (service: ReviewService, published = false) => { const identity = service.config.identity; service.store.transitionTask(identity, service.store.getTask(identity).stateVersion, 'queued'); const attempt = service.store.admitAttempt(identity, { expectedStateVersion: service.store.getTask(identity).stateVersion, @@ -35,7 +46,8 @@ const markRunnerOwned = (service: ReviewService) => { service.store.markRunning(identity, attempt.id); service.store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true, result: { unchanged: false, head: service.store.getSnapshot(identity).head } }); - service.store.transitionTask(identity, service.store.getTask(identity).stateVersion, 'in review'); + if (published) publishPullRequest(service); + else service.store.transitionTask(identity, service.store.getTask(identity).stateVersion, 'in review'); service.config.runnerRepository = service.config.repository; }; const markRunnerUnchanged = (service: ReviewService) => { @@ -75,6 +87,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: rebaseResult?: (service: ReviewService, input: { oldHead: string; onto: string }, run: number) => string | undefined; /** Branch reads never answer; only their abort signal ends them. */ branchReadHangs?: boolean; + /** The runner published the task's PR 7 (merging then requires preparation, as in production). */ + published?: boolean; /** Set up everything but start no preparation; `result` is then null. */ deferStart?: boolean; /** Remote fetches fail once codeboost has pushed. */ @@ -115,7 +129,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: service.store.createPlan(JSON.stringify(plan), 'json', context, base, head); let view = service.load(); service.store.recordHistory(identity, view.expected, base, head, [{ sha: head, owner: 'P1', origin: 'owned', sourceSha: null }]); - if (options.runnerCommitted !== false) markRunnerOwned(service); else markRunnerUnchanged(service); + if (options.runnerCommitted !== false) markRunnerOwned(service, options.published === true); else markRunnerUnchanged(service); view = service.load(); view = service.act({ action: 'approve', item: 'P1', token: view.token }); let coordinator!: PreMergeCoordinator, rebaseRuns = 0; let rebaseAborts = 0; @@ -1403,14 +1417,21 @@ it('starts no settlement read when less than the process-settlement reserve is l // F6b of #22: the acceptance cases, run through the one-click merge job with real preparation. async function mergeJob(rebasedText: string, commandExit?: number, options: Parameters[2] = {}) { - const fixture = await rebaseFixture(rebasedText, commandExit, { ...options, deferStart: true }); + const fixture = await rebaseFixture(rebasedText, commandExit, { ...options, deferStart: true, published: true }); const merged: string[] = []; + // As production builds it with a runner: the task's published PR, which requires durable preparation readiness. const gateway: MergeGateway = { - inspect: async () => ({ base: fixture.remote().base, head: fixture.remote().head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', - rulesKnown: true, atomicBaseGuard: true, mergeQueue: false, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }), + inspect: async request => { + const pr = fixture.service.store.taskPullRequests(fixture.service.config.identity).find(row => row.number === request?.target?.pullRequest)!; + return { base: fixture.remote().base, head: fixture.remote().head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', + rulesKnown: true, atomicBaseGuard: true, mergeQueue: false, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false, + published: { headBranch: request!.target!.headBranch!, baseBranch: 'main', crossRepository: false, marker: openingMarker(pr.openingId), + branchOpen: [{ number: 7, base: 'main' }] } }; + }, merge: async head => { merged.push(head); return { url: 'https://github.com/acme/app/pull/7' }; }, }; - const merges = new MergeCoordinator(fixture.service, gateway); + const merges = new MergeCoordinator(fixture.service, gateway, undefined, undefined, + { repository: 'acme/app', baseBranch: 'main', requiresPreparation: true }); const jobs = new MergeJobCoordinator(fixture.service, fixture.coordinator, merges, undefined, { checkWaitMs: 2_000, firstDelayMs: 5, maxDelayMs: 20 }); const start = () => jobs.start(fixture.service.load().token, randomUUID()); const settled = async () => { await jobs.current; return jobs.latest()!; }; diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 2ba086ff..c24fd18c 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1828,9 +1828,11 @@ describe('the one-click merge job (F6b of #22)', () => { hanging = true; await vi.waitFor(() => expect(gh.hung).toHaveLength(1), { timeout: 10_000 }); const database = served.app.service.config.database, closing = Date.now(); - await served.close(); - expect(Date.now() - closing).toBeLessThan(10_000); + const closed = served.close(); + // Shutdown's first synchronous turn stops the job, before any drain, so it cannot ask GitHub to merge meanwhile. expect(gh.hung[0]!.aborted).toBe(true); + await closed; + expect(Date.now() - closing).toBeLessThan(10_000); const reopened = new Store(database); try { expect(reopened.latestMergeJob(served.identity)).toMatchObject({ state: 'running', step: 'checks' }); } finally { reopened.close(); } }, 60_000); diff --git a/web/server.ts b/web/server.ts index 797be9d7..267eeb70 100644 --- a/web/server.ts +++ b/web/server.ts @@ -764,6 +764,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge url: `http://127.0.0.1:${address.port}/#${token}`, close: async () => { // Step 1, one synchronous turn: reject new API requests and new runner work. Admitted requests drain (step 2). stopping = true; + // Same turn: a merge job (F6b) stops before it can ask GitHub to merge; its settlement is awaited below. + mergeJobs?.stop(); // Same turn as the admission flag: a request already reading its body must not start a new Ask worker. questions.stopAdmission(); runner?.rejectAdmission(); From e9575aa056d74d1f6035bf9dd1e7239f5ae4d616 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 19:42:46 -0700 Subject: [PATCH 023/163] Scan from the PR's own pre-PR check; settle a job from its attempt first 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 --- docs/architecture.md | 4 ++-- docs/implementation/guarded-merge.md | 4 ++-- docs/implementation/merge-handoff.md | 4 ++-- github/merge.ts | 2 +- runner/merge-job.ts | 16 ++++++++----- runner/merge.ts | 11 +++++---- runner/store.ts | 16 ++++++++++--- test/merge-job.test.ts | 10 ++++++++ test/merge-task-pr.test.ts | 17 ++++++++++++++ test/runner-publishing.test.ts | 34 ++++++++++++++++++++++++++-- 10 files changed, 95 insertions(+), 23 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ee173603..82151648 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Writing standard: plain language, ISO 24495-1:2023 - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from the last clear check's base (F6b, `docs/implementation/merge-handoff.md`). + - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from the base of the PR's own pre-PR check (F6b, `docs/implementation/merge-handoff.md`). - **Not built:** screens for the runner actions, the merge job's full step list and Cancel button, review rounds, and the Queue and Learning screens. ## Terms used @@ -410,7 +410,7 @@ These are the decisions that shape the structure. The full list, with the reason | C, K | Guarded merge, merge queue | Done | | D | Agent isolation boundary | Done. Since the isolation gate: bounded diff export (#76), task-change inspection and the runner's commit as a bounded `git bundle` (#66, PRs #85 and #90), owner-scoped recovery for Ask (#95), the plan schema for Claude (#97), gitlink mounts and the pre-launch tree check (#81, #99, PR #100), and Codex refused in every phase (#93, PR #106). The runner-contract follow-ups (#51) are closed: the last one tests export and removal through a recovery handle after a real restart (PR #109). | | E | Planning library and suggestions | Done. E4 (PR #45) added the acceptance fixtures and recorded Claude output that replays through validation and the Store. | -| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from the last clear check's base; F6c owns common CI. | +| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from the base of the PR's own pre-PR check; F6c owns common CI. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | | G | Planning screen | Done (#145). The screen displays/imports the current plan, requests and renders a next-revision draft or suggestion cards, and applies results with revision-bound exact ambiguous replay. G4 composes the browser with the production planning setup, D-backed provider boundary, F endpoints and SQLite persistence; focused adapter, recorded-output and exact-head real-Docker planning suites cover the lower boundary. | diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index 4168704b..8a92d9dc 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base the task's last clear already-fixed check (the pre-PR check) scanned from, so base commits that a pre-merge rebase brought under the PR are covered too (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base the PR's own pre-PR already-fixed check scanned from (recorded with the opening), so base commits that a pre-merge rebase brought under the PR are covered too (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. @@ -35,7 +35,7 @@ Shutdown sets a terminal admission flag before inspecting active work and checks ## Deferred runner work -This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts at the base of the last clear check. +This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts at the base of the PR's own pre-PR check. ## Validation diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index e43a7484..af33b789 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -52,7 +52,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base of the snapshot on which the last clear already-fixed check ran (the pre-PR check) to the base branch's tip. The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base of the snapshot on which the pull request's own pre-PR check ran (recorded with the opening, schema v19) to the base branch's tip, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the latest clear check. The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. ## Cancel, shutdown and restart @@ -61,7 +61,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | -| Shutdown | the job is stopped in shutdown's first, synchronous step, before requests drain, and awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends `submitted` and the merge attempt reconciles as before | +| Shutdown | the job is stopped in shutdown's first, synchronous step, before requests drain, and awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused, otherwise `submitted`, and the attempt reconciles as before | | Restart | a `running` job becomes `failed` ("The server restarted before the merge finished"); a `prepare-merge` action and a push marker are settled as before | ## Storage diff --git a/github/merge.ts b/github/merge.ts index 5b470e59..1a2ba55b 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -61,7 +61,7 @@ export interface MergeTarget { /** The task branch of a published target (#121). The inspection then also reads `RemoteMergeState.published`. */ headBranch?: string; /** - * Where the already-fixed commit scan starts: the base the task's last clear check scanned from. A rebase moves the + * Where the already-fixed commit scan starts: the base the PR's own pre-PR check scanned from. A rebase moves the * PR's base past base commits that check never saw (F6b of #22). Default: the PR's base commit. */ alreadyFixedSince?: string; diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 4d8998a1..ee1240f2 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { GuardRefusal, MERGEABLE_STATUSES, settleWith, type ShutdownCapability } from './lifecycle.ts'; -import { MergeNotApplied, MergeOutcomeUnknown, type MergeCoordinator, type MergeStatus } from './merge.ts'; +import { MergeOutcomeUnknown, type MergeCoordinator, type MergeStatus } from './merge.ts'; import type { PreMergeCoordinator } from './pre-merge.ts'; import type { ReviewService } from './review.ts'; import type { MergeJob, MergeJobState } from './store.ts'; @@ -131,14 +131,18 @@ export class MergeJobCoordinator { this.#finish(job.id, attempt?.kind === 'queue' && attempt.state !== 'merged' ? 'queued' : 'merged', null, merged.result.url || null); } catch (error) { const reason = error instanceof Error ? error.message : String(error); + // Once this job's merge attempt exists, GitHub was asked: its recorded state is the answer, whatever else (a cancel, + // shutdown) happened meanwhile. A refusal blocks (design: "GitHub refused the merge"); anything else is unknown. + const attempt = store.getMergeAttempt(identity); + if (attempt?.actionId === mergeId) { + if (attempt.state === 'failed' || attempt.state === 'removed') return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`); + if (attempt.state === 'merged') return this.#finish(job.id, 'merged', null, attempt.url); + if (attempt.state === 'queued') return this.#finish(job.id, 'queued', null, attempt.url); + return this.#finish(job.id, 'submitted', reason); + } if (error instanceof MergeOutcomeUnknown) return this.#finish(job.id, 'submitted', reason); // A cancel or shutdown, whichever way the merge reported it (stopped, or refused because the task was cancelled). if (signal.aborted || this.#cancelled()) return this.#end(job, signal); - if (error instanceof MergeNotApplied) return this.#finish(job.id, 'failed', reason); - // GitHub refused the attempt this job admitted: approved, merge blocked (design: "GitHub refused the merge"). - const attempt = store.getMergeAttempt(identity); - if (attempt?.actionId === mergeId && (attempt.state === 'failed' || attempt.state === 'removed')) - return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`); this.#finish(job.id, 'failed', reason); } } diff --git a/runner/merge.ts b/runner/merge.ts index a4521c1e..ddb0e33d 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -112,17 +112,18 @@ export class MergeCoordinator { if (!latest || latest.number === null) throw new MergeTargetUnavailable(`The task has no published pull request into ${published.baseBranch}.`); if (published.configured !== undefined && published.configured !== latest.number) throw new MergeTargetUnavailable(`github.pullRequest is #${published.configured}, but the task's pull request is #${latest.number}. Remove github.pullRequest from the review configuration.`); - return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch, ...this.#alreadyFixedRange() }, + return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch, ...this.#alreadyFixedRange(latest.checkId) }, openingId: latest.openingId, marker: openingMarker(latest.openingId), headBranch: latest.headBranch }; } /** - * The already-fixed scan of a runner-published PR (F6b of #22): from the base the task's last clear check scanned (the - * pre-PR check), so base commits a pre-merge rebase brought under the PR are covered, excluding the task's own commits. + * The already-fixed scan of a runner-published PR (F6b of #22): from the base the PR's own pre-PR check scanned, so base + * commits any pre-merge rebase brought under the PR are covered, even after a re-publish recorded a newer check from a + * rebased base. The task's own commits are excluded. An opening recorded before schema v19 uses the latest clear check. */ - #alreadyFixedRange(): { alreadyFixedSince?: string; ownCommits: string[] } { + #alreadyFixedRange(openingCheck: string | null): { alreadyFixedSince?: string; ownCommits: string[] } { const { store, config } = this.service, identity = config.identity; - const check = store.latestAlreadyFixed(identity); + const check = (openingCheck ? store.getAlreadyFixed(identity, openingCheck) : null) ?? store.latestAlreadyFixed(identity); const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); if (!check || check.result.outcome !== 'clear') return { ownCommits }; return { alreadyFixedSince: store.getSnapshot(identity, check.snapshotId).base, ownCommits }; diff --git a/runner/store.ts b/runner/store.ts index 83e939f8..b9732ed3 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -40,6 +40,8 @@ export interface TaskPullRequest { ownerReviewVersion: number; /** An update of this open PR that started and has not been confirmed; the PR may already show it. */ refresh: { head: string; draft: boolean; stateVersion: number } | null; + /** The clear already-fixed check this opening followed; null for an opening recorded before schema v19. */ + checkId: string | null; } export interface AlreadyFixedCheck { id: string; snapshotId: string; result: AlreadyFixedResult; stateVersion: number; reviewVersion: number; checkedAt: string } export type MergeAttemptState = 'submitting' | 'queued' | 'merged' | 'removed' | 'failed'; @@ -351,6 +353,9 @@ export class Store { this.#db.exec(`CREATE TABLE IF NOT EXISTS merge_jobs ( id TEXT PRIMARY KEY, plan_key TEXT NOT NULL REFERENCES plans(key), data TEXT NOT NULL); CREATE INDEX IF NOT EXISTS merge_jobs_plan ON merge_jobs(plan_key);`); + // The pre-PR already-fixed check each opening followed: the merge's scan starts at that check's base. + if (!this.#db.prepare('PRAGMA table_info(task_pull_requests)').all().some(column => column.name === 'check_id')) + this.#db.exec('ALTER TABLE task_pull_requests ADD COLUMN check_id TEXT'); this.#db.exec('PRAGMA user_version=19'); } }); @@ -2239,6 +2244,7 @@ export class Store { number: row.number as number | null, url: row.url as string | null, createdAt: row.created_at as string, ownerVersion: row.owner_version as number, ownerReviewVersion: row.owner_review_version as number, refresh: row.refresh_head === null ? null : { head: row.refresh_head as string, draft: row.refresh_draft === 1, stateVersion: row.refresh_version as number }, + checkId: (row.check_id as string | null | undefined) ?? null, }; } /** Every PR codeboost opened or started to open for the task, oldest first. */ @@ -2246,6 +2252,10 @@ export class Store { const key = identityKey(identity); this.#task(key); return this.#db.prepare('SELECT * FROM task_pull_requests WHERE plan_key=? ORDER BY rowid').all(key).map(row => this.#pullRequestRecord(row)); } + getAlreadyFixed(identity: PlanIdentity, id: string): AlreadyFixedCheck | null { + const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? AND id=?', identityKey(identity), id); + return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; + } latestAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { const key = identityKey(identity); this.#task(key); const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? ORDER BY rowid DESC LIMIT 1', key); @@ -2311,9 +2321,9 @@ export class Store { if (this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND state='opening'", key)) throw new GuardRefusal('A pull request is already being opened; recover it first.'); const openingId = randomUUID(), now = new Date().toISOString(); this.#touch(key); - this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at) - VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, - this.#task(key).state_version as number, this.#current(key).review_version as number, now, now); + this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at,check_id) + VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, + this.#task(key).state_version as number, this.#current(key).review_version as number, now, now, input.checkId); return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; }); } diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index e80734e7..f1939a93 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -303,3 +303,13 @@ it('asks GitHub to merge nothing once stopped, even if the checks pass meanwhile expect(f.gh.merged).toEqual([]); expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); }); + +it('ends blocked when GitHub refuses the merge while shutdown stops the job', async () => { + let coordinatorRef: MergeJobCoordinator | null = null; + const f = setup({ merge: async () => { coordinatorRef!.stop(); throw new MergeSubmissionError('Base branch was modified.', 'refused'); } }); + coordinatorRef = f.coordinator; + f.coordinator.start('review-token', randomUUID()); + await f.coordinator.current; + expect(f.coordinator.latest()).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge/) }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index 2ecbbd66..203c6e1f 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -343,6 +343,23 @@ describe('the merge gate with a runner block (#121)', () => { expect([...gh.targets.at(-1)!.ownCommits!].sort()).toEqual([oid(12), oid(2)].sort()); }); + it('keeps scanning from the PR\'s own pre-PR check after a re-publish records a newer check from a rebased base', async () => { + const { store } = published(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + // A re-publish after an amendment: the runner runs again and records a clear check scanned from the rebased base. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + expect(clear(store).snapshotId).toBe(store.getSnapshot(identity).id); + expect(store.getSnapshot(identity).base).toBe(oid(10)); + toReview(store); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(1) }); + }); + it('refuses pre-merge preparation for a pull request that is no longer open', async () => { const { store } = published(); const gh = github(store, () => ({ pullRequestState: 'CLOSED' })); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index c24fd18c..a945a1a0 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1814,6 +1814,35 @@ describe('the one-click merge job (F6b of #22)', () => { expect(served.store.getTask(served.identity).status).toBe('merged'); }, 60_000); + it('closes the pull requests of a task whose cancel waited for the merge job\'s preparation to settle', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, release!: () => void, preparing = false; + const held = new Promise(resolve => { release = resolve; }); + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, mergeGateway: gh, runnerPublished: true, + // A preparation that answers a cancel with "stopping" and closes the task only once its own work has settled. + preMerge: service => ({ + get active() { return preparing; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, close: async () => undefined, + cancelTask: () => 'stopping' as const, + start: async (expected: { actionId: string }) => { + preparing = true; await held; preparing = false; + const identity = service.config.identity, snapshot = service.store.getSnapshot(identity); + service.store.cancelTask(identity, service.store.getTask(identity).stateVersion, randomUUID()); + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'Task cancelled.' }, null); + }, + } as unknown as PreMergeCoordinator) }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(() => expect(preparing).toBe(true), { timeout: 10_000 }); + expect((await act(served.app, 'cancel-task')).body.result).toEqual({ outcome: 'stopping' }); + expect(w.github.prs[0]!.open).toBe(true); + release(); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'cancelled' }), { timeout: 10_000 }); + await vi.waitFor(() => expect(w.github.prs[0]!.open).toBe(false), { timeout: 20_000 }); + }, 60_000); + it('stops a waiting merge job promptly on shutdown and leaves it running on disk for the next startup', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, hanging = false; @@ -1842,13 +1871,14 @@ describe('the one-click merge job (F6b of #22)', () => { let storeRef: Store | null = null; const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }]); const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, - mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + // Waits far longer than this test: only the cancel route's own stop can end the job in time. + mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 60_000, maxDelayMs: 60_000 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks' }), { timeout: 10_000 }); expect((await act(served.app, 'cancel-task')).body.result).toEqual({ outcome: 'closed' }); - await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'cancelled' }), { timeout: 10_000 }); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'cancelled' }), { timeout: 5_000 }); await vi.waitFor(() => expect(w.github.prs[0]!.open).toBe(false), { timeout: 20_000 }); expect(gh.merged).toEqual([]); }, 60_000); From ffb995e72e3986bc3969b0c86ea37a2d6ec8afa0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 20:00:00 -0700 Subject: [PATCH 024/163] Settle a restarted job from its attempt; offer merge for a base conflict 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 --- docs/implementation/merge-handoff.md | 5 ++-- runner/merge-job.ts | 9 ++++-- runner/merge.ts | 2 +- runner/store.ts | 43 ++++++++++++++++++++++++---- test/merge-job.test.ts | 22 ++++++++++++++ test/merge-task-pr.test.ts | 33 ++++++++++++++++++--- 6 files changed, 98 insertions(+), 16 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index af33b789..e60fecc9 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -52,7 +52,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base of the snapshot on which the pull request's own pre-PR check ran (recorded with the opening, schema v19) to the base branch's tip, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the latest clear check. The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base of the snapshot on which the pull request's own pre-PR check ran (recorded with the opening, schema v19) to the base branch's tip, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the latest clear check, whatever later checks found. The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. ## Cancel, shutdown and restart @@ -62,7 +62,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | | Shutdown | the job is stopped in shutdown's first, synchronous step, before requests drain, and awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused, otherwise `submitted`, and the attempt reconciles as before | -| Restart | a `running` job becomes `failed` ("The server restarted before the merge finished"); a `prepare-merge` action and a push marker are settled as before | +| Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked. A `prepare-merge` action and a push marker are settled as before | ## Storage @@ -72,6 +72,7 @@ Schema v19 adds `merge_jobs`: one row per job (`action_id`, task key, `state`, ` - `POST /api/action` with `merge` returns `{ job }` at once when a runner is configured. - `GET /api/merge` returns `{ queue, job }`. +- Merge PR is offered while the only blockers left are ones the job resolves: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, pending required checks, and mergeability GitHub is still computing. - The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, lists waiting checks in the blocker dialog, and offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. ## Tests diff --git a/runner/merge-job.ts b/runner/merge-job.ts index ee1240f2..6260499b 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -120,9 +120,10 @@ export class MergeJobCoordinator { if (verdict.kind === 'blocked') return this.#block(job, verdict.reason); if (verdict.kind === 'failed') return this.#finish(job.id, 'failed', verdict.reason); // 3. Merge through the guarded merge, which re-validates everything and reruns the already-fixed check. - this.settle(() => store.updateMergeJob(identity, job.id, { step: 'merging', checks: [] })); - view = this.service.load(); + // The merge's action ID is saved first: after a restart, the attempt carrying it gives the job's outcome. const mergeId = randomUUID(); + this.settle(() => store.updateMergeJob(identity, job.id, { step: 'merging', checks: [], mergeActionId: mergeId })); + view = this.service.load(); try { // The job's signal stops the guarded merge as shutdown or a deadline stops a click: before GitHub is asked, nothing // is applied; after, the outcome is unknown. @@ -209,7 +210,9 @@ export function withMergeJob !JOB_RESOLVES.has(blocker.code) && !(blocker.code === 'mergeable' && computing)); + // A conflict after the base moved is the job's too: its preparation rebases, through the sandboxed conflict path. + const rebaseResolves = status.remote?.mergeable === 'CONFLICTING' && status.blockers.some(blocker => blocker.code === 'base'); + const personal = status.blockers.filter(blocker => !JOB_RESOLVES.has(blocker.code) && !(blocker.code === 'mergeable' && (computing || rebaseResolves))); const ready = !running && !attempt && status.remote !== null && personal.length === 0; return { ...status, ready, action: ready ? (taskStatus === 'approved but merge blocked' || status.action === 'retry' ? 'retry' : 'merge') : null, job }; } diff --git a/runner/merge.ts b/runner/merge.ts index ddb0e33d..9dc5ce6a 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -123,7 +123,7 @@ export class MergeCoordinator { */ #alreadyFixedRange(openingCheck: string | null): { alreadyFixedSince?: string; ownCommits: string[] } { const { store, config } = this.service, identity = config.identity; - const check = (openingCheck ? store.getAlreadyFixed(identity, openingCheck) : null) ?? store.latestAlreadyFixed(identity); + const check = (openingCheck ? store.getAlreadyFixed(identity, openingCheck) : null) ?? store.latestClearAlreadyFixed(identity); const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); if (!check || check.result.outcome !== 'clear') return { ownCommits }; return { alreadyFixedSince: store.getSnapshot(identity, check.snapshotId).base, ownCommits }; diff --git a/runner/store.ts b/runner/store.ts index b9732ed3..2ff7fef9 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -137,6 +137,8 @@ export type MergeJobStep = 'preparing' | 'checks' | 'merging'; export interface MergeJob { /** The click's action ID. */ id: string; + /** The action ID of the guarded merge this job started, once it reaches that step: its attempt carries it. */ + mergeActionId: string | null; /** The review token the click was made with; a resend must carry the same one. */ token: string; state: MergeJobState; @@ -2109,7 +2111,6 @@ export class Store { const saved = decode<{ ok?: unknown; value?: { outcome?: unknown }; preMergeReadiness?: PreMergeReadiness }>(row.response); return saved.ok === true && saved.value?.outcome === 'ready' && stable(saved.preMergeReadiness) === stable(readiness); } - /** Startup recovery makes an interrupted preparation definite and replayable before new work is admitted. */ /** * Admit a merge job for this click. A resend of the same click returns its job; another click while a job runs, or a * reused action ID with another token, is refused. Never touches the task. @@ -2130,14 +2131,15 @@ export class Store { if (this.#closed(task.status as TaskStatus)) throw new GuardRefusal(`The task is ${task.status}; it cannot be merged.`); if (this.runningMergeJob(identity)) throw new GuardRefusal('A merge is already running for this task.'); const now = input.now ?? new Date().toISOString(); - const job: MergeJob = { id: input.actionId, token: input.token, state: 'running', step: 'preparing', reason: null, checks: [], + const job: MergeJob = { id: input.actionId, mergeActionId: null, token: input.token, state: 'running', step: 'preparing', reason: null, checks: [], url: null, startedAt: now, updatedAt: now }; this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); return { job, resent: false }; }); } /** Move a running job to another step or record what it waits for. A job no longer running is left unchanged. */ - updateMergeJob(identity: PlanIdentity, id: string, patch: { step?: MergeJobStep; reason?: string | null; checks?: { context: string; state: string }[] }): MergeJob | null { + updateMergeJob(identity: PlanIdentity, id: string, patch: { step?: MergeJobStep; reason?: string | null; checks?: { context: string; state: string }[]; + mergeActionId?: string }): MergeJob | null { return this.#changeMergeJob(identity, id, job => job.state === 'running' ? { ...job, ...patch } : null); } /** End a running job. Returns false when it had already ended. */ @@ -2170,12 +2172,36 @@ export class Store { identityKey(identity)); return row ? decode(row.data) : null; } - /** At startup: a job the server stopped running cannot continue; it fails, and the person merges again. */ + /** + * At startup: a job the server stopped running cannot continue. If it had asked GitHub to merge, its merge attempt's + * recorded state is its outcome (a refusal blocks the task, as the job would have); otherwise it fails, and the person + * merges again. + */ settleInterruptedMergeJobs(identity: PlanIdentity): void { const running = this.runningMergeJob(identity); - if (running) this.finishMergeJob(identity, running.id, { state: 'failed', - reason: 'The server restarted before the merge finished. Merge again.' }); + if (!running) return; + const attempt = running.mergeActionId ? this.getMergeAttempt(identity) : null; + if (!attempt || attempt.actionId !== running.mergeActionId) { + this.finishMergeJob(identity, running.id, { state: 'failed', reason: 'The server restarted before the merge finished. Merge again.' }); + return; + } + if (attempt.state === 'merged' || attempt.state === 'queued') { + this.finishMergeJob(identity, running.id, { state: attempt.state, url: attempt.url }); + return; + } + if (attempt.state === 'submitting') { + this.finishMergeJob(identity, running.id, { state: 'submitted', + reason: 'The server restarted while GitHub\'s merge outcome was unknown; it reconciles from GitHub.' }); + return; + } + const task = this.getTask(identity); + if (task.status === 'in review') { + try { this.transitionTask(identity, task.stateVersion, 'approved but merge blocked'); } + catch (error) { if (!(error instanceof GuardRefusal)) throw error; } + } + this.finishMergeJob(identity, running.id, { state: 'blocked', reason: `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'}` }); } + /** Startup recovery makes an interrupted preparation definite and replayable before new work is admitted. */ settleInterruptedPreMergeActions(identity: PlanIdentity): void { const snapshot = this.getSnapshot(identity); const result: PreMergeActionResult = { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], @@ -2252,6 +2278,11 @@ export class Store { const key = identityKey(identity); this.#task(key); return this.#db.prepare('SELECT * FROM task_pull_requests WHERE plan_key=? ORDER BY rowid').all(key).map(row => this.#pullRequestRecord(row)); } + /** The latest already-fixed check that found nothing. */ + latestClearAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { + const row = this.#get(`SELECT * FROM already_fixed_checks WHERE plan_key=? AND outcome='clear' ORDER BY rowid DESC LIMIT 1`, identityKey(identity)); + return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; + } getAlreadyFixed(identity: PlanIdentity, id: string): AlreadyFixedCheck | null { const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? AND id=?', identityKey(identity), id); return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index f1939a93..cb75ed85 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -284,6 +284,9 @@ it('offers Merge PR while only blockers the job resolves remain, and a retry aft expect(withMergeJob(status([], { queue: { state: 'submitting' } }), null, 'in review')).toMatchObject({ ready: false }); expect(withMergeJob(status([], { remote: null }), null, 'in review')).toMatchObject({ ready: false }); expect(withMergeJob(status(['preparation']), blocked, 'approved but merge blocked')).toMatchObject({ ready: true, action: 'retry', job: blocked }); + // A conflict after the base moved is the job's rebase to resolve; a conflict on its own is a person's. + expect(withMergeJob(status(['base', 'mergeable'], { remote: { mergeable: 'CONFLICTING' } }), null, 'in review')).toMatchObject({ ready: true }); + expect(withMergeJob(status(['mergeable'], { remote: { mergeable: 'CONFLICTING' } }), null, 'in review')).toMatchObject({ ready: false }); }); it('ends as failed, not blocked, when checks fail on a head that moved while waiting', async () => { @@ -313,3 +316,22 @@ it('ends blocked when GitHub refuses the merge while shutdown stops the job', as expect(f.coordinator.latest()).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge/) }); expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); }); + +it('settles a job a crash left in its merge step from its merge attempt at startup', async () => { + for (const [settle, expected, status] of [ + [() => undefined, 'submitted', 'in review'], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'merged', url: 'https://github.com/owner/repo/pull/7' }), 'merged', 'merged'], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }), 'blocked', 'approved but merge blocked'], + ] as const) { + // GitHub never answers: the job is in its merge step when the process "crashes". + const f = setup({ merge: signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })) }); + f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.gh.merged).toHaveLength(1)); + const job = f.store.runningMergeJob(identity)!, attempt = f.store.getMergeAttempt(identity)!; + expect(job).toMatchObject({ step: 'merging', mergeActionId: attempt.actionId }); + settle(f.store, attempt.id); + f.store.settleInterruptedMergeJobs(identity); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: expected }); + expect(f.store.getTask(identity).status).toBe(status); + } +}); diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index 203c6e1f..ff68be08 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; +import { DatabaseSync } from 'node:sqlite'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; import type { ReviewService } from '../runner/review.ts'; @@ -30,8 +31,8 @@ const stores: Store[] = [], roots: string[] = []; afterEach(() => { for (const store of stores.splice(0)) store.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); /** A running task with head `oid(2)` over `oid(1)`, its last attempt settled. */ -function runningTask(): Store { - const store = new Store(':memory:'); stores.push(store); +function runningTask(path = ':memory:'): Store { + const store = new Store(path); stores.push(store); store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]); store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); @@ -47,8 +48,8 @@ const opened = (store: Store, openingId: string, number: number, mayReview = tru const toReview = (store: Store) => store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); /** The runner published PR 7 for the task, which is now in review. */ -function published() { - const store = runningTask(); +function published(path?: string) { + const store = runningTask(path); const opening = begin(store); expect(opened(store, opening.openingId, 7)).toBe('in review'); return { store, opening }; @@ -360,6 +361,30 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(1) }); }); + it('scans from the latest clear check for a PR opened before schema v19, even when a later check was not clear', async () => { + const root = mkdtempSync(join(tmpdir(), 'codeboost-v19-')); roots.push(root); + const path = join(root, 'state.sqlite'); + const { store } = published(path); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + // A later re-publish whose check could not complete (unknown), recorded from the rebased base. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'unknown', reason: 'GitHub could not be read.' } }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); + // The opening was recorded before v19: it carries no check. + store.close(); stores.splice(stores.indexOf(store), 1); + const raw = new DatabaseSync(path); raw.exec('UPDATE task_pull_requests SET check_id=NULL'); raw.close(); + const reopened = new Store(path); stores.push(reopened); + expect(reopened.taskPullRequests(identity)[0]!.checkId).toBeNull(); + const gh = github(reopened); + const merges = new MergeCoordinator(service(reopened), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(1) }); + }); + it('refuses pre-merge preparation for a pull request that is no longer open', async () => { const { store } = published(); const gh = github(store, () => ({ pullRequestState: 'CLOSED' })); From 922007f35afbea7b63c7b38f64109ca75dd71b18 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 20:19:49 -0700 Subject: [PATCH 025/163] Let a merge-blocked task start an amended plan; fix cancel test setups 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 --- docs/implementation/merge-handoff.md | 2 +- test/merge-job.test.ts | 2 ++ test/pre-merge.test.ts | 3 ++- test/runner-publishing.test.ts | 40 +++++++++++++++++++--------- test/runner-start.test.ts | 10 +++++++ web/server.ts | 8 +++--- 6 files changed, 47 insertions(+), 18 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index e60fecc9..dfbac1ce 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -43,7 +43,7 @@ While waiting, an unknown mergeability (GitHub is still computing it) and a pend |---|---|---| | `merged`, `queued` | merged, or in review while queued | nothing | | `submitted` | unchanged | refresh; the merge attempt settles from GitHub | -| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge** | +| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**; or amend the plan and start it, so the runner fixes the code (start accepts a blocked task as it accepts one in review) | | `review-required` | unchanged | review the refreshed pull request, then merge again | | `failed` | unchanged | read the reason, then merge again | | `cancelled` | cancelled | nothing | diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index cb75ed85..effdb1b7 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -182,6 +182,8 @@ it('stops waiting at once when the task is cancelled', async () => { const f = setup({ checks: () => [pending()] }); f.coordinator.start('review-token', randomUUID()); await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + // As the cancel route does: the task's cancel commits first, then the job is told. + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); f.coordinator.cancel(); expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled' }); expect(f.gh.merged).toEqual([]); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index f24dbc0a..ee65bdd1 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1482,8 +1482,9 @@ it('ends the job cancelled when the task is cancelled while a command check runs f.start(); await vi.waitFor(() => expect(f.runner.isActive(f.service.config.identity)).toBe(true)); const identity = f.service.config.identity; + // As the cancel route does: the task's cancel commits (it waits on the running check), then the job is told. + expect(f.coordinator.cancelTask(f.service.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); f.jobs.cancel(); - f.coordinator.cancelTask(f.service.store.getTask(identity).stateVersion, randomUUID()); expect(await f.settled()).toMatchObject({ state: 'cancelled' }); expect(new Set(f.cancelReasons)).toEqual(new Set(['cancelled'])); expect(f.merged).toEqual([]); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index a945a1a0..87524a0c 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1820,27 +1820,41 @@ describe('the one-click merge job (F6b of #22)', () => { const held = new Promise(resolve => { release = resolve; }); const gh = gateway(() => storeRef!, () => []); const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, mergeGateway: gh, runnerPublished: true, - // A preparation that answers a cancel with "stopping" and closes the task only once its own work has settled. - preMerge: service => ({ - get active() { return preparing; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, close: async () => undefined, - cancelTask: () => 'stopping' as const, - start: async (expected: { actionId: string }) => { - preparing = true; await held; preparing = false; - const identity = service.config.identity, snapshot = service.store.getSnapshot(identity); - service.store.cancelTask(identity, service.store.getTask(identity).stateVersion, randomUUID()); - return service.store.settlePreMergeAction(identity, expected.actionId, - { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'Task cancelled.' }, null); - }, - } as unknown as PreMergeCoordinator) }); + // A preparation caught in its push: it holds a real push marker, so a cancel waits ("stopping") until the push + // settles, and clearing the marker closes the task, as production does. + preMerge: service => { + const identity = service.config.identity; + let marker: { attemptId: string } | null = null; + return { + get active() { return preparing; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, close: async () => undefined, + cancelTask: (stateVersion: number, actionId: string) => service.store.cancelTask(identity, stateVersion, actionId), + start: async (expected: { actionId: string }) => { + preparing = true; + const reviewed = () => ({ revision: service.store.getPlan(identity).revision, snapshotId: service.store.getSnapshot(identity).id, reviewVersion: service.store.reviewVersion(identity) }); + const head = service.store.getSnapshot(identity).head, rewritten = 'e'.repeat(40); + service.store.recordRebase(identity, reviewed(), 'f'.repeat(40), rewritten, [{ oldSha: head, newSha: rewritten }]); + marker = service.store.beginPrePush(identity, reviewed(), service.store.getTask(identity).stateVersion, + { branch: service.store.taskPullRequests(identity)[0]!.headBranch, from: head, to: rewritten }); + await held; + service.store.finishPrePush(service.store.getTask(identity).planKey, marker.attemptId); + preparing = false; + const snapshot = service.store.getSnapshot(identity); + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'Task cancelled.' }, null); + }, + } as unknown as PreMergeCoordinator; + } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); - await vi.waitFor(() => expect(preparing).toBe(true), { timeout: 10_000 }); + await vi.waitFor(() => expect(served.store.getTask(served.identity).pushInProgress).not.toBeNull(), { timeout: 10_000 }); expect((await act(served.app, 'cancel-task')).body.result).toEqual({ outcome: 'stopping' }); + expect(served.store.getTask(served.identity)).toMatchObject({ status: 'in review', cancelRequested: expect.any(String) }); expect(w.github.prs[0]!.open).toBe(true); release(); await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'cancelled' }), { timeout: 10_000 }); await vi.waitFor(() => expect(w.github.prs[0]!.open).toBe(false), { timeout: 20_000 }); + expect(served.store.getTask(served.identity).status).toBe('cancelled'); }, 60_000); it('stops a waiting merge job promptly on shutdown and leaves it running on disk for the next startup', async () => { diff --git a/test/runner-start.test.ts b/test/runner-start.test.ts index 7123e19b..b3c251de 100644 --- a/test/runner-start.test.ts +++ b/test/runner-start.test.ts @@ -190,6 +190,16 @@ describe('start (#91 part 2)', () => { expect(store.getAttempts(identity)).toHaveLength(1); expect(await view(app)).toMatchObject({ startable: false }); }); + it('starts a task whose merge job was blocked, so an amended plan can fix what blocked it (F6b)', async () => { + const { app, identity, store, items } = await serve(); + // As a merge job leaves it when a required check failed or GitHub refused the merge. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'approved but merge blocked'); + expect(await view(app)).toMatchObject({ startable: true, resumable: false }); + const started = await act(app, 'start'); + expect(started.body.result).toMatchObject({ outcome: 'started', item: items[0] }); + expect(store.getAttempts(identity)).toEqual([expect.objectContaining({ kind: 'execute', item: items[0] })]); + await app.executor!.close(); + }); it('rolls the move to queued back when admission refuses the first item, and records the refusal', async () => { // Only earlier-revision attempts, the budget spent, and back in review: runChoice passes, so the action moves the // task to queued and admission itself refuses. diff --git a/web/server.ts b/web/server.ts index 267eeb70..d959fb75 100644 --- a/web/server.ts +++ b/web/server.ts @@ -229,20 +229,22 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge // Commits from another revision without a scope checkpoint have no audited continuation prefix to reconcile; rerunning // the plan on top of them could redo or contradict that work. Earlier attempts that committed nothing leave none. if (!continuation && !begun && earlierCommits) throw new GuardRefusal('The plan changed after runner commits without a scope checkpoint; those commits cannot be reconciled safely.'); + const reviewed = task.status === 'in review' || task.status === 'approved but merge blocked'; if (action === 'start') { if (continuation) throw new GuardRefusal('This task has a scope checkpoint; resume it after approving the amended continuation.'); // Point to resume wherever resume could run (a running task that ran any revision too); any other status is named as it is. const ran = begun || task.requeuePending; if (next !== null && ((ran && task.status === 'queued') || (task.status === 'running' && (started || task.requeuePending)))) throw new GuardRefusal('This plan has already started running; resume the task instead.'); - if (task.status !== 'in review' && task.status !== 'queued') throw new GuardRefusal(`The task is ${task.status}; start runs a task that is in review or queued.`); + // A task whose merge was blocked (F6b) is reviewed work too: an amended plan may run on it to fix what blocked it. + if (!reviewed && task.status !== 'queued') throw new GuardRefusal(`The task is ${task.status}; start runs a task that is in review or queued.`); if (ran) throw new GuardRefusal(begun ? `This plan revision has already run; the task is ${task.status}.` : 'Recovery left this task to requeue; it cannot start until that is resolved.'); const unapproved = service.store.unapprovedExecutionItems(identity, service.store.getPlan(identity).revision); if (unapproved.length) throw new GuardRefusal(`Approve every plan item before running it. Waiting for: ${unapproved.join(', ')}.`); - return { fromItem: next!, claimRequeue: false, queue: task.status === 'in review' }; + return { fromItem: next!, claimRequeue: false, queue: reviewed }; } // As for start: point to start only where start's own checks pass. - const startWould = (task.status === 'in review' || task.status === 'queued') && !begun && !task.requeuePending; + const startWould = (reviewed || task.status === 'queued') && !begun && !task.requeuePending; const toStart = 'This plan revision has not started running yet; start the task instead.'; if (task.status !== 'running' && task.status !== 'queued') { if (startWould) throw new GuardRefusal(toStart); From def4b0a6d357741105c9d9a8552cb5a7a816979b Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 20:41:29 -0700 Subject: [PATCH 026/163] Block job and task together; decide a job only from its own attempt 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 --- docs/implementation/merge-handoff.md | 2 +- runner/merge-job.ts | 12 +++-------- runner/store.ts | 23 ++++++++++++++------ test/merge-job.test.ts | 32 ++++++++++++++++++++++++++++ test/runner-start.test.ts | 10 --------- web/server.ts | 8 +++---- 6 files changed, 56 insertions(+), 31 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index dfbac1ce..228e9b78 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -43,7 +43,7 @@ While waiting, an unknown mergeability (GitHub is still computing it) and a pend |---|---|---| | `merged`, `queued` | merged, or in review while queued | nothing | | `submitted` | unchanged | refresh; the merge attempt settles from GitHub | -| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**; or amend the plan and start it, so the runner fixes the code (start accepts a blocked task as it accepts one in review) | +| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | | `review-required` | unchanged | review the refreshed pull request, then merge again | | `failed` | unchanged | read the reason, then merge again | | `cancelled` | cancelled | nothing | diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 6260499b..02c96e3d 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -158,16 +158,10 @@ export class MergeJobCoordinator { this.#finish(job.id, 'cancelled', 'Task cancelled.'); } - /** Approved, merge blocked: the task's next merge prepares again from the start. */ + /** Approved, merge blocked: the task's next merge prepares again from the start. One Store transaction. */ #block(job: MergeJob, reason: string): void { - const { store, config } = this.service, identity = config.identity; - try { - this.settle(() => { - const task = store.getTask(identity); - if (task.status === 'in review') store.transitionTask(identity, task.stateVersion, 'approved but merge blocked'); - }); - } catch { /* The task moved meanwhile (for example, cancelled); the job still ends blocked. */ } - this.#finish(job.id, 'blocked', reason); + try { this.settle(() => this.service.store.blockMergeJob(this.service.config.identity, job.id, reason)); } + catch { /* Startup settles a job that could not be ended now. */ } } async #awaitChecks(job: MergeJob, signal: AbortSignal): Promise { diff --git a/runner/store.ts b/runner/store.ts index 2ff7fef9..a3905dea 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2142,6 +2142,22 @@ export class Store { mergeActionId?: string }): MergeJob | null { return this.#changeMergeJob(identity, id, job => job.state === 'running' ? { ...job, ...patch } : null); } + /** + * End a running job `blocked` and, in the same transaction, move a task still in review to approved, merge blocked, + * so the job and the task cannot disagree after a crash. A task that moved meanwhile keeps its status. + */ + blockMergeJob(identity: PlanIdentity, id: string, reason: string): boolean { + const key = identityKey(identity); + return this.#transaction(() => { + const task = this.#task(key); + if (task.status === 'in review' && !this.#activeAttempt(key) && !this.#activeMerge(key) + && task.rebase_in_progress === null && (task.push_in_progress === null || task.push_in_progress === undefined)) { + this.#run('UPDATE tasks SET status=? WHERE plan_key=?', 'approved but merge blocked', key); + this.#touch(key); + } + return this.finishMergeJob(identity, id, { state: 'blocked', reason }); + }); + } /** End a running job. Returns false when it had already ended. */ finishMergeJob(identity: PlanIdentity, id: string, outcome: { state: Exclude; reason?: string | null; url?: string | null }): boolean { return this.#changeMergeJob(identity, id, job => job.state === 'running' @@ -2194,12 +2210,7 @@ export class Store { reason: 'The server restarted while GitHub\'s merge outcome was unknown; it reconciles from GitHub.' }); return; } - const task = this.getTask(identity); - if (task.status === 'in review') { - try { this.transitionTask(identity, task.stateVersion, 'approved but merge blocked'); } - catch (error) { if (!(error instanceof GuardRefusal)) throw error; } - } - this.finishMergeJob(identity, running.id, { state: 'blocked', reason: `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'}` }); + this.blockMergeJob(identity, running.id, `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'}`); } /** Startup recovery makes an interrupted preparation definite and replayable before new work is admitted. */ settleInterruptedPreMergeActions(identity: PlanIdentity): void { diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index effdb1b7..cc5ccb8e 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -337,3 +337,35 @@ it('settles a job a crash left in its merge step from its merge attempt at start expect(f.store.getTask(identity).status).toBe(status); } }); + +it('blocks the job and the task in one write, leaving a task that moved meanwhile as it is', () => { + const store = publishedTask(), first = randomUUID(), second = randomUUID(); + store.beginMergeJob(identity, { actionId: first, token: 'review-token' }); + expect(store.blockMergeJob(identity, first, 'Required check failed: ci.')).toBe(true); + expect(store.getMergeJob(identity, first)).toMatchObject({ state: 'blocked', reason: 'Required check failed: ci.' }); + expect(store.getTask(identity).status).toBe('approved but merge blocked'); + store.beginMergeJob(identity, { actionId: second, token: 'review-token' }); + expect(store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); + expect(store.blockMergeJob(identity, second, 'late')).toBe(true); + expect(store.getTask(identity).status).toBe('cancelled'); +}); + +it('decides a retried job only from its own merge attempt, never from an earlier refused one', async () => { + // The first job's merge is refused: its attempt stays on record as failed. + let refuse = true, conflictFrom = Number.POSITIVE_INFINITY; + const f = setup({ merge: async () => { if (refuse) throw new MergeSubmissionError('Base branch was modified.', 'refused'); return { url: 'u' }; }, + change: n => (n >= conflictFrom ? { mergeable: 'CONFLICTING' } : {}) }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked' }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed' }); + // Retry: the guarded merge's second validation pass now sees a conflict, so it refuses before asking GitHub. + refuse = false; conflictFrom = f.gh.inspections() + 3; + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/merge conflicts/) }); + // At startup too: a job that saved its merge action ID but never got an attempt fails, whatever older attempts say. + const crashed = randomUUID(); + f.store.beginMergeJob(identity, { actionId: crashed, token: 'review-token' }); + f.store.updateMergeJob(identity, crashed, { step: 'merging', mergeActionId: randomUUID() }); + f.store.settleInterruptedMergeJobs(identity); + expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); +}); diff --git a/test/runner-start.test.ts b/test/runner-start.test.ts index b3c251de..7123e19b 100644 --- a/test/runner-start.test.ts +++ b/test/runner-start.test.ts @@ -190,16 +190,6 @@ describe('start (#91 part 2)', () => { expect(store.getAttempts(identity)).toHaveLength(1); expect(await view(app)).toMatchObject({ startable: false }); }); - it('starts a task whose merge job was blocked, so an amended plan can fix what blocked it (F6b)', async () => { - const { app, identity, store, items } = await serve(); - // As a merge job leaves it when a required check failed or GitHub refused the merge. - store.transitionTask(identity, store.getTask(identity).stateVersion, 'approved but merge blocked'); - expect(await view(app)).toMatchObject({ startable: true, resumable: false }); - const started = await act(app, 'start'); - expect(started.body.result).toMatchObject({ outcome: 'started', item: items[0] }); - expect(store.getAttempts(identity)).toEqual([expect.objectContaining({ kind: 'execute', item: items[0] })]); - await app.executor!.close(); - }); it('rolls the move to queued back when admission refuses the first item, and records the refusal', async () => { // Only earlier-revision attempts, the budget spent, and back in review: runChoice passes, so the action moves the // task to queued and admission itself refuses. diff --git a/web/server.ts b/web/server.ts index d959fb75..267eeb70 100644 --- a/web/server.ts +++ b/web/server.ts @@ -229,22 +229,20 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge // Commits from another revision without a scope checkpoint have no audited continuation prefix to reconcile; rerunning // the plan on top of them could redo or contradict that work. Earlier attempts that committed nothing leave none. if (!continuation && !begun && earlierCommits) throw new GuardRefusal('The plan changed after runner commits without a scope checkpoint; those commits cannot be reconciled safely.'); - const reviewed = task.status === 'in review' || task.status === 'approved but merge blocked'; if (action === 'start') { if (continuation) throw new GuardRefusal('This task has a scope checkpoint; resume it after approving the amended continuation.'); // Point to resume wherever resume could run (a running task that ran any revision too); any other status is named as it is. const ran = begun || task.requeuePending; if (next !== null && ((ran && task.status === 'queued') || (task.status === 'running' && (started || task.requeuePending)))) throw new GuardRefusal('This plan has already started running; resume the task instead.'); - // A task whose merge was blocked (F6b) is reviewed work too: an amended plan may run on it to fix what blocked it. - if (!reviewed && task.status !== 'queued') throw new GuardRefusal(`The task is ${task.status}; start runs a task that is in review or queued.`); + if (task.status !== 'in review' && task.status !== 'queued') throw new GuardRefusal(`The task is ${task.status}; start runs a task that is in review or queued.`); if (ran) throw new GuardRefusal(begun ? `This plan revision has already run; the task is ${task.status}.` : 'Recovery left this task to requeue; it cannot start until that is resolved.'); const unapproved = service.store.unapprovedExecutionItems(identity, service.store.getPlan(identity).revision); if (unapproved.length) throw new GuardRefusal(`Approve every plan item before running it. Waiting for: ${unapproved.join(', ')}.`); - return { fromItem: next!, claimRequeue: false, queue: reviewed }; + return { fromItem: next!, claimRequeue: false, queue: task.status === 'in review' }; } // As for start: point to start only where start's own checks pass. - const startWould = (reviewed || task.status === 'queued') && !begun && !task.requeuePending; + const startWould = (task.status === 'in review' || task.status === 'queued') && !begun && !task.requeuePending; const toStart = 'This plan revision has not started running yet; start the task instead.'; if (task.status !== 'running' && task.status !== 'queued') { if (startWould) throw new GuardRefusal(toStart); From 19d5abc918ee68c08b6e615d16dd520689554672 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 21:01:04 -0700 Subject: [PATCH 027/163] Scan from where the pre-PR check stopped; document the 250-commit limit 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 --- docs/architecture.md | 4 ++-- docs/implementation/guarded-merge.md | 4 ++-- docs/implementation/merge-handoff.md | 4 ++-- github/merge.ts | 2 +- runner/merge.ts | 11 +++++++---- test/merge-job.test.ts | 11 +++++++++++ test/merge-task-pr.test.ts | 13 +++++++------ 7 files changed, 32 insertions(+), 17 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 82151648..08128b5d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Writing standard: plain language, ISO 24495-1:2023 - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from the base of the PR's own pre-PR check (F6b, `docs/implementation/merge-handoff.md`). + - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from where the PR's own pre-PR check stopped (F6b, `docs/implementation/merge-handoff.md`). - **Not built:** screens for the runner actions, the merge job's full step list and Cancel button, review rounds, and the Queue and Learning screens. ## Terms used @@ -410,7 +410,7 @@ These are the decisions that shape the structure. The full list, with the reason | C, K | Guarded merge, merge queue | Done | | D | Agent isolation boundary | Done. Since the isolation gate: bounded diff export (#76), task-change inspection and the runner's commit as a bounded `git bundle` (#66, PRs #85 and #90), owner-scoped recovery for Ask (#95), the plan schema for Claude (#97), gitlink mounts and the pre-launch tree check (#81, #99, PR #100), and Codex refused in every phase (#93, PR #106). The runner-contract follow-ups (#51) are closed: the last one tests export and removal through a recovery handle after a real restart (PR #109). | | E | Planning library and suggestions | Done. E4 (PR #45) added the acceptance fixtures and recorded Claude output that replays through validation and the Store. | -| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from the base of the PR's own pre-PR check; F6c owns common CI. | +| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from where the PR's own pre-PR check stopped; F6c owns common CI. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | | G | Planning screen | Done (#145). The screen displays/imports the current plan, requests and renders a next-revision draft or suggestion cards, and applies results with revision-bound exact ambiguous replay. G4 composes the browser with the production planning setup, D-backed provider boundary, F endpoints and SQLite persistence; focused adapter, recorded-output and exact-head real-Docker planning suites cover the lower boundary. | diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index 8a92d9dc..fa3232bc 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base the PR's own pre-PR already-fixed check scanned from (recorded with the opening), so base commits that a pre-merge rebase brought under the PR are covered too (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip the PR's own pre-PR already-fixed check scanned through (recorded with the opening), so base commits that a pre-merge rebase brought under the PR are covered too (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. @@ -35,7 +35,7 @@ Shutdown sets a terminal admission flag before inspecting active work and checks ## Deferred runner work -This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts at the base of the PR's own pre-PR check. +This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts where the PR's own pre-PR check stopped. ## Validation diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 228e9b78..08feb6be 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -52,7 +52,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base of the snapshot on which the pull request's own pre-PR check ran (recorded with the opening, schema v19) to the base branch's tip, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the latest clear check, whatever later checks found. The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base-branch tip the pull request's own pre-PR check scanned through (that check is recorded with the opening, schema v19) to the base branch's tip now, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the latest clear check, whatever later checks found. The check reads at most 250 base commits: if more landed since the pre-PR check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. ## Cancel, shutdown and restart @@ -72,7 +72,7 @@ Schema v19 adds `merge_jobs`: one row per job (`action_id`, task key, `state`, ` - `POST /api/action` with `merge` returns `{ job }` at once when a runner is configured. - `GET /api/merge` returns `{ queue, job }`. -- Merge PR is offered while the only blockers left are ones the job resolves: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, pending required checks, and mergeability GitHub is still computing. +- Merge PR is offered while the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, lists waiting checks in the blocker dialog, and offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. ## Tests diff --git a/github/merge.ts b/github/merge.ts index 1a2ba55b..99a94ec2 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -61,7 +61,7 @@ export interface MergeTarget { /** The task branch of a published target (#121). The inspection then also reads `RemoteMergeState.published`. */ headBranch?: string; /** - * Where the already-fixed commit scan starts: the base the PR's own pre-PR check scanned from. A rebase moves the + * Where the already-fixed commit scan starts: the base-branch tip the PR's own pre-PR check scanned through. A rebase moves the * PR's base past base commits that check never saw (F6b of #22). Default: the PR's base commit. */ alreadyFixedSince?: string; diff --git a/runner/merge.ts b/runner/merge.ts index 9dc5ce6a..cffc2908 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -117,16 +117,19 @@ export class MergeCoordinator { } /** - * The already-fixed scan of a runner-published PR (F6b of #22): from the base the PR's own pre-PR check scanned, so base - * commits any pre-merge rebase brought under the PR are covered, even after a re-publish recorded a newer check from a - * rebased base. The task's own commits are excluded. An opening recorded before schema v19 uses the latest clear check. + * The already-fixed scan of a runner-published PR (F6b of #22): from the base-branch tip the PR's own pre-PR check + * scanned through, so base commits any pre-merge rebase brought under the PR are covered, even after a re-publish + * recorded a newer check from a rebased base. The task's own commits are excluded. An opening recorded before schema + * v19 uses the latest clear check. */ #alreadyFixedRange(openingCheck: string | null): { alreadyFixedSince?: string; ownCommits: string[] } { const { store, config } = this.service, identity = config.identity; const check = (openingCheck ? store.getAlreadyFixed(identity, openingCheck) : null) ?? store.latestClearAlreadyFixed(identity); const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); if (!check || check.result.outcome !== 'clear') return { ownCommits }; - return { alreadyFixedSince: store.getSnapshot(identity, check.snapshotId).base, ownCommits }; + // That check scanned its snapshot's base up to the base branch's tip then (`baseHead`) and found nothing, so the merge + // scans only what landed after that tip: every base commit a rebase can bring in, and no more. + return { alreadyFixedSince: check.result.baseHead, ownCommits }; } /** Whether GitHub shows the task's published PR where it was opened, as the only open PR from its branch. */ diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index cc5ccb8e..618e84e7 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -341,7 +341,10 @@ it('settles a job a crash left in its merge step from its merge attempt at start it('blocks the job and the task in one write, leaving a task that moved meanwhile as it is', () => { const store = publishedTask(), first = randomUUID(), second = randomUUID(); store.beginMergeJob(identity, { actionId: first, token: 'review-token' }); + const before = store.getTask(identity).stateVersion; expect(store.blockMergeJob(identity, first, 'Required check failed: ci.')).toBe(true); + // The status change advances the task state version, so the next merge prepares again from the start. + expect(store.getTask(identity).stateVersion).toBe(before + 1); expect(store.getMergeJob(identity, first)).toMatchObject({ state: 'blocked', reason: 'Required check failed: ci.' }); expect(store.getTask(identity).status).toBe('approved but merge blocked'); store.beginMergeJob(identity, { actionId: second, token: 'review-token' }); @@ -369,3 +372,11 @@ it('decides a retried job only from its own merge attempt, never from an earlier f.store.settleInterruptedMergeJobs(identity); expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); }); + +it('refuses a click while a preparation is already running, saving nothing', () => { + const f = setup(); + (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('Pre-merge preparation is already running.'); }; + expect(() => f.coordinator.start('review-token', randomUUID())).toThrow('Pre-merge preparation is already running.'); + expect(f.coordinator.latest()).toBeNull(); + expect(f.prep.calls).toEqual([]); +}); diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index ff68be08..b7b9bf21 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -253,7 +253,7 @@ describe('the merge gate with a runner block (#121)', () => { const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); const status = await merges.status(); expect(status.blockers).toEqual([]); - expect(gh.targets[0]).toEqual({ pullRequest: 7, ownPullRequests: [7], headBranch: BRANCH, alreadyFixedSince: oid(1), ownCommits: [oid(2)] }); + expect(gh.targets[0]).toEqual({ pullRequest: 7, ownPullRequests: [7], headBranch: BRANCH, alreadyFixedSince: oid(9), ownCommits: [oid(2)] }); await merges.merge('review-token'); expect(gh.merged).toEqual([7]); expect(store.getMergeAttempt(identity)).toMatchObject({ pullRequest: 7, state: 'merged' }); @@ -340,7 +340,7 @@ describe('the merge gate with a runner block (#121)', () => { store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); await merges.status(undefined, true); - expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 7, alreadyFixedSince: oid(1) }); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 7, alreadyFixedSince: oid(9) }); expect([...gh.targets.at(-1)!.ownCommits!].sort()).toEqual([oid(12), oid(2)].sort()); }); @@ -353,12 +353,13 @@ describe('the merge gate with a runner block (#121)', () => { const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); store.markRunning(identity, attempt.id); store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); - expect(clear(store).snapshotId).toBe(store.getSnapshot(identity).id); + expect(store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(19) } }).snapshotId).toBe(store.getSnapshot(identity).id); expect(store.getSnapshot(identity).base).toBe(oid(10)); toReview(store); const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); await merges.status(undefined, true); - expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(1) }); + expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); }); it('scans from the latest clear check for a PR opened before schema v19, even when a later check was not clear', async () => { @@ -382,7 +383,7 @@ describe('the merge gate with a runner block (#121)', () => { const gh = github(reopened); const merges = new MergeCoordinator(service(reopened), gh.client, undefined, undefined, PUBLISHED); await merges.status(undefined, true); - expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(1) }); + expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); }); it('refuses pre-merge preparation for a pull request that is no longer open', async () => { @@ -411,7 +412,7 @@ describe('the merge gate with a runner block (#121)', () => { toReview(store); const gh = github(store); await new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED).status(); - expect(gh.targets[0]).toEqual({ pullRequest: 8, ownPullRequests: [7, 8], headBranch: BRANCH, alreadyFixedSince: oid(1), ownCommits: [oid(2)] }); + expect(gh.targets[0]).toEqual({ pullRequest: 8, ownPullRequests: [7, 8], headBranch: BRANCH, alreadyFixedSince: oid(9), ownCommits: [oid(2)] }); }); it('refuses when the task\'s PR changes between the validation passes', async () => { From 18fc56831546bee51c1a87da5e2051e037f0e9a1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 23:04:54 -0700 Subject: [PATCH 028/163] Address Copilot: continuous scan, replayed refusals, strict wait, focus - 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 --- docs/implementation/merge-handoff.md | 7 ++-- runner/merge-job.ts | 59 ++++++++++++++++++++------- runner/merge.ts | 4 +- runner/store.ts | 34 ++++++++++++++-- test/browser/review.spec.ts | 21 +++++++--- test/merge-job.test.ts | 61 ++++++++++++++++++++++------ test/merge-task-pr.test.ts | 6 ++- web/public/app.js | 9 +++- 8 files changed, 156 insertions(+), 45 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 08feb6be..4f526f95 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -30,12 +30,12 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, or something is busy (nothing is saved; the click may be sent again) | +| Admission | The click's review token must be current, the task mergeable, and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; only a refusal because the server is shutting down saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task stays in review. +While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so the job never merges after its limit. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task stays in review. ## Outcomes @@ -52,7 +52,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base-branch tip the pull request's own pre-PR check scanned through (that check is recorded with the opening, schema v19) to the base branch's tip now, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the latest clear check, whatever later checks found. The check reads at most 250 base commits: if more landed since the pre-PR check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base-branch tip the pull request's own pre-PR check scanned through (that check is recorded with the opening, schema v19) to the base branch's tip now, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the task's oldest clear check: a compare range excludes its own start commit, so only a scan from the first clear check's tip onward is provably continuous. The check reads at most 250 base commits: if more landed since the pre-PR check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. ## Cancel, shutdown and restart @@ -73,6 +73,7 @@ Schema v19 adds `merge_jobs`: one row per job (`action_id`, task key, `state`, ` - `POST /api/action` with `merge` returns `{ job }` at once when a runner is configured. - `GET /api/merge` returns `{ queue, job }`. - Merge PR is offered while the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. +- While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. - The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, lists waiting checks in the blocker dialog, and offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. ## Tests diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 02c96e3d..6f954075 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -62,17 +62,21 @@ export class MergeJobCoordinator { start(token: unknown, actionId: string, busy: () => string | null = () => null): MergeJobView { const { store, config } = this.service, identity = config.identity; const saved = store.getMergeJob(identity, actionId); - if (saved) return mergeJobView(store.beginMergeJob(identity, { actionId, token: token as string }).job); + if (saved) { + const { job } = store.beginMergeJob(identity, { actionId, token: token as string }); + // A refused click replays its refusal; anything else is the job this click started. + if (job.refused) throw new GuardRefusal(job.reason ?? 'The merge was refused.'); + return mergeJobView(job); + } + // Shutdown decides nothing about this click: it may be sent again, so nothing is saved. if (this.#closing) throw new GuardRefusal('The server is shutting down.'); - if (this.#active) throw new GuardRefusal('A merge is already running for this task.'); - if (typeof token !== 'string' || this.service.load().token !== token) throw new GuardRefusal('Stale review state. Refresh before merging.'); - const task = store.getTask(identity); - if (!MERGEABLE_STATUSES.includes(task.status)) throw new GuardRefusal(`The task is ${task.status}; merge it from review.`); - if (task.cancelRequested !== null) throw new GuardRefusal('The task is being cancelled.'); - const reason = busy(); - if (reason) throw new GuardRefusal(reason); - this.preMerge.assertStartable(); - const { job } = store.beginMergeJob(identity, { actionId, token }); + const refusal = this.#admissionRefusal(token, busy); + if (refusal !== null) { + // A definite refusal is this click's outcome; a resend replays it (as the guarded merge does). + if (typeof token === 'string' && token) store.recordMergeJobRefusal(identity, { actionId, token, reason: refusal }); + throw new GuardRefusal(refusal); + } + const { job } = store.beginMergeJob(identity, { actionId, token: token as string }); const abort = new AbortController(); this.#abort = abort; const run = Promise.resolve().then(() => this.#run(job, abort.signal)).catch(error => { // Anything unexpected ends the job, unless shutdown stopped it: startup then settles it. @@ -82,6 +86,24 @@ export class MergeJobCoordinator { return mergeJobView(job); } + /** Why this click may not start a job now, or null. A preparation stopping for shutdown throws instead (not definite). */ + #admissionRefusal(token: unknown, busy: () => string | null): string | null { + const { store, config } = this.service, identity = config.identity; + if (this.#active) return 'A merge is already running for this task.'; + if (typeof token !== 'string' || this.service.load().token !== token) return 'Stale review state. Refresh before merging.'; + const task = store.getTask(identity); + if (!MERGEABLE_STATUSES.includes(task.status)) return `The task is ${task.status}; merge it from review.`; + if (task.cancelRequested !== null) return 'The task is being cancelled.'; + const reason = busy(); + if (reason) return reason; + try { this.preMerge.assertStartable(); } + catch (error) { + if (!(error instanceof GuardRefusal) || /shutting down/i.test(error.message)) throw error; + return error.message; + } + return null; + } + /** The task was cancelled: stop waiting at once. Preparation and the guarded merge stop through their own routes. */ cancel(): void { this.#abort?.abort(Object.assign(new Error('Task cancelled.'), { code: 'CANCELLED' })); } @@ -168,27 +190,34 @@ export class MergeJobCoordinator { const { store, config } = this.service, identity = config.identity; const until = performance.now() + this.checkWaitMs; let delay = this.firstDelayMs, waiting: { context: string; state: string }[] = []; + const timedOut = (): CheckVerdict => ({ kind: 'blocked', reason: `Required checks did not pass within ${Math.round(this.checkWaitMs / 60_000)} minutes: ${ + waiting.length ? waiting.map(check => `${check.context} is ${check.state}`).join(', ') : 'GitHub has not determined mergeability'}.` }); for (;;) { if (signal.aborted) return { kind: 'cancelled' }; const task = store.getTask(identity); if (task.status === 'cancelled' || task.cancelRequested !== null) return { kind: 'cancelled' }; + // No inspection starts after the wait's limit, and each one is stopped when the limit arrives. + const left = Math.floor(until - performance.now()); + if (left < 1) return timedOut(); + const limit = AbortSignal.timeout(left); let status: MergeStatus; - try { status = await this.merges.status(this.service.load(), true, signal); } + try { status = await this.merges.status(this.service.load(), true, AbortSignal.any([signal, limit])); } catch (error) { if (signal.aborted) return { kind: 'cancelled' }; + if (limit.aborted) return timedOut(); return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }; } const verdict = classify(status); if (verdict.kind !== 'wait') return verdict; waiting = verdict.checks; this.settle(() => store.updateMergeJob(identity, job.id, { checks: waiting })); - const left = until - performance.now(); - if (left <= 0) return { kind: 'blocked', reason: `Required checks did not pass within ${Math.round(this.checkWaitMs / 60_000)} minutes: ${ - waiting.length ? waiting.map(check => `${check.context} is ${check.state}`).join(', ') : 'GitHub has not determined mergeability'}.` }; - await sleep(Math.min(delay, left), signal).catch(() => undefined); + const remaining = until - performance.now(); + if (remaining <= 0) return timedOut(); + await sleep(Math.min(delay, remaining), signal).catch(() => undefined); delay = Math.min(delay * 2, this.maxDelayMs); } } + } /** Blockers the merge job resolves itself: it prepares (rebase, push, cmd: checks) and waits for required checks. */ diff --git a/runner/merge.ts b/runner/merge.ts index cffc2908..3e6ec93e 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -120,11 +120,11 @@ export class MergeCoordinator { * The already-fixed scan of a runner-published PR (F6b of #22): from the base-branch tip the PR's own pre-PR check * scanned through, so base commits any pre-merge rebase brought under the PR are covered, even after a re-publish * recorded a newer check from a rebased base. The task's own commits are excluded. An opening recorded before schema - * v19 uses the latest clear check. + * v19 uses the task's oldest clear check, the only start provably continuous with what was scanned before. */ #alreadyFixedRange(openingCheck: string | null): { alreadyFixedSince?: string; ownCommits: string[] } { const { store, config } = this.service, identity = config.identity; - const check = (openingCheck ? store.getAlreadyFixed(identity, openingCheck) : null) ?? store.latestClearAlreadyFixed(identity); + const check = (openingCheck ? store.getAlreadyFixed(identity, openingCheck) : null) ?? store.oldestClearAlreadyFixed(identity); const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); if (!check || check.result.outcome !== 'clear') return { ownCommits }; // That check scanned its snapshot's base up to the base branch's tip then (`baseHead`) and found nothing, so the merge diff --git a/runner/store.ts b/runner/store.ts index a3905dea..e11db138 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -139,6 +139,8 @@ export interface MergeJob { id: string; /** The action ID of the guarded merge this job started, once it reaches that step: its attempt carries it. */ mergeActionId: string | null; + /** The click was refused at admission; a resend replays the refusal (`reason`). */ + refused?: boolean; /** The review token the click was made with; a resend must carry the same one. */ token: string; state: MergeJobState; @@ -2137,6 +2139,29 @@ export class Store { return { job, resent: false }; }); } + /** + * Save a definite admission refusal under the click's action ID, so a resend replays it rather than starting a job. + * A resend of the same click returns the saved record; another request under that ID is refused as a reused ID. + */ + recordMergeJobRefusal(identity: PlanIdentity, input: { actionId: string; token: string; reason: string }): MergeJob { + assertUuidV4(input.actionId, 'Action ID'); + if (typeof input.token !== 'string' || !input.token || input.token.length > 512) throw new Error('A review token is required.'); + const key = identityKey(identity); + return this.#transaction(() => { + this.#task(key); + const existing = this.#get('SELECT plan_key, data FROM merge_jobs WHERE id=?', input.actionId); + if (existing) { + const job = decode(existing.data); + if (job.token !== input.token || existing.plan_key !== key) throw new ActionIdReused('Action ID already used for a different request.'); + return job; + } + const now = new Date().toISOString(); + const job: MergeJob = { id: input.actionId, mergeActionId: null, token: input.token, state: 'failed', step: null, + reason: input.reason.slice(0, 2_000), checks: [], url: null, startedAt: now, updatedAt: now, refused: true }; + this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); + return job; + }); + } /** Move a running job to another step or record what it waits for. A job no longer running is left unchanged. */ updateMergeJob(identity: PlanIdentity, id: string, patch: { step?: MergeJobStep; reason?: string | null; checks?: { context: string; state: string }[]; mergeActionId?: string }): MergeJob | null { @@ -2289,9 +2314,12 @@ export class Store { const key = identityKey(identity); this.#task(key); return this.#db.prepare('SELECT * FROM task_pull_requests WHERE plan_key=? ORDER BY rowid').all(key).map(row => this.#pullRequestRecord(row)); } - /** The latest already-fixed check that found nothing. */ - latestClearAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { - const row = this.#get(`SELECT * FROM already_fixed_checks WHERE plan_key=? AND outcome='clear' ORDER BY rowid DESC LIMIT 1`, identityKey(identity)); + /** + * The first already-fixed check that found nothing. A compare range excludes its start commit, so only a scan from + * this check's tip onward is provably continuous for an opening that did not record its own check. + */ + oldestClearAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { + const row = this.#get(`SELECT * FROM already_fixed_checks WHERE plan_key=? AND outcome='clear' ORDER BY rowid ASC LIMIT 1`, identityKey(identity)); return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; } getAlreadyFixed(identity: PlanIdentity, id: string): AlreadyFixedCheck | null { diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 3794ad53..9b7cf92a 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -636,22 +636,31 @@ test('follows a one-click merge job from preparation through the required-check let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('running','merging'),job('merged',null)]; - let polls=0,reviews=0; - await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + let polls=0,reviews=0,mergeClicks=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing'); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); - await page.route('**/api/review',async route=>{reviews++;await route.continue();}); + // A runner server reports merging as a job (its merge status carries `job`). + await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const button=page.locator('#merge'); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); - await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toBeDisabled(); + // The focused control keeps focus while its job runs: marked, not disabled (AGENTS.md, Async review UI). + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); + // Not disabled in the DOM (Playwright's toBeEnabled counts aria-disabled as disabled), and still focused. + await expect(button).toHaveJSProperty('disabled',false);await expect(button).toBeFocused(); await expect(page.locator('#banner')).toContainText('Preparing the merge'); - await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toBeDisabled(); + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(button).toBeFocused(); await expect(page.locator('#banner')).toContainText('Waiting for required checks: ci is pending.'); + // A repeat activation while the job runs starts nothing. + await button.press('Enter'); await page.getByRole('button',{name:'Merge status',exact:true}).click(); await expect(page.locator('dialog')).toContainText('ci: pending'); await page.keyboard.press('Escape'); - await expect(page.getByRole('button',{name:'Merging…',exact:true})).toBeDisabled(); + await expect(page.getByRole('button',{name:'Merging…',exact:true})).toHaveAttribute('aria-disabled','true'); + expect(mergeClicks).toBe(1); const reloadsBeforeEnd=reviews; await expect(page.locator('#banner')).toHaveText('Merged.'); expect(reviews).toBeGreaterThan(reloadsBeforeEnd); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 618e84e7..6e8c047a 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -199,15 +199,17 @@ it('keeps the readiness preparation recorded valid while the job records its pro expect(readyAt).not.toBeNull(); }); -it('replays a resent click, refuses a second click while a job runs, and saves nothing for a stale token', async () => { +it('replays a resent click, refuses a second click while a job runs, and replays a refusal for a stale token', async () => { const f = setup({ checks: () => [pending()] }); - const id = randomUUID(); - expect(() => f.coordinator.start('stale-token', randomUUID())).toThrow(/Stale review state/); - expect(f.coordinator.latest()).toBeNull(); + const id = randomUUID(), stale = randomUUID(); + expect(() => f.coordinator.start('stale-token', stale)).toThrow(/Stale review state/); + expect(f.store.getMergeJob(identity, stale)).toMatchObject({ state: 'failed', refused: true }); + expect(() => f.coordinator.start('stale-token', stale)).toThrow(/Stale review state/); const first = f.coordinator.start('review-token', id); expect(f.coordinator.start('review-token', id)).toMatchObject({ id: first.id, state: 'running' }); expect(() => f.coordinator.start('review-token', randomUUID())).toThrow(GuardRefusal); expect(() => f.coordinator.start('other-token', id)).toThrow(/already used/); + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); f.coordinator.cancel(); await settled(f.coordinator); expect(f.prep.calls).toHaveLength(1); }); @@ -231,13 +233,24 @@ it('ends queued when the merge queue accepted the head', async () => { expect(f.store.getTask(identity).status).toBe('in review'); }); -it('refuses a click while the runner or publisher is busy, saving nothing', () => { - const f = setup(); - expect(() => f.coordinator.start('review-token', randomUUID(), () => 'The runner is busy.')).toThrow('The runner is busy.'); - expect(f.coordinator.latest()).toBeNull(); +it('refuses a click while the runner or publisher is busy, and replays that refusal once it is idle', () => { + const f = setup(), id = randomUUID(); + expect(() => f.coordinator.start('review-token', id, () => 'The runner is busy.')).toThrow('The runner is busy.'); + // The response was lost; the same click is resent after the runner became idle: it replays, starting nothing. + expect(() => f.coordinator.start('review-token', id)).toThrow('The runner is busy.'); + expect(() => f.coordinator.start('other-token', id)).toThrow(/already used/); + expect(f.coordinator.latest()).toMatchObject({ id, state: 'failed', refused: true, reason: 'The runner is busy.' }); + expect(f.coordinator.active).toBe(false); expect(f.prep.calls).toEqual([]); }); +it('saves nothing for a click refused because the server is shutting down, so it may be sent again', () => { + const f = setup(), id = randomUUID(); + f.coordinator.stop(); + expect(() => f.coordinator.start('review-token', id)).toThrow(/shutting down/); + expect(f.store.getMergeJob(identity, id)).toBeNull(); +}); + it('ends cancelled when the task was cancelled while the job waited, even if the job was not told', async () => { const f = setup({ checks: () => [pending()] }); f.coordinator.start('review-token', randomUUID()); @@ -373,10 +386,34 @@ it('decides a retried job only from its own merge attempt, never from an earlier expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); }); -it('refuses a click while a preparation is already running, saving nothing', () => { - const f = setup(); +it('refuses a click while a preparation is already running, and replays that refusal', () => { + const f = setup(), id = randomUUID(); (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('Pre-merge preparation is already running.'); }; - expect(() => f.coordinator.start('review-token', randomUUID())).toThrow('Pre-merge preparation is already running.'); - expect(f.coordinator.latest()).toBeNull(); + expect(() => f.coordinator.start('review-token', id)).toThrow('Pre-merge preparation is already running.'); + expect(f.coordinator.latest()).toMatchObject({ id, refused: true }); expect(f.prep.calls).toEqual([]); }); + +it('starts no inspection after the wait limit and stops one that runs into it', async () => { + let inspections = 0, hang = false; + const f = setup({ checks: () => [pending()] }); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + inspections++; + // From the second inspection on, GitHub does not answer; only the wait's limit (or a cancel) ends the read. + if (hang && request?.signal) await new Promise((_resolve, reject) => request.signal!.addEventListener('abort', () => reject(request.signal!.reason), { once: true })); + return inspect(request); + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 400, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(coordinator.latest()?.step).toBe('checks')); + hang = true; + const started = Date.now(); + await coordinator.current; + expect(Date.now() - started).toBeLessThan(3_000); + expect(coordinator.latest()).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/did not pass within/) }); + const after = inspections; + await new Promise(resolve => setTimeout(resolve, 100)); + expect(inspections).toBe(after); + expect(f.gh.merged).toEqual([]); +}); diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index b7b9bf21..e856c568 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -362,17 +362,19 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); }); - it('scans from the latest clear check for a PR opened before schema v19, even when a later check was not clear', async () => { + it('scans from the oldest clear check for a PR opened before schema v19, whatever later checks found', async () => { const root = mkdtempSync(join(tmpdir(), 'codeboost-v19-')); roots.push(root); const path = join(root, 'state.sqlite'); const { store } = published(path); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); - // A later re-publish whose check could not complete (unknown), recorded from the rebased base. + // A later re-publish recorded a clear check scanned from the rebased base, then one that could not complete. A compare + // range excludes its start, so starting at the later clear check's tip could skip the rebased base commit itself. store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); store.markRunning(identity, attempt.id); store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(19) } }); store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'unknown', reason: 'GitHub could not be read.' } }); store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); // The opening was recorded before v19: it carries no check. diff --git a/web/public/app.js b/web/public/app.js index 6003cde9..286a18d8 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -159,9 +159,12 @@ function renderMerge() { // Once the attempt this retained key started has ended, a retry is a new action and needs a new key. if (mergeAction && merge.queue?.actionId === mergeAction.actionId && ["merged", "removed", "failed"].includes(merge.queue.state)) mergeAction = null; $("merge").disabled = !merge.ready; + $("merge").removeAttribute("aria-disabled"); const job = merge.job; if (job?.state === "running") { - $("merge").disabled = true; + // Keep the control focusable while its job runs; repeat activation is ignored because the merge is not ready. + $("merge").disabled = false; + $("merge").setAttribute("aria-disabled", "true"); $("merge").textContent = { preparing: "Preparing…", checks: "Waiting for checks…", merging: "Merging…" }[job.step] ?? "Merging…"; $("merge-details").hidden = false; $("merge-details").textContent = "Merge status"; @@ -577,7 +580,9 @@ $("merge").onclick = async () => { mergeGeneration++; mergePollState = null; mergePollDelay = 2000; - $("merge").disabled = true; + // With merge jobs, the click returns at once and the focused control must keep focus: mark it, do not disable it. + if (data.merge && "job" in data.merge) $("merge").setAttribute("aria-disabled", "true"); + else $("merge").disabled = true; try { rememberDraft(); mergeAction ??= { actionId: crypto.randomUUID(), token: data.token }; From 727f2797c14b8a422ca3d969585030df33178c2c Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 23:20:15 -0700 Subject: [PATCH 029/163] Keep saved merge refusals out of the task's latest job 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 --- docs/implementation/merge-handoff.md | 2 +- runner/merge-job.ts | 3 ++- runner/store.ts | 4 +++- test/merge-job.test.ts | 14 +++++++++++--- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 4f526f95..6dbef423 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -71,7 +71,7 @@ Schema v19 adds `merge_jobs`: one row per job (`action_id`, task key, `state`, ` ## API and browser - `POST /api/action` with `merge` returns `{ job }` at once when a runner is configured. -- `GET /api/merge` returns `{ queue, job }`. +- `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - Merge PR is offered while the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. - The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, lists waiting checks in the blocker dialog, and offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 6f954075..f62cd013 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -57,7 +57,8 @@ export class MergeJobCoordinator { /** * Admit the click and start its job after the caller's transaction commits. A resend of the same click returns its - * job without starting another. Refusals save nothing, so the same click may be sent again. + * job without starting another. A definite refusal is saved under the click's action ID and replayed on resend; only a + * refusal during shutdown saves nothing, so that click may be sent again. */ start(token: unknown, actionId: string, busy: () => string | null = () => null): MergeJobView { const { store, config } = this.service, identity = config.identity; diff --git a/runner/store.ts b/runner/store.ts index e11db138..c819d7aa 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2204,8 +2204,10 @@ export class Store { const row = this.#get('SELECT data FROM merge_jobs WHERE id=? AND plan_key=?', id, identityKey(identity)); return row ? decode(row.data) : null; } + /** The task's latest job. A refused click started none: its refusal is replayed to that click alone, never shown here. */ latestMergeJob(identity: PlanIdentity): MergeJob | null { - const row = this.#get('SELECT data FROM merge_jobs WHERE plan_key=? ORDER BY rowid DESC LIMIT 1', identityKey(identity)); + const row = this.#get(`SELECT data FROM merge_jobs WHERE plan_key=? AND json_extract(data,'$.refused') IS NOT 1 + ORDER BY rowid DESC LIMIT 1`, identityKey(identity)); return row ? decode(row.data) : null; } runningMergeJob(identity: PlanIdentity): MergeJob | null { diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 6e8c047a..00187230 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -207,7 +207,14 @@ it('replays a resent click, refuses a second click while a job runs, and replays expect(() => f.coordinator.start('stale-token', stale)).toThrow(/Stale review state/); const first = f.coordinator.start('review-token', id); expect(f.coordinator.start('review-token', id)).toMatchObject({ id: first.id, state: 'running' }); - expect(() => f.coordinator.start('review-token', randomUUID())).toThrow(GuardRefusal); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + const second = randomUUID(); + expect(() => f.coordinator.start('review-token', second)).toThrow(/already running/); + expect(f.store.getMergeJob(identity, second)).toMatchObject({ refused: true }); + // The saved refusal is that click's alone: the running job stays the task's job, and Merge PR stays withheld. + expect(f.coordinator.latest()).toMatchObject({ id: first.id, state: 'running', step: 'checks' }); + const resolvable = { ready: false, action: null, blockers: [{ code: 'check', message: 'pending' }], remote: { mergeable: 'MERGEABLE' }, queue: null } as Parameters[0]; + expect(withMergeJob(resolvable, f.coordinator.latest(), 'in review')).toMatchObject({ ready: false, action: null }); expect(() => f.coordinator.start('other-token', id)).toThrow(/already used/); expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); f.coordinator.cancel(); await settled(f.coordinator); @@ -239,7 +246,8 @@ it('refuses a click while the runner or publisher is busy, and replays that refu // The response was lost; the same click is resent after the runner became idle: it replays, starting nothing. expect(() => f.coordinator.start('review-token', id)).toThrow('The runner is busy.'); expect(() => f.coordinator.start('other-token', id)).toThrow(/already used/); - expect(f.coordinator.latest()).toMatchObject({ id, state: 'failed', refused: true, reason: 'The runner is busy.' }); + expect(f.store.getMergeJob(identity, id)).toMatchObject({ state: 'failed', refused: true, reason: 'The runner is busy.' }); + expect(f.coordinator.latest()).toBeNull(); expect(f.coordinator.active).toBe(false); expect(f.prep.calls).toEqual([]); }); @@ -390,7 +398,7 @@ it('refuses a click while a preparation is already running, and replays that ref const f = setup(), id = randomUUID(); (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('Pre-merge preparation is already running.'); }; expect(() => f.coordinator.start('review-token', id)).toThrow('Pre-merge preparation is already running.'); - expect(f.coordinator.latest()).toMatchObject({ id, refused: true }); + expect(f.store.getMergeJob(identity, id)).toMatchObject({ refused: true }); expect(f.prep.calls).toEqual([]); }); From db189ae3fe1eff6c312ca44961112e400c084088 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 23:39:38 -0700 Subject: [PATCH 030/163] Admit issue trust before a merge job starts; correct two doc claims 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 --- docs/implementation/merge-handoff.md | 8 +++---- runner/merge-job.ts | 2 ++ test/runner-publishing.test.ts | 34 ++++++++++++++++++++++++++++ web/server.ts | 12 ++++++++-- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 6dbef423..933b40d4 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -30,12 +30,12 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; only a refusal because the server is shutting down saves nothing and may be sent again | +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down, or because trust could not be read, saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so the job never merges after its limit. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task stays in review. +While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task stays in review. ## Outcomes @@ -66,7 +66,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t ## Storage -Schema v19 adds `merge_jobs`: one row per job (`action_id`, task key, `state`, `step`, `reason`, required-check names and states, the merge result URL, times). Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. +Schema v19 adds `merge_jobs`: one row per job or refused click (`action_id`, task key, `state`, `step`, `reason`, required-check names and states, the merge result URL, times). A refused click's row is marked `refused`: it is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. ## API and browser @@ -78,7 +78,7 @@ Schema v19 adds `merge_jobs`: one row per job (`action_id`, task key, `state`, ` ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, `GET /api/merge`, the `prepare-merge` refusal, cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` refusal, cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. ## Check this document with a reader diff --git a/runner/merge-job.ts b/runner/merge-job.ts index f62cd013..95f7b069 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -50,6 +50,8 @@ export class MergeJobCoordinator { get active(): boolean { return this.#active !== null; } /** The running job's settlement, for the server's after-work hooks; null when none runs. */ get current(): Promise | null { return this.#active; } + /** Whether this click already has a saved job or refusal, which a resend replays without new admission reads. */ + saved(actionId: string): boolean { return this.service.store.getMergeJob(this.service.config.identity, actionId) !== null; } latest(): MergeJobView | null { const job = this.service.store.latestMergeJob(this.service.config.identity); return job ? mergeJobView(job) : null; diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 87524a0c..dbdbe4a4 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1814,6 +1814,40 @@ describe('the one-click merge job (F6b of #22)', () => { expect(served.store.getTask(served.identity).status).toBe('merged'); }, 60_000); + it('admits issue trust before a merge job starts: an untrusted click is refused and replayed, a failed read saves nothing', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, access: 'untrusted' | 'unreadable' | 'trusted' = 'trusted', reads = 0, prepared = 0; + const gh = gateway(() => storeRef!, () => []); + const gate: IssueTrustGateway = { ...issueGateway, async issueAccess(number) { + reads++; + if (access === 'unreadable') throw new Error('GitHub returned HTTP 502'); + return access === 'trusted' ? { number, authorLogin: 'member', collaborator: true } : { number, authorLogin: 'outside', collaborator: false }; + } }; + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway: gate, mergeGateway: gh, runnerPublished: true, + preMerge: service => { const real = preparation(service); return { ...real, get active() { return false; }, get last() { return null; }, + start: (expected: { actionId: string }) => { prepared++; return (real.start as (value: unknown) => unknown)(expected); } } as unknown as PreMergeCoordinator; } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + // A failed read decides nothing: nothing is saved, so the same click may be sent again. + access = 'unreadable'; + expect((await merge(served.app, token, id)).status).toBe(502); + expect(served.store.getMergeJob(served.identity, id)).toBeNull(); + access = 'untrusted'; + expect(await merge(served.app, token, id)).toMatchObject({ status: 409, body: { error: expect.stringMatching(/not trusted/) } }); + expect(served.store.getMergeJob(served.identity, id)).toMatchObject({ refused: true }); + // The resend replays the refusal without another read, even once the issue is trusted. + access = 'trusted'; const before = reads; + expect(await merge(served.app, token, id)).toMatchObject({ status: 409, body: { error: expect.stringMatching(/not trusted/) } }); + expect(reads).toBe(before); + expect(prepared).toBe(0); + expect((await mergeState(served.app)).job).toBeNull(); + // A new click on a trusted issue starts the job. + expect((await merge(served.app, token)).status).toBe(200); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); + expect(prepared).toBe(1); + }, 60_000); + it('closes the pull requests of a task whose cancel waited for the merge job\'s preparation to settle', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, release!: () => void, preparing = false; diff --git a/web/server.ts b/web/server.ts index 267eeb70..27403995 100644 --- a/web/server.ts +++ b/web/server.ts @@ -679,8 +679,16 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge // With a runner, one click starts the whole merge job (F6b of #22) and returns at once; the browser follows it // through GET /api/merge. if(mergeJobs) { - const job=mergeJobs.start(input.token,input.actionId,()=>runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) - ?'The runner or publisher is busy; the merge cannot start yet.':null); + // Trust is admitted before the job starts, as for prepare-merge: an untrusted issue's click is refused, and the + // refusal saved. A failed read decides nothing: it saves nothing, and the same click may be sent again. + let untrusted: string|null=null; + if(config.github&&!mergeJobs.saved(input.actionId)) { + if(stopping||runner?.closing)throw new ShuttingDownError(); + const access=await readIssueAccess(requestAbort.signal); + try { requireTrustedIssue(access); } catch (error) { if(!(error instanceof GuardRefusal))throw error; untrusted=error.message; } + } + const job=mergeJobs.start(input.token,input.actionId,()=>untrusted??(runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) + ?'The runner or publisher is busy; the merge cannot start yet.':null)); const running=mergeJobs.current; if(running)void running.finally(()=>{ if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); }); json(200,{...(await load(requestAbort.signal)),job});return; From 841e395cbe922f58296391305e3b0a0c8d770f83 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 23:50:55 -0700 Subject: [PATCH 031/163] Say exactly when shutdown stops a merge job's preparation Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 933b40d4..384b9853 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -61,7 +61,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | -| Shutdown | the job is stopped in shutdown's first, synchronous step, before requests drain, and awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused, otherwise `submitted`, and the attempt reconciles as before | +| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused, otherwise `submitted`, and the attempt reconciles as before | | Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked. A `prepare-merge` action and a push marker are settled as before | ## Storage From fc57d201674eb00c266fcadf6a150e9b36dfa57d Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 00:04:29 -0700 Subject: [PATCH 032/163] Correct the task status the merge-handoff doc gives for queued and failed jobs Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 384b9853..0caebd5e 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,13 +35,14 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task stays in review. +While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. ## Outcomes | Job state | Task status afterwards | What the person does | |---|---|---| -| `merged`, `queued` | merged, or in review while queued | nothing | +| `merged` | merged | nothing | +| `queued` | unchanged (in review, or approved, merge blocked on a retry) while GitHub's queue holds it | nothing | | `submitted` | unchanged | refresh; the merge attempt settles from GitHub | | `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | | `review-required` | unchanged | review the refreshed pull request, then merge again | From 76c81f2a954090da3810f8dedd85d7a299f2ea9a Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 00:04:53 -0700 Subject: [PATCH 033/163] Test that a retried job's queue leaves the task approved, merge blocked Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 00187230..41bf5c63 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -240,6 +240,16 @@ it('ends queued when the merge queue accepted the head', async () => { expect(f.store.getTask(identity).status).toBe('in review'); }); +it('leaves the task approved, merge blocked when a retried job is queued', async () => { + const f = setup({ queue: true, checks: n => (n === 1 ? [{ context: 'build', appId: null, state: 'failure' }] : []) }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'queued' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + it('refuses a click while the runner or publisher is busy, and replays that refusal once it is idle', () => { const f = setup(), id = randomUUID(); expect(() => f.coordinator.start('review-token', id, () => 'The runner is busy.')).toThrow('The runner is busy.'); From 368495cbb55b2b66735ecef01dccb1da29829c02 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 00:22:35 -0700 Subject: [PATCH 034/163] Keep the Merge button focused when a merge-job click is refused; qualify the scan-range claim In job mode the button is marked aria-disabled, never disabled, so a refusal or a job's end keeps keyboard focus. The docs now say that status shown while a merge attempt is in flight scans from the PR base. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/guarded-merge.md | 2 +- docs/implementation/merge-handoff.md | 2 +- test/browser/review.spec.ts | 21 +++++++++++++++++++++ web/public/app.js | 13 ++++++++++--- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index fa3232bc..04bdfaf3 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip the PR's own pre-PR already-fixed check scanned through (recorded with the opening), so base commits that a pre-merge rebase brought under the PR are covered too (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip the PR's own pre-PR already-fixed check scanned through (recorded with the opening), so base commits that a pre-merge rebase brought under the PR are covered too (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 0caebd5e..6aae90b2 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -53,7 +53,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base-branch tip the pull request's own pre-PR check scanned through (that check is recorded with the opening, schema v19) to the base branch's tip now, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the task's oldest clear check: a compare range excludes its own start commit, so only a scan from the first clear check's tip onward is provably continuous. The check reads at most 250 base commits: if more landed since the pre-PR check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base-branch tip the pull request's own pre-PR check scanned through (that check is recorded with the opening, schema v19) to the base branch's tip now, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the task's oldest clear check: a compare range excludes its own start commit, so only a scan from the first clear check's tip onward is provably continuous. The check reads at most 250 base commits: if more landed since the pre-PR check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. So does the status shown while a merge attempt is submitting, queued or merged: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. ## Cancel, shutdown and restart diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 9b7cf92a..61f10529 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -629,6 +629,27 @@ test('hides Retry until a timed-out invocation has actually settled',async({page settle('Late answer'); await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible({timeout:10000}); }); +test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let mergeClicks=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; + await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Issue #1 is not trusted for its current author.'})});}); + // A runner server reports merging as a job (its merge status carries `job`). + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const button=page.locator('#merge'); + await button.focus();await button.press('Enter'); + await expect(page.locator('#banner')).toHaveText(/^Merge blocked\. Issue #1 is not trusted/); + // The refusal leaves the control marked, not disabled, and focused (AGENTS.md, Async review UI). + await expect(button).toHaveAttribute('aria-disabled','true'); + await expect(button).toHaveJSProperty('disabled',false);await expect(button).toBeFocused(); + // Activating it again starts nothing: the merge is not ready. + await button.press('Enter'); + expect(mergeClicks).toBe(1); +}); test('follows a one-click merge job from preparation through the required-check wait to merged (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; diff --git a/web/public/app.js b/web/public/app.js index 286a18d8..d629930e 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -158,12 +158,19 @@ function renderMerge() { if (!merge?.available) return; // Once the attempt this retained key started has ended, a retry is a new action and needs a new key. if (mergeAction && merge.queue?.actionId === mergeAction.actionId && ["merged", "removed", "failed"].includes(merge.queue.state)) mergeAction = null; - $("merge").disabled = !merge.ready; - $("merge").removeAttribute("aria-disabled"); + // With merge jobs (their status carries `job`), a click's answer arrives while the control may hold focus, so it is + // marked, never disabled; activation is ignored while the merge is not ready. + if ("job" in merge) { + $("merge").disabled = false; + if (merge.ready) $("merge").removeAttribute("aria-disabled"); + else $("merge").setAttribute("aria-disabled", "true"); + } else { + $("merge").disabled = !merge.ready; + $("merge").removeAttribute("aria-disabled"); + } const job = merge.job; if (job?.state === "running") { // Keep the control focusable while its job runs; repeat activation is ignored because the merge is not ready. - $("merge").disabled = false; $("merge").setAttribute("aria-disabled", "true"); $("merge").textContent = { preparing: "Preparing…", checks: "Waiting for checks…", merging: "Merging…" }[job.step] ?? "Merging…"; $("merge-details").hidden = false; From 9e0ee39e34531fdf3b5e6b4568cf8028c0e20411 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 00:44:49 -0700 Subject: [PATCH 035/163] Draw an aria-disabled Merge button as inactive The #merge primary rules outranked the aria-disabled rule, so in runner mode a button that could not act kept the primary look and hover. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 6 ++++++ web/public/style.css | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 61f10529..22184ae2 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -629,6 +629,9 @@ test('hides Retry until a timed-out invocation has actually settled',async({page settle('Late answer'); await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible({timeout:10000}); }); +/** Whether the element is drawn with the muted surface background a control that cannot act uses (poll it: buttons may animate their background). */ +const looksInactive=(locator:import('@playwright/test').Locator)=>locator.evaluate(el=>{const probe=document.createElement('div');probe.style.background='var(--surface)';document.body.append(probe); + const surface=getComputedStyle(probe).backgroundColor;probe.remove();return getComputedStyle(el).backgroundColor===surface;}); test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; @@ -646,6 +649,8 @@ test('keeps focus on the Merge button when a merge-job click is refused (F6b)',a // The refusal leaves the control marked, not disabled, and focused (AGENTS.md, Async review UI). await expect(button).toHaveAttribute('aria-disabled','true'); await expect(button).toHaveJSProperty('disabled',false);await expect(button).toBeFocused(); + // It is drawn as a control that cannot act, as a disabled one is. + await expect.poll(()=>looksInactive(button)).toBe(true); // Activating it again starts nothing: the merge is not ready. await button.press('Enter'); expect(mergeClicks).toBe(1); @@ -671,6 +676,7 @@ test('follows a one-click merge job from preparation through the required-check await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); // Not disabled in the DOM (Playwright's toBeEnabled counts aria-disabled as disabled), and still focused. await expect(button).toHaveJSProperty('disabled',false);await expect(button).toBeFocused(); + await expect.poll(()=>looksInactive(button)).toBe(true); await expect(page.locator('#banner')).toContainText('Preparing the merge'); await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toHaveAttribute('aria-disabled','true'); await expect(button).toBeFocused(); diff --git a/web/public/style.css b/web/public/style.css index f767008a..1272ef7d 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -100,7 +100,7 @@ button[aria-disabled="true"] { } button.primary, #approve, -#merge:not(:disabled) { +#merge:not(:disabled):not([aria-disabled="true"]) { background: var(--primary); color: var(--on-primary); border-color: var(--primary); @@ -112,7 +112,7 @@ button.primary[aria-disabled="true"] { border-color: var(--line); } #approve:hover, -#merge:not(:disabled):hover { +#merge:not(:disabled):not([aria-disabled="true"]):hover { background: var(--hover); } [hidden] { From 1234091854999a596e04d3f4528ee52f33aa167f Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 01:08:45 -0700 Subject: [PATCH 036/163] Update only the merge control when a merge job ends Reloading the whole review from the background poll erased the person's selection and input. The page now shows the outcome on the merge control and banner and asks for Refresh, as the merge-queue poll does. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- test/browser/review.spec.ts | 14 ++++++++++++-- web/public/app.js | 12 ++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 6aae90b2..f06cdb47 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -75,7 +75,7 @@ Schema v19 adds `merge_jobs`: one row per job or refused click (`action_id`, tas - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - Merge PR is offered while the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, lists waiting checks in the blocker dialog, and offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. ## Tests diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 22184ae2..a8d68da1 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -689,7 +689,17 @@ test('follows a one-click merge job from preparation through the required-check await expect(page.getByRole('button',{name:'Merging…',exact:true})).toHaveAttribute('aria-disabled','true'); expect(mergeClicks).toBe(1); const reloadsBeforeEnd=reviews; - await expect(page.locator('#banner')).toHaveText('Merged.'); - expect(reviews).toBeGreaterThan(reloadsBeforeEnd); + // The job's end updates only the merge control (AGENTS.md, Async review UI): no background reload erases the + // person's selection or input. + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + expect(reviews).toBe(reloadsBeforeEnd); + await expect(button).toHaveAttribute('aria-disabled','true');await expect(button).toHaveJSProperty('disabled',false); expect(polls).toBe(3); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog')).toContainText('Merged. Refresh to see the result.'); + await page.keyboard.press('Escape'); + // Refresh loads the outcome. + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + expect(reviews).toBeGreaterThan(reloadsBeforeEnd); }); diff --git a/web/public/app.js b/web/public/app.js index d629930e..9dba3d5f 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -218,7 +218,8 @@ async function pollMergeQueue(generation) { const update = await api("/api/merge"); if (generation !== mergeGeneration || !data?.merge?.available) return; if (data.merge.job?.state === "running") { - // A merge job (F6b): follow its step; once it ends, reload the review, which shows the outcome. + // A merge job (F6b): follow its step. Once it ends, update only the merge control, as the merge-queue poll does: + // a background reload would erase the person's selection and input; Refresh shows the outcome. const job = update.job; if (job?.state === "running") { const changed = job.step !== data.merge.job.step || JSON.stringify(job.checks) !== JSON.stringify(data.merge.job.checks); @@ -228,8 +229,11 @@ async function pollMergeQueue(generation) { renderMerge(); return; } - await refresh(); - if (job) $("banner").textContent = mergeJobText(job); + const ended = `${job ? mergeJobText(job) : "The merge job ended."} Refresh to see the result.`; + const blockers = data.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); + data = { ...data, merge: { ...data.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message: ended }, ...blockers] } }; + $("banner").textContent = ended; + renderMerge(); return; } const queue = update.queue; @@ -257,7 +261,7 @@ function mergeJobText(job) { failed: `Merge stopped. ${job.reason ?? ""} Merge again when ready.`.trim(), cancelled: "Merge cancelled.", }[job.state] ?? job.state; } -const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge"]); +const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge", "job-refresh"]); function withMergeQueue(view, queue) { const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); if (queue?.state === "merged") From ab74aecce25b9fb5a24798c105ca358f069e8812 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 01:28:24 -0700 Subject: [PATCH 037/163] Scan for an earlier fix from the task's first clear check, whichever opening merges A new opening after a pre-merge rebase (the person closed the PR, then published again) scanned from its own check, which started at the rebased base, so base commits the rebase brought in were never scanned. Every runner-published PR now scans from the tip the task's first clear pre-PR check reached. The per-opening check_id column is no longer needed and is dropped from schema v19. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 4 +-- docs/implementation/guarded-merge.md | 4 +-- docs/implementation/merge-handoff.md | 2 +- runner/merge.ts | 16 +++++------ runner/store.ts | 20 ++++--------- test/merge-task-pr.test.ts | 42 +++++++++++++--------------- 6 files changed, 37 insertions(+), 51 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 08128b5d..531eeb0a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Writing standard: plain language, ISO 24495-1:2023 - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from where the PR's own pre-PR check stopped (F6b, `docs/implementation/merge-handoff.md`). + - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from where the task's first clear pre-PR check stopped (F6b, `docs/implementation/merge-handoff.md`). - **Not built:** screens for the runner actions, the merge job's full step list and Cancel button, review rounds, and the Queue and Learning screens. ## Terms used @@ -410,7 +410,7 @@ These are the decisions that shape the structure. The full list, with the reason | C, K | Guarded merge, merge queue | Done | | D | Agent isolation boundary | Done. Since the isolation gate: bounded diff export (#76), task-change inspection and the runner's commit as a bounded `git bundle` (#66, PRs #85 and #90), owner-scoped recovery for Ask (#95), the plan schema for Claude (#97), gitlink mounts and the pre-launch tree check (#81, #99, PR #100), and Codex refused in every phase (#93, PR #106). The runner-contract follow-ups (#51) are closed: the last one tests export and removal through a recovery handle after a real restart (PR #109). | | E | Planning library and suggestions | Done. E4 (PR #45) added the acceptance fixtures and recorded Claude output that replays through validation and the Store. | -| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from where the PR's own pre-PR check stopped; F6c owns common CI. | +| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from where the task's first clear pre-PR check stopped; F6c owns common CI. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | | G | Planning screen | Done (#145). The screen displays/imports the current plan, requests and renders a next-revision draft or suggestion cards, and applies results with revision-bound exact ambiguous replay. G4 composes the browser with the production planning setup, D-backed provider boundary, F endpoints and SQLite persistence; focused adapter, recorded-output and exact-head real-Docker planning suites cover the lower boundary. | diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index 04bdfaf3..08b1ec29 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip the PR's own pre-PR already-fixed check scanned through (recorded with the opening), so base commits that a pre-merge rebase brought under the PR are covered too (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip the task's first clear pre-PR already-fixed check scanned through, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. @@ -35,7 +35,7 @@ Shutdown sets a terminal admission flag before inspecting active work and checks ## Deferred runner work -This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts where the PR's own pre-PR check stopped. +This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts where the task's first clear pre-PR check stopped. ## Validation diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index f06cdb47..6a8bf89b 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -53,7 +53,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base-branch tip the pull request's own pre-PR check scanned through (that check is recorded with the opening, schema v19) to the base branch's tip now, even after a re-publish recorded a newer check from a rebased base. An opening recorded before schema v19 uses the task's oldest clear check: a compare range excludes its own start commit, so only a scan from the first clear check's tip onward is provably continuous. The check reads at most 250 base commits: if more landed since the pre-PR check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. So does the status shown while a merge attempt is submitting, queued or merged: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base-branch tip the task's first clear pre-PR check scanned through to the base branch's tip now. That check scanned from the task's base to its tip and found nothing, and every base commit a later rebase can bring in landed after that tip, so the scan covers them whichever opening the merge follows: after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base. A newer check is not a safe start: it scanned only from the rebased base, and a compare range excludes its own start commit. The check reads at most 250 base commits: if more landed since the first clear check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. So does the status shown while a merge attempt is submitting, queued or merged: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. ## Cancel, shutdown and restart diff --git a/runner/merge.ts b/runner/merge.ts index 3e6ec93e..0272d6d0 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -112,23 +112,23 @@ export class MergeCoordinator { if (!latest || latest.number === null) throw new MergeTargetUnavailable(`The task has no published pull request into ${published.baseBranch}.`); if (published.configured !== undefined && published.configured !== latest.number) throw new MergeTargetUnavailable(`github.pullRequest is #${published.configured}, but the task's pull request is #${latest.number}. Remove github.pullRequest from the review configuration.`); - return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch, ...this.#alreadyFixedRange(latest.checkId) }, + return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch, ...this.#alreadyFixedRange() }, openingId: latest.openingId, marker: openingMarker(latest.openingId), headBranch: latest.headBranch }; } /** - * The already-fixed scan of a runner-published PR (F6b of #22): from the base-branch tip the PR's own pre-PR check - * scanned through, so base commits any pre-merge rebase brought under the PR are covered, even after a re-publish - * recorded a newer check from a rebased base. The task's own commits are excluded. An opening recorded before schema - * v19 uses the task's oldest clear check, the only start provably continuous with what was scanned before. + * The already-fixed scan of a runner-published PR (F6b of #22): from the base-branch tip the task's first clear + * pre-PR check scanned through. That check scanned the task's base up to that tip and found nothing; every base commit + * a later rebase can bring under the PR landed after it, so the scan covers them whichever opening the merge follows + * (a new opening after a rebase included). The task's own commits are excluded. */ - #alreadyFixedRange(openingCheck: string | null): { alreadyFixedSince?: string; ownCommits: string[] } { + #alreadyFixedRange(): { alreadyFixedSince?: string; ownCommits: string[] } { const { store, config } = this.service, identity = config.identity; - const check = (openingCheck ? store.getAlreadyFixed(identity, openingCheck) : null) ?? store.oldestClearAlreadyFixed(identity); + const check = store.oldestClearAlreadyFixed(identity); const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); if (!check || check.result.outcome !== 'clear') return { ownCommits }; // That check scanned its snapshot's base up to the base branch's tip then (`baseHead`) and found nothing, so the merge - // scans only what landed after that tip: every base commit a rebase can bring in, and no more. + // scans only what landed after that tip. return { alreadyFixedSince: check.result.baseHead, ownCommits }; } diff --git a/runner/store.ts b/runner/store.ts index c819d7aa..a2e108df 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -40,8 +40,6 @@ export interface TaskPullRequest { ownerReviewVersion: number; /** An update of this open PR that started and has not been confirmed; the PR may already show it. */ refresh: { head: string; draft: boolean; stateVersion: number } | null; - /** The clear already-fixed check this opening followed; null for an opening recorded before schema v19. */ - checkId: string | null; } export interface AlreadyFixedCheck { id: string; snapshotId: string; result: AlreadyFixedResult; stateVersion: number; reviewVersion: number; checkedAt: string } export type MergeAttemptState = 'submitting' | 'queued' | 'merged' | 'removed' | 'failed'; @@ -357,9 +355,6 @@ export class Store { this.#db.exec(`CREATE TABLE IF NOT EXISTS merge_jobs ( id TEXT PRIMARY KEY, plan_key TEXT NOT NULL REFERENCES plans(key), data TEXT NOT NULL); CREATE INDEX IF NOT EXISTS merge_jobs_plan ON merge_jobs(plan_key);`); - // The pre-PR already-fixed check each opening followed: the merge's scan starts at that check's base. - if (!this.#db.prepare('PRAGMA table_info(task_pull_requests)').all().some(column => column.name === 'check_id')) - this.#db.exec('ALTER TABLE task_pull_requests ADD COLUMN check_id TEXT'); this.#db.exec('PRAGMA user_version=19'); } }); @@ -2308,7 +2303,6 @@ export class Store { number: row.number as number | null, url: row.url as string | null, createdAt: row.created_at as string, ownerVersion: row.owner_version as number, ownerReviewVersion: row.owner_review_version as number, refresh: row.refresh_head === null ? null : { head: row.refresh_head as string, draft: row.refresh_draft === 1, stateVersion: row.refresh_version as number }, - checkId: (row.check_id as string | null | undefined) ?? null, }; } /** Every PR codeboost opened or started to open for the task, oldest first. */ @@ -2317,17 +2311,13 @@ export class Store { return this.#db.prepare('SELECT * FROM task_pull_requests WHERE plan_key=? ORDER BY rowid').all(key).map(row => this.#pullRequestRecord(row)); } /** - * The first already-fixed check that found nothing. A compare range excludes its start commit, so only a scan from - * this check's tip onward is provably continuous for an opening that did not record its own check. + * The task's first already-fixed check that found nothing. It scanned the task's base up to the base branch's tip then; + * every base commit a later rebase can bring in landed after that tip, so a scan from there onward covers them. */ oldestClearAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { const row = this.#get(`SELECT * FROM already_fixed_checks WHERE plan_key=? AND outcome='clear' ORDER BY rowid ASC LIMIT 1`, identityKey(identity)); return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; } - getAlreadyFixed(identity: PlanIdentity, id: string): AlreadyFixedCheck | null { - const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? AND id=?', identityKey(identity), id); - return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; - } latestAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { const key = identityKey(identity); this.#task(key); const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? ORDER BY rowid DESC LIMIT 1', key); @@ -2393,9 +2383,9 @@ export class Store { if (this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND state='opening'", key)) throw new GuardRefusal('A pull request is already being opened; recover it first.'); const openingId = randomUUID(), now = new Date().toISOString(); this.#touch(key); - this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at,check_id) - VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, - this.#task(key).state_version as number, this.#current(key).review_version as number, now, now, input.checkId); + this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, + this.#task(key).state_version as number, this.#current(key).review_version as number, now, now); return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; }); } diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index e856c568..f020da39 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; -import { DatabaseSync } from 'node:sqlite'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; import type { ReviewService } from '../runner/review.ts'; @@ -31,8 +30,8 @@ const stores: Store[] = [], roots: string[] = []; afterEach(() => { for (const store of stores.splice(0)) store.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); /** A running task with head `oid(2)` over `oid(1)`, its last attempt settled. */ -function runningTask(path = ':memory:'): Store { - const store = new Store(path); stores.push(store); +function runningTask(): Store { + const store = new Store(':memory:'); stores.push(store); store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]); store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); @@ -48,8 +47,8 @@ const opened = (store: Store, openingId: string, number: number, mayReview = tru const toReview = (store: Store) => store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); /** The runner published PR 7 for the task, which is now in review. */ -function published(path?: string) { - const store = runningTask(path); +function published() { + const store = runningTask(); const opening = begin(store); expect(opened(store, opening.openingId, 7)).toBe('in review'); return { store, opening }; @@ -333,7 +332,7 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.merged).toEqual([]); }); - it('scans for an earlier fix from the pre-PR check\'s base after a rebase, skipping the task\'s own commits', async () => { + it('scans for an earlier fix from the first clear check\'s base after a rebase, skipping the task\'s own commits', async () => { const { store } = published(), gh = github(store); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); // A pre-merge rebase moved the reviewed base from oid(1) to oid(10) and rewrote the owned head oid(2) as oid(12). @@ -344,7 +343,7 @@ describe('the merge gate with a runner block (#121)', () => { expect([...gh.targets.at(-1)!.ownCommits!].sort()).toEqual([oid(12), oid(2)].sort()); }); - it('keeps scanning from the PR\'s own pre-PR check after a re-publish records a newer check from a rebased base', async () => { + it('keeps scanning from the task\'s first clear check after a re-publish records a newer check from a rebased base', async () => { const { store } = published(), gh = github(store); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); @@ -362,30 +361,27 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); }); - it('scans from the oldest clear check for a PR opened before schema v19, whatever later checks found', async () => { - const root = mkdtempSync(join(tmpdir(), 'codeboost-v19-')); roots.push(root); - const path = join(root, 'state.sqlite'); - const { store } = published(path); + it('scans from the task\'s first clear check for a new pull request opened after a rebase', async () => { + const { store } = published(); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // A pre-merge rebase brought base commits after oid(9) under the task. The person then closed PR 7 and published + // again: the new opening's own pre-PR check scanned only from the rebased base (to oid(19)), and a later check could + // not complete. Base commits between oid(9) and the rebased base were scanned by no check. store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); - // A later re-publish recorded a clear check scanned from the rebased base, then one that could not complete. A compare - // range excludes its start, so starting at the later clear check's tip could skip the rebased base commit itself. store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); store.markRunning(identity, attempt.id); store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); - store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(19) } }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(19) } }); + const later = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'main', headBranch: BRANCH, headSha: store.getSnapshot(identity).head, draft: false }); + store.recordPullRequestOpened(identity, later.openingId, { number: 8, url: url(8), headSha: store.getSnapshot(identity).head, draft: false }, false); store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'unknown', reason: 'GitHub could not be read.' } }); - store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); - // The opening was recorded before v19: it carries no check. - store.close(); stores.splice(stores.indexOf(store), 1); - const raw = new DatabaseSync(path); raw.exec('UPDATE task_pull_requests SET check_id=NULL'); raw.close(); - const reopened = new Store(path); stores.push(reopened); - expect(reopened.taskPullRequests(identity)[0]!.checkId).toBeNull(); - const gh = github(reopened); - const merges = new MergeCoordinator(service(reopened), gh.client, undefined, undefined, PUBLISHED); + toReview(store); + const gh = github(store); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); await merges.status(undefined, true); - expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(9) }); }); it('refuses pre-merge preparation for a pull request that is no longer open', async () => { From be4f391c0fa94d068628f02e31d7763925cdd22f Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 01:48:49 -0700 Subject: [PATCH 038/163] Scan from the first opening into the current base branch Starting every scan at the task's first clear check broke tasks whose github.baseBranch changed: that check scanned another branch, so the compare diverged and merging stayed blocked for good. Each opening again records its pre-PR check (schema v19), and the scan starts at the check of the task's first opening into the current base branch; a pre-v19 first opening falls back to the oldest clear check. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 4 +- docs/implementation/guarded-merge.md | 4 +- docs/implementation/merge-handoff.md | 4 +- runner/merge.ts | 22 +++++----- runner/store.ts | 21 +++++++--- test/merge-task-pr.test.ts | 60 ++++++++++++++++++++++++---- 6 files changed, 87 insertions(+), 28 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 531eeb0a..d53e5060 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Writing standard: plain language, ISO 24495-1:2023 - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from where the task's first clear pre-PR check stopped (F6b, `docs/implementation/merge-handoff.md`). + - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from where the pre-PR check of the task's first opening into the base branch stopped (F6b, `docs/implementation/merge-handoff.md`). - **Not built:** screens for the runner actions, the merge job's full step list and Cancel button, review rounds, and the Queue and Learning screens. ## Terms used @@ -410,7 +410,7 @@ These are the decisions that shape the structure. The full list, with the reason | C, K | Guarded merge, merge queue | Done | | D | Agent isolation boundary | Done. Since the isolation gate: bounded diff export (#76), task-change inspection and the runner's commit as a bounded `git bundle` (#66, PRs #85 and #90), owner-scoped recovery for Ask (#95), the plan schema for Claude (#97), gitlink mounts and the pre-launch tree check (#81, #99, PR #100), and Codex refused in every phase (#93, PR #106). The runner-contract follow-ups (#51) are closed: the last one tests export and removal through a recovery handle after a real restart (PR #109). | | E | Planning library and suggestions | Done. E4 (PR #45) added the acceptance fixtures and recorded Claude output that replays through validation and the Store. | -| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from where the task's first clear pre-PR check stopped; F6c owns common CI. | +| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from where the pre-PR check of the task's first opening into the base branch stopped; F6c owns common CI. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | | G | Planning screen | Done (#145). The screen displays/imports the current plan, requests and renders a next-revision draft or suggestion cards, and applies results with revision-bound exact ambiguous replay. G4 composes the browser with the production planning setup, D-backed provider boundary, F endpoints and SQLite persistence; focused adapter, recorded-output and exact-head real-Docker planning suites cover the lower boundary. | diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index 08b1ec29..2994ea90 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip the task's first clear pre-PR already-fixed check scanned through, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check), so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings into that branch the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. @@ -35,7 +35,7 @@ Shutdown sets a terminal admission flag before inspecting active work and checks ## Deferred runner work -This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts where the task's first clear pre-PR check stopped. +This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts where the pre-PR check of the task's first opening into the base branch stopped. ## Validation diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 6a8bf89b..50e3d8ec 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -53,7 +53,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each merge inspection of a runner-published pull request therefore scans from the base-branch tip the task's first clear pre-PR check scanned through to the base branch's tip now. That check scanned from the task's base to its tip and found nothing, and every base commit a later rebase can bring in landed after that tip, so the scan covers them whichever opening the merge follows: after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base. A newer check is not a safe start: it scanned only from the rebased base, and a compare range excludes its own start commit. The check reads at most 250 base commits: if more landed since the first clear check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. So does the status shown while a merge attempt is submitting, queued or merged: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each opening records the pre-PR check it followed (schema v19). Each merge inspection of a runner-published pull request scans from the base-branch tip that the check of the task's **first opening into the current base branch** scanned through, to the base branch's tip now. That check scanned the branch from the task's base to its tip and found nothing, and every base commit a later rebase onto the branch can bring in landed after that tip. So the scan covers them whichever of the task's openings into that branch the merge follows: after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base. A newer check is not a safe start: it scanned only from the rebased base, and a compare range excludes its own start commit. An opening into another base branch, left by a change of the `github.baseBranch` setting, says nothing about this branch and is not used. A first opening recorded before schema v19 has no check; the task's oldest clear check is used instead; if that check scanned another branch whose tip then is not on this one, the answer is unknown and merging stays blocked. If the base branch was force-pushed so that the start is no longer on it, the answer is unknown and merging stays blocked. The check reads at most 250 base commits: if more landed since the first opening's check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. So does the status shown while a merge attempt is submitting, queued or merged: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. ## Cancel, shutdown and restart @@ -67,7 +67,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t ## Storage -Schema v19 adds `merge_jobs`: one row per job or refused click (`action_id`, task key, `state`, `step`, `reason`, required-check names and states, the merge result URL, times). A refused click's row is marked `refused`: it is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. +Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click (`action_id`, task key, `state`, `step`, `reason`, required-check names and states, the merge result URL, times). A refused click's row is marked `refused`: it is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. ## API and browser diff --git a/runner/merge.ts b/runner/merge.ts index 0272d6d0..31ddbb79 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -1,5 +1,5 @@ import type { ReviewService } from './review.ts'; -import { mergeActionResponse, type MergeAttempt } from './store.ts'; +import { mergeActionResponse, type MergeAttempt, type TaskPullRequest } from './store.ts'; import { ActionIdReused, GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, assertUuidV4, settleWith, type ShutdownCapability } from './lifecycle.ts'; import { MERGE_INSPECTION_TIMEOUT_MS, MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type MergeQueueObservation, type MergeResult, type MergeTarget, type RemoteMergeState } from '../github/merge.ts'; import { openingMarker } from '../github/pull-requests.ts'; @@ -112,23 +112,25 @@ export class MergeCoordinator { if (!latest || latest.number === null) throw new MergeTargetUnavailable(`The task has no published pull request into ${published.baseBranch}.`); if (published.configured !== undefined && published.configured !== latest.number) throw new MergeTargetUnavailable(`github.pullRequest is #${published.configured}, but the task's pull request is #${latest.number}. Remove github.pullRequest from the review configuration.`); - return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch, ...this.#alreadyFixedRange() }, + return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch, ...this.#alreadyFixedRange(here, published.baseBranch) }, openingId: latest.openingId, marker: openingMarker(latest.openingId), headBranch: latest.headBranch }; } /** - * The already-fixed scan of a runner-published PR (F6b of #22): from the base-branch tip the task's first clear - * pre-PR check scanned through. That check scanned the task's base up to that tip and found nothing; every base commit - * a later rebase can bring under the PR landed after it, so the scan covers them whichever opening the merge follows - * (a new opening after a rebase included). The task's own commits are excluded. + * The already-fixed scan of a runner-published PR (F6b of #22): from the base-branch tip the pre-PR check of the + * task's first opening into the current base branch scanned through. That check scanned the branch from the task's + * base to that tip and found nothing; every base commit a later rebase onto the branch can bring under the PR landed + * after it, so the scan covers them whichever of the task's openings into the branch the merge follows (a re-publish, + * or a new opening after the person closed the PR). An earlier opening into another base branch says nothing about + * this one. A first opening recorded before schema v19 has no check: the task's oldest clear check is used. The task's + * own commits are excluded. */ - #alreadyFixedRange(): { alreadyFixedSince?: string; ownCommits: string[] } { + #alreadyFixedRange(here: TaskPullRequest[], base: string): { alreadyFixedSince?: string; ownCommits: string[] } { const { store, config } = this.service, identity = config.identity; - const check = store.oldestClearAlreadyFixed(identity); + const first = here.find(pr => pr.base === base); + const check = first?.checkId ? store.getAlreadyFixed(identity, first.checkId) : store.oldestClearAlreadyFixed(identity); const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); if (!check || check.result.outcome !== 'clear') return { ownCommits }; - // That check scanned its snapshot's base up to the base branch's tip then (`baseHead`) and found nothing, so the merge - // scans only what landed after that tip. return { alreadyFixedSince: check.result.baseHead, ownCommits }; } diff --git a/runner/store.ts b/runner/store.ts index a2e108df..5231338e 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -40,6 +40,8 @@ export interface TaskPullRequest { ownerReviewVersion: number; /** An update of this open PR that started and has not been confirmed; the PR may already show it. */ refresh: { head: string; draft: boolean; stateVersion: number } | null; + /** The clear already-fixed check this opening followed; null for an opening recorded before schema v19. */ + checkId: string | null; } export interface AlreadyFixedCheck { id: string; snapshotId: string; result: AlreadyFixedResult; stateVersion: number; reviewVersion: number; checkedAt: string } export type MergeAttemptState = 'submitting' | 'queued' | 'merged' | 'removed' | 'failed'; @@ -355,6 +357,9 @@ export class Store { this.#db.exec(`CREATE TABLE IF NOT EXISTS merge_jobs ( id TEXT PRIMARY KEY, plan_key TEXT NOT NULL REFERENCES plans(key), data TEXT NOT NULL); CREATE INDEX IF NOT EXISTS merge_jobs_plan ON merge_jobs(plan_key);`); + // The pre-PR already-fixed check each opening followed: the merge's scan starts at that check's base. + if (!this.#db.prepare('PRAGMA table_info(task_pull_requests)').all().some(column => column.name === 'check_id')) + this.#db.exec('ALTER TABLE task_pull_requests ADD COLUMN check_id TEXT'); this.#db.exec('PRAGMA user_version=19'); } }); @@ -2303,6 +2308,7 @@ export class Store { number: row.number as number | null, url: row.url as string | null, createdAt: row.created_at as string, ownerVersion: row.owner_version as number, ownerReviewVersion: row.owner_review_version as number, refresh: row.refresh_head === null ? null : { head: row.refresh_head as string, draft: row.refresh_draft === 1, stateVersion: row.refresh_version as number }, + checkId: (row.check_id as string | null | undefined) ?? null, }; } /** Every PR codeboost opened or started to open for the task, oldest first. */ @@ -2311,13 +2317,18 @@ export class Store { return this.#db.prepare('SELECT * FROM task_pull_requests WHERE plan_key=? ORDER BY rowid').all(key).map(row => this.#pullRequestRecord(row)); } /** - * The task's first already-fixed check that found nothing. It scanned the task's base up to the base branch's tip then; - * every base commit a later rebase can bring in landed after that tip, so a scan from there onward covers them. + * The task's first already-fixed check that found nothing: the scan start for an opening recorded before schema v19, + * which did not record its own check. A compare range excludes its start commit, so only a scan from this check's tip + * onward is provably continuous with what was scanned before. */ oldestClearAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { const row = this.#get(`SELECT * FROM already_fixed_checks WHERE plan_key=? AND outcome='clear' ORDER BY rowid ASC LIMIT 1`, identityKey(identity)); return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; } + getAlreadyFixed(identity: PlanIdentity, id: string): AlreadyFixedCheck | null { + const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? AND id=?', identityKey(identity), id); + return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; + } latestAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { const key = identityKey(identity); this.#task(key); const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? ORDER BY rowid DESC LIMIT 1', key); @@ -2383,9 +2394,9 @@ export class Store { if (this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND state='opening'", key)) throw new GuardRefusal('A pull request is already being opened; recover it first.'); const openingId = randomUUID(), now = new Date().toISOString(); this.#touch(key); - this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at) - VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, - this.#task(key).state_version as number, this.#current(key).review_version as number, now, now); + this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at,check_id) + VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, + this.#task(key).state_version as number, this.#current(key).review_version as number, now, now, input.checkId); return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; }); } diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index f020da39..c203fdc7 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; +import { DatabaseSync } from 'node:sqlite'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; import type { ReviewService } from '../runner/review.ts'; @@ -30,8 +31,8 @@ const stores: Store[] = [], roots: string[] = []; afterEach(() => { for (const store of stores.splice(0)) store.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); /** A running task with head `oid(2)` over `oid(1)`, its last attempt settled. */ -function runningTask(): Store { - const store = new Store(':memory:'); stores.push(store); +function runningTask(path = ':memory:'): Store { + const store = new Store(path); stores.push(store); store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]); store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); @@ -47,8 +48,8 @@ const opened = (store: Store, openingId: string, number: number, mayReview = tru const toReview = (store: Store) => store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); /** The runner published PR 7 for the task, which is now in review. */ -function published() { - const store = runningTask(); +function published(path?: string) { + const store = runningTask(path); const opening = begin(store); expect(opened(store, opening.openingId, 7)).toBe('in review'); return { store, opening }; @@ -332,7 +333,7 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.merged).toEqual([]); }); - it('scans for an earlier fix from the first clear check\'s base after a rebase, skipping the task\'s own commits', async () => { + it('scans for an earlier fix from the pre-PR check\'s base after a rebase, skipping the task\'s own commits', async () => { const { store } = published(), gh = github(store); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); // A pre-merge rebase moved the reviewed base from oid(1) to oid(10) and rewrote the owned head oid(2) as oid(12). @@ -343,7 +344,7 @@ describe('the merge gate with a runner block (#121)', () => { expect([...gh.targets.at(-1)!.ownCommits!].sort()).toEqual([oid(12), oid(2)].sort()); }); - it('keeps scanning from the task\'s first clear check after a re-publish records a newer check from a rebased base', async () => { + it('keeps scanning from the PR\'s own pre-PR check after a re-publish records a newer check from a rebased base', async () => { const { store } = published(), gh = github(store); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); @@ -361,7 +362,7 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); }); - it('scans from the task\'s first clear check for a new pull request opened after a rebase', async () => { + it('scans from the first opening\'s check for a new pull request opened after a rebase', async () => { const { store } = published(); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); // A pre-merge rebase brought base commits after oid(9) under the task. The person then closed PR 7 and published @@ -384,6 +385,51 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(9) }); }); + it('scans from the oldest clear check for a PR opened before schema v19, whatever later checks found', async () => { + const root = mkdtempSync(join(tmpdir(), 'codeboost-v19-')); roots.push(root); + const path = join(root, 'state.sqlite'); + const { store } = published(path); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + // A later re-publish recorded a clear check scanned from the rebased base, then one that could not complete. A compare + // range excludes its start, so starting at the later clear check's tip could skip the rebased base commit itself. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(19) } }); + store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'unknown', reason: 'GitHub could not be read.' } }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); + // The opening was recorded before v19: it carries no check. + store.close(); stores.splice(stores.indexOf(store), 1); + const raw = new DatabaseSync(path); raw.exec('UPDATE task_pull_requests SET check_id=NULL'); raw.close(); + const reopened = new Store(path); stores.push(reopened); + expect(reopened.taskPullRequests(identity)[0]!.checkId).toBeNull(); + const gh = github(reopened); + const merges = new MergeCoordinator(service(reopened), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); + }); + + it('scans from the first opening into the current base branch after the base branch setting changed', async () => { + const { store } = published(); + // PR 7 went into main (its check scanned main to oid(9)). github.baseBranch then became release: the person closed + // PR 7 and published again, into release, after a check that scanned release to oid(29). main's tip says nothing + // about release. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(29) } }); + const release = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'release', headBranch: BRANCH, headSha: oid(2), draft: false }); + opened(store, release.openingId, 8, false); + toReview(store); + const gh = github(store); + await new MergeCoordinator(service(store), gh.client, undefined, undefined, { ...PUBLISHED, baseBranch: 'release' }).status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(29) }); + }); + it('refuses pre-merge preparation for a pull request that is no longer open', async () => { const { store } = published(); const gh = github(store, () => ({ pullRequestState: 'CLOSED' })); From f6eb190a32d605bde886d92bcc792da8691ad2da Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 02:07:13 -0700 Subject: [PATCH 039/163] Scan from the task's original base when the first opening's check did not The first opening into the current base branch may follow a rebase (the base branch setting changed after one), so its check scanned only from the rebased base. Its tip is now used only when that check scanned from the task's original base (its first snapshot's); otherwise the scan starts at the original base. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 4 ++-- docs/implementation/guarded-merge.md | 4 ++-- docs/implementation/merge-handoff.md | 2 +- runner/merge.ts | 24 ++++++++++++++---------- runner/store.ts | 6 ++++++ test/merge-task-pr.test.ts | 21 +++++++++++++++++++++ 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d53e5060..2eba6376 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Writing standard: plain language, ISO 24495-1:2023 - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now scans from where the pre-PR check of the task's first opening into the base branch stopped (F6b, `docs/implementation/merge-handoff.md`). + - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now covers every base commit after the task's original base (F6b, `docs/implementation/merge-handoff.md`). - **Not built:** screens for the runner actions, the merge job's full step list and Cancel button, review rounds, and the Queue and Learning screens. ## Terms used @@ -410,7 +410,7 @@ These are the decisions that shape the structure. The full list, with the reason | C, K | Guarded merge, merge queue | Done | | D | Agent isolation boundary | Done. Since the isolation gate: bounded diff export (#76), task-change inspection and the runner's commit as a bounded `git bundle` (#66, PRs #85 and #90), owner-scoped recovery for Ask (#95), the plan schema for Claude (#97), gitlink mounts and the pre-launch tree check (#81, #99, PR #100), and Codex refused in every phase (#93, PR #106). The runner-contract follow-ups (#51) are closed: the last one tests export and removal through a recovery handle after a real restart (PR #109). | | E | Planning library and suggestions | Done. E4 (PR #45) added the acceptance fixtures and recorded Claude output that replays through validation and the Store. | -| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and the already-fixed scan from where the pre-PR check of the task's first opening into the base branch stopped; F6c owns common CI. | +| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and an already-fixed scan covering every base commit after the task's original base; F6c owns common CI. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | | G | Planning screen | Done (#145). The screen displays/imports the current plan, requests and renders a next-revision draft or suggestion cards, and applies results with revision-bound exact ambiguous replay. G4 composes the browser with the production planning setup, D-backed provider boundary, F endpoints and SQLite persistence; focused adapter, recorded-output and exact-head real-Docker planning suites cover the lower boundary. | diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index 2994ea90..905f1b53 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check), so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings into that branch the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check) when that check scanned from the task's original base, and otherwise at the task's original base, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. @@ -35,7 +35,7 @@ Shutdown sets a terminal admission flag before inspecting active work and checks ## Deferred runner work -This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still starts where the pre-PR check of the task's first opening into the base branch stopped. +This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still covers every base commit after the task's original base. ## Validation diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 50e3d8ec..239db681 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -53,7 +53,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. Each opening records the pre-PR check it followed (schema v19). Each merge inspection of a runner-published pull request scans from the base-branch tip that the check of the task's **first opening into the current base branch** scanned through, to the base branch's tip now. That check scanned the branch from the task's base to its tip and found nothing, and every base commit a later rebase onto the branch can bring in landed after that tip. So the scan covers them whichever of the task's openings into that branch the merge follows: after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base. A newer check is not a safe start: it scanned only from the rebased base, and a compare range excludes its own start commit. An opening into another base branch, left by a change of the `github.baseBranch` setting, says nothing about this branch and is not used. A first opening recorded before schema v19 has no check; the task's oldest clear check is used instead; if that check scanned another branch whose tip then is not on this one, the answer is unknown and merging stays blocked. If the base branch was force-pushed so that the start is no longer on it, the answer is unknown and merging stays blocked. The check reads at most 250 base commits: if more landed since the first opening's check, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. Without such a check, the scan starts at the pull request's base, as before. So does the status shown while a merge attempt is submitting, queued or merged: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. The scan must cover every base commit after the task's original base (the base of its first snapshot), since a rebase can bring any of them under the pull request. Each opening records the pre-PR check it followed (schema v19). That check of the task's **first opening into the current base branch** scanned the branch from its snapshot's base to the branch's tip then, and found nothing. When it scanned from the task's original base, each merge inspection scans from that tip to the branch's tip now: every base commit a later rebase onto the branch can bring in landed after that tip, so the scan covers them whichever of the task's openings into that branch the merge follows (after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base). A newer check is not a safe start: it scanned only from a rebased base, and a compare range excludes its own start commit. An opening into another base branch, left by a change of the `github.baseBranch` setting, says nothing about this branch and is not used. A first opening recorded before schema v19 has no check; the task's oldest clear check stands in. When there is no such check, or it scanned from a rebased base (the base branch setting changed after a rebase), the scan starts at the task's original base. If that start is not on the base branch (for example, it was force-pushed), the answer is unknown and merging stays blocked. The check reads at most 250 base commits: if more landed since the scan's start, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. The status shown while a merge attempt is submitting, queued or merged scans from the pull request's base, as before: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. ## Cancel, shutdown and restart diff --git a/runner/merge.ts b/runner/merge.ts index 31ddbb79..13e0a563 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -117,21 +117,25 @@ export class MergeCoordinator { } /** - * The already-fixed scan of a runner-published PR (F6b of #22): from the base-branch tip the pre-PR check of the - * task's first opening into the current base branch scanned through. That check scanned the branch from the task's - * base to that tip and found nothing; every base commit a later rebase onto the branch can bring under the PR landed - * after it, so the scan covers them whichever of the task's openings into the branch the merge follows (a re-publish, - * or a new opening after the person closed the PR). An earlier opening into another base branch says nothing about - * this one. A first opening recorded before schema v19 has no check: the task's oldest clear check is used. The task's - * own commits are excluded. + * The already-fixed scan of a runner-published PR (F6b of #22). It must cover every base commit after the task's + * original base (its first snapshot's), since a rebase can bring any of them under the PR. The pre-PR check of the + * task's first opening into the current base branch scanned that branch from its snapshot's base to its tip and found + * nothing; when that base is the task's original base, the scan starts at that tip, and every later rebase onto the + * branch brought in only commits after it, whichever of the task's openings the merge follows. An opening into another + * base branch says nothing about this one. A first opening recorded before schema v19 has no check: the task's oldest + * clear check stands in. Otherwise (no such check, or one that scanned from a rebased base), the scan starts at the + * task's original base. The task's own commits are excluded. */ - #alreadyFixedRange(here: TaskPullRequest[], base: string): { alreadyFixedSince?: string; ownCommits: string[] } { + #alreadyFixedRange(here: TaskPullRequest[], base: string): { alreadyFixedSince: string; ownCommits: string[] } { const { store, config } = this.service, identity = config.identity; + const original = store.firstSnapshot(identity).base; const first = here.find(pr => pr.base === base); const check = first?.checkId ? store.getAlreadyFixed(identity, first.checkId) : store.oldestClearAlreadyFixed(identity); const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); - if (!check || check.result.outcome !== 'clear') return { ownCommits }; - return { alreadyFixedSince: check.result.baseHead, ownCommits }; + if (check?.result.outcome !== 'clear') return { alreadyFixedSince: original, ownCommits }; + let scannedFrom: string | null = null; + try { scannedFrom = store.getSnapshot(identity, check.snapshotId).base; } catch { /* unknown: scan from the original base */ } + return { alreadyFixedSince: scannedFrom === original ? check.result.baseHead : original, ownCommits }; } /** Whether GitHub shows the task's published PR where it was opened, as the only open PR from its branch. */ diff --git a/runner/store.ts b/runner/store.ts index 5231338e..39a6f416 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -506,6 +506,12 @@ export class Store { this.#run("UPDATE requests SET state='invalidated',reason='Repository snapshot changed.' WHERE key=? AND state IN ('pending','ready')", key); return snapshot; } + /** The task's first snapshot: its base is where the task began, before any rebase. */ + firstSnapshot(identity: PlanIdentity): Snapshot { + const row = this.#get('SELECT data FROM snapshots WHERE key=? ORDER BY rowid ASC LIMIT 1', identityKey(identity)); + if (!row) throw new Error('Unknown snapshot.'); + return decode(row.data); + } getSnapshot(identity: PlanIdentity, id?: string): Snapshot { const key = identityKey(identity); const row = this.#get('SELECT data FROM snapshots WHERE key=? AND id=?', key, id ?? this.#current(key).snapshot_id!); diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index c203fdc7..eb64848e 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -430,6 +430,27 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(29) }); }); + it('scans from the task\'s original base when the first opening into the base branch followed a rebase', async () => { + const { store } = published(); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // PR 7 went into main from the original base oid(1). A pre-merge rebase moved the task onto main's oid(10), then + // github.baseBranch became release (which contains oid(10)); the first opening into release followed a check that + // scanned release only from oid(10). Base commits between oid(1) and oid(10) were scanned on release by no check. + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(29) } }); + const release = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'release', headBranch: BRANCH, headSha: store.getSnapshot(identity).head, draft: false }); + store.recordPullRequestOpened(identity, release.openingId, { number: 8, url: url(8), headSha: store.getSnapshot(identity).head, draft: false }, false); + toReview(store); + const gh = github(store); + await new MergeCoordinator(service(store), gh.client, undefined, undefined, { ...PUBLISHED, baseBranch: 'release' }).status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(1) }); + }); + it('refuses pre-merge preparation for a pull request that is no longer open', async () => { const { store } = published(); const gh = github(store, () => ({ pullRequestState: 'CLOSED' })); From 887f0cd03c12fbc16cdd023e5de1d7c9e1118392 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 02:19:25 -0700 Subject: [PATCH 040/163] Explain correctly why the already-fixed scan covers a rebase's commits Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- runner/merge.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 239db681..b84b7e6f 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -53,7 +53,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. The scan must cover every base commit after the task's original base (the base of its first snapshot), since a rebase can bring any of them under the pull request. Each opening records the pre-PR check it followed (schema v19). That check of the task's **first opening into the current base branch** scanned the branch from its snapshot's base to the branch's tip then, and found nothing. When it scanned from the task's original base, each merge inspection scans from that tip to the branch's tip now: every base commit a later rebase onto the branch can bring in landed after that tip, so the scan covers them whichever of the task's openings into that branch the merge follows (after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base). A newer check is not a safe start: it scanned only from a rebased base, and a compare range excludes its own start commit. An opening into another base branch, left by a change of the `github.baseBranch` setting, says nothing about this branch and is not used. A first opening recorded before schema v19 has no check; the task's oldest clear check stands in. When there is no such check, or it scanned from a rebased base (the base branch setting changed after a rebase), the scan starts at the task's original base. If that start is not on the base branch (for example, it was force-pushed), the answer is unknown and merging stays blocked. The check reads at most 250 base commits: if more landed since the scan's start, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. The status shown while a merge attempt is submitting, queued or merged scans from the pull request's base, as before: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. The scan must cover every base commit after the task's original base (the base of its first snapshot), since a rebase can bring any of them under the pull request. Each opening records the pre-PR check it followed (schema v19). That check of the task's **first opening into the current base branch** scanned the branch from its snapshot's base to the branch's tip then, and found nothing. When it scanned from the task's original base, each merge inspection scans from that tip to the branch's tip now. A later rebase onto the branch can bring in any commit after the original base: those up to that tip were scanned by that check, and later ones are in the merge's scan, so the scan covers them whichever of the task's openings into that branch the merge follows (after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base). A newer check is not a safe start: it scanned only from a rebased base, and a compare range excludes its own start commit. An opening into another base branch, left by a change of the `github.baseBranch` setting, says nothing about this branch and is not used. A first opening recorded before schema v19 has no check; the task's oldest clear check stands in. When there is no such check, or it scanned from a rebased base (the base branch setting changed after a rebase), the scan starts at the task's original base. If that start is not on the base branch (for example, it was force-pushed), the answer is unknown and merging stays blocked. The check reads at most 250 base commits: if more landed since the scan's start, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. The status shown while a merge attempt is submitting, queued or merged scans from the pull request's base, as before: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. ## Cancel, shutdown and restart diff --git a/runner/merge.ts b/runner/merge.ts index 13e0a563..55d44e52 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -120,8 +120,9 @@ export class MergeCoordinator { * The already-fixed scan of a runner-published PR (F6b of #22). It must cover every base commit after the task's * original base (its first snapshot's), since a rebase can bring any of them under the PR. The pre-PR check of the * task's first opening into the current base branch scanned that branch from its snapshot's base to its tip and found - * nothing; when that base is the task's original base, the scan starts at that tip, and every later rebase onto the - * branch brought in only commits after it, whichever of the task's openings the merge follows. An opening into another + * nothing; when that base is the task's original base, the scan starts at that tip: commits a later rebase onto the + * branch brings in up to that tip were scanned by that check, and later ones are in this scan, whichever of the + * task's openings the merge follows. An opening into another * base branch says nothing about this one. A first opening recorded before schema v19 has no check: the task's oldest * clear check stands in. Otherwise (no such check, or one that scanned from a rebased base), the scan starts at the * task's original base. The task's own commits are excluded. From 7302d682561870393878fe352ce62dd8a42e9950 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 02:19:30 -0700 Subject: [PATCH 041/163] Reflow the already-fixed range comment Co-Authored-By: Claude Opus 5.5 --- runner/merge.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/runner/merge.ts b/runner/merge.ts index 55d44e52..0aa67ad7 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -122,9 +122,8 @@ export class MergeCoordinator { * task's first opening into the current base branch scanned that branch from its snapshot's base to its tip and found * nothing; when that base is the task's original base, the scan starts at that tip: commits a later rebase onto the * branch brings in up to that tip were scanned by that check, and later ones are in this scan, whichever of the - * task's openings the merge follows. An opening into another - * base branch says nothing about this one. A first opening recorded before schema v19 has no check: the task's oldest - * clear check stands in. Otherwise (no such check, or one that scanned from a rebased base), the scan starts at the + * task's openings the merge follows. An opening into another base branch says nothing about this one. A first + * opening recorded before schema v19 has no check: the task's oldest clear check stands in. Otherwise (no such check, or one that scanned from a rebased base), the scan starts at the * task's original base. The task's own commits are excluded. */ #alreadyFixedRange(here: TaskPullRequest[], base: string): { alreadyFixedSince: string; ownCommits: string[] } { From b3ba376215160918d8e1851c72a892ebe7672eb5 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 02:35:56 -0700 Subject: [PATCH 042/163] Block a task only with its still-running job, and test the shared transaction blockMergeJob now leaves the task alone when the job already ended. A test shows a failure to end the job rolls the task's move back. The github/merge.ts comments now describe the runner-chosen scan start. Co-Authored-By: Claude Opus 5.5 --- github/merge.ts | 10 +++++----- runner/store.ts | 4 ++++ test/merge-job.test.ts | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/github/merge.ts b/github/merge.ts index 99a94ec2..8260bc3d 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -61,8 +61,8 @@ export interface MergeTarget { /** The task branch of a published target (#121). The inspection then also reads `RemoteMergeState.published`. */ headBranch?: string; /** - * Where the already-fixed commit scan starts: the base-branch tip the PR's own pre-PR check scanned through. A rebase moves the - * PR's base past base commits that check never saw (F6b of #22). Default: the PR's base commit. + * Where the already-fixed commit scan starts, so it covers every base commit after the task's original base, which a + * rebase can bring under the PR (F6b of #22; the runner picks it, `merge-handoff.md`). Default: the PR's base commit. */ alreadyFixedSince?: string; /** Commits the task made; the already-fixed scan does not count them. Default: none. */ @@ -202,9 +202,9 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { /** * The pre-merge "already fixed" check runs the pre-PR check (`github/already-fixed.ts`) with the same rules. This PR and - * the task's other recorded PRs are its own: excluded while open, a match once merged. The base-branch scan starts at the - * PR's base commit, the base the merge is validated against; the task has no own commits on the base branch before its - * merge. + * the task's other recorded PRs are its own: excluded while open, a match once merged. The base-branch scan starts at + * `target.alreadyFixedSince` when given (a runner-published PR), else at the PR's base commit; `target.ownCommits`, the + * task's own commits a rebase may have rewritten, are not counted. * The check stops early enough that its `gh` processes, which may need both grace periods after an abort, settle before * the inspection's deadline; stopped that way it is `unknown`. */ diff --git a/runner/store.ts b/runner/store.ts index 39a6f416..651b762c 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2180,6 +2180,10 @@ export class Store { blockMergeJob(identity: PlanIdentity, id: string, reason: string): boolean { const key = identityKey(identity); return this.#transaction(() => { + const job = this.getMergeJob(identity, id); + if (!job) throw new Error('Unknown merge job.'); + // A job that already ended decides nothing about the task now. + if (job.state !== 'running') return false; const task = this.#task(key); if (task.status === 'in review' && !this.#activeAttempt(key) && !this.#activeMerge(key) && task.rebase_in_progress === null && (task.push_in_progress === null || task.push_in_progress === undefined)) { diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 41bf5c63..d80a7aa8 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -384,6 +384,22 @@ it('blocks the job and the task in one write, leaving a task that moved meanwhil expect(store.getTask(identity).status).toBe('cancelled'); }); +it('rolls the task back when ending a blocked job fails, and leaves the task alone for a job that already ended', () => { + const store = publishedTask(), id = randomUUID(); + store.beginMergeJob(identity, { actionId: id, token: 'review-token' }); + const finish = store.finishMergeJob.bind(store); + store.finishMergeJob = () => { throw new Error('disk full'); }; + expect(() => store.blockMergeJob(identity, id, 'Required check failed: ci.')).toThrow('disk full'); + // One transaction: the task did not move without its job. + expect(store.getTask(identity).status).toBe('in review'); + store.finishMergeJob = finish; + expect(store.finishMergeJob(identity, id, { state: 'failed', reason: 'The pull request moved.' })).toBe(true); + const before = store.getTask(identity).stateVersion; + expect(store.blockMergeJob(identity, id, 'late')).toBe(false); + expect(store.getTask(identity)).toMatchObject({ status: 'in review', stateVersion: before }); + expect(store.getMergeJob(identity, id)).toMatchObject({ state: 'failed' }); +}); + it('decides a retried job only from its own merge attempt, never from an earlier refused one', async () => { // The first job's merge is refused: its attempt stays on record as failed. let refuse = true, conflictFrom = Number.POSITIVE_INFINITY; From 4ea872412128467023d7432b737743da2583cdf8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 02:50:03 -0700 Subject: [PATCH 043/163] Test a queued attempt at startup and a failed GitHub read while waiting Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 4 ++-- test/merge-job.test.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index b84b7e6f..2077e507 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -32,7 +32,7 @@ Without a runner, **Merge PR** works as before. |---|---|---|---| | Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down, or because trust could not be read, saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | -| Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, or another blocker appears (job: `failed`) | +| Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, another blocker appears, or GitHub cannot be read (job: `failed`; one failed read fails closed rather than retrying) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. @@ -79,7 +79,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` refusal, cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting, GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` refusal, cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. ## Check this document with a reader diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index d80a7aa8..36da44a6 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -67,12 +67,13 @@ function preparation(store: Store, result: (call: number) => PreMergeResult['sta } /** GitHub showing PR 7 as published; `checks(n)` gives the required checks of the n-th inspection. */ function github(store: Store, options: { checks?: (n: number) => RequiredCheck[]; change?: (n: number) => Partial; - merge?: (signal?: AbortSignal) => Promise<{ url: string }>; queue?: boolean } = {}) { + merge?: (signal?: AbortSignal) => Promise<{ url: string }>; queue?: boolean; unreadable?: (n: number) => boolean } = {}) { let inspections = 0; const merged: string[] = []; const client: MergeGateway = { inspect: vi.fn(async request => { inspections++; + if (options.unreadable?.(inspections)) throw new Error('GitHub returned HTTP 502'); const record = store.taskPullRequests(identity).find(pr => pr.number === 7)!; const snapshot = store.getSnapshot(identity); return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, @@ -141,6 +142,15 @@ it('stops at once when a required check fails, without waiting for the time limi expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); }); +it('ends failed, without merging, when GitHub cannot be read while waiting for checks', async () => { + const f = setup({ checks: () => [pending()], unreadable: n => n === 2 }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/HTTP 502/) }); + expect(f.gh.inspections()).toBe(2); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + it('waits while GitHub is still computing mergeability', async () => { const f = setup({ change: n => (n <= 2 ? { mergeable: 'UNKNOWN' } : {}) }); f.coordinator.start('review-token', randomUUID()); @@ -354,6 +364,7 @@ it('settles a job a crash left in its merge step from its merge attempt at start for (const [settle, expected, status] of [ [() => undefined, 'submitted', 'in review'], [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'merged', url: 'https://github.com/owner/repo/pull/7' }), 'merged', 'merged'], + [(store: Store, id: string) => store.queueMergeAttempt(identity, id, 'https://github.com/owner/repo/pull/7'), 'queued', 'in review'], [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }), 'blocked', 'approved but merge blocked'], ] as const) { // GitHub never answers: the job is in its merge step when the process "crashes". From 3293278d6406f016e2b4185f4e4a7f530044c5db Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 03:10:07 -0700 Subject: [PATCH 044/163] Test that shutdown awaits a merge job before the store closes Co-Authored-By: Claude Opus 5.5 --- test/runner-publishing.test.ts | 36 ++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index dbdbe4a4..2881a1f6 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -16,7 +16,7 @@ import { PullRequestPublisher } from '../runner/publish.ts'; import { ensureCommit, openRunnerRepository } from '../runner/runner-repository.ts'; import { baseBranch } from '../runner/production.ts'; import { MergeCoordinator } from '../runner/merge.ts'; -import type { MergeGateway } from '../github/merge.ts'; +import { MergeSubmissionError, type MergeGateway } from '../github/merge.ts'; import { OWED_REFUSAL } from '../runner/publishing.ts'; import { PullRequestMisplaced, openingMarker, type OpenPullRequestInput, type PullRequestGateway } from '../github/pull-requests.ts'; import type { AlreadyFixedGateway } from '../github/already-fixed.ts'; @@ -24,7 +24,7 @@ import type { PlanIdentity } from '../core/identity.ts'; import { approveItem } from '../core/approvals.ts'; import { GhIssueGateway, type IssueTrustGateway } from '../github/issues.ts'; import type { PreMergeCoordinator } from '../runner/pre-merge.ts'; -import type { MergeJobTiming } from '../runner/merge-job.ts'; +import { MergeJobCoordinator, type MergeJobTiming } from '../runner/merge-job.ts'; import type { RemoteMergeState, RequiredCheck } from '../github/merge.ts'; vi.setConfig({ testTimeout: 30_000 }); @@ -1914,6 +1914,38 @@ describe('the one-click merge job (F6b of #22)', () => { try { expect(reopened.latestMergeJob(served.identity)).toMatchObject({ state: 'running', step: 'checks' }); } finally { reopened.close(); } }, 60_000); + it('awaits a merge job that settles after the shutdown abort before the store closes', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, merging: AbortSignal | null = null; + const base = gateway(() => storeRef!, () => []); + // GitHub was asked to merge; after the abort it answers, a little later, with a refusal. + const gh: MergeGateway = { inspect: base.inspect, merge: (_head, options) => new Promise((_resolve, reject) => { + merging = options!.signal!; + merging.addEventListener('abort', () => setTimeout(() => reject(new MergeSubmissionError('Base branch was modified.', 'refused')), 300), { once: true }); + }) }; + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(() => expect(merging).not.toBeNull(), { timeout: 10_000 }); + const database = served.app.service.config.database, order: string[] = []; + const jobClose = MergeJobCoordinator.prototype.close, storeClose = Store.prototype.close; + const jobs = vi.spyOn(MergeJobCoordinator.prototype, 'close').mockImplementation(async function (this: MergeJobCoordinator) { + await jobClose.call(this); order.push('job settled'); + }); + const stores = vi.spyOn(Store.prototype, 'close').mockImplementation(function (this: Store) { order.push('store closed'); return storeClose.call(this); }); + try { await served.close(); } finally { jobs.mockRestore(); stores.mockRestore(); } + // Shutdown awaited the job before the store closed, so its last write landed: it ended as its merge attempt records it. + expect(order[0]).toBe('job settled'); + expect(order).toContain('store closed'); + const reopened = new Store(database); + try { + expect(reopened.latestMergeJob(served.identity)).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge/) }); + expect(reopened.getTask(served.identity).status).toBe('approved but merge blocked'); + } finally { reopened.close(); } + }, 60_000); + it('stops a waiting merge job when the task is cancelled, and closes the task\'s pull requests', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null; From 2e3e860cde52e772b70d4bfddc57b9005b18034b Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 03:24:05 -0700 Subject: [PATCH 045/163] Test that closing the merge job waits for it to settle Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 18 ++++++++++++++++++ test/runner-publishing.test.ts | 5 +++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 36da44a6..1ba44955 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -231,6 +231,24 @@ it('replays a resent click, refuses a second click while a job runs, and replays expect(f.prep.calls).toHaveLength(1); }); +it('resolves close only once the job has settled, so shutdown can await its last write', async () => { + const f = setup(); + // Preparation that finishes its own work after the shutdown abort (as a push or command check settling does). + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const prepared = f.prep.fake.start.bind(f.prep.fake); + (f.prep.fake as unknown as { start: typeof prepared }).start = async expected => { await held; return prepared(expected); }; + f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()).toMatchObject({ state: 'running', step: 'preparing' })); + let closed = false; + const closing = f.coordinator.close().then(() => { closed = true; }); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(closed).toBe(false); + release(); await closing; + expect(f.coordinator.active).toBe(false); + expect(f.gh.merged).toEqual([]); +}); + it('leaves a job running on shutdown, and startup ends it as failed', async () => { const f = setup({ checks: () => [pending()] }); f.coordinator.start('review-token', randomUUID()); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 2881a1f6..65c63982 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1914,7 +1914,7 @@ describe('the one-click merge job (F6b of #22)', () => { try { expect(reopened.latestMergeJob(served.identity)).toMatchObject({ state: 'running', step: 'checks' }); } finally { reopened.close(); } }, 60_000); - it('awaits a merge job that settles after the shutdown abort before the store closes', async () => { + it('closes the merge job before the store, so a job settling after the shutdown abort records its outcome', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, merging: AbortSignal | null = null; const base = gateway(() => storeRef!, () => []); @@ -1936,7 +1936,8 @@ describe('the one-click merge job (F6b of #22)', () => { }); const stores = vi.spyOn(Store.prototype, 'close').mockImplementation(function (this: Store) { order.push('store closed'); return storeClose.call(this); }); try { await served.close(); } finally { jobs.mockRestore(); stores.mockRestore(); } - // Shutdown awaited the job before the store closed, so its last write landed: it ended as its merge attempt records it. + // Shutdown closed the job before the store (close awaiting the job is shown in merge-job.test.ts), and the job's last + // write landed: it ended as its merge attempt records it. expect(order[0]).toBe('job settled'); expect(order).toContain('store closed'); const reopened = new Store(database); From 777c9b541f4ae195ffcb971def228b3e2f65d4dd Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 03:39:36 -0700 Subject: [PATCH 046/163] Name the pre-v19 fallback in the guarded-merge scan description Co-Authored-By: Claude Opus 5.5 --- docs/implementation/guarded-merge.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index 905f1b53..f2ef50a8 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check) when that check scanned from the task's original base, and otherwise at the task's original base, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check from schema v19; for an earlier first opening, the task's oldest clear check stands in) when that check scanned from the task's original base, and otherwise at the task's original base, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. From 93af1763b5c090bfe7a1696f4b63d97843d8ad7a Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 09:36:42 -0700 Subject: [PATCH 047/163] Name schema version 18 in the architecture doc Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index cfd322da..542d7ddb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -357,7 +357,7 @@ The diagram shows the designed transitions. With the `runner` block, running, ne ### Stored data -All state is in one SQLite file, opened with WAL and full synchronization. Each write takes an immediate write lock. The schema version is `PRAGMA user_version`, and migrations run in explicit steps (version 17 today); an unknown version fails. +All state is in one SQLite file, opened with WAL and full synchronization. Each write takes an immediate write lock. The schema version is `PRAGMA user_version`, and migrations run in explicit steps (version 18 today); an unknown version fails. | Group | Tables | Notes | |---|---|---| From 0180256cdd05f2504e59da628513be1fa5b02671 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 12:05:58 -0700 Subject: [PATCH 048/163] Address Copilot: block only while readiness holds; shutdown answers 503 A failing or slow check read before a review edit no longer moves the changed task to approved, merge blocked: the block re-checks the preparation's readiness in its transaction and otherwise ends the job failed. A click refused because the server is shutting down (including a preparation stopping for it) now answers 503, so the browser keeps the click's key. Comment wording fixed. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 4 ++-- runner/merge-job.ts | 21 +++++++++-------- runner/store.ts | 12 +++++++++- test/merge-job.test.ts | 34 +++++++++++++++++++++++++--- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 2077e507..c14ae4b3 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -30,9 +30,9 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down, or because trust could not be read, saves nothing and may be sent again | +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503), or because trust could not be read, saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | -| Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`); the pull request moved, another blocker appears, or GitHub cannot be read (job: `failed`; one failed read fails closed rather than retrying) | +| Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub cannot be read (job: `failed`; one failed read fails closed rather than retrying) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 95f7b069..824cf061 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { GuardRefusal, MERGEABLE_STATUSES, settleWith, type ShutdownCapability } from './lifecycle.ts'; +import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, settleWith, type ShutdownCapability } from './lifecycle.ts'; import { MergeOutcomeUnknown, type MergeCoordinator, type MergeStatus } from './merge.ts'; import type { PreMergeCoordinator } from './pre-merge.ts'; import type { ReviewService } from './review.ts'; @@ -23,8 +23,8 @@ type CheckVerdict = { kind: 'ready' } | { kind: 'wait'; checks: { context: strin /** * The one-click merge (F6b of #22): preparation, the wait for GitHub's required checks on the pushed head, then the - * guarded merge. One job per task at a time; its progress is saved in `merge_jobs`, never on the task, so the readiness - * preparation records stays bound while the job waits. + * guarded merge. One job per task at a time; its progress is saved in `merge_jobs`, never on the task, so the + * preparation's readiness record stays bound while the job waits. */ export class MergeJobCoordinator { readonly service: ReviewService; @@ -71,8 +71,8 @@ export class MergeJobCoordinator { if (job.refused) throw new GuardRefusal(job.reason ?? 'The merge was refused.'); return mergeJobView(job); } - // Shutdown decides nothing about this click: it may be sent again, so nothing is saved. - if (this.#closing) throw new GuardRefusal('The server is shutting down.'); + // Shutdown decides nothing about this click: nothing is saved, and the route answers 503 so the same click is resent. + if (this.#closing) throw new ShuttingDownError(); const refusal = this.#admissionRefusal(token, busy); if (refusal !== null) { // A definite refusal is this click's outcome; a resend replays it (as the guarded merge does). @@ -101,7 +101,8 @@ export class MergeJobCoordinator { if (reason) return reason; try { this.preMerge.assertStartable(); } catch (error) { - if (!(error instanceof GuardRefusal) || /shutting down/i.test(error.message)) throw error; + if (error instanceof GuardRefusal && /shutting down/i.test(error.message)) throw new ShuttingDownError(); + if (!(error instanceof GuardRefusal)) throw error; return error.message; } return null; @@ -142,7 +143,8 @@ export class MergeJobCoordinator { this.settle(() => store.updateMergeJob(identity, job.id, { step: 'checks' })); const verdict = await this.#awaitChecks(job, signal); if (verdict.kind === 'cancelled') return this.#end(job, signal); - if (verdict.kind === 'blocked') return this.#block(job, verdict.reason); + // A failing or slow check blocks only while the readiness it was read for still holds (in the block's transaction). + if (verdict.kind === 'blocked') return this.#block(job, verdict.reason, true); if (verdict.kind === 'failed') return this.#finish(job.id, 'failed', verdict.reason); // 3. Merge through the guarded merge, which re-validates everything and reruns the already-fixed check. // The merge's action ID is saved first: after a restart, the attempt carrying it gives the job's outcome. @@ -184,8 +186,9 @@ export class MergeJobCoordinator { } /** Approved, merge blocked: the task's next merge prepares again from the start. One Store transaction. */ - #block(job: MergeJob, reason: string): void { - try { this.settle(() => this.service.store.blockMergeJob(this.service.config.identity, job.id, reason)); } + #block(job: MergeJob, reason: string, whileReady = false): void { + const readyFor = whileReady ? { commandPolicyDigest: this.service.commandPolicyDigest() } : undefined; + try { this.settle(() => this.service.store.blockMergeJob(this.service.config.identity, job.id, reason, readyFor)); } catch { /* Startup settles a job that could not be ended now. */ } } diff --git a/runner/store.ts b/runner/store.ts index 651b762c..9c9f7d18 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2176,8 +2176,11 @@ export class Store { /** * End a running job `blocked` and, in the same transaction, move a task still in review to approved, merge blocked, * so the job and the task cannot disagree after a crash. A task that moved meanwhile keeps its status. + * With `readyFor`, the block applies only while the preparation's readiness still holds (checked in the same + * transaction): a check result that arrived after a review edit or another task change says nothing about the + * changed review, so the job then ends `failed` and the task keeps its status. */ - blockMergeJob(identity: PlanIdentity, id: string, reason: string): boolean { + blockMergeJob(identity: PlanIdentity, id: string, reason: string, readyFor?: { commandPolicyDigest: string }): boolean { const key = identityKey(identity); return this.#transaction(() => { const job = this.getMergeJob(identity, id); @@ -2185,6 +2188,13 @@ export class Store { // A job that already ended decides nothing about the task now. if (job.state !== 'running') return false; const task = this.#task(key); + if (readyFor) { + const snapshot = this.getSnapshot(identity); + if (!this.preMergeReady(identity, { stateVersion: task.state_version as number, reviewVersion: this.#current(key).review_version as number, + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, commandPolicyDigest: readyFor.commandPolicyDigest })) + return this.finishMergeJob(identity, id, { state: 'failed', + reason: 'The review or task changed while waiting for required checks; the preparation no longer applies. Merge again.' }); + } if (task.status === 'in review' && !this.#activeAttempt(key) && !this.#activeMerge(key) && task.rebase_in_progress === null && (task.push_in_progress === null || task.push_in_progress === undefined)) { this.#run('UPDATE tasks SET status=? WHERE plan_key=?', 'approved but merge blocked', key); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 1ba44955..5f0f676a 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { afterEach, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; -import { GuardRefusal } from '../runner/lifecycle.ts'; +import { GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; import { MergeJobCoordinator, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; import type { PreMergeCoordinator, PreMergeResult } from '../runner/pre-merge.ts'; @@ -67,13 +67,16 @@ function preparation(store: Store, result: (call: number) => PreMergeResult['sta } /** GitHub showing PR 7 as published; `checks(n)` gives the required checks of the n-th inspection. */ function github(store: Store, options: { checks?: (n: number) => RequiredCheck[]; change?: (n: number) => Partial; - merge?: (signal?: AbortSignal) => Promise<{ url: string }>; queue?: boolean; unreadable?: (n: number) => boolean } = {}) { + merge?: (signal?: AbortSignal) => Promise<{ url: string }>; queue?: boolean; unreadable?: (n: number) => boolean; + during?: (n: number) => void } = {}) { let inspections = 0; const merged: string[] = []; const client: MergeGateway = { inspect: vi.fn(async request => { inspections++; if (options.unreadable?.(inspections)) throw new Error('GitHub returned HTTP 502'); + // Something that happens locally while GitHub's answer is on its way. + options.during?.(inspections); const record = store.taskPullRequests(identity).find(pr => pr.number === 7)!; const snapshot = store.getSnapshot(identity); return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, @@ -151,6 +154,20 @@ it('ends failed, without merging, when GitHub cannot be read while waiting for c expect(f.store.getTask(identity).status).toBe('in review'); }); +it('ends failed, leaving the task in review, when a failing check arrives after the review changed', async () => { + let storeRef: Store | null = null; + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }], during: () => { + // A person adds a change request while the job's inspection is reading GitHub. + const store = storeRef!, snapshot = store.getSnapshot(identity); + store.addReviewNote(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, 'P1', 'change', 'Changed during the wait.'); + } }); + storeRef = f.store; + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed while waiting for required checks/) }); + expect(f.store.getTask(identity).status).toBe('in review'); + expect(f.gh.merged).toEqual([]); +}); + it('waits while GitHub is still computing mergeability', async () => { const f = setup({ change: n => (n <= 2 ? { mergeable: 'UNKNOWN' } : {}) }); f.coordinator.start('review-token', randomUUID()); @@ -257,7 +274,10 @@ it('leaves a job running on shutdown, and startup ends it as failed', async () = expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); f.store.settleInterruptedMergeJobs(identity); expect(f.coordinator.latest()).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); - expect(() => f.coordinator.start('review-token', randomUUID())).toThrow(/shutting down/); + // Not a definite refusal: the route answers 503 and the browser keeps the click's key (nothing was saved). + const id = randomUUID(); + expect(() => f.coordinator.start('review-token', id)).toThrow(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); }); it('ends queued when the merge queue accepted the head', async () => { @@ -449,6 +469,14 @@ it('decides a retried job only from its own merge attempt, never from an earlier expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); }); +it('answers a preparation stopping for shutdown as not applied, saving nothing', () => { + const f = setup(), id = randomUUID(); + (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('The server is shutting down.'); }; + expect(() => f.coordinator.start('review-token', id)).toThrow(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); + expect(f.prep.calls).toEqual([]); +}); + it('refuses a click while a preparation is already running, and replays that refusal', () => { const f = setup(), id = randomUUID(); (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('Pre-merge preparation is already running.'); }; From 7180aad1ffea691ed2c2c49330c1c0e3ccd59992 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 12:29:42 -0700 Subject: [PATCH 049/163] Fail the job when GitHub refuses a changed head; answer 503 when shutdown stops a merge click A refusal that needs a fresh review (the head changed) no longer moves the task to approved, merge blocked, live or at startup. Shutdown stopping a merge click's trust read, or its reload after the job was saved, now answers 503 so the browser resends the same click. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 8 ++--- runner/merge-job.ts | 3 ++ runner/store.ts | 6 ++++ test/merge-job.test.ts | 9 ++++++ test/runner-publishing.test.ts | 45 ++++++++++++++++++++++++++++ web/server.ts | 11 +++++-- 6 files changed, 76 insertions(+), 6 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index c14ae4b3..0f8cb55d 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -30,10 +30,10 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503), or because trust could not be read, saves nothing and may be sent again | +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub cannot be read (job: `failed`; one failed read fails closed rather than retrying) | -| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | +| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. @@ -62,8 +62,8 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | -| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused, otherwise `submitted`, and the attempt reconciles as before | -| Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked. A `prepare-merge` action and a push marker are settled as before | +| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed), otherwise `submitted`, and the attempt reconciles as before | +| Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed). A `prepare-merge` action and a push marker are settled as before | ## Storage diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 824cf061..98500f36 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -163,6 +163,9 @@ export class MergeJobCoordinator { // shutdown) happened meanwhile. A refusal blocks (design: "GitHub refused the merge"); anything else is unknown. const attempt = store.getMergeAttempt(identity); if (attempt?.actionId === mergeId) { + // A refusal because the head changed needs a fresh review, not a retry: the task is not "approved" any more. + if ((attempt.state === 'failed' || attempt.state === 'removed') && attempt.requiresFreshReview) + return this.#finish(job.id, 'failed', `GitHub refused the merge: ${attempt.reason ?? reason} Review the new head, then merge again.`); if (attempt.state === 'failed' || attempt.state === 'removed') return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`); if (attempt.state === 'merged') return this.#finish(job.id, 'merged', null, attempt.url); if (attempt.state === 'queued') return this.#finish(job.id, 'queued', null, attempt.url); diff --git a/runner/store.ts b/runner/store.ts index 9c9f7d18..71d9600e 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2257,6 +2257,12 @@ export class Store { reason: 'The server restarted while GitHub\'s merge outcome was unknown; it reconciles from GitHub.' }); return; } + if (attempt.requiresFreshReview) { + // The head changed: the task needs a fresh review, so it is not moved to approved, merge blocked. + this.finishMergeJob(identity, running.id, { state: 'failed', + reason: `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'} Review the new head, then merge again.` }); + return; + } this.blockMergeJob(identity, running.id, `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'}`); } /** Startup recovery makes an interrupted preparation definite and replayable before new work is admitted. */ diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 5f0f676a..9004001e 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -154,6 +154,14 @@ it('ends failed, without merging, when GitHub cannot be read while waiting for c expect(f.store.getTask(identity).status).toBe('in review'); }); +it('ends failed, leaving the task in review, when GitHub refuses because the head changed', async () => { + const f = setup({ merge: async () => { throw new MergeSubmissionError('Head branch was modified. Review and try the merge again.', 'refused'); } }); + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/^GitHub refused the merge: Head branch was modified.*Review the new head/) }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed', requiresFreshReview: true }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + it('ends failed, leaving the task in review, when a failing check arrives after the review changed', async () => { let storeRef: Store | null = null; const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }], during: () => { @@ -403,6 +411,7 @@ it('settles a job a crash left in its merge step from its merge attempt at start [() => undefined, 'submitted', 'in review'], [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'merged', url: 'https://github.com/owner/repo/pull/7' }), 'merged', 'merged'], [(store: Store, id: string) => store.queueMergeAttempt(identity, id, 'https://github.com/owner/repo/pull/7'), 'queued', 'in review'], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Head branch was modified.', requiresFreshReview: true }), 'failed', 'in review'], [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }), 'blocked', 'approved but merge blocked'], ] as const) { // GitHub never answers: the job is in its merge step when the process "crashes". diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 65c63982..27ed8df4 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1848,6 +1848,51 @@ describe('the one-click merge job (F6b of #22)', () => { expect(prepared).toBe(1); }, 60_000); + it('answers 503 and saves nothing when shutdown stops a merge click\'s trust read', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, hang = false, reading = false; + const gh = gateway(() => storeRef!, () => []); + const gate: IssueTrustGateway = { ...issueGateway, issueAccess(number, options = {}) { + if (!hang) return Promise.resolve({ number, authorLogin: 'member', collaborator: true }); + reading = true; + // GitHub does not answer; only the request's own abort (at the end of shutdown's drain) ends the read. + return new Promise((_resolve, reject) => options.signal?.addEventListener('abort', () => reject(options.signal!.reason), { once: true })); + } }; + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; + hang = true; + const clicked = merge(served.app, token, id); + await vi.waitFor(() => expect(reading).toBe(true), { timeout: 10_000 }); + const closed = served.close(); + expect(await clicked).toMatchObject({ status: 503 }); + await closed; + const reopened = new Store(database); + try { expect(reopened.getMergeJob(served.identity, id)).toBeNull(); } finally { reopened.close(); } + }, 60_000); + + it('answers 503 when shutdown stops the reload after a merge job was saved, and the resent click replays it', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, hanging = false; + // From the click on, GitHub does not answer; only the caller's abort ends each read. + const gh = gateway(() => storeRef!, () => [], () => hanging); + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; + hanging = true; + const clicked = merge(served.app, token, id); + await vi.waitFor(() => expect(served.store.getMergeJob(served.identity, id)).not.toBeNull(), { timeout: 10_000 }); + await vi.waitFor(() => expect(gh.hung.length).toBeGreaterThan(0), { timeout: 10_000 }); + const closed = served.close(); + expect(await clicked).toMatchObject({ status: 503 }); + await closed; + // The job was saved: a resend after restart replays it rather than starting another. + const reopened = new Store(database); + try { expect(reopened.getMergeJob(served.identity, id)).toMatchObject({ id }); } finally { reopened.close(); } + }, 60_000); + it('closes the pull requests of a task whose cancel waited for the merge job\'s preparation to settle', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, release!: () => void, preparing = false; diff --git a/web/server.ts b/web/server.ts index 27403995..7674b768 100644 --- a/web/server.ts +++ b/web/server.ts @@ -684,14 +684,21 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge let untrusted: string|null=null; if(config.github&&!mergeJobs.saved(input.actionId)) { if(stopping||runner?.closing)throw new ShuttingDownError(); - const access=await readIssueAccess(requestAbort.signal); + let access: IssueAccess; + // A read stopped by shutdown decides nothing: 503, and the browser resends the same click. + try { access=await readIssueAccess(requestAbort.signal); } + catch (error) { if(stopping||runner?.closing||requestAbort.signal.aborted)throw new ShuttingDownError(); throw error; } try { requireTrustedIssue(access); } catch (error) { if(!(error instanceof GuardRefusal))throw error; untrusted=error.message; } } const job=mergeJobs.start(input.token,input.actionId,()=>untrusted??(runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) ?'The runner or publisher is busy; the merge cannot start yet.':null)); const running=mergeJobs.current; if(running)void running.finally(()=>{ if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); }); - json(200,{...(await load(requestAbort.signal)),job});return; + // The job is saved; a reload stopped by shutdown answers 503, and the resent click replays the job. + let loaded: Awaited>; + try { loaded=await load(requestAbort.signal); } + catch (error) { if(stopping||runner?.closing||requestAbort.signal.aborted)throw new ShuttingDownError(); throw error; } + json(200,{...loaded,job});return; } let merged: Awaited>; // A merge stopped for a passing reason applied nothing, so answer 503 and the browser resends the same key. From cbacd19cbe77a2cc5bf4927dbefa2db0ccc8f6d3 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 12:51:42 -0700 Subject: [PATCH 050/163] Name the shutdown reload test for what it checks Co-Authored-By: Claude Opus 5.5 --- test/runner-publishing.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 27ed8df4..9437f813 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1872,7 +1872,7 @@ describe('the one-click merge job (F6b of #22)', () => { try { expect(reopened.getMergeJob(served.identity, id)).toBeNull(); } finally { reopened.close(); } }, 60_000); - it('answers 503 when shutdown stops the reload after a merge job was saved, and the resent click replays it', async () => { + it('answers 503 when shutdown stops the reload after a merge job was saved, keeping the saved job for a resend', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, hanging = false; // From the click on, GitHub does not answer; only the caller's abort ends each read. @@ -1888,7 +1888,7 @@ describe('the one-click merge job (F6b of #22)', () => { const closed = served.close(); expect(await clicked).toMatchObject({ status: 503 }); await closed; - // The job was saved: a resend after restart replays it rather than starting another. + // The job was saved under the click's action ID, which a resend replays (merge-job.test.ts covers the replay). const reopened = new Store(database); try { expect(reopened.getMergeJob(served.identity, id)).toMatchObject({ id }); } finally { reopened.close(); } }, 60_000); From f3bc3e1176c9c7436e82faa216b67582854e9fb5 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 13:00:30 -0700 Subject: [PATCH 051/163] Address Copilot: a branch read never waits on an escaped pipe holder Production readBranch ran Git without allowUnsettledReturn, so a process that left Git's group while holding its output could hold the push settlement read, startup recovery, a repeated cancel or shutdown without bound. A read changes nothing, so it now returns failed after the bounded drain, and the push marker stays. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/branch-push.ts | 8 +++++--- test/runner-branch-push.test.ts | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 7f8e1a53..198b9095 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -43,7 +43,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | -| Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | +| Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. A process that left Git's group and still holds its output does not hold the read either: the read changes nothing, so after the bounded drain it returns failed rather than waiting, and the marker stays. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | | Unreadable, deadline or shutdown | The marker stays when the branch cannot be read, when the preparation's own deadline ended the push (less than the settlement reserve is left, so no read starts), or on shutdown. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. diff --git a/runner/branch-push.ts b/runner/branch-push.ts index 8bfffefc..e0bb12db 100644 --- a/runner/branch-push.ts +++ b/runner/branch-push.ts @@ -180,19 +180,21 @@ export class GitBranchPusher implements BranchPusher { /** The commit `ref` points at on the remote, or null when it does not exist. */ async #read(ref: string, signal?: AbortSignal): Promise { - const out = await this.#git(['ls-remote', '--refs', '--', this.#url, ref], signal, true); + // A read changes nothing, so it need not wait for a process that escaped Git's group and holds its output: it returns + // failed (the output may be incomplete) after the bounded drain. The caller then knows nothing: a push marker stays. + const out = await this.#git(['ls-remote', '--refs', '--', this.#url, ref], signal, true, undefined, true); // ls-remote matches patterns by their tail, so `refs/heads/x/` would match too: keep only the exact name. const matches = out.split('\n').filter(Boolean).map(line => line.split('\t')).filter(([, name]) => name === ref); if (matches.length > 1 || (matches[0] && !COMMIT_ID.test(matches[0][0]!))) throw new Error(`The remote's answer for ${ref} is malformed.`); return matches[0]?.[0] ?? null; } - async #git(args: readonly string[], signal?: AbortSignal, remote = false, input?: Buffer): Promise { + async #git(args: readonly string[], signal?: AbortSignal, remote = false, input?: Buffer, readOnly = false): Promise { const protocol = this.#url.startsWith('/') ? 'file' : 'https'; const outcome = await runInProcessGroup('git', [...GIT_OPTIONS, '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', ...remote ? ['-c', `protocol.${protocol}.allow=always`, ...CREDENTIAL_HELPER] : [], ...args], { cwd: this.#config.repository.path, env: pushEnvironment(this.#config.env), timeoutMs: this.#config.timeoutMs ?? 120_000, - signal, onProcessGroup: this.#config.onProcessGroup, maxBuffer: 1024 * 1024, input }); + signal, onProcessGroup: this.#config.onProcessGroup, maxBuffer: 1024 * 1024, input, allowUnsettledReturn: readOnly }); // Aborted before or while Git ran: the caller's reason (such as shutdown) is what leaves, not Git's failure. signal?.throwIfAborted(); if (outcome.status !== 0) throw gitFailure(args[0]!, outcome.status, [outcome.stderr.trim(), outcome.stdout.trim()].filter(Boolean).join('\n'), diff --git a/test/runner-branch-push.test.ts b/test/runner-branch-push.test.ts index f13c4401..d5969743 100644 --- a/test/runner-branch-push.test.ts +++ b/test/runner-branch-push.test.ts @@ -311,6 +311,23 @@ describe('GitBranchPusher', () => { expect(remoteRefs(s)).toBe(''); }); + it('fails a branch read promptly when a process outside Git\'s group holds its output', async () => { + const s = await setup(), bin = join(s.root, 'fake-bin'), pidFile = join(s.root, 'escaped.pid'); + mkdirSync(bin); + // Git's place on PATH: `ls-remote` leaves a process in a new session holding the inherited output, then exits. + writeFileSync(join(bin, 'git'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} -e ${JSON.stringify( + `const c = require('node:child_process').spawn('sleep', ['60'], { detached: true, stdio: ['ignore', 'inherit', 'inherit'] });` + + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(c.pid)); c.unref();`)}\n`, { mode: 0o755 }); + try { + const began = performance.now(); + const read = pusher(s, { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }).instance.readBranch(IDENTITY, BRANCH); + await expect(read).rejects.toThrow(/outside its group still holds its output/); + expect(performance.now() - began).toBeLessThan(20_000); + } finally { + try { process.kill(Number(readFileSync(pidFile, 'utf8')), 'SIGKILL'); } catch { /* already gone */ } + } + }, 40_000); + it('removes a configured enterprise token of any shape from Git\'s error text', async () => { const s = await setup(), head = await runnerCommit(s, 'one'), token = 'enterprise-secret-0123456789'; const missing = new GitBranchPusher({ repository: s.repository, repositoryId: 'repo-1', remote: 'acme/app', From 97b6305163ccfb13a612e5ca86204c38b2c00376 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 14:12:59 -0700 Subject: [PATCH 052/163] Address Copilot: bound only escaped pipe holders on branch reads allowUnsettledReturn also stopped waiting for processes still in Git's group, which a read has no durable ownership of. A new process-group option, boundEscapedOutput, bounds only the wait for a process outside the group that holds the output; group members are still waited for, fail-closed. Branch reads use it. Tests cover both cases. Co-Authored-By: Claude Opus 5.5 --- agents/process-group.ts | 12 +++++++++-- docs/implementation/pre-merge-rebase.md | 2 +- runner/branch-push.ts | 5 +++-- test/agent-process-group.test.ts | 28 +++++++++++++++++++++++++ test/runner-branch-push.test.ts | 13 ++++++++++++ 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/agents/process-group.ts b/agents/process-group.ts index bd460e57..f7bc914b 100644 --- a/agents/process-group.ts +++ b/agents/process-group.ts @@ -49,6 +49,13 @@ export interface ProcessGroupOptions { * Callers without durable ownership wait fail-closed instead of returning while unknown work is still live. */ readonly allowUnsettledReturn?: boolean; + /** + * Bound only the wait for the output pipes once the whole group is gone: a process that left the group but still holds + * them makes the call return failed (`ESTDIOHELD`) after the pipe drain, instead of waiting for it. Members of the + * group are still waited for, fail-closed. For callers whose command changes nothing (a read), so a process that + * escaped the group cannot affect anything they decide. + */ + readonly boundEscapedOutput?: boolean; } const DEFAULT_GRACE_MS = 5_000; @@ -94,7 +101,8 @@ export const drainProcessGroup = (group: ProcessGroup): Promise => drai * returns control to the event loop. On abort or at the deadline the whole group gets SIGTERM, then SIGKILL after the * grace period, so a child that ignores SIGTERM is still bounded. The promise settles only after the leader has * exited and every other member of its group is gone, unless a caller with durable recovery ownership explicitly - * allows an unsettled return. Never rejects: failures are in the outcome, shaped like `runDocker`'s, so `status` is a + * allows an unsettled return. `boundEscapedOutput` bounds only the wait for a process outside the group that holds the + * output pipes. Never rejects: failures are in the outcome, shaped like `runDocker`'s, so `status` is a * number only when the command ran to completion. */ export function runInProcessGroup(file: string, args: readonly string[], @@ -175,7 +183,7 @@ export function runInProcessGroup(file: string, args: readonly string[], options.signal?.removeEventListener('abort', onAbort); void drainGroup(group, options.allowUnsettledReturn === true).then(async drained => { let stdioTimer: ReturnType | undefined; - if (options.allowUnsettledReturn) { + if (options.allowUnsettledReturn || options.boundEscapedOutput) { await Promise.race([closed, new Promise(done => { stdioTimer = setTimeout(done, STDIO_CLOSE_MS); })]); } else await closed; // Cleared so a finished call never keeps the process alive. diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 198b9095..fe1af51d 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -43,7 +43,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | -| Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. A process that left Git's group and still holds its output does not hold the read either: the read changes nothing, so after the bounded drain it returns failed rather than waiting, and the marker stays. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | +| Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. A process that left Git's group and still holds its output does not hold the read either: the read changes nothing, so after the 1-second pipe drain it returns failed rather than waiting, and the marker stays. A process that stays in Git's group after Git exits is waited for, as for every Git call: it cannot be signalled safely once Git has exited, because its group ID may be reused, so that one case is not bounded. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | | Unreadable, deadline or shutdown | The marker stays when the branch cannot be read, when the preparation's own deadline ended the push (less than the settlement reserve is left, so no read starts), or on shutdown. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. diff --git a/runner/branch-push.ts b/runner/branch-push.ts index e0bb12db..32656503 100644 --- a/runner/branch-push.ts +++ b/runner/branch-push.ts @@ -181,7 +181,8 @@ export class GitBranchPusher implements BranchPusher { /** The commit `ref` points at on the remote, or null when it does not exist. */ async #read(ref: string, signal?: AbortSignal): Promise { // A read changes nothing, so it need not wait for a process that escaped Git's group and holds its output: it returns - // failed (the output may be incomplete) after the bounded drain. The caller then knows nothing: a push marker stays. + // failed (the output may be incomplete) after the pipe drain. The caller then knows nothing: a push marker stays. + // Members still in Git's group are waited for, as for every Git call. const out = await this.#git(['ls-remote', '--refs', '--', this.#url, ref], signal, true, undefined, true); // ls-remote matches patterns by their tail, so `refs/heads/x/` would match too: keep only the exact name. const matches = out.split('\n').filter(Boolean).map(line => line.split('\t')).filter(([, name]) => name === ref); @@ -194,7 +195,7 @@ export class GitBranchPusher implements BranchPusher { const outcome = await runInProcessGroup('git', [...GIT_OPTIONS, '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', ...remote ? ['-c', `protocol.${protocol}.allow=always`, ...CREDENTIAL_HELPER] : [], ...args], { cwd: this.#config.repository.path, env: pushEnvironment(this.#config.env), timeoutMs: this.#config.timeoutMs ?? 120_000, - signal, onProcessGroup: this.#config.onProcessGroup, maxBuffer: 1024 * 1024, input, allowUnsettledReturn: readOnly }); + signal, onProcessGroup: this.#config.onProcessGroup, maxBuffer: 1024 * 1024, input, boundEscapedOutput: readOnly }); // Aborted before or while Git ran: the caller's reason (such as shutdown) is what leaves, not Git's failure. signal?.throwIfAborted(); if (outcome.status !== 0) throw gitFailure(args[0]!, outcome.status, [outcome.stderr.trim(), outcome.stdout.trim()].filter(Boolean).join('\n'), diff --git a/test/agent-process-group.test.ts b/test/agent-process-group.test.ts index 1a4edfb1..0cb718ff 100644 --- a/test/agent-process-group.test.ts +++ b/test/agent-process-group.test.ts @@ -260,6 +260,34 @@ describe('runInProcessGroup', () => { } }, 30_000); + it('with boundEscapedOutput, returns failed promptly while a process outside the group holds its output pipe', async () => { + const dir = mkdtempSync(join(tmpdir(), 'escaped-bounded-')), pidFile = join(dir, 'pid'); + const escape = `const c = require('node:child_process').spawn('sleep', ['60'], { detached: true, ` + + `stdio: ['ignore', 'inherit', 'inherit'] }); require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, ` + + `String(c.pid)); c.unref(); console.log('leader done');`; + try { + const began = performance.now(); + const outcome = await runInProcessGroup(process.execPath, ['-e', escape], { env, timeoutMs: 30_000, boundEscapedOutput: true }); + expect(outcome).toMatchObject({ status: null, stdout: 'leader done\n' }); + expect((outcome.error as NodeJS.ErrnoException).code).toBe('ESTDIOHELD'); + expect(performance.now() - began).toBeLessThan(10_000); + } finally { + try { process.kill(Number(readFileSync(pidFile, 'utf8')), 'SIGKILL'); } catch { /* already gone */ } + rmSync(dir, { recursive: true, force: true }); + } + }, 30_000); + + it('with boundEscapedOutput, still waits for a member that stays in the group after the leader exits', async () => { + // The leader leaves a child in its own group (not detached, no inherited output) that outlives the 10 s bounded + // group drain an unsettled return would stop at. + const linger = `require('node:child_process').spawn('sleep', ['12'], { stdio: 'ignore' }).unref(); console.log('leader done');`; + const began = performance.now(); + const outcome = await runInProcessGroup(process.execPath, ['-e', linger], { env, timeoutMs: 30_000, boundEscapedOutput: true }); + expect(performance.now() - began).toBeGreaterThanOrEqual(11_500); + expect(outcome).toMatchObject({ status: 0, stdout: 'leader done\n' }); + expect(outcome.error).toBeUndefined(); + }, 60_000); + it('does not release an untracked caller while a process outside the group holds its output pipe', async () => { const dir = mkdtempSync(join(tmpdir(), 'escaped-untracked-')), pidFile = join(dir, 'pid'); const escape = `const c = require('node:child_process').spawn('sleep', ['60'], { detached: true, ` diff --git a/test/runner-branch-push.test.ts b/test/runner-branch-push.test.ts index d5969743..192af747 100644 --- a/test/runner-branch-push.test.ts +++ b/test/runner-branch-push.test.ts @@ -328,6 +328,19 @@ describe('GitBranchPusher', () => { } }, 40_000); + it('waits for a process that stays in Git\'s group before a branch read returns', async () => { + const s = await setup(), bin = join(s.root, 'fake-bin-group'); + mkdirSync(bin); + // Git's place on PATH: `ls-remote` answers nothing and exits, leaving a child in its own group for 12 s more, past + // the 10 s bounded group drain an unsettled return would stop at. + writeFileSync(join(bin, 'git'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} -e ${JSON.stringify( + `require('node:child_process').spawn('sleep', ['12'], { stdio: 'ignore' }).unref();`)}\n`, { mode: 0o755 }); + const began = performance.now(); + await expect(pusher(s, { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }).instance.readBranch(IDENTITY, BRANCH)).resolves.toBeNull(); + // A member of Git's group is never abandoned: the read returned only after it had gone. + expect(performance.now() - began).toBeGreaterThanOrEqual(11_500); + }, 60_000); + it('removes a configured enterprise token of any shape from Git\'s error text', async () => { const s = await setup(), head = await runnerCommit(s, 'one'), token = 'enterprise-secret-0123456789'; const missing = new GitBranchPusher({ repository: s.repository, repositoryId: 'repo-1', remote: 'acme/app', From 1b81ee9835605318f4d890f448fcc8038473bfbd Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 14:20:26 -0700 Subject: [PATCH 053/163] Address Copilot: closed tasks never offered, refusals bound to their review, neutral poll message withMergeJob now requires a mergeable task status read after the inspection. A GitHub refusal blocks the task only while the review, snapshot and plan revision are those the merge attempt was admitted for; otherwise the job ends failed (live and at startup). A failed job poll says 'Could not refresh merge status'. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 6 +++--- runner/merge-job.ts | 5 ++++- runner/store.ts | 9 +++++++++ test/browser/review.spec.ts | 17 +++++++++++++++++ test/merge-job.test.ts | 24 ++++++++++++++++++++++++ web/public/app.js | 2 +- 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 0f8cb55d..6d263ca8 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -33,7 +33,7 @@ Without a runner, **Merge PR** works as before. | Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub cannot be read (job: `failed`; one failed read fails closed rather than retrying) | -| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | +| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. @@ -63,7 +63,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | | Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed), otherwise `submitted`, and the attempt reconciles as before | -| Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed). A `prepare-merge` action and a push marker are settled as before | +| Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed or the review changed while GitHub was asked). A `prepare-merge` action and a push marker are settled as before | ## Storage @@ -73,7 +73,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - `POST /api/action` with `merge` returns `{ job }` at once when a runner is configured. - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. -- Merge PR is offered while the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. +- Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. - The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 98500f36..00b794f0 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -245,7 +245,10 @@ export function withMergeJob blocker.code === 'base'); const personal = status.blockers.filter(blocker => !JOB_RESOLVES.has(blocker.code) && !(blocker.code === 'mergeable' && (computing || rebaseResolves))); - const ready = !running && !attempt && status.remote !== null && personal.length === 0; + // The task's status is read after the inspection that produced `status`: a task cancelled or merged meanwhile is never + // offered Merge PR, even if that inspection had no task blocker. + const ready = !running && !attempt && status.remote !== null && personal.length === 0 + && MERGEABLE_STATUSES.includes(taskStatus as (typeof MERGEABLE_STATUSES)[number]); return { ...status, ready, action: ready ? (taskStatus === 'approved but merge blocked' || status.action === 'retry' ? 'retry' : 'merge') : null, job }; } diff --git a/runner/store.ts b/runner/store.ts index 71d9600e..8b501f7f 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2195,6 +2195,15 @@ export class Store { return this.finishMergeJob(identity, id, { state: 'failed', reason: 'The review or task changed while waiting for required checks; the preparation no longer applies. Merge again.' }); } + // GitHub refused the job's own merge attempt: the block applies only to the review that attempt was admitted for. + // A review edit, new snapshot or plan revision while GitHub was asked leaves the changed task as it is. + const attempt = job.mergeActionId ? this.getMergeAttempt(identity) : null; + if (attempt && attempt.actionId === job.mergeActionId) { + const plan = this.#current(key); + if (plan.revision !== attempt.revision || plan.snapshot_id !== attempt.snapshotId || plan.review_version !== attempt.reviewVersion) + return this.finishMergeJob(identity, id, { state: 'failed', + reason: `${reason} The review changed while GitHub was asked to merge; review it, then merge again.` }); + } if (task.status === 'in review' && !this.#activeAttempt(key) && !this.#activeMerge(key) && task.rebase_in_progress === null && (task.push_in_progress === null || task.push_in_progress === undefined)) { this.#run('UPDATE tasks SET status=? WHERE plan_key=?', 'approved but merge blocked', key); diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index a8d68da1..cef19eac 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -632,6 +632,23 @@ test('hides Retry until a timed-out invocation has actually settled',async({page /** Whether the element is drawn with the muted surface background a control that cannot act uses (poll it: buttons may animate their background). */ const looksInactive=(locator:import('@playwright/test').Locator)=>locator.evaluate(el=>{const probe=document.createElement('div');probe.style.background='var(--surface)';document.body.append(probe); const surface=getComputedStyle(probe).backgroundColor;probe.remove();return getComputedStyle(el).backgroundColor===surface;}); +test('names a failed merge-job poll as a merge-status failure (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const running={id:'0f6b0000-0000-4000-8000-000000000001',state:'running',step:'checks',reason:null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + // A job is running and no merge queue exists; GitHub's status cannot be read. + await page.route('**/api/merge',route=>route.fulfill({status:502,contentType:'application/json',body:JSON.stringify({error:'GitHub returned HTTP 502.'})})); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toHaveText(/^Could not refresh merge status\. GitHub returned HTTP 502\./,{timeout:15_000}); +}); + test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 9004001e..33a292be 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -162,6 +162,21 @@ it('ends failed, leaving the task in review, when GitHub refuses because the hea expect(f.store.getTask(identity).status).toBe('in review'); }); +it('ends failed, leaving the task in review, when the review changed while GitHub was asked to merge and refused', async () => { + let storeRef: Store | null = null; + const f = setup({ merge: async () => { + // A person adds a change request while the merge command is on its way; then GitHub refuses. + const store = storeRef!, snapshot = store.getSnapshot(identity); + store.addReviewNote(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, 'P1', 'change', 'Changed during the merge.'); + throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); + } }); + storeRef = f.store; + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge/) }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed' }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + it('ends failed, leaving the task in review, when a failing check arrives after the review changed', async () => { let storeRef: Store | null = null; const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }], during: () => { @@ -373,6 +388,9 @@ it('offers Merge PR while only blockers the job resolves remain, and a retry aft expect(withMergeJob(status([], { queue: { state: 'submitting' } }), null, 'in review')).toMatchObject({ ready: false }); expect(withMergeJob(status([], { remote: null }), null, 'in review')).toMatchObject({ ready: false }); expect(withMergeJob(status(['preparation']), blocked, 'approved but merge blocked')).toMatchObject({ ready: true, action: 'retry', job: blocked }); + // The task's status, read after the inspection, wins: a task cancelled or merged meanwhile is never offered Merge PR. + for (const closed of ['cancelled', 'merged', 'running']) + expect(withMergeJob(status([]), null, closed)).toMatchObject({ ready: false, action: null }); // A conflict after the base moved is the job's rebase to resolve; a conflict on its own is a person's. expect(withMergeJob(status(['base', 'mergeable'], { remote: { mergeable: 'CONFLICTING' } }), null, 'in review')).toMatchObject({ ready: true }); expect(withMergeJob(status(['mergeable'], { remote: { mergeable: 'CONFLICTING' } }), null, 'in review')).toMatchObject({ ready: false }); @@ -412,6 +430,12 @@ it('settles a job a crash left in its merge step from its merge attempt at start [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'merged', url: 'https://github.com/owner/repo/pull/7' }), 'merged', 'merged'], [(store: Store, id: string) => store.queueMergeAttempt(identity, id, 'https://github.com/owner/repo/pull/7'), 'queued', 'in review'], [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Head branch was modified.', requiresFreshReview: true }), 'failed', 'in review'], + // GitHub refused while a person changed the review: the refusal says nothing about the changed review. + [(store: Store, id: string) => { + const snapshot = store.getSnapshot(identity); + store.addReviewNote(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, 'P1', 'change', 'Changed during the merge.'); + store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }); + }, 'failed', 'in review'], [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }), 'blocked', 'approved but merge blocked'], ] as const) { // GitHub never answers: the job is in its merge step when the process "crashes". diff --git a/web/public/app.js b/web/public/app.js index 9dba3d5f..444fe37b 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -244,7 +244,7 @@ async function pollMergeQueue(generation) { renderMerge(); } catch (error) { if (generation !== mergeGeneration || !data?.merge?.available) return; - $("banner").textContent = `Could not refresh merge-queue status. ${error.message}`; + $("banner").textContent = `Could not refresh merge status. ${error.message}`; backOffMergePoll(); scheduleMergePoll(); } From 230e5b6cad5c04f116b2b5e8706dc39900fea6af Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 14:20:57 -0700 Subject: [PATCH 054/163] Say which branch-read case the settlement bound excludes Co-Authored-By: Claude Opus 5.5 --- runner/pre-merge.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 211069b9..1f5b81af 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -22,7 +22,7 @@ export interface PreMergeRemote { /** The commit the task branch points at on GitHub, or null when it does not exist. */ readBranch(branch: string, signal?: AbortSignal): Promise; } -/** One bounded read of the branch settles a push whose outcome is unknown. */ +/** One read of the branch, bounded as `#settlePush` says, settles a push whose outcome is unknown. */ export const PUSH_SETTLEMENT_TIMEOUT_MS = 30_000; /** * GitHub's pull-request API reports a pushed head asynchronously. After a push, no new inspection starts once this long @@ -44,7 +44,10 @@ type PreparedResult = PreMergeResult & { readiness?: PreMergeReadiness }; // D may spend 30 s on its first cleanup and 60 s retrying it; F may then spend 30 s releasing task storage. Keep all // of that ownership settlement inside the preparation's one overall deadline. export const COMMAND_CHECK_SETTLEMENT_RESERVE_MS = 120_000; -/** Git and GitHub subprocesses abort before the advertised operation deadline, leaving their bounded stop/drain time. */ +/** + * Git and GitHub subprocesses abort before the advertised operation deadline, leaving their stop/drain time (bounded, + * except for a process that stays in a subprocess's group after it exits, which is waited for). + */ export const PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS = DEFAULT_PROCESS_SETTLEMENT_MS; /** Keep a full process-settlement window between live rebase expiry and its separately budgeted durable cleanup. */ const REBASE_CLEANUP_HANDOFF_RESERVE_MS = PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS; @@ -200,7 +203,8 @@ export class PreMergeCoordinator { /** * One branch read, all of it, Git's settlement after an abort included, within `limitMs` (at most * PUSH_SETTLEMENT_TIMEOUT_MS): the read is aborted the process-settlement reserve before that, and does not start - * when less than the reserve is left. + * when less than the reserve is left. One case is not bounded: a process that stays in Git's group after Git exits + * is waited for, as for every Git call, since its group ID may be reused and it cannot be signalled safely. */ async #settlePush(marker: PushMarker, limitMs = PUSH_SETTLEMENT_TIMEOUT_MS): Promise<{ outcome: PushOutcome; before: number; after: number }> { const readMs = Math.min(PUSH_SETTLEMENT_TIMEOUT_MS, limitMs) - this.processSettlementReserveMs; @@ -323,7 +327,8 @@ export class PreMergeCoordinator { } catch (error) { if (error instanceof BranchPushRefused) return { result: 'refused', stateVersion: owned(this.#finishPush(marker)) }; // Shutdown: no new GitHub read may start; startup settles the marker. Otherwise one read, within what is left of - // the preparation's deadline with Git's settlement reserve kept inside it, settles it; a cancel does not stop that + // the preparation's deadline with Git's settlement reserve kept inside it (except for a process that stays in + // Git's group after Git exits, which is waited for; see #settlePush), settles it; a cancel does not stop that // read. When the preparation's own deadline ended the push, less than that reserve is left, so no read starts and // the marker stays, as for an unreadable branch. let settled = binding; From 33450c8fb1bab2ee67a289ac45976becb86bb980 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 14:31:46 -0700 Subject: [PATCH 055/163] Name both unbounded subprocess cases, and an inspection's full settle time Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/pre-merge.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index fe1af51d..47e2f661 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -42,7 +42,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | -| Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (6 seconds in production), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | +| Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (a 6-second deadline in production, plus up to 6 seconds for a stopped `gh` call to settle), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | | Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. A process that left Git's group and still holds its output does not hold the read either: the read changes nothing, so after the 1-second pipe drain it returns failed rather than waiting, and the marker stays. A process that stays in Git's group after Git exits is waited for, as for every Git call: it cannot be signalled safely once Git has exited, because its group ID may be reused, so that one case is not bounded. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | | Unreadable, deadline or shutdown | The marker stays when the branch cannot be read, when the preparation's own deadline ended the push (less than the settlement reserve is left, so no read starts), or on shutdown. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 1f5b81af..d7091bf6 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -45,8 +45,9 @@ type PreparedResult = PreMergeResult & { readiness?: PreMergeReadiness }; // of that ownership settlement inside the preparation's one overall deadline. export const COMMAND_CHECK_SETTLEMENT_RESERVE_MS = 120_000; /** - * Git and GitHub subprocesses abort before the advertised operation deadline, leaving their stop/drain time (bounded, - * except for a process that stays in a subprocess's group after it exits, which is waited for). + * Git and GitHub subprocesses abort before the advertised operation deadline, leaving their stop/drain time. That time + * is bounded except in two cases, which are waited for, fail-closed: a process that stays in a subprocess's group after + * it exits, and, for Git calls that change something (fetch, push), a process that left the group but holds its output. */ export const PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS = DEFAULT_PROCESS_SETTLEMENT_MS; /** Keep a full process-settlement window between live rebase expiry and its separately budgeted durable cleanup. */ From 7604a7951347eba3c60096651617f128469dc38c Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 14:37:55 -0700 Subject: [PATCH 056/163] Name the review-change case in the merge-handoff shutdown row Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 6d263ca8..bd271f34 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -62,7 +62,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | -| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed), otherwise `submitted`, and the attempt reconciles as before | +| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | | Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed or the review changed while GitHub was asked). A `prepare-merge` action and a push marker are settled as before | ## Storage From 57b7ccb0730d2fb36f2590a509d5ddd38cfbf7bd Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 14:47:38 -0700 Subject: [PATCH 057/163] Bound cat-file reads like ls-remote; correct stale rebaser doc line cat-file only reads, so a process that left Git's group but holds its output no longer holds fetchCommits or the push's head check: the call fails after the pipe drain. The reserve comment now lists the Git calls that still wait (fetch, update-ref, push). The pre-merge-rebase doc no longer says no production request starts the rebaser. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/branch-push.ts | 8 ++++++-- runner/pre-merge.ts | 3 ++- test/runner-branch-push.test.ts | 20 ++++++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 47e2f661..b198e136 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -1,6 +1,6 @@ # Pre-merge rebase and sandboxed conflict boundary -**Issue and lane:** #22, lanes F3-F4. This is the trusted local rewrite, recovery, and sandboxed conflict engine boundary. The `rebase-fix` child adapter is assembled inside the production rebaser through lane D with durable resource ownership and bounded exact-path copy-in/copy-out. No production request starts that rebaser yet: F5-F6 must own admission plus the current base/head refresh before invoking it, then push the rewritten head, run plan `cmd:` checks, wait for GitHub checks, and hand the validated pair to the merge coordinator. Exposing the raw rebaser earlier would bypass those required guards. +**Issue and lane:** #22, lanes F3-F4. This is the trusted local rewrite, recovery, and sandboxed conflict engine boundary. The `rebase-fix` child adapter is assembled inside the production rebaser through lane D with durable resource ownership and bounded exact-path copy-in/copy-out. `prepare-merge` starts it after admission and the current base/head refresh (F5); the rewritten head is then pushed (F6a) and plan `cmd:` checks run. Waiting for GitHub's checks and handing the validated pair to the merge coordinator follow in F6b. Exposing the raw rebaser earlier would bypass those required guards. ## Ownership and lifecycle diff --git a/runner/branch-push.ts b/runner/branch-push.ts index 32656503..b54161b8 100644 --- a/runner/branch-push.ts +++ b/runner/branch-push.ts @@ -116,7 +116,7 @@ export class GitBranchPusher implements BranchPusher { const unique = [...new Set(commits)]; await this.#git(['fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--', this.#url, ...unique], signal, true); const found = await this.#git(['cat-file', '--batch-check=%(objectname) %(objecttype)'], signal, false, - Buffer.from(`${unique.join('\n')}\n`)); + Buffer.from(`${unique.join('\n')}\n`), true); const records = found.split('\n'); if (records.length !== unique.length || records.some((record, index) => record !== `${unique[index]} commit`)) throw new Error('The refreshed remote base or head is not a commit in the runner repository.'); @@ -134,7 +134,7 @@ export class GitBranchPusher implements BranchPusher { if (input.expected !== undefined && !COMMIT_ID.test(input.expected)) throw new Error('A full commit ID is required.'); const ref = this.#ref(input.branch); // Git answers " missing" for an absent object; any other failure (no repository, permissions) keeps Git's error. - const found = await this.#git(['cat-file', '--batch-check=%(objectname) %(objecttype)'], signal, false, Buffer.from(`${input.head}\n`)); + const found = await this.#git(['cat-file', '--batch-check=%(objectname) %(objecttype)'], signal, false, Buffer.from(`${input.head}\n`), true); if (found === `${input.head} missing`) throw new Error(`The runner repository has no commit ${input.head}.`); if (found !== `${input.head} commit`) throw new Error(`${input.head} is not a commit in the runner repository.`); const remote = await this.#read(ref, signal); @@ -190,6 +190,10 @@ export class GitBranchPusher implements BranchPusher { return matches[0]?.[0] ?? null; } + /** + * `readOnly` (ls-remote, cat-file: commands that change nothing) bounds the wait for a process that left Git's group + * but holds its output: the call fails after the pipe drain instead. Members of Git's group are always waited for. + */ async #git(args: readonly string[], signal?: AbortSignal, remote = false, input?: Buffer, readOnly = false): Promise { const protocol = this.#url.startsWith('/') ? 'file' : 'https'; const outcome = await runInProcessGroup('git', [...GIT_OPTIONS, '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index d7091bf6..1e9f58cd 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -47,7 +47,8 @@ export const COMMAND_CHECK_SETTLEMENT_RESERVE_MS = 120_000; /** * Git and GitHub subprocesses abort before the advertised operation deadline, leaving their stop/drain time. That time * is bounded except in two cases, which are waited for, fail-closed: a process that stays in a subprocess's group after - * it exits, and, for Git calls that change something (fetch, push), a process that left the group but holds its output. + * it exits, and, for Git calls that change something (fetch, update-ref, push), a process that left the group but holds + * its output (reads, ls-remote and cat-file, fail instead after the pipe drain). */ export const PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS = DEFAULT_PROCESS_SETTLEMENT_MS; /** Keep a full process-settlement window between live rebase expiry and its separately budgeted durable cleanup. */ diff --git a/test/runner-branch-push.test.ts b/test/runner-branch-push.test.ts index 192af747..cf09289b 100644 --- a/test/runner-branch-push.test.ts +++ b/test/runner-branch-push.test.ts @@ -328,6 +328,26 @@ describe('GitBranchPusher', () => { } }, 40_000); + it('fails a commit check promptly when a process outside Git\'s group holds its output', async () => { + const s = await setup(), bin = join(s.root, 'fake-bin-cat'), pidFile = join(s.root, 'escaped-cat.pid'); + mkdirSync(bin); + // Git's place on PATH: other commands succeed at once with no output; `cat-file` leaves a process in a new session + // holding its output. + const escape = `const c = require('node:child_process').spawn('sleep', ['60'], { detached: true, stdio: ['ignore', 'inherit', 'inherit'] });` + + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(c.pid)); c.unref();`; + writeFileSync(join(bin, 'git'), `#!/bin/sh\ncase " $* " in *" cat-file "*) exec ${JSON.stringify(process.execPath)} -e ${JSON.stringify(escape)} ;; esac\nexit 0\n`, { mode: 0o755 }); + try { + const began = performance.now(); + const instance = pusher(s, { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }).instance; + await expect(instance.fetchCommits([s.base])).rejects.toThrow(/outside its group still holds its output/); + // A push checks its head with cat-file before anything is written: that read is bounded the same way. + await expect(instance.push(IDENTITY, { head: s.base, branch: BRANCH })).rejects.toThrow(/outside its group still holds its output/); + expect(performance.now() - began).toBeLessThan(30_000); + } finally { + try { process.kill(Number(readFileSync(pidFile, 'utf8')), 'SIGKILL'); } catch { /* already gone */ } + } + }, 40_000); + it('waits for a process that stays in Git\'s group before a branch read returns', async () => { const s = await setup(), bin = join(s.root, 'fake-bin-group'); mkdirSync(bin); From 749a977c04b626b7c253310732f6c361ae16348c Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 16:10:30 -0700 Subject: [PATCH 058/163] Address Copilot: review a collaborator head found after an unknown push outcome When the push's read-back fails because a collaborator moved the branch, the settlement read now returns moved and preparation reviews that head (review-required, attribution and approvals refreshed), as after a refusal, instead of failing with the read-back error. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/pre-merge.ts | 13 +++++++++---- test/pre-merge.test.ts | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index b198e136..9a4dd433 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -43,7 +43,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (a 6-second deadline in production, plus up to 6 seconds for a stopped `gh` call to settle), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | -| Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. A process that left Git's group and still holds its output does not hold the read either: the read changes nothing, so after the 1-second pipe drain it returns failed rather than waiting, and the marker stays. A process that stays in Git's group after Git exits is waited for, as for every Git call: it cannot be signalled safely once Git has exited, because its group ID may be reused, so that one case is not bounded. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | +| Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. A process that left Git's group and still holds its output does not hold the read either: the read changes nothing, so after the 1-second pipe drain it returns failed rather than waiting, and the marker stays. A process that stays in Git's group after Git exits is waited for, as for every Git call: it cannot be signalled safely once Git has exited, because its group ID may be reused, so that one case is not bounded. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. After `moved`, the preparation reviews that head as after a refusal: it waits for the pull request to show it, refreshes attribution and approvals, and returns `review-required`. After `pushed` or `not pushed`, the push's error fails the preparation. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | | Unreadable, deadline or shutdown | The marker stays when the branch cannot be read, when the preparation's own deadline ended the push (less than the settlement reserve is left, so no read starts), or on shutdown. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 1e9f58cd..fdfbe26a 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -298,7 +298,7 @@ export class PreMergeCoordinator { async #pushRewrite(remote: RemotePair, view: ReturnType, signal: AbortSignal, deadline: number, authorize: () => Promise, bind: (reviewed: { reviewVersion: number; snapshotId: string }, pair: RemotePair, stateVersion?: number) => void): - Promise<{ result: 'pushed' | 'refused'; stateVersion: number }> { + Promise<{ result: 'pushed' | 'refused' | 'moved'; stateVersion: number }> { const identity = this.service.config.identity; if (!remote.branch) throw new GuardRefusal('The pull request was not published by the runner, so its rewritten head cannot be pushed.'); const reviewed = { revision: view.expected.revision, snapshotId: view.expected.snapshotId, @@ -333,13 +333,16 @@ export class PreMergeCoordinator { // Git's group after Git exits, which is waited for; see #settlePush), settles it; a cancel does not stop that // read. When the preparation's own deadline ended the push, less than that reserve is left, so no read starts and // the marker stays, as for an unreadable branch. - let settled = binding; + let settled = binding, outcome: PushOutcome | null = null; const left = Math.floor(deadline - performance.now()); if (!this.#closing && left >= 1) { - try { settled = owned(await this.#settlePush(marker, left)); } + try { const read = await this.#settlePush(marker, left); settled = owned(read); outcome = read.outcome; } catch { /* The marker stays; the next preparation or startup reads the branch again. */ } } bind(reviewed, { base: view.snapshot.base, head: view.snapshot.head }, settled); + // The branch holds neither the pre-push head nor ours: someone else moved it while the push's outcome was unknown + // (for example, right after the push, before its read-back). That head is reviewed, as after a refusal. + if (outcome === 'moved') return { result: 'moved', stateVersion: settled }; throw error; } return { result: 'pushed', stateVersion: owned(this.#finishPush(marker)) }; @@ -504,7 +507,9 @@ export class PreMergeCoordinator { return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], reason: pushed === 'pushed' ? 'The pull request moved after the rewritten head was pushed; attribution and approvals were refreshed.' - : 'The pull request branch moved before the rewritten head could be pushed; attribution and approvals were refreshed.' }; + : pushed === 'moved' + ? 'The pull request branch moved while the rewritten head was being pushed; attribution and approvals were refreshed.' + : 'The pull request branch moved before the rewritten head could be pushed; attribution and approvals were refreshed.' }; } // Nothing but this push may have changed the task meanwhile: a plan revision or review edit applied while it was in // flight would otherwise be adopted by the reload below and run checks under approvals it made stale. diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index e215ad4f..bc970735 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -78,6 +78,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: pushedFirst?: (service: ReviewService) => string; /** Runs once the push has landed and may move the branch again, as a collaborator push would. */ afterPush?: (service: ReviewService, pushed: string) => string; + /** The push lands, but its read-back (as GitBranchPusher's) sees another head and throws. */ + readBackFails?: boolean; pushFailure?: (service: ReviewService, input: { from: string; to: string }) => Error; branchRead?: (current: string, signal?: AbortSignal) => string | null | Promise; /** Runs while the push reads the remote, before its final local checks (the pusher's `beforePush`). */ @@ -225,6 +227,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: lagged = { ...lagged, base }; remotePair = { ...remotePair, base }; } if (options.afterPush) remotePair = { ...remotePair, head: options.afterPush(service, input.to) }; + if (options.readBackFails && remotePair.head !== input.to) + throw new Error(`After the push, the branch ${input.branch} is at ${remotePair.head}, not ${input.to}.`); }, // A real branch read is aborted by its signal; so is this one, so a cancel that stopped settlement would show. readBranch: async (_branch, signal) => { @@ -1093,6 +1097,22 @@ it('reviews a collaborator push that lands on the rewritten head right after it await fixture.coordinator.close(); await fixture.runner.close(); }); +it('reviews a collaborator push that lands between the push and its read-back', async () => { + let collaborator = ''; + const fixture = await rebaseFixture('feature\n', 0, { readBackFails: true, afterPush(service, pushed) { + const tree = fixtureGit(service.config.repository, 'rev-parse', `${pushed}^{tree}`); + collaborator = fixtureGit(service.config.repository, 'commit-tree', tree, '-p', pushed, '-m', 'collaborator before the read-back'); + return collaborator; + } }); + // The push's own read-back failed; the settlement read found the collaborator's head, so it is reviewed, not failed. + expect(fixture.result).toMatchObject({ state: 'review-required', head: collaborator, checked: [] }); + expect(fixture.result.reason).toMatch(/moved while the rewritten head was being pushed/); + expect(fixture.service.store.getTask(fixture.service.config.identity).pushInProgress).toBeNull(); + expect(fixture.service.load().snapshot).toMatchObject({ base: fixture.remote().base, head: collaborator }); + expect(fixture.checkedHeads).toEqual([]); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('keeps waiting while GitHub still reports the pre-push head, even if the base moved meanwhile', async () => { let movedBase = ''; const fixture = await rebaseFixture('feature\n', 0, { pushVisibleAfter: 2, movedBaseAfterPush(service) { From 8b365868e04a8a15a976a44c5f186b88bab8f0e3 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 16:10:54 -0700 Subject: [PATCH 059/163] Test admission guards; say a retry after a refused merge and a rebase needs review Tests now cover the job's refusal (saved and replayed) for a task that is not mergeable or is being cancelled. When a refused merge attempt is followed by a new snapshot (a retry's preparation rebased), the blocker asks for every item to be approved again instead of repeating GitHub's old refusal text; the doc names the path. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- runner/merge.ts | 10 ++++++- test/merge-job.test.ts | 42 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index bd271f34..bd6b4c68 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -44,7 +44,7 @@ While waiting, an unknown mergeability (GitHub is still computing it) and a pend | `merged` | merged | nothing | | `queued` | unchanged (in review, or approved, merge blocked on a retry) while GitHub's queue holds it | nothing | | `submitted` | unchanged | refresh; the merge attempt settles from GitHub | -| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | +| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**. After GitHub refused a merge, a retry whose preparation rebases makes a new snapshot, and that needs every item approved again (the replacement-snapshot rule of `merge-queue.md`); the retry then ends `failed` and says so. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | | `review-required` | unchanged | review the refreshed pull request, then merge again | | `failed` | unchanged | read the reason, then merge again | | `cancelled` | cancelled | nothing | diff --git a/runner/merge.ts b/runner/merge.ts index 0aa67ad7..bd529ab9 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -279,7 +279,15 @@ export class MergeCoordinator { ? attempt.reason ? `The merge submission outcome is unknown. ${attempt.reason} Waiting for GitHub reconciliation.` : attempt.kind === 'queue' ? 'The reviewed head is being submitted to the merge queue.' : 'The reviewed head is being submitted for direct merge.' : 'The reviewed head is queued. Waiting for GitHub to confirm the outcome.' }; if (attempt?.state === 'merged') return { code: 'queue-merged', message: 'GitHub confirmed that the reviewed head was merged.' }; - if (attempt && !this.#freshReviewComplete(attempt)) return { code: 'queue-head', message: attempt.reason ?? 'The pull request snapshot changed after the queue attempt. Review the replacement snapshot.' }; + if (attempt && !this.#freshReviewComplete(attempt)) { + // A new snapshot or plan revision after an attempt (a rebase, for example) needs every item approved again for it; + // the attempt's own reason (often GitHub's refusal) would not say so. + const { store, config } = this.service; + const replaced = store.getSnapshot(config.identity).id !== attempt.snapshotId || store.getPlan(config.identity).revision !== attempt.revision; + return { code: 'queue-head', message: replaced + ? `The pull request changed after the last merge attempt${attempt.reason ? ` (${attempt.reason})` : ''}. Approve every item again for the replacement snapshot, then merge.` + : attempt.reason ?? 'The pull request snapshot changed after the queue attempt. Review the replacement snapshot.' }; + } return null; } diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 33a292be..a21ab7c1 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -502,6 +502,48 @@ it('decides a retried job only from its own merge attempt, never from an earlier expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); }); +it('ends a retry whose preparation rebased after a refused merge as failed, asking for the replacement snapshot\'s review', async () => { + let storeRef: Store | null = null; + const f = setup({ merge: async () => { throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); }, + prepare: call => { + // The retry's preparation rebases: a new snapshot, whose approvals carry over but were given for the old one. + if (call === 2) { + const store = storeRef!, snapshot = store.getSnapshot(identity); + store.recordRebase(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + } + return 'ready'; + } }); + storeRef = f.store; + f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + f.coordinator.start('review-token', randomUUID()); + // A refused attempt followed by a new snapshot needs every item approved again (#24): the job says so, not GitHub's old text. + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/Approve every item again for the replacement snapshot/) }); + expect(f.gh.merged).toHaveLength(1); +}); + +it('refuses, saves and replays a click on a task that is not mergeable or is being cancelled', () => { + const f = setup(), queued = randomUUID(); + f.store.transitionTask(identity, f.store.getTask(identity).stateVersion, 'queued'); + expect(() => f.coordinator.start('review-token', queued)).toThrow('The task is queued; merge it from review.'); + expect(f.store.getMergeJob(identity, queued)).toMatchObject({ refused: true }); + f.store.transitionTask(identity, f.store.getTask(identity).stateVersion, 'in review'); + // A resend replays the refusal, even though the task is in review again. + expect(() => f.coordinator.start('review-token', queued)).toThrow('The task is queued; merge it from review.'); + // A cancel that waits for a push of the rewritten head to settle keeps the task in review, cancel requested. + const snapshot = f.store.getSnapshot(identity), reviewed = { revision: 1, snapshotId: snapshot.id, reviewVersion: f.store.reviewVersion(identity) }; + f.store.recordRebase(identity, reviewed, oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + const after = f.store.getSnapshot(identity); + f.store.beginPrePush(identity, { revision: 1, snapshotId: after.id, reviewVersion: f.store.reviewVersion(identity) }, f.store.getTask(identity).stateVersion, + { branch: BRANCH, from: oid(2), to: oid(12) }); + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + const cancelling = randomUUID(); + expect(() => f.coordinator.start('review-token', cancelling)).toThrow('The task is being cancelled.'); + expect(f.store.getMergeJob(identity, cancelling)).toMatchObject({ refused: true }); + expect(f.prep.calls).toEqual([]); +}); + it('answers a preparation stopping for shutdown as not applied, saving nothing', () => { const f = setup(), id = randomUUID(); (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('The server is shutting down.'); }; From ad00767bb5a2d3874ef371a2d9a777f5e87f6b9d Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 16:39:44 -0700 Subject: [PATCH 060/163] Test the retry after a refused merge and a rebase through real preparation The earlier test used fakes that kept items approved after a rebase, so it claimed the retry ends failed. Through real preparation the items go stale and the retry ends review-required; the merge status then asks for every item to be approved again. Doc corrected. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- test/merge-job.test.ts | 21 ---------------- test/pre-merge.test.ts | 37 +++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index bd6b4c68..e18a1493 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -44,7 +44,7 @@ While waiting, an unknown mergeability (GitHub is still computing it) and a pend | `merged` | merged | nothing | | `queued` | unchanged (in review, or approved, merge blocked on a retry) while GitHub's queue holds it | nothing | | `submitted` | unchanged | refresh; the merge attempt settles from GitHub | -| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**. After GitHub refused a merge, a retry whose preparation rebases makes a new snapshot, and that needs every item approved again (the replacement-snapshot rule of `merge-queue.md`); the retry then ends `failed` and says so. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | +| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**. After GitHub refused a merge, a retry whose preparation rebases makes a new snapshot, and that needs every item approved again (the replacement-snapshot rule of `merge-queue.md`): the items show stale, the retry ends `review-required`, and the merge status asks for every item to be approved again. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | | `review-required` | unchanged | review the refreshed pull request, then merge again | | `failed` | unchanged | read the reason, then merge again | | `cancelled` | cancelled | nothing | diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index a21ab7c1..60e8d9a8 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -502,27 +502,6 @@ it('decides a retried job only from its own merge attempt, never from an earlier expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); }); -it('ends a retry whose preparation rebased after a refused merge as failed, asking for the replacement snapshot\'s review', async () => { - let storeRef: Store | null = null; - const f = setup({ merge: async () => { throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); }, - prepare: call => { - // The retry's preparation rebases: a new snapshot, whose approvals carry over but were given for the old one. - if (call === 2) { - const store = storeRef!, snapshot = store.getSnapshot(identity); - store.recordRebase(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); - } - return 'ready'; - } }); - storeRef = f.store; - f.coordinator.start('review-token', randomUUID()); - expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked' }); - expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); - f.coordinator.start('review-token', randomUUID()); - // A refused attempt followed by a new snapshot needs every item approved again (#24): the job says so, not GitHub's old text. - expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/Approve every item again for the replacement snapshot/) }); - expect(f.gh.merged).toHaveLength(1); -}); - it('refuses, saves and replays a click on a task that is not mergeable or is being cancelled', () => { const f = setup(), queued = randomUUID(); f.store.transitionTask(identity, f.store.getTask(identity).stateVersion, 'queued'); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index c646bb98..4a6a27af 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -7,7 +7,7 @@ import { COMMAND_CHECK_SETTLEMENT_RESERVE_MS, PRE_MERGE_PROCESS_SETTLEMENT_RESER type PreMergeAuthorization, type PreMergeResult } from '../runner/pre-merge.ts'; import { MergeCoordinator } from '../runner/merge.ts'; import { MergeJobCoordinator } from '../runner/merge-job.ts'; -import type { MergeGateway } from '../github/merge.ts'; +import { MergeSubmissionError, type MergeGateway } from '../github/merge.ts'; import { openingMarker } from '../github/pull-requests.ts'; import { ReviewService } from '../runner/review.ts'; import { createDemo } from '../scripts/demo.ts'; @@ -1436,7 +1436,7 @@ it('starts no settlement read when less than the process-settlement reserve is l }); // F6b of #22: the acceptance cases, run through the one-click merge job with real preparation. -async function mergeJob(rebasedText: string, commandExit?: number, options: Parameters[2] = {}) { +async function mergeJob(rebasedText: string, commandExit?: number, options: Parameters[2] = {}, refuse: () => boolean = () => false) { const fixture = await rebaseFixture(rebasedText, commandExit, { ...options, deferStart: true, published: true }); const merged: string[] = []; // As production builds it with a runner: the task's published PR, which requires durable preparation readiness. @@ -1448,7 +1448,11 @@ async function mergeJob(rebasedText: string, commandExit?: number, options: Para published: { headBranch: request!.target!.headBranch!, baseBranch: 'main', crossRepository: false, marker: openingMarker(pr.openingId), branchOpen: [{ number: 7, base: 'main' }] } }; }, - merge: async head => { merged.push(head); return { url: 'https://github.com/acme/app/pull/7' }; }, + merge: async head => { + merged.push(head); + if (refuse()) throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); + return { url: 'https://github.com/acme/app/pull/7' }; + }, }; const merges = new MergeCoordinator(fixture.service, gateway, undefined, undefined, { repository: 'acme/app', baseBranch: 'main', requiresPreparation: true }); @@ -1456,7 +1460,7 @@ async function mergeJob(rebasedText: string, commandExit?: number, options: Para const start = () => jobs.start(fixture.service.load().token, randomUUID()); const settled = async () => { await jobs.current; return jobs.latest()!; }; const close = async () => { await jobs.close(); await fixture.coordinator.close(); await fixture.runner.close(); }; - return { ...fixture, merged, jobs, start, settled, close }; + return { ...fixture, merged, merges, jobs, start, settled, close }; } it('merges through the job when a moved base leaves approvals valid, pushing and checking the rewritten head', async () => { @@ -1497,6 +1501,31 @@ it('stops the job for review when a collaborator pushed to the pull request', as await f.close(); }); +it('stops a retry for review when its preparation rebases after GitHub refused the merge', async () => { + let refuse = true; + const f = await mergeJob('feature\n', 0, { + // The retry's rebase replays the already-rebased head onto the moved base, keeping its content. + rebaseResult: (service, input, run) => run < 2 ? undefined : fixtureGit(service.config.repository, 'commit-tree', + fixtureGit(service.config.repository, 'rev-parse', `${input.oldHead}^{tree}`), '-p', input.onto, '-m', 'feature, rebased again'), + }, () => refuse); + f.start(); + expect(await f.settled()).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge/) }); + expect(f.service.store.getTask(f.service.config.identity).status).toBe('approved but merge blocked'); + // The base moves again before the person retries: the retry's preparation rebases, making a new snapshot. + refuse = false; + const repository = f.service.config.repository, base = f.remote().base; + const moved = fixtureGit(repository, 'commit-tree', fixtureGit(repository, 'rev-parse', `${base}^{tree}`), '-p', base, '-m', 'base moved after the refusal'); + f.moveRemote({ base: moved, head: f.remote().head }); + f.start(); + // After a merge attempt, a replacement snapshot needs every item approved again (#24): the retry stops for review. + expect(await f.settled()).toMatchObject({ state: 'review-required', reason: expect.stringMatching(/requires refreshed attribution or approval/) }); + expect(f.service.load().items.every(item => item.state === 'stale')).toBe(true); + expect((await f.merges.status()).blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'queue-head', message: expect.stringMatching(/Approve every item again for the replacement snapshot/) })])); + expect(f.merged).toHaveLength(1); + await f.close(); +}, 60_000); + it('ends the job cancelled when the task is cancelled while a command check runs', async () => { const f = await mergeJob('feature\n', 0, { commandWaitsForCancellation: true }); f.start(); From 8c3b171b16da141768501f59ac2f20668371186b Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 16:53:05 -0700 Subject: [PATCH 061/163] Word the replacement-review blocker by its cause A plan revision without a new snapshot no longer reads as a replacement snapshot: the blocker names the plan. Existing queue tests now assert both messages. Co-Authored-By: Claude Opus 5.5 --- runner/merge.ts | 11 +++++++---- test/merge.test.ts | 5 +++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/runner/merge.ts b/runner/merge.ts index bd529ab9..0d96b02e 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -283,10 +283,13 @@ export class MergeCoordinator { // A new snapshot or plan revision after an attempt (a rebase, for example) needs every item approved again for it; // the attempt's own reason (often GitHub's refusal) would not say so. const { store, config } = this.service; - const replaced = store.getSnapshot(config.identity).id !== attempt.snapshotId || store.getPlan(config.identity).revision !== attempt.revision; - return { code: 'queue-head', message: replaced - ? `The pull request changed after the last merge attempt${attempt.reason ? ` (${attempt.reason})` : ''}. Approve every item again for the replacement snapshot, then merge.` - : attempt.reason ?? 'The pull request snapshot changed after the queue attempt. Review the replacement snapshot.' }; + const after = attempt.reason ? ` (${attempt.reason})` : ''; + const message = store.getSnapshot(config.identity).id !== attempt.snapshotId + ? `The pull request changed after the last merge attempt${after}. Approve every item again for the replacement snapshot, then merge.` + : store.getPlan(config.identity).revision !== attempt.revision + ? `The plan changed after the last merge attempt${after}. Approve every item again for the new plan revision, then merge.` + : attempt.reason ?? 'The pull request snapshot changed after the queue attempt. Review the replacement snapshot.'; + return { code: 'queue-head', message }; } return null; } diff --git a/test/merge.test.ts b/test/merge.test.ts index 03cca720..42d412f4 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -711,6 +711,8 @@ it('requires current-snapshot approvals after an ordinary queue removal and forc await h.coordinator.pollQueue(); h.replaceHead(sha('c')); expect((await h.coordinator.status(h.view())).action).toBeNull(); + expect((await h.coordinator.status(h.view())).blockers).toEqual(expect.arrayContaining([{ code: 'queue-head', + message: 'The pull request changed after the last merge attempt (Checks failed.). Approve every item again for the replacement snapshot, then merge.' }])); h.store.saveReview(h.identity, h.view().expected, [{ item: 'P1', fingerprint: 'replacement-review' }], []); expect((await h.coordinator.status(h.view())).action).toBe('merge'); } finally { await h.coordinator.close(); h.store.close(); } @@ -723,6 +725,9 @@ it('requires current-revision approvals after a same-snapshot plan amendment', a await h.coordinator.pollQueue(); h.amendPlan(); expect((await h.coordinator.status(h.view())).action).toBeNull(); + // The snapshot is unchanged: the message names the plan, not a replacement snapshot. + expect((await h.coordinator.status(h.view())).blockers).toEqual(expect.arrayContaining([{ code: 'queue-head', + message: 'The plan changed after the last merge attempt (Checks failed.). Approve every item again for the new plan revision, then merge.' }])); h.store.saveReview(h.identity, h.view().expected, [{ item: 'P1', fingerprint: 'amended-plan-review' }], []); expect((await h.coordinator.status(h.view())).action).toBe('merge'); } finally { await h.coordinator.close(); h.store.close(); } From 702d11e2e4bfcbd2e04cf9c3204c3df750570883 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 18:15:24 -0700 Subject: [PATCH 062/163] Address Copilot: recheck the push binding before reviewing a refused or moved push A plan revision or review edit that landed during the wait for the new head (or the fetch) was adopted when the observed head was recorded. The binding the push left is now checked before and after the fetch, as on the pushed path; such a change fails the preparation as stale. Co-Authored-By: Claude Opus 5.5 --- runner/pre-merge.ts | 5 +++++ test/pre-merge.test.ts | 30 +++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index fdfbe26a..9c4e29c6 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -500,7 +500,12 @@ export class PreMergeCoordinator { // GitHub's PR API reports that asynchronously, so wait for it; its lag must not read as the old head. const seen = await this.#awaitHeadChange(initial, signal, remaining); if (pushed !== 'pushed' || seen.head !== view.snapshot.head || seen.base !== initial.base) { + // As on the pushed path below: a plan revision or review edit that landed during the wait (or the fetch) must not + // have this preparation's remote observation recorded into it. + const binding = { stateVersion: pushState, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id }; + assertCurrent(binding); const moved = seen; await this.remote.fetch(moved, signal); signal.throwIfAborted(); + assertCurrent(binding); // Traced from the reviewed head and its rewrite lineage, nearest first. That lineage holds the pushed head, every // earlier rewrite codeboost may already have pushed, and the original head a collaborator may have built on. view = track(this.#refresh(moved, reviewBudget, view.snapshot.head)); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index bc970735..3c177477 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -49,6 +49,8 @@ const markRunnerUnchanged = (service: ReviewService) => { async function rebaseFixture(rebasedText: string, commandExit?: number, options: { duringRebase?: (service: ReviewService, coordinator: PreMergeCoordinator, timeoutMs?: number) => void; duringFinalInspect?: (service: ReviewService, coordinator: PreMergeCoordinator, read: number) => void; + /** Runs on each inspection that still shows the pre-push pull request (the wait for the new head to show). */ + duringVisibilityWait?: (service: ReviewService) => void; duringAuthorize?: (service: ReviewService, call: number) => { base: string; head: string } | void; closeDuringCommand?: boolean; closeAfterCommandAdmission?: boolean; @@ -203,7 +205,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: coordinator = new PreMergeCoordinator(service, runner, rebaser, { inspect: async () => { - if (lagging > 0) { lagging--; return lagged; } + if (lagging > 0) { lagging--; options.duringVisibilityWait?.(service); return lagged; } remoteReads++; // Read 2 is the post-push visibility check; hooks keep counting the later inspections as before the push existed. if (remoteReads > 2) options.duringFinalInspect?.(service, coordinator, remoteReads - 1); @@ -942,6 +944,27 @@ it('returns to review without pushing when the branch moved off the rewritten he await fixture.coordinator.close(); await fixture.runner.close(); }); +it('does not record a refused push\'s observation into a review edited during the wait for the new head', async () => { + let collaborator = '', edited = false; + const fixture = await rebaseFixture('feature\n', undefined, { pushVisibleAfter: 1, pushedFirst(service) { + writeFileSync(join(service.config.repository, 'late.txt'), 'collaborator\n'); + fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'collaborator'); + collaborator = fixtureGit(service.config.repository, 'rev-parse', 'HEAD'); + return collaborator; + }, duringVisibilityWait(service) { + // A person edits the review while GitHub still shows the pre-push head. + if (edited) return; edited = true; + service.store.addReviewNote(service.config.identity, service.load().expected, 'P1', 'change', 'Changed during the wait.'); + } }); + expect(edited).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed during preparation/i) }); + expect(fixture.coordinator.last?.stale).toBe(true); + // The collaborator head was not recorded into the edited review. + expect(fixture.service.load().snapshot.head).not.toBe(collaborator); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('waits for GitHub to report the collaborator head that refused the push before reviewing it', async () => { let collaborator = ''; const fixture = await rebaseFixture('feature\n', undefined, { pushVisibleAfter: 2, pushedFirst(service) { @@ -1327,7 +1350,8 @@ it('reports a failure after a push as stale when a plan revision landed while th const tree = fixtureGit(service.config.repository, 'rev-parse', `${head}^{tree}`); return fixtureGit(service.config.repository, 'commit-tree', tree, '-p', head, '-m', 'collaborator on top'); } }); - expect(pushed.result).toMatchObject({ state: 'failed', reason: 'fetch failed after the push' }); + // The revision is caught before the collaborator head is fetched and recorded: the failure names the change. + expect(pushed.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed during preparation/i) }); expect(pushed.coordinator.last).toMatchObject({ state: 'failed', stale: true }); await pushed.coordinator.close(); await pushed.runner.close(); // A refused push, then a failure while reviewing the collaborator head that refused it. @@ -1337,7 +1361,7 @@ it('reports a failure after a push as stale when a plan revision landed while th fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'collaborator'); return fixtureGit(service.config.repository, 'rev-parse', 'HEAD'); } }); - expect(refused.result).toMatchObject({ state: 'failed', reason: 'fetch failed after the push' }); + expect(refused.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed during preparation/i) }); expect(refused.coordinator.last).toMatchObject({ state: 'failed', stale: true }); await refused.coordinator.close(); await refused.runner.close(); }); From da0204a9ed498c89e264914b87a4fe6e0896d767 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 18:27:03 -0700 Subject: [PATCH 063/163] Address Copilot: new key after a job ends, keep in-flight drafts, answer the latest job The browser drops a retained merge key once the job it started has ended, so Retry merge is a new action rather than a replay. A merge click keeps what was typed while its request was in flight. The merge route answers the job's latest record, which may have ended while the review reloaded. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 49 ++++++++++++++++++++++++++++++++++ test/runner-publishing.test.ts | 21 +++++++++++++++ web/public/app.js | 5 ++++ web/server.ts | 4 ++- 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index cef19eac..868b14c7 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -649,6 +649,55 @@ test('names a failed merge-job poll as a merge-status failure (F6b)',async({page await expect(page.locator('#banner')).toHaveText(/^Could not refresh merge status\. GitHub returned HTTP 502\./,{timeout:15_000}); }); +test('keeps a draft typed while a merge-job click is in flight (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + let release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + await held;const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing'); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:job('x','running','preparing')})})); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await page.getByLabel('Question about this item').fill('Before the click.'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The person keeps typing while the click's request is in flight. + await page.getByLabel('Question about this item').fill('Typed while the merge request was in flight.'); + release(); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.getByLabel('Question about this item')).toHaveValue('Typed while the merge request was in flight.'); +}); + +test('starts a new action for Retry merge once the job its retained key started has ended (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const clicks:string[]=[];let first:string|null=null; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); + if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing'); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:job('x','running','preparing')})})); + // The first click's job was saved and has since ended blocked: the review offers Retry merge. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,job:first?job(first,'blocked',null):null,...(first?{ready:true,action:'retry'}:{})};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Merge blocked.'); + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect.poll(()=>clicks.length).toBe(2); + // The retry is a new action: resending the first key would only replay the ended job. + expect(clicks[1]).not.toBe(clicks[0]); +}); + test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 9437f813..3e735b05 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1893,6 +1893,27 @@ describe('the one-click merge job (F6b of #22)', () => { try { expect(reopened.getMergeJob(served.identity, id)).toMatchObject({ id }); } finally { reopened.close(); } }, 60_000); + it('answers a merge click with the job\'s latest record when it ended during the click\'s reload', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, slow = false; + const base = gateway(() => storeRef!, () => []); + // The click's reload reads GitHub's display status (not fresh) slowly; the job's own fresh inspections answer at once. + const gh: MergeGateway = { merge: base.merge, inspect: async request => { + if (slow && !request?.fresh) await new Promise(resolve => setTimeout(resolve, 1_500)); + return base.inspect(request); + } }; + const served = await serve(w, { before: service => { completeAll(service); acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + slow = true; + const clicked = await merge(served.app, token, id); + // The job merged while the click's response was being prepared: the response says so, not "preparing". + expect(served.store.getMergeJob(served.identity, id)).toMatchObject({ state: 'merged' }); + expect(clicked).toMatchObject({ status: 200, body: { job: { id, state: 'merged' } } }); + }, 60_000); + it('closes the pull requests of a task whose cancel waited for the merge job\'s preparation to settle', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, release!: () => void, preparing = false; diff --git a/web/public/app.js b/web/public/app.js index 444fe37b..d7d5e71e 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -158,6 +158,8 @@ function renderMerge() { if (!merge?.available) return; // Once the attempt this retained key started has ended, a retry is a new action and needs a new key. if (mergeAction && merge.queue?.actionId === mergeAction.actionId && ["merged", "removed", "failed"].includes(merge.queue.state)) mergeAction = null; + // Likewise once the merge job this key started has ended (a job is saved under its click's action ID): a retry is new. + if (mergeAction && merge.job?.id === mergeAction.actionId && merge.job.state !== "running") mergeAction = null; // With merge jobs (their status carries `job`), a click's answer arrives while the control may hold focus, so it is // marked, never disabled; activation is ignored while the merge is not ready. if ("job" in merge) { @@ -598,6 +600,8 @@ $("merge").onclick = async () => { rememberDraft(); mergeAction ??= { actionId: crypto.randomUUID(), token: data.token }; const updated = await api("/api/action", { action: "merge", token: mergeAction.token, actionId: mergeAction.actionId }); + // The composer stayed editable while the request was in flight: keep what the person typed meanwhile. + rememberDraft(); mergeAction = null; if (updated.job) { // A merge job runs in the background (F6b); the page follows it. @@ -614,6 +618,7 @@ $("merge").onclick = async () => { render(); $("banner").textContent = `${queued ? "Merge queued" : "Merge submitted"}. ${updated.mergeResult.url}`; } catch (error) { + rememberDraft(); // Only a parsed, definite server answer resolves this click. No response, an unreadable body, 503 (nothing // applied) or an admitted attempt with an unknown outcome keeps the key until that attempt ends. if (typeof error.status === "number" && error.status !== 503 && !error.outcomeUnknown) mergeAction = null; diff --git a/web/server.ts b/web/server.ts index 90147722..dea583cf 100644 --- a/web/server.ts +++ b/web/server.ts @@ -727,7 +727,9 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge let loaded: Awaited>; try { loaded=await load(requestAbort.signal); } catch (error) { if(stopping||runner?.closing||requestAbort.signal.aborted)throw new ShuttingDownError(); throw error; } - json(200,{...loaded,job});return; + // The job may have moved on (or ended) while the review reloaded: answer its latest record, not the admission's. + const latest=mergeJobs.latest(); + json(200,{...loaded,job:latest?.id===job.id?latest:job});return; } let merged: Awaited>; // A merge stopped for a passing reason applied nothing, so answer 503 and the browser resends the same key. From f7754db17589a750e77aee2752d73ebacf022f8c Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 18:33:09 -0700 Subject: [PATCH 064/163] Test the binding check after the fetch; document the stale outcome Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- test/pre-merge.test.ts | 26 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 9a4dd433..7a2acd12 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -39,7 +39,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Step | What happens | |---|---| | Admission | The task state version is captured, then issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | -| Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. | +| Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. The same check runs before and after the new head is fetched for review (after a refusal, a `moved` outcome, or a pull request that moved after the push): a change made during the push, the wait or the fetch fails the preparation as stale instead of being reviewed. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (a 6-second deadline in production, plus up to 6 seconds for a stopped `gh` call to settle), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 3c177477..57e10f7c 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -51,6 +51,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: duringFinalInspect?: (service: ReviewService, coordinator: PreMergeCoordinator, read: number) => void; /** Runs on each inspection that still shows the pre-push pull request (the wait for the new head to show). */ duringVisibilityWait?: (service: ReviewService) => void; + /** Runs inside each fetch after a push or refusal, while the fetch is in flight. */ + duringFetchAfterPush?: (service: ReviewService) => void; duringAuthorize?: (service: ReviewService, call: number) => { base: string; head: string } | void; closeDuringCommand?: boolean; closeAfterCommandAdmission?: boolean; @@ -210,7 +212,10 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: // Read 2 is the post-push visibility check; hooks keep counting the later inspections as before the push existed. if (remoteReads > 2) options.duringFinalInspect?.(service, coordinator, remoteReads - 1); return remotePair; - }, fetch: async () => { if (options.fetchFailsAfterPush && pushes.length) throw new Error('fetch failed after the push'); }, + }, fetch: async () => { + if (pushes.length) options.duringFetchAfterPush?.(service); + if (options.fetchFailsAfterPush && pushes.length) throw new Error('fetch failed after the push'); + }, push: async input => { pushes.push({ branch: input.branch, from: input.from, to: input.to }); options.duringPushRead?.(service, coordinator); @@ -965,6 +970,25 @@ it('does not record a refused push\'s observation into a review edited during th await fixture.coordinator.close(); await fixture.runner.close(); }); +it('does not record a refused push\'s observation into a review edited while the new head was fetched', async () => { + let collaborator = '', edited = false; + const fixture = await rebaseFixture('feature\n', undefined, { pushedFirst(service) { + writeFileSync(join(service.config.repository, 'late.txt'), 'collaborator\n'); + fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'collaborator'); + collaborator = fixtureGit(service.config.repository, 'rev-parse', 'HEAD'); + return collaborator; + }, duringFetchAfterPush(service) { + // A person edits the review while the collaborator head is being fetched. + if (edited) return; edited = true; + service.store.addReviewNote(service.config.identity, service.load().expected, 'P1', 'change', 'Changed during the fetch.'); + } }); + expect(edited).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed during preparation/i) }); + expect(fixture.coordinator.last?.stale).toBe(true); + expect(fixture.service.load().snapshot.head).not.toBe(collaborator); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('waits for GitHub to report the collaborator head that refused the push before reviewing it', async () => { let collaborator = ''; const fixture = await rebaseFixture('feature\n', undefined, { pushVisibleAfter: 2, pushedFirst(service) { From a734646a7e91690702bdf5df15d3ebe44ca716e5 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 18:45:55 -0700 Subject: [PATCH 065/163] Test that the binding check before the fetch runs first Co-Authored-By: Claude Opus 5.5 --- test/pre-merge.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 57e10f7c..6be84a5e 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -198,7 +198,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }, context.allowedCommands, 'a'.repeat(32))); let remoteReads = 0, remotePair: { base: string; head: string; branch?: string } = { base: onto, head, branch: BRANCH }; const pushes: { branch: string; from: string; to: string }[] = []; - let lagging = 0, lagged = remotePair, branchReads = 0; + let lagging = 0, lagged = remotePair, branchReads = 0, fetchesAfterPush = 0; let authorizationChecks = 0; const authorize = options.authorize ?? (options.duringAuthorize ? async () => ({ refresh: async () => undefined, @@ -213,6 +213,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: if (remoteReads > 2) options.duringFinalInspect?.(service, coordinator, remoteReads - 1); return remotePair; }, fetch: async () => { + if (pushes.length) fetchesAfterPush++; if (pushes.length) options.duringFetchAfterPush?.(service); if (options.fetchFailsAfterPush && pushes.length) throw new Error('fetch failed after the push'); }, @@ -258,7 +259,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons, pushes, remote: () => remotePair, - branchReads: () => branchReads, + branchReads: () => branchReads, fetchesAfterPush: () => fetchesAfterPush, /** The branch moves (as GitHub's Git data shows it); the PR API reports `shown` for the next `reads` inspections. */ moveRemote: (pair: { base: string; head: string }, shown?: { base: string; head: string }, reads = 0) => { remotePair = { ...remotePair, ...pair }; @@ -966,6 +967,8 @@ it('does not record a refused push\'s observation into a review edited during th expect(fixture.coordinator.last?.stale).toBe(true); // The collaborator head was not recorded into the edited review. expect(fixture.service.load().snapshot.head).not.toBe(collaborator); + // Caught before the collaborator head was even fetched. + expect(fixture.fetchesAfterPush()).toBe(0); expect(pushMarker(fixture)).toBeNull(); await fixture.coordinator.close(); await fixture.runner.close(); }); From 2a9534dfe9e8f9643d6c0489805038c9d436ade6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 19:35:41 -0700 Subject: [PATCH 066/163] Drop a retained merge key once another job is the latest One job runs at a time, so when the latest job is not this key's job running, the key's job has ended or never started: a click is a new action rather than a replay of an old job. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 26 ++++++++++++++++++++++++++ web/public/app.js | 5 +++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 868b14c7..b6306a14 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -698,6 +698,32 @@ test('starts a new action for Retry merge once the job its retained key started expect(clicks[1]).not.toBe(clicks[0]); }); +test('starts a new action once another job became the latest after a retained key (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const clicks:string[]=[];let first:string|null=null; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); + if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing'); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:job('x','running','preparing')})})); + // The first click's job ended, then another client's job ran and ended blocked: the latest job is not this key's. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,job:first?job('0f6b0000-0000-4000-8000-0000000000b2','blocked',null):null,...(first?{ready:true,action:'retry'}:{})};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Merge blocked.'); + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect.poll(()=>clicks.length).toBe(2); + // The retry is a new action: resending the first key would only replay the ended job. + expect(clicks[1]).not.toBe(clicks[0]); +}); + test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; diff --git a/web/public/app.js b/web/public/app.js index d7d5e71e..86e55694 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -158,8 +158,9 @@ function renderMerge() { if (!merge?.available) return; // Once the attempt this retained key started has ended, a retry is a new action and needs a new key. if (mergeAction && merge.queue?.actionId === mergeAction.actionId && ["merged", "removed", "failed"].includes(merge.queue.state)) mergeAction = null; - // Likewise once the merge job this key started has ended (a job is saved under its click's action ID): a retry is new. - if (mergeAction && merge.job?.id === mergeAction.actionId && merge.job.state !== "running") mergeAction = null; + // Likewise once the merge job this key started has ended (a job is saved under its click's action ID), or another job is + // the latest: one job runs at a time, so this key's job has ended or never started. Either way a click is a new action. + if (mergeAction && merge.job && !(merge.job.id === mergeAction.actionId && merge.job.state === "running")) mergeAction = null; // With merge jobs (their status carries `job`), a click's answer arrives while the control may hold focus, so it is // marked, never disabled; activation is ignored while the merge is not ready. if ("job" in merge) { From a23ac7837cc62ee4efdc9dacc355fe70eadccd04 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 15:15:01 -0700 Subject: [PATCH 067/163] Refuse a push whose post-rebase review reload adopted a change (F6a, #170) Since #170 the review reload after a rebase awaits a history worker. A reassignment or review edit landing during it was adopted into the push's binding, so the push could run under a state the caller never checked. The task and review the rebase left are now checked again after the reload. Regressions for both cases fail without the check. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/pre-merge.ts | 8 +++++++- test/pre-merge.test.ts | 26 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 7a2acd12..1c73b800 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -38,7 +38,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Step | What happens | |---|---| -| Admission | The task state version is captured, then issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | +| Admission | The review reload after the rebase awaits a history worker, so the task and review the rebase left are checked again after it: a reassignment or review edit during that reload refuses the push. The task state version is then captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | | Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. The same check runs before and after the new head is fetched for review (after a refusal, a `moved` outcome, or a pull request that moved after the push): a change made during the push, the wait or the fetch fails the preparation as stale instead of being reviewed. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index d9825886..5b996d36 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -308,7 +308,8 @@ export class PreMergeCoordinator { const reviewed = { revision: view.expected.revision, snapshotId: view.expected.snapshotId, reviewVersion: view.expected.reviewVersion! }; // Captured before the asynchronous authorization read: any task write that lands during it (a reassignment, a - // referenced-code change) must refuse the claim, not be adopted by it. + // referenced-code change) must refuse the claim, not be adopted by it. The caller has already checked that the + // review reload before this (after a rebase, it awaits a history worker) adopted nothing new. const admitted = this.service.store.getTask(identity).stateVersion; const authorization = await authorize(); signal.throwIfAborted(); @@ -491,7 +492,12 @@ export class PreMergeCoordinator { bindRebaseFailure(reviewed, { base: oldBase, head: oldHead }); throw error; } + // What the rebase left, read in the turn it committed. The reload awaits a history worker (#170): a reassignment or + // review edit that lands during it must refuse the push below, not become the state the push binds to. + const rebased = { stateVersion: this.service.store.getTask(identity).stateVersion, + reviewVersion: this.service.store.reviewVersion(identity), snapshotId: this.service.store.getSnapshot(identity).id }; view = await load(); + assertCurrent(rebased); } const blocker = this.#reviewBlocker(view); if (blocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 542bd967..fb5efdd6 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1624,6 +1624,32 @@ it('pushes nothing when the task is reassigned while the push authorization is r await fixture.coordinator.close(); await fixture.runner.close(); }); +it.each([ + ['reassigned', (service: ReviewService) => { + const identity = service.config.identity; + service.store.setAssignment(identity, service.store.getTask(identity).stateVersion, 'someone-else', 'c'.repeat(40)); + }], + ['given a review edit', (service: ReviewService) => { + service.store.addReviewNote(service.config.identity, reviewState(service), 'P1', 'question', 'Asked during the reload after the rebase.'); + }], +] as const)('pushes nothing when the task is %s during the review reload after the rebase (#170)', async (_case, change) => { + // The first load sees the original head; the first one that sees another head is the reload after the rebase. + let firstHead: string | undefined, changed = false; + const fixture = await rebaseFixture('feature\n', undefined, { duringLoad(service) { + const head = service.store.getSnapshot(service.config.identity).head; + firstHead ??= head; + if (head === firstHead || changed) return; + changed = true; + change(service); + } }); + expect(changed).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'The task or review changed during preparation. Reload first.' }); + expect(fixture.pushes).toEqual([]); + expect(fixture.remote().head).not.toBe(fixture.rebased); + expect(pushMarker(fixture)).toBeNull(); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('keeps the process-settlement reserve inside a settlement read\'s time limit', async () => { // Reserve plus 2 s left: the read may run for at most about 2 s, leaving the reserve for Git to settle. let startedAt = 0, failedAt = 0, pushedAt = 0; From 9578d8cce4690bf3568334dfef298b8a3e37cd10 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 15:31:05 -0700 Subject: [PATCH 068/163] Guard every preparation reload and report its refusals as stale (F6a, #170) Since #170 each review reload in a preparation awaits a history worker. The reload after a command check adopted a reassignment into readiness, and every reload recorded the post-await state before its guard ran, so a refusal was reported as current rather than stale. A guarded reload now checks the state it must still match before recording the view; every reload uses it. Regression for the command-check reload, and stale assertions on every reload refusal; both fail without the change. Both gaps also exist on main since #170. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/pre-merge.ts | 28 +++++++++++++++++-------- test/pre-merge.test.ts | 23 ++++++++++++++++++-- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 1c73b800..72687690 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -38,7 +38,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Step | What happens | |---|---| -| Admission | The review reload after the rebase awaits a history worker, so the task and review the rebase left are checked again after it: a reassignment or review edit during that reload refuses the push. The task state version is then captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | +| Admission | Every review reload in a preparation awaits a history worker (#170), so each is guarded: the state it must still match (what the rebase, the push or a command check left, or the final binding) is checked after the reload and before the view is recorded, and a reassignment or review edit during it refuses the preparation, which is reported as stale. After the rebase that refusal comes before any push. The task state version is then captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | | Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. The same check runs before and after the new head is fetched for review (after a refusal, a `moved` outcome, or a pull request that moved after the push): a change made during the push, the wait or the fetch fails the preparation as stale instead of being reviewed. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 5b996d36..125a2b15 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -408,7 +408,14 @@ export class PreMergeCoordinator { } }); } catch { /* An unbound failure is conservatively historical. */ } }; - const load = async () => track(await this.service.load({ maxDurationMs: Math.min(30_000, reviewBudget()), signal })); + const loadView = () => this.service.load({ maxDurationMs: Math.min(30_000, reviewBudget()), signal }); + // Every review load awaits a history worker (#170). A load that must not adopt a change made while it awaited checks + // its guard before the view is recorded, so a refusal stays bound to the earlier state and is reported as stale. + const reload = async (guard: { stateVersion: number; reviewVersion: number; snapshotId: string }) => { + const loaded = await loadView(); + assertCurrent(guard); + return track(loaded); + }; signal.throwIfAborted(); if (this.runner.isActive(identity)) throw new GuardRefusal('An attempt is already active for this task.'); const runnerStatus = this.runner.status(identity); @@ -428,9 +435,10 @@ export class PreMergeCoordinator { // Settling advanced task state. The caller's binding is stale now; it reloads and starts again. throw new GuardRefusal('An earlier push of the rewritten head was settled. Reload, then prepare the merge again.'); } - let view = await load(), task = this.service.store.getTask(identity); + let view = await loadView(), task = this.service.store.getTask(identity); if (task.stateVersion !== expected.stateVersion || view.expected.reviewVersion !== expected.reviewVersion || view.snapshot.id !== expected.snapshotId) throw new GuardRefusal('The review changed before preparation started. Reload first.'); + track(view); if (!MERGEABLE_STATUSES.includes(task.status)) throw new GuardRefusal(`The task is ${task.status}; prepare it from review.`); if (task.cancelRequested !== null) throw new GuardRefusal('The task is being cancelled.'); if (!this.service.reviewRepository().runnerOwned) @@ -496,8 +504,7 @@ export class PreMergeCoordinator { // review edit that lands during it must refuse the push below, not become the state the push binds to. const rebased = { stateVersion: this.service.store.getTask(identity).stateVersion, reviewVersion: this.service.store.reviewVersion(identity), snapshotId: this.service.store.getSnapshot(identity).id }; - view = await load(); - assertCurrent(rebased); + view = await reload(rebased); } const blocker = this.#reviewBlocker(view); if (blocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, @@ -534,8 +541,7 @@ export class PreMergeCoordinator { const pushedBinding = { stateVersion: pushState, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id }; assertCurrent(pushedBinding); initial = seen; - view = await load(); - assertCurrent(pushedBinding); + view = await reload(pushedBinding); } const checked: string[] = []; for (const item of view.items.filter(candidate => candidate.acceptance.some(check => check.type === 'cmd'))) { @@ -577,7 +583,11 @@ export class PreMergeCoordinator { throw new GuardRefusal('Command-check cleanup could not be confirmed; restart and recover owned resources before preparing a merge.'); if (settled.state !== 'completed') throw new GuardRefusal(settled.exitCode === null && settled.diagnostic ? settled.diagnostic : `${item.id} command checks did not pass.`); - checked.push(item.id); onChecked(checked); view = await load(); + checked.push(item.id); onChecked(checked); + // The state the check left (its own attempt advanced the task), read in this turn: a reassignment or review edit + // during the reload below must refuse, not be adopted into readiness. + view = await reload({ stateVersion: this.service.store.getTask(identity).stateVersion, + reviewVersion: view.expected.reviewVersion!, snapshotId: this.service.store.getSnapshot(identity).id }); const changed = this.#reviewBlocker(view); if (changed) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: changed }; } @@ -593,7 +603,7 @@ export class PreMergeCoordinator { return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The pull request moved during preparation; attribution and approvals were refreshed.' }; } - view = await load(); + view = await reload(guarded); if (view.snapshot.base !== prepared.base || view.snapshot.head !== prepared.head) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The local review changed during preparation; reload it.' }; @@ -604,7 +614,7 @@ export class PreMergeCoordinator { // Authorization is asynchronous. A review edit during that read invalidates its result just as one during the // final remote inspection does; nothing may persist readiness from the pre-authorization view. assertCurrent(guarded); - view = await load(); + view = await reload(guarded); if (view.snapshot.base !== prepared.base || view.snapshot.head !== prepared.head) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The local review changed during final authorization; reload it.' }; diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index fb5efdd6..f9526ce3 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -423,10 +423,27 @@ it('refuses a review edit made during the review reload after the push, which aw expect(edited).toBe(true); expect(fixture.pushes).toHaveLength(1); expect(fixture.result).toMatchObject({ state: 'failed', reason: 'The task or review changed during preparation. Reload first.' }); + expect(fixture.coordinator.last?.stale).toBe(true); expect(fixture.checkedHeads).toEqual([]); // No command check ran under the stale approvals. await fixture.coordinator.close(); await fixture.runner.close(); }); +it('refuses readiness when the task is reassigned during the review reload after a command check (#170)', async () => { + // A passing command check; the reload that follows it is the first load after the check settled. + let settledChecks = 0, changed = false; + const fixture = await rebaseFixture('feature\n', 0, { duringLoad(service) { + const identity = service.config.identity; + settledChecks = service.store.getAttempts(identity).filter(attempt => attempt.kind === 'check' && attempt.state === 'completed').length; + if (settledChecks !== 1 || changed) return; + changed = true; + service.store.setAssignment(identity, service.store.getTask(identity).stateVersion, 'someone-else', 'c'.repeat(40)); + } }); + expect(changed).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'The task or review changed during preparation. Reload first.' }); + expect(fixture.coordinator.last?.stale).toBe(true); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('refuses readiness when local issue trust is revoked during the final review load', async () => { let trusted = true, refreshes = 0, inspected = 0; const fixture = await rebaseFixture('feature\n', undefined, { @@ -469,9 +486,10 @@ it('refuses readiness when the task changes during the final review load', async }, }); expect(cancelled).toBe(true); - // Refused by the check after the final remote inspection, not earlier. - expect(inspected).toBe(3); + // Refused by the guard on the final review load itself, before the final remote inspection, and reported as stale. + expect(inspected).toBe(2); expect(fixture.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/task or review changed during preparation/i) }); + expect(fixture.coordinator.last?.stale).toBe(true); await fixture.coordinator.close(); await fixture.runner.close(); }); @@ -1644,6 +1662,7 @@ it.each([ } }); expect(changed).toBe(true); expect(fixture.result).toMatchObject({ state: 'failed', reason: 'The task or review changed during preparation. Reload first.' }); + expect(fixture.coordinator.last?.stale).toBe(true); expect(fixture.pushes).toEqual([]); expect(fixture.remote().head).not.toBe(fixture.rebased); expect(pushMarker(fixture)).toBeNull(); From df8966a45c9d10c0005f3c093f96537f8dea2a9f Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 15:40:34 -0700 Subject: [PATCH 069/163] Bind preparation failures as current or stale by their cause (F6a, #170) - A failure of the review reload after a rebase (a worker timeout) is bound to the state the rebase left, so it is reported as current. - A command check that a reassignment settled as stale no longer adopts the reassigned state, so its failure is reported as stale (pre-existing since F5). - Regressions for both, and for a change request added while a check runs, which is refused as stale rather than adopted as review-required. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/pre-merge.ts | 9 +++-- test/pre-merge.test.ts | 46 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 72687690..83a9e0b7 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -38,7 +38,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Step | What happens | |---|---| -| Admission | Every review reload in a preparation awaits a history worker (#170), so each is guarded: the state it must still match (what the rebase, the push or a command check left, or the final binding) is checked after the reload and before the view is recorded, and a reassignment or review edit during it refuses the preparation, which is reported as stale. After the rebase that refusal comes before any push. The task state version is then captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | +| Admission | Every review reload in a preparation awaits a history worker (#170), so each is guarded: the state it must still match (what the rebase, the push or a command check left, or the final binding) is checked after the reload and before the view is recorded, and a reassignment or review edit during it refuses the preparation, which is reported as stale. After the rebase that refusal comes before any push. A review edit made while a command check runs is refused the same way after the check (it is not adopted as `review-required`), and a reassignment during a check settles the check as stale and the preparation as a stale failure. A failure of a reload itself (a worker timeout) is reported as current. The task state version is then captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | | Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. The same check runs before and after the new head is fetched for review (after a refusal, a `moved` outcome, or a pull request that moved after the push): a change made during the push, the wait or the fetch fails the preparation as stale instead of being reviewed. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 125a2b15..2c4686e9 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -504,6 +504,9 @@ export class PreMergeCoordinator { // review edit that lands during it must refuse the push below, not become the state the push binds to. const rebased = { stateVersion: this.service.store.getTask(identity).stateVersion, reviewVersion: this.service.store.reviewVersion(identity), snapshotId: this.service.store.getSnapshot(identity).id }; + // Bind a failure of the reload itself (a worker timeout) to the state the rebase left: it is current, not stale. + const rebasedSnapshot = this.service.store.getSnapshot(identity); + bindRebaseFailure(rebased, { base: rebasedSnapshot.base, head: rebasedSnapshot.head }, rebased.stateVersion); view = await reload(rebased); } const blocker = this.#reviewBlocker(view); @@ -568,8 +571,10 @@ export class PreMergeCoordinator { if (signal.aborted) stop(); try { await this.runner.settled(identity); } finally { signal.removeEventListener('abort', stop); } // The check attempt itself advances task state even when it fails. Bind that owned transition without adopting a - // concurrent review edit; such an edit must leave the preparation historical and stale. - if (this.service.store.reviewVersion(identity) === view.expected.reviewVersion) { + // concurrent review edit or reassignment; either must leave the preparation historical and stale. A reassignment + // during the check settles it as stale. + if (this.service.store.reviewVersion(identity) === view.expected.reviewVersion + && this.service.store.getAttempt(identity, attempt.id).state !== 'stale') { const snapshot = this.service.store.getSnapshot(identity); onObserved({ pair: { base: snapshot.base, head: snapshot.head }, binding: { stateVersion: this.service.store.getTask(identity).stateVersion, diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index f9526ce3..48c6edd5 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -109,6 +109,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: duringPushRead?: (service: ReviewService, coordinator: PreMergeCoordinator) => void; /** Saves the preparation's outcome under this action ID, as the server's prepare-merge action does. */ actionId?: string; + /** Runs while a command check runs (when its workspace is materialized). */ + duringCommand?: (service: ReviewService) => void; } = {}) { const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-base-')); roots.push(root); const repository = join(root, 'repo'); fixtureGit(root, 'init', '-q', '-b', 'main', repository); @@ -168,6 +170,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: const workspace: TaskWorkspace = { async materialize(attempt, checkedHead) { checkedHeads.push(checkedHead); + options.duringCommand?.(service); return { clone: { id: attempt.id, taskId: 'task', directory: repository, head: checkedHead }, storage: {} } as WorkspaceRef; }, async snapshotDeclaredLinks() { throw new Error('not used'); }, async checkTree() { throw new Error('not used'); }, @@ -444,6 +447,49 @@ it('refuses readiness when the task is reassigned during the review reload after await fixture.coordinator.close(); await fixture.runner.close(); }); +it('reports a failure of the review reload after the rebase itself as current, not stale', async () => { + let firstHead: string | undefined, failed = false; + const fixture = await rebaseFixture('feature\n', undefined, { duringLoad(service) { + const head = service.store.getSnapshot(service.config.identity).head; + firstHead ??= head; + if (head === firstHead || failed) return; + failed = true; + throw new Error('History worker timed out.'); // The reload fails on its own; nothing else changed. + } }); + expect(failed).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'History worker timed out.' }); + expect(fixture.coordinator.last?.stale).toBe(false); + expect(fixture.pushes).toEqual([]); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('reports a command check stopped by a reassignment as stale, not as a current failure', async () => { + let changed = false; + const fixture = await rebaseFixture('feature\n', 0, { duringCommand(service) { + if (changed) return; + changed = true; + const identity = service.config.identity; + service.store.setAssignment(identity, service.store.getTask(identity).stateVersion, 'someone-else', 'c'.repeat(40)); + } }); + expect(changed).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed' }); + expect(fixture.coordinator.last?.stale).toBe(true); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('refuses, as stale, a change request added while a command check runs, instead of adopting it', async () => { + let changed = false; + const fixture = await rebaseFixture('feature\n', 0, { duringCommand(service) { + if (changed) return; + changed = true; + service.store.addReviewNote(service.config.identity, reviewState(service), 'P1', 'change', 'Requested during the check.'); + } }); + expect(changed).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'The task or review changed during preparation. Reload first.' }); + expect(fixture.coordinator.last?.stale).toBe(true); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('refuses readiness when local issue trust is revoked during the final review load', async () => { let trusted = true, refreshes = 0, inspected = 0; const fixture = await rebaseFixture('feature\n', undefined, { From 0069d8f6711b8bcf45a063b125f7f8c503048cc8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 16:04:34 -0700 Subject: [PATCH 070/163] Refuse a command check whose context changed after it settled (F6a, #170) runner.settled() resolves only after the runner releases the check's workspace. A reassignment or plan change in that window left the attempt completed and was adopted into readiness. The check's state is now adopted only while its context equals the current one; otherwise the preparation is refused as stale. The test fixture's failing load now rejects after the real load settles, as a worker failure would, so no abandoned load rejects after the test. Docs note that a refresh's closing reload is not guarded and why. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 2 +- runner/pre-merge.ts | 15 +++++++------ test/pre-merge.test.ts | 28 +++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 83a9e0b7..17f863a6 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -38,7 +38,7 @@ This ordering is intentionally local-only. The push below extends the durable li | Step | What happens | |---|---| -| Admission | Every review reload in a preparation awaits a history worker (#170), so each is guarded: the state it must still match (what the rebase, the push or a command check left, or the final binding) is checked after the reload and before the view is recorded, and a reassignment or review edit during it refuses the preparation, which is reported as stale. After the rebase that refusal comes before any push. A review edit made while a command check runs is refused the same way after the check (it is not adopted as `review-required`), and a reassignment during a check settles the check as stale and the preparation as a stale failure. A failure of a reload itself (a worker timeout) is reported as current. The task state version is then captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | +| Admission | Every review reload in a preparation awaits a history worker (#170), so each one that can lead to a push, a command check or readiness is guarded: the state it must still match (what the rebase, the push or a command check left, or the final binding) is checked after the reload and before the view is recorded, and a reassignment or review edit during it refuses the preparation, which is reported as stale. After the rebase that refusal comes before any push. A review edit made while a command check runs is refused the same way after the check (it is not adopted as `review-required`), and a reassignment during a check settles the check as stale and the preparation as a stale failure; one after the check's terminal write, while the runner releases its workspace, is caught by comparing the check's context with the current one and refuses as stale too. A failure of a reload itself (a worker timeout) is reported as current. The reload that ends a refresh after the pull request moved is not guarded: its own history write precedes it, and it always ends the preparation as `review-required`, so nothing is pushed, checked or made ready from it (traced, not tested). The task state version is then captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | | Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. The same check runs before and after the new head is fetched for review (after a refusal, a `moved` outcome, or a pull request that moved after the push): a change made during the push, the wait or the fetch fails the preparation as stale instead of being reviewed. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 2c4686e9..6ee29385 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -3,7 +3,7 @@ import { readHistoryInWorker } from './history-job.ts'; import { DEFAULT_PROCESS_SETTLEMENT_MS } from '../agents/process-group.ts'; import type { PlanIdentity } from '../core/identity.ts'; import type { RunnerCoordinator } from './coordinator.ts'; -import { GuardRefusal, MERGEABLE_STATUSES, settleWith, type ShutdownCapability } from './lifecycle.ts'; +import { GuardRefusal, MERGEABLE_STATUSES, sameContext, settleWith, type ShutdownCapability } from './lifecycle.ts'; import { MAX_REBASE_TIMEOUT_MS, MIN_REBASE_CLEANUP_TIMEOUT_MS, MIN_REBASE_TIMEOUT_MS, RebaseResourcesUnsettled, type GitRebaser } from './rebase.ts'; import type { ReviewService, ReviewView } from './review.ts'; @@ -571,10 +571,13 @@ export class PreMergeCoordinator { if (signal.aborted) stop(); try { await this.runner.settled(identity); } finally { signal.removeEventListener('abort', stop); } // The check attempt itself advances task state even when it fails. Bind that owned transition without adopting a - // concurrent review edit or reassignment; either must leave the preparation historical and stale. A reassignment - // during the check settles it as stale. - if (this.service.store.reviewVersion(identity) === view.expected.reviewVersion - && this.service.store.getAttempt(identity, attempt.id).state !== 'stale') { + // concurrent review edit, reassignment or plan change; any of them must leave the preparation historical and stale. + // A change while the check ran settles it as stale; one after its terminal write, while the runner released its + // workspace, leaves its context different from the current one. + const settled = this.service.store.getAttempt(identity, attempt.id); + const owned = this.service.store.reviewVersion(identity) === view.expected.reviewVersion && settled.state !== 'stale' + && sameContext(settled.context, this.service.store.currentContext(identity)); + if (owned) { const snapshot = this.service.store.getSnapshot(identity); onObserved({ pair: { base: snapshot.base, head: snapshot.head }, binding: { stateVersion: this.service.store.getTask(identity).stateVersion, @@ -582,12 +585,12 @@ export class PreMergeCoordinator { } }); } signal.throwIfAborted(); - const settled = this.service.store.getAttempt(identity, attempt.id); const runnerStatus = this.runner.status(identity); if (runnerStatus.unresolved || this.runner.unreleased) throw new GuardRefusal('Command-check cleanup could not be confirmed; restart and recover owned resources before preparing a merge.'); if (settled.state !== 'completed') throw new GuardRefusal(settled.exitCode === null && settled.diagnostic ? settled.diagnostic : `${item.id} command checks did not pass.`); + if (!owned) throw new GuardRefusal('The task or review changed during preparation. Reload first.'); checked.push(item.id); onChecked(checked); // The state the check left (its own attempt advanced the task), read in this turn: a reassignment or review edit // during the reload below must refuse, not be adopted into readiness. diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 48c6edd5..4bf62be4 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -111,6 +111,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: actionId?: string; /** Runs while a command check runs (when its workspace is materialized). */ duringCommand?: (service: ReviewService) => void; + /** Runs after a command check's terminal write, while the runner releases its workspace. */ + duringRelease?: (service: ReviewService) => void; } = {}) { const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-base-')); roots.push(root); const repository = join(root, 'repo'); fixtureGit(root, 'init', '-q', '-b', 'main', repository); @@ -175,7 +177,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }, async snapshotDeclaredLinks() { throw new Error('not used'); }, async checkTree() { throw new Error('not used'); }, async inspectChanges() { throw new Error('not used'); }, async commit() { throw new Error('not used'); }, - async release() { if (options.releaseFails) throw new Error('cleanup failed'); }, + async release() { options.duringRelease?.(service); if (options.releaseFails) throw new Error('cleanup failed'); }, }; const cancelReasons: string[] = []; const runner = new RunnerCoordinator(service.store, commandCheckDeps(service.store, workspace, input => { @@ -230,7 +232,13 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }) : undefined); if (options.duringLoad) { const load = service.load.bind(service); - service.load = (loadOptions?: Parameters[0]) => { const loading = load(loadOptions); options.duringLoad!(service); return loading; }; + service.load = (loadOptions?: Parameters[0]) => { + const loading = load(loadOptions); + // A hook that throws fails this load as a worker failure would: asynchronously, once the real load has settled. + try { options.duringLoad!(service); } + catch (error) { return loading.then(() => { throw error; }, () => { throw error; }); } + return loading; + }; } coordinator = new PreMergeCoordinator(service, runner, @@ -477,6 +485,22 @@ it('reports a command check stopped by a reassignment as stale, not as a current await fixture.coordinator.close(); await fixture.runner.close(); }); +it('refuses, as stale, a reassignment after a command check\'s terminal write, while its workspace is released', async () => { + let changed = false; + const fixture = await rebaseFixture('feature\n', 0, { duringRelease(service) { + if (changed) return; + changed = true; + const identity = service.config.identity; + // The check has already settled as completed; only the task's context changes now. + expect(service.store.getAttempts(identity).some(attempt => attempt.kind === 'check' && attempt.state === 'completed')).toBe(true); + service.store.setAssignment(identity, service.store.getTask(identity).stateVersion, 'someone-else', 'c'.repeat(40)); + } }); + expect(changed).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'The task or review changed during preparation. Reload first.' }); + expect(fixture.coordinator.last?.stale).toBe(true); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('refuses, as stale, a change request added while a command check runs, instead of adopting it', async () => { let changed = false; const fixture = await rebaseFixture('feature\n', 0, { duringCommand(service) { From aa34f3616a27bd348c66bf0be581cc5487f6f5ae Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 16:12:44 -0700 Subject: [PATCH 071/163] Give the preparation reload rules their own doc section Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 7 +++++-- docs/implementation/read-only-review.md | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 17f863a6..45644258 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -35,10 +35,9 @@ This ordering is intentionally local-only. The push below extends the durable li ## Push of the rewritten head (F6a) `prepare-merge` pushes a rewritten head only when the refreshed review has no blocker: every item approved, no ambiguous or unplanned change, no open change request. A rewrite that made an approval stale stays local until a person reviews it; a later preparation recognizes the unpushed rewrite through the durable rewrite lineage. - | Step | What happens | |---|---| -| Admission | Every review reload in a preparation awaits a history worker (#170), so each one that can lead to a push, a command check or readiness is guarded: the state it must still match (what the rebase, the push or a command check left, or the final binding) is checked after the reload and before the view is recorded, and a reassignment or review edit during it refuses the preparation, which is reported as stale. After the rebase that refusal comes before any push. A review edit made while a command check runs is refused the same way after the check (it is not adopted as `review-required`), and a reassignment during a check settles the check as stale and the preparation as a stale failure; one after the check's terminal write, while the runner releases its workspace, is caught by comparing the check's context with the current one and refuses as stale too. A failure of a reload itself (a worker timeout) is reported as current. The reload that ends a refresh after the pull request moved is not guarded: its own history write precedes it, and it always ends the preparation as `review-required`, so nothing is pushed, checked or made ready from it (traced, not tested). The task state version is then captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | +| Admission | The task state version is captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | | Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. The same check runs before and after the new head is fetched for review (after a refusal, a `moved` outcome, or a pull request that moved after the push): a change made during the push, the wait or the fetch fails the preparation as stale instead of being reviewed. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | @@ -48,6 +47,10 @@ This ordering is intentionally local-only. The push below extends the durable li A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. +### Review reloads during a preparation + +Every review reload in a preparation awaits a history worker (#170), so each one that can lead to a push, a command check or readiness is guarded: the state it must still match (what the rebase, the push or a command check left, or the final binding) is checked after the reload and before the view is recorded, and a reassignment or review edit during it refuses the preparation, which is reported as stale. After the rebase that refusal comes before any push. A review edit made while a command check runs is refused the same way after the check (it is not adopted as `review-required`), and a reassignment during a check settles the check as stale and the preparation as a stale failure; one after the check's terminal write, while the runner releases its workspace, is caught by comparing the check's context with the current one and refuses as stale too. A failure of a reload itself (a worker timeout) is reported as current. The reload that ends a refresh after the pull request moved is not guarded: its own history write precedes it, and it always ends the preparation as `review-required`, so nothing is pushed, checked or made ready from it (traced, not tested). + ## Regression evidence `test/runner-rebase.test.ts` covers clean one-to-one replay, the already-current no-op, process-group ownership, the whole-operation deadline, owned and foreign conflict resolution, clean sibling files, gitlink-pointer auditing and manual gitlink-conflict routing, embedded-repository refusal, literal replacement-character paths, invalid ledger classification, outside-file, resolver-commit and rebase-control refusal, cancellation before and during Git, exact cleanup, retained refs, and a read-only source checkout. `test/runner-rebase-conflict.test.ts` covers owned-item prompt binding, plan-revision races before launch and copy-back, claim-before-prepare ordering, distinct allocation identities, bounded no-follow host snapshots, nested modify/delete copy-in, copy-back audit, child settlement deadlines, cancellation settlement, cleanup ordering, and retained ownership for unreleased resources. `test/runner-lifecycle-store.test.ts` covers admission, durable process and child ownership, compare-and-swap races, resolved-source validation, and owned/foreign provenance. `test/runner-recovery.test.ts` covers marker validation, live-group fail-closed recovery, exact child-storage recovery, partial-match refusal, and restart ordering. `test/agent-process-group.test.ts` covers delayed-signal suppression after exit and retained ownership for an orphaned group. The real-container suite covers dirty-conflict admission and bounded exact-path export. `test/history.test.ts` and `test/browser/review.spec.ts` cover Unplanned attribution and the visible conflict-resolution label. `test/runner-production.test.ts` covers exact-plan recovery routing and adapter construction. diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md index ee92c3cb..fe6817b3 100644 --- a/docs/implementation/read-only-review.md +++ b/docs/implementation/read-only-review.md @@ -143,5 +143,5 @@ Review loading used to run history reading and linking on the server's event loo - **Cancellation.** An abort (a request ending at shutdown, a merge or preparation deadline) sends the worker a cancel. The worker kills its running Git process, waits for it to exit, and replies. A worker that has not replied within one second is terminated: one in synchronous work (linking, blob decoding, the object storage walk), which runs no Git process, or, rarely, one whose killed Git process is stuck in the kernel, which then outlives the load. The load rejects with the abort's own reason. - **Staleness.** The plan, snapshot, review version and ledger ownership are read before the worker starts. Every ledger write records a new snapshot, so an unchanged review version, plan revision and snapshot after the worker mean its inputs are current. If they changed (another load recorded a new HEAD, or a write landed), the load reads the history again from the current state, in at most three reads in all within its deadline, and is refused as stale only after that. - **Review commands.** A command applies, synchronously, to a review loaded just before it (inside the recorded action when it has an action ID). Its writes are guarded by that review's state. A load failure before the command is not recorded under its action ID, so the same ID can be resent. If the reload after the command was applied fails, or shutdown stops it, the refusal says `outcomeUnknown`, and a question saved during shutdown still gets its retryable "Server stopped" answer. -- **Merge and preparation.** Every merge load takes the click's operation signal; a replayed click's status refresh takes the coordinator's shutdown signal, and shutdown waits for it; a replay reached while the click is still running also takes the click's own signal, and a click already aborted reads nothing (#48). Every preparation load takes the preparation's signal. A review load awaits a worker, so neither runs one after its final remote read: merge admission inspects the pull request once more after its final review load, and preparation inspects it after its last review load. Both then check local trust and the review's generations in the same turn as the write. Preparation also checks its generations after reading the old history it rebases and before it records a moved pull request head. Each write is either in the same turn as those checks, or (a rebase, after its authorization read) guarded again by the Store in its own transaction. +- **Merge and preparation.** Every merge load takes the click's operation signal; a replayed click's status refresh takes the coordinator's shutdown signal, and shutdown waits for it; a replay reached while the click is still running also takes the click's own signal, and a click already aborted reads nothing (#48). Every preparation load takes the preparation's signal. A review load awaits a worker, so neither runs one after its final remote read: merge admission inspects the pull request once more after its final review load, and preparation inspects it after its last review load. Both then check local trust and the review's generations in the same turn as the write. Preparation also checks its generations after reading the old history it rebases and before it records a moved pull request head, and each review reload that can lead to a push, a command check or readiness is guarded the same way (see `pre-merge-rebase.md`, "Review reloads during a preparation"). Each write is either in the same turn as those checks, or (a rebase, after its authorization read) guarded again by the Store in its own transaction. From a75248228862108c7e5842e7eb467163004d9a51 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 16:19:05 -0700 Subject: [PATCH 072/163] Restore the blank line before the push table Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 45644258..134da741 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -35,6 +35,7 @@ This ordering is intentionally local-only. The push below extends the durable li ## Push of the rewritten head (F6a) `prepare-merge` pushes a rewritten head only when the refreshed review has no blocker: every item approved, no ambiguous or unplanned change, no open change request. A rewrite that made an approval stale stays local until a person reviews it; a later preparation recognizes the unpushed rewrite through the durable rewrite lineage. + | Step | What happens | |---|---| | Admission | The task state version is captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | From 7717bf82636587ec34701b3b8f04415962e687a0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 17:18:34 -0700 Subject: [PATCH 073/163] Settle merge job admission and reload races introduced by #170 (F6b) - Once a click's job is saved, a failure of the click's own review reload (which now awaits a history worker while the job prepares) answers 503, so the browser keeps the key and the resend returns the job, instead of an error that dropped it. - A cancel during one of the job's own review loads ends the job `cancelled`, not `failed`. - The unit fixture's review load is asynchronous, as in production, and can be held; regressions cover a resend during the admission load, shutdown during it, a load stopped by shutdown, a load that fails for another reason, and a cancel during the job's load. Each fails when its fix is removed. merge-handoff.md describes the admission load. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 4 +- runner/merge-job.ts | 7 ++- test/merge-job.test.ts | 75 +++++++++++++++++++++++++++- test/runner-publishing.test.ts | 28 +++++++++++ web/server.ts | 9 +++- 5 files changed, 116 insertions(+), 7 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index e18a1493..b7822dea 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -11,7 +11,7 @@ With a runner, **Merge PR** no longer merges at once. It starts one **merge job* 2. waits for GitHub's required checks on the pushed head, for up to 30 minutes; 3. merges through the existing guarded merge, which reruns the already-fixed check and pins the exact head. -Any change that needs a person (a stale approval, a moved pull request, a failing check) stops the job and says why. A task cancelled while the job runs ends it `cancelled`, however the job learns of it. A failing or slow required check, or a refusal from GitHub, moves the task to **approved, merge blocked**; **Retry merge** starts the whole job again. The browser follows the job by polling. +Any change that needs a person (a stale approval, a moved pull request, a failing check) stops the job and says why. A task cancelled while the job runs ends it `cancelled`, however the job learns of it, including during one of the job's own review loads. A failing or slow required check, or a refusal from GitHub, moves the task to **approved, merge blocked**; **Retry merge** starts the whole job again. The browser follows the job by polling. Without a runner, **Merge PR** works as before. @@ -30,7 +30,7 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. Once the job is saved, a failure of the click's own review reload answers 503, and the resent click returns the job. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub cannot be read (job: `failed`; one failed read fails closed rather than retrying) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 6f0a73f7..d1a3d640 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -90,8 +90,11 @@ export class MergeJobCoordinator { const { job } = store.beginMergeJob(identity, { actionId, token: token as string }); const abort = new AbortController(); this.#abort = abort; const run = Promise.resolve().then(() => this.#run(job, abort.signal)).catch(error => { - // Anything unexpected ends the job, unless shutdown stopped it: startup then settles it. - if (!abort.signal.aborted || !this.#closing) this.#finish(job.id, 'failed', error instanceof Error ? error.message : String(error)); + // Anything unexpected ends the job, unless shutdown stopped it (startup then settles it) or the task was cancelled + // (a cancel during one of the job's own review loads, which await a history worker since #170). + if (abort.signal.aborted && this.#closing) return; + if ((abort.signal.reason as { code?: string } | undefined)?.code === 'CANCELLED' || this.#cancelled()) return this.#end(job, abort.signal); + this.#finish(job.id, 'failed', error instanceof Error ? error.message : String(error)); }).finally(() => { if (this.#active === run) { this.#active = null; this.#abort = null; } }); this.#active = run; return mergeJobView(job); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index a237ec15..f9a2b654 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -46,7 +46,8 @@ function service(store: Store) { expected: { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, }; }; - return { store, config: { identity }, load: vi.fn(view), commandPolicyDigest: () => commandDigest([]) } as unknown as ReviewService; + // Asynchronous, as since #170 (the review load awaits a history worker). + return { store, config: { identity }, view, load: vi.fn(async () => view()), commandPolicyDigest: () => commandDigest([]) } as unknown as ReviewService & { view: () => unknown }; } /** Preparation as F5/F6a end it: `ready` records exact readiness on the job's own prepare-merge action. */ function preparation(store: Store, result: (call: number) => PreMergeResult['state'] | Error = () => 'ready') { @@ -102,6 +103,21 @@ function setup(options: Parameters[1] & { prepare?: Parameters { await coordinator.current; return coordinator.latest()!; }; +/** Holds the next review load until released or failed; a load whose signal aborts rejects with the abort's reason. */ +function holdNextLoad(review: ReviewService & { view: () => unknown }) { + let release!: () => void, fail!: (error: Error) => void, started!: (signal?: AbortSignal) => void; + const gate = new Promise((resolve, reject) => { release = resolve; fail = reject; }); + const loading = new Promise(resolve => { started = resolve; }); + (review.load as unknown as ReturnType).mockImplementationOnce(async (options?: { signal?: AbortSignal }) => { + started(options?.signal); + await new Promise((resolve, reject) => { + gate.then(resolve, reject); + options?.signal?.addEventListener('abort', () => reject(options.signal!.reason), { once: true }); + }); + return review.view(); + }); + return { release, fail, loading }; +} const pending = (context = 'ci'): RequiredCheck => ({ context, appId: null, state: 'pending' }); const passing = (context = 'ci'): RequiredCheck => ({ context, appId: null, state: 'success' }); @@ -562,3 +578,60 @@ it('starts no inspection after the wait limit and stops one that runs into it', expect(inspections).toBe(after); expect(f.gh.merged).toEqual([]); }); + +// Since #170 a click's admission awaits the review load; these pin what may happen while it does. +it('gives a resend of the same click, during the first send\'s review load, the same job', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const first = f.coordinator.start('review-token', id); + await held.loading; + const second = await f.coordinator.start('review-token', id); // Its own load is not held: it admits the job. + held.release(); + const firstAnswer = await first; + expect(firstAnswer.id).toBe(second.id); + expect(f.store.getMergeJob(identity, id)?.refused).toBeFalsy(); // Not saved as an "already running" refusal. + await settled(f.coordinator); +}); + +it('admits nothing and saves nothing when shutdown starts during the admission load', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const starting = f.coordinator.start('review-token', id); + await held.loading; + f.coordinator.stop(); + held.release(); + await expect(starting).rejects.toBeInstanceOf(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); + expect(f.coordinator.current).toBeNull(); +}); + +it('answers an admission load stopped by shutdown as not applied, saving nothing', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never), request = new AbortController(); + const starting = f.coordinator.start('review-token', id, () => null, request.signal); + await held.loading; + request.abort(new Error('Request cancelled during shutdown.')); + await expect(starting).rejects.toBeInstanceOf(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); +}); + +it('saves nothing when the admission load fails for another reason, so the click may be sent again', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const starting = f.coordinator.start('review-token', id); + await held.loading; + held.fail(new Error('History worker timed out.')); + await expect(starting).rejects.toThrow('History worker timed out.'); + expect(f.store.getMergeJob(identity, id)).toBeNull(); + // The same click, sent again, starts the job. + expect(await f.coordinator.start('review-token', id)).toMatchObject({ state: 'running' }); + await settled(f.coordinator); +}); + +it('ends the job cancelled when the task is cancelled during the job\'s own review load', async () => { + const f = setup(), id = randomUUID(); + // The admission's load passes; the job's own first load (its first step) is held. + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + const held = holdNextLoad(f.review as never); + await f.coordinator.start('review-token', id); + await held.loading; + f.coordinator.cancel(); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); + expect(f.gh.merged).toEqual([]); +}); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index f7f44a41..517082af 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1920,6 +1920,34 @@ describe('the one-click merge job (F6b of #22)', () => { try { expect(reopened.getMergeJob(served.identity, id)).toMatchObject({ id }); } finally { reopened.close(); } }, 60_000); + it('answers 503 when a started job\'s click cannot reload the review, and the resent click returns the job', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + // The click's own reload after its job was saved fails once (as a history worker timing out would). The admission load + // and the reload share the request's signal; the job's loads carry the job's own signal. + const service = served.app.service, realLoad = service.load.bind(service), requestSignals = new Set(); + let failed = false; + service.load = async (options?: Parameters[0]) => { + const signal = options?.signal; + if (signal && !failed && requestSignals.has(signal)) { failed = true; throw new Error('History worker timed out.'); } + if (signal) requestSignals.add(signal); + return realLoad(options); + }; + const clicked = await merge(served.app, token, id); + expect(failed).toBe(true); + expect(clicked).toMatchObject({ status: 503, body: { error: expect.stringMatching(/merge started, but the review could not be reloaded: History worker timed out/) } }); + expect(served.store.getMergeJob(served.identity, id)).not.toBeNull(); // The click's job was saved and started. + // The same click, sent again, replays its job. + const resent = await merge(served.app, token, id); + expect(resent).toMatchObject({ status: 200, body: { job: { id: served.store.getMergeJob(served.identity, id)!.id } } }); + }, 60_000); + it('answers a merge click with the job\'s latest record when it ended during the click\'s reload', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, slow = false; diff --git a/web/server.ts b/web/server.ts index 0d7337c8..b073fe71 100644 --- a/web/server.ts +++ b/web/server.ts @@ -737,10 +737,15 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge ?'The runner or publisher is busy; the merge cannot start yet.':null),requestAbort.signal); const running=mergeJobs.current; if(running)void running.finally(()=>{ if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); }); - // The job is saved; a reload stopped by shutdown answers 503, and the resent click replays the job. + // The job is saved, so its click is settled: a reload that fails, however it fails, answers 503, and the resent + // click replays the job. The reload awaits a history worker (#170) while the job's preparation writes, so it can + // fail without anything being wrong with the job. let loaded: Awaited>; try { loaded=await load(requestAbort.signal); } - catch (error) { if(stopping||runner?.closing||requestAbort.signal.aborted)throw new ShuttingDownError(); throw error; } + catch (error) { + if(stopping||runner?.closing||requestAbort.signal.aborted)throw new ShuttingDownError(); + json(503,{error:`The merge started, but the review could not be reloaded: ${error instanceof Error?error.message:String(error)} Send the click again to follow it.`});return; + } // The job may have moved on (or ended) while the review reloaded: answer its latest record, not the admission's. const latest=mergeJobs.latest(); json(200,{...loaded,job:latest?.id===job.id?latest:job});return; From 0605a8581918db6ce482b0bfdeac6c3c19a396e6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 17:31:16 -0700 Subject: [PATCH 074/163] Follow a started job when its click cannot reload the review (F6b) - A click whose job was saved but whose review reload then failed is answered with the job and mergeRefreshRequired, not 503: the browser never resent that click, so it showed "Merge blocked" while the job ran. The page now keeps its review and follows the job (new browser test; it fails with the previous app.js). - The job's first step binds its preparation to the task and review read in one turn after its review load, ends at once on a cancel or shutdown during that load, and checks that a preparation can start before saving the preparation's placeholder. - The cancel regression cancels the task first, as the route does. - merge-handoff.md: the check wait's local review load, and the click answer after a failed reload. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 4 ++-- runner/merge-job.ts | 15 +++++++++++---- test/browser/review.spec.ts | 28 ++++++++++++++++++++++++++++ test/merge-job.test.ts | 2 ++ test/runner-publishing.test.ts | 8 +++++--- web/public/app.js | 7 +++++-- web/server.ts | 10 ++++++---- 7 files changed, 59 insertions(+), 15 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index b7822dea..638e39c0 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -30,9 +30,9 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. Once the job is saved, a failure of the click's own review reload answers 503, and the resent click returns the job. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | -| Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub cannot be read (job: `failed`; one failed read fails closed rather than retrying) | +| Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. diff --git a/runner/merge-job.ts b/runner/merge-job.ts index d1a3d640..728c5d08 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -141,12 +141,19 @@ export class MergeJobCoordinator { const { store, config } = this.service, identity = config.identity; // 1. Prepare: refresh, rebase, review check, push, cmd: checks. let view = await this.service.load({ signal }); - const prepareId = randomUUID(), task = store.getTask(identity); + // The load awaited a history worker (#170). A cancel or shutdown during it ends here; otherwise the preparation is + // bound to the task and review as they are now, all read in this turn (the preparation refuses if they move). + if (signal.aborted || this.#cancelled()) return this.#end(job, signal); + const prepareId = randomUUID(), task = store.getTask(identity), snapshot = store.getSnapshot(identity); + const reviewVersion = store.reviewVersion(identity); + // Refused here, before the placeholder below is saved, if a preparation cannot start now (the preparation's own check + // repeats it in this turn). + this.preMerge.assertStartable(); store.userAction(identity, { actionId: prepareId, kind: 'prepare-merge', - request: { expectedStateVersion: task.stateVersion, expectedReviewVersion: view.expected.reviewVersion, mergeJob: job.id } }, + request: { expectedStateVersion: task.stateVersion, expectedReviewVersion: reviewVersion, mergeJob: job.id } }, () => ({ outcome: 'preparing' })); - const prepared = await this.preMerge.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, - snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, actionId: prepareId }); + const prepared = await this.preMerge.start({ stateVersion: task.stateVersion, reviewVersion, + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, actionId: prepareId }); if (signal.aborted || this.#cancelled()) return this.#end(job, signal); if (prepared.state !== 'ready') return this.#finish(job.id, prepared.state === 'review-required' ? 'review-required' : 'failed', prepared.reason); diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index e23f53c3..28747c00 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -749,6 +749,34 @@ test('keeps focus on the Merge button when a merge-job click is refused (F6b)',a await button.press('Enter'); expect(mergeClicks).toBe(1); }); +test('follows a started merge job when the server could not reload the review after the click (F6b, #170)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)]; + let polls=0,reviews=0; + // The click's job started, but its review reload failed: the server answers only the job and asks for a refresh. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:job('running','preparing'),mergeRefreshRequired:true})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await expect(page.locator('#items button').first()).toBeVisible(); + const items=await page.locator('#items button').count(); + expect(items).toBeGreaterThan(0); + const reloadsBefore=reviews; + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The page keeps its review and follows the job: its progress, then its end, with no reload. + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#banner')).toContainText('Preparing the merge'); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:15000}); + expect(polls).toBeGreaterThanOrEqual(2); + expect(reviews).toBe(reloadsBefore); + expect(await page.locator('#items button').count()).toBe(items); +}); + test('follows a one-click merge job from preparation through the required-check wait to merged (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index f9a2b654..7906cd3b 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -631,6 +631,8 @@ it('ends the job cancelled when the task is cancelled during the job\'s own revi const held = holdNextLoad(f.review as never); await f.coordinator.start('review-token', id); await held.loading; + // As the cancel route does: the task's cancel commits, then the job is told. + f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID()); f.coordinator.cancel(); expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); expect(f.gh.merged).toEqual([]); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 517082af..d350f388 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1920,7 +1920,7 @@ describe('the one-click merge job (F6b of #22)', () => { try { expect(reopened.getMergeJob(served.identity, id)).toMatchObject({ id }); } finally { reopened.close(); } }, 60_000); - it('answers 503 when a started job\'s click cannot reload the review, and the resent click returns the job', async () => { + it('answers the started job when its click cannot reload the review, and the resent click returns the job', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null; const gh = gateway(() => storeRef!, () => []); @@ -1941,8 +1941,10 @@ describe('the one-click merge job (F6b of #22)', () => { }; const clicked = await merge(served.app, token, id); expect(failed).toBe(true); - expect(clicked).toMatchObject({ status: 503, body: { error: expect.stringMatching(/merge started, but the review could not be reloaded: History worker timed out/) } }); - expect(served.store.getMergeJob(served.identity, id)).not.toBeNull(); // The click's job was saved and started. + const saved = served.store.getMergeJob(served.identity, id); + expect(saved).not.toBeNull(); // The click's job was saved and started. + // The job itself, so the page follows it, and a request to refresh the review. + expect(clicked).toMatchObject({ status: 200, body: { job: { id: saved!.id }, mergeRefreshRequired: true } }); // The same click, sent again, replays its job. const resent = await merge(served.app, token, id); expect(resent).toMatchObject({ status: 200, body: { job: { id: served.store.getMergeJob(served.identity, id)!.id } } }); diff --git a/web/public/app.js b/web/public/app.js index 1db7a409..f2aa28ad 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -622,8 +622,11 @@ $("merge").onclick = async () => { rememberDraft(); mergeAction = null; if (updated.job) { - // A merge job runs in the background (F6b); the page follows it. - data = { ...updated, merge: { ...updated.merge, job: updated.job } }; + // A merge job runs in the background (F6b); the page follows it. When the server could not reload the review after + // starting the job, it answers only the job: keep the page's review and follow the job on it. + data = updated.mergeRefreshRequired + ? { ...data, merge: { ...data.merge, ready: false, action: null, job: updated.job } } + : { ...updated, merge: { ...updated.merge, job: updated.job } }; render(); $("banner").textContent = mergeJobText(updated.job); return; diff --git a/web/server.ts b/web/server.ts index b073fe71..471bf498 100644 --- a/web/server.ts +++ b/web/server.ts @@ -737,14 +737,16 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge ?'The runner or publisher is busy; the merge cannot start yet.':null),requestAbort.signal); const running=mergeJobs.current; if(running)void running.finally(()=>{ if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); }); - // The job is saved, so its click is settled: a reload that fails, however it fails, answers 503, and the resent - // click replays the job. The reload awaits a history worker (#170) while the job's preparation writes, so it can - // fail without anything being wrong with the job. + // The job is saved, so its click is settled. The reload awaits a history worker (#170) while the job's preparation + // writes, so it can fail without anything being wrong with the job: answer the job, and the page follows it and + // asks for a refresh (as after a direct merge whose reload fails). A reload stopped by shutdown answers 503, and + // the resent click replays the job. let loaded: Awaited>; try { loaded=await load(requestAbort.signal); } catch (error) { if(stopping||runner?.closing||requestAbort.signal.aborted)throw new ShuttingDownError(); - json(503,{error:`The merge started, but the review could not be reloaded: ${error instanceof Error?error.message:String(error)} Send the click again to follow it.`});return; + const latest=mergeJobs.latest(); + json(200,{job:latest?.id===job.id?latest:job,mergeRefreshRequired:true});return; } // The job may have moved on (or ended) while the review reloaded: answer its latest record, not the admission's. const latest=mergeJobs.latest(); From 3b74ef79f69f4ed304e5e21c8ec05b1fb40b4996 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 17:36:55 -0700 Subject: [PATCH 075/163] Ask for a refresh when a click's merge job has already ended (F6b) The click's answer carries the job's latest record, which can already be ended (for example when its first review load failed as the click's reload did). The page now shows the same job-refresh blocker and banner as the job poll does when a job ends, instead of a silent stop. Browser regression; it fails with the previous app.js. merge-handoff.md now describes the click's answer and when merge jobs are used accurately. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 4 ++-- test/browser/review.spec.ts | 22 ++++++++++++++++++++++ web/public/app.js | 10 ++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 638e39c0..d0c489e6 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -30,7 +30,7 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job (`running`, step `preparing`). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | @@ -71,7 +71,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## API and browser -- `POST /api/action` with `merge` returns `{ job }` at once when a runner is configured. +- `POST /api/action` with `merge` returns at once when the server runs merge jobs (a runner and a pull request published by it, which requires preparation): the reloaded review with the job's latest record as `job`, or, if that reload failed, `{ job, mergeRefreshRequired: true }`. The job may already have moved on or ended by then; the page follows a running job and, for an ended one, asks for a refresh. - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 28747c00..be7f310e 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -749,6 +749,28 @@ test('keeps focus on the Merge button when a merge-job click is refused (F6b)',a await button.press('Enter'); expect(mergeClicks).toBe(1); }); +test('asks for a refresh when the click is answered with a merge job that has already ended (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + let polls=0; + // The job failed before the click was answered (its review load failed as the click's reload did). + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:job('failed',null,[],'History worker timed out.'),mergeRefreshRequired:true})});}); + await page.route('**/api/merge',async route=>{polls++;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.locator('#banner')).toContainText('Refresh to see the result.'); + await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog')).toContainText('Refresh to see the result.'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(2500); // Past the first poll delay: an ended job is not polled. + expect(polls).toBe(0); +}); + test('follows a started merge job when the server could not reload the review after the click (F6b, #170)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; diff --git a/web/public/app.js b/web/public/app.js index f2aa28ad..930fb699 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -627,6 +627,16 @@ $("merge").onclick = async () => { data = updated.mergeRefreshRequired ? { ...data, merge: { ...data.merge, ready: false, action: null, job: updated.job } } : { ...updated, merge: { ...updated.merge, job: updated.job } }; + if (updated.job.state !== "running") { + // The job had already ended when the click was answered: nothing to follow, so say how to see its result, as the + // job poll does when a job ends. + const ended = `${mergeJobText(updated.job)} Refresh to see the result.`; + const blockers = data.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); + data = { ...data, merge: { ...data.merge, ready: false, action: null, blockers: [{ code: "job-refresh", message: ended }, ...blockers] } }; + render(); + $("banner").textContent = ended; + return; + } render(); $("banner").textContent = mergeJobText(updated.job); return; From 445081f77c46da8ca5be3156624b18234baeb29d Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 17:41:20 -0700 Subject: [PATCH 076/163] Give the ended-job browser test a runner server's merge view Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index be7f310e..368bd3a0 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -760,9 +760,12 @@ test('asks for a refresh when the click is answered with a merge job that has al await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:job('failed',null,[],'History worker timed out.'),mergeRefreshRequired:true})});}); await page.route('**/api/merge',async route=>{polls++;await route.continue();}); + // A runner server reports merging as a job (its merge status carries `job`), as production does. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toContainText('Refresh to see the result.'); + await expect(page.locator('#merge')).toHaveJSProperty('disabled',false); // Marked, not disabled: it keeps focus. await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); await page.locator('#merge-details').click(); await expect(page.locator('dialog')).toContainText('Refresh to see the result.'); From 79ed50c7f5c99c244941627eb07adf6a7f24e190 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 18:08:04 -0700 Subject: [PATCH 077/163] Wait for the Merge button before pressing it in the refused-click focus test (F6b) The review now loads asynchronously (#170), so the press could land before the page offered the merge, and the test failed every time. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 368bd3a0..ea427f34 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -738,6 +738,8 @@ test('keeps focus on the Merge button when a merge-job click is refused (F6b)',a await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const button=page.locator('#merge'); + // The review loads asynchronously (#170); press only once the page offers the merge. + await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeEnabled(); await button.focus();await button.press('Enter'); await expect(page.locator('#banner')).toHaveText(/^Merge blocked\. Issue #1 is not trusted/); // The refusal leaves the control marked, not disabled, and focused (AGENTS.md, Async review UI). From 7fe156de943caea4f9d74b7b02edd1403a5b5d12 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 18:11:01 -0700 Subject: [PATCH 078/163] Give a second merge request time to arrive before counting clicks (F6b) The focus test counted merge requests straight after the second press, before a request could reach the route, so it passed even without the not-ready guard. It now fails with 2 requests when the guard is removed. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index ea427f34..7e3a5b5c 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -747,8 +747,10 @@ test('keeps focus on the Merge button when a merge-job click is refused (F6b)',a await expect(button).toHaveJSProperty('disabled',false);await expect(button).toBeFocused(); // It is drawn as a control that cannot act, as a disabled one is. await expect.poll(()=>looksInactive(button)).toBe(true); - // Activating it again starts nothing: the merge is not ready. + // Activating it again starts nothing: the merge is not ready. A request would reach the + // route only after the confirm and the fetch, so give one time to arrive before counting. await button.press('Enter'); + await page.waitForTimeout(1000); expect(mergeClicks).toBe(1); }); test('asks for a refresh when the click is answered with a merge job that has already ended (F6b)',async({page})=>{ From 7b1b5fa5d7fe27dcbad81593aa35015d910e02be Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 18:14:37 -0700 Subject: [PATCH 079/163] Await the merge job's admission in the check-wait limit test (F6b) start() is async since #170; an unawaited rejection would surface as an unhandled rejection instead of a test failure. Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 7906cd3b..d3f3ce1d 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -566,7 +566,7 @@ it('starts no inspection after the wait limit and stops one that runs into it', return inspect(request); }) as typeof inspect; const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 400, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); - coordinator.start('review-token', randomUUID()); + await coordinator.start('review-token', randomUUID()); await vi.waitFor(() => expect(coordinator.latest()?.step).toBe('checks')); hang = true; const started = Date.now(); From 19a1049aa86bcc05a9e4a53d510ff06a9ec9ed3e Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 07:44:26 -0700 Subject: [PATCH 080/163] Refuse a rewritten-head push once shutdown has begun, and kill every escaped test process Server shutdown sets the runner's admission flag in its first step but closes the pre-merge coordinator only after the HTTP drain. The push boundary and its failure-path branch read now check the runner's flag, so no push or new GitHub read starts in that interval. The cat-file escape test appends each holder's PID, so both detached sleeps are killed. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pre-merge-rebase.md | 4 ++-- runner/pre-merge.ts | 10 +++++++--- test/pre-merge.test.ts | 18 ++++++++++++++++++ test/runner-branch-push.test.ts | 8 ++++++-- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index 134da741..bf2fd10e 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -39,12 +39,12 @@ This ordering is intentionally local-only. The push below extends the durable li | Step | What happens | |---|---| | Admission | The task state version is captured, and issue trust is refreshed. `Store.beginPrePush` then records `{ attemptId, branch, from, to, snapshotId, startedAt }` in `tasks.push_in_progress` (schema v18) and advances the task state version. It requires that captured state version (a reassignment during the trust read refuses), a mergeable status, no cancel, no active attempt, merge, rebase or push, `to` equal to the snapshot head, and `from` a durable rewrite predecessor of `to`. | -| Last local check | Run by the pusher as its synchronous finalizer, as Git starts: trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. The same check runs before and after the new head is fetched for review (after a refusal, a `moved` outcome, or a pull request that moved after the push): a change made during the push, the wait or the fetch fails the preparation as stale instead of being reviewed. | +| Last local check | Run by the pusher as its synchronous finalizer, as Git starts: a shutdown already begun refuses the push (the runner's admission flag, set in shutdown's first step, before the HTTP drain), trust is revalidated and the task state version, review version and snapshot must be unchanged since admission. After a push, the same three must still be exactly what the push left before any `cmd:` check runs, so a plan revision or review edit made while the push was in flight fails the preparation. The same check runs before and after the new head is fetched for review (after a refusal, a `moved` outcome, or a pull request that moved after the push): a change made during the push, the wait or the fetch fails the preparation as stale instead of being reviewed. | | Push | `GitBranchPusher` pushes `to` to the task PR's `codeboost/` branch with a lease pinned to exactly `from`, the PR head read before the rebase. Any other value at the tip refuses with nothing pushed, even a commit codeboost made. The branch is read back after the push. | | Refused | Nothing was pushed. The marker clears. Once GitHub's PR API reports the branch's new head (the wait below), that head is reviewed and preparation returns `review-required`. | | Visible on the PR | After a push or a refusal, GitHub's pull-request API reports the branch's new head asynchronously. Preparation inspects the PR with backoff until it reports a head other than the pre-push head. It starts no inspection after 30 seconds, so the wait lasts at most 30 seconds plus one inspection (a 6-second deadline in production, plus up to 6 seconds for a stopped `gh` call to settle), within the preparation deadline. A base change alone does not end the wait. The pushed head continues to the `cmd:` checks; any other head or base is reviewed and returns `review-required`; a PR still at the pre-push head fails the preparation without touching the review. | | Unknown outcome | A failure, a timeout or a cancel during the push reads the branch once, unless the preparation's own deadline ended the push (see the next row). The read and Git's settlement after an abort together fit within 30 seconds and what is left of the preparation's deadline: the read is aborted the 16-second process-settlement reserve early, and it does not start when less than that reserve is left. A process that left Git's group and still holds its output does not hold the read either: the read changes nothing, so after the 1-second pipe drain it returns failed rather than waiting, and the marker stays. A process that stays in Git's group after Git exits is waited for, as for every Git call: it cannot be signalled safely once Git has exited, because its group ID may be reused, so that one case is not bounded. Its answer: `to` means pushed, `from` means not pushed yet, anything else means moved. The marker then clears. After `moved`, the preparation reviews that head as after a refusal: it waits for the pull request to show it, refreshes attribution and approvals, and returns `review-required`. After `pushed` or `not pushed`, the push's error fails the preparation. Only shutdown aborts that read; a cancel requested during the push lets it finish and closes the task when the marker clears. | -| Unreadable, deadline or shutdown | The marker stays when the branch cannot be read, when the preparation's own deadline ended the push (less than the settlement reserve is left, so no read starts), or on shutdown. Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | +| Unreadable, deadline or shutdown | The marker stays when the branch cannot be read, when the preparation's own deadline ended the push (less than the settlement reserve is left, so no read starts), or on shutdown (from shutdown's first step: the push's own failure path then starts no read). Merge admission, runner admission, status changes, reassignment, another rebase and another push all refuse while it exists, and merge status names it. Publishing cannot overlap it either: it runs only from `running` or `needs human`, and a push requires a mergeable status. After a restart, a tracked job started under the verified runner lock reads the branch and clears it; a cancel requested while no preparation runs starts the same job, and repeating that cancel starts it again if its read failed; preparation requests are refused as already running until it ends, and a task it closes as cancelled has its PRs closed. Otherwise the next preparation reads the branch, clears the marker, and asks for a reload. | A push that was killed (by a cancel, a deadline or a crash) after GitHub received it may still land after the settlement read saw `from`. Its lease still names `from`, so it overwrites nothing newer, and a later preparation reviews the branch as it finds it. diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 6ee29385..771f6c4f 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -322,7 +322,10 @@ export class PreMergeCoordinator { const owned = (cleared: { before: number; after: number }) => cleared.before === binding ? cleared.after : binding; try { await this.remote.push({ branch: marker.branch, from: marker.from, to: marker.to, beforePush: () => { - // The last local checks before the external write: trust, and no task or review change since the claim. + // The last local checks before the external write: shutdown, trust, and no task or review change since the + // claim. Shutdown sets the runner's admission flag first and closes this coordinator only after the HTTP drain, + // so between the two only the runner's flag shows that shutdown began. + if (this.runner.closing) throw new GuardRefusal('The server is shutting down.'); authorization?.validate(); const task = this.service.store.getTask(identity); if (task.stateVersion !== binding || task.cancelRequested !== null @@ -333,14 +336,15 @@ export class PreMergeCoordinator { } }, signal); } catch (error) { if (error instanceof BranchPushRefused) return { result: 'refused', stateVersion: owned(this.#finishPush(marker)) }; - // Shutdown: no new GitHub read may start; startup settles the marker. Otherwise one read, within what is left of + // Shutdown (from its first step, when the runner stops admission): no new GitHub read may start; startup settles + // the marker. Otherwise one read, within what is left of // the preparation's deadline with Git's settlement reserve kept inside it (except for a process that stays in // Git's group after Git exits, which is waited for; see #settlePush), settles it; a cancel does not stop that // read. When the preparation's own deadline ended the push, less than that reserve is left, so no read starts and // the marker stays, as for an unreadable branch. let settled = binding, outcome: PushOutcome | null = null; const left = Math.floor(deadline - performance.now()); - if (!this.#closing && left >= 1) { + if (!this.#closing && !this.runner.closing && left >= 1) { try { const read = await this.#settlePush(marker, left); settled = owned(read); outcome = read.outcome; } catch { /* The marker stays; the next preparation or startup reads the branch again. */ } } diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 4bf62be4..53210f1f 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -1457,6 +1457,24 @@ it('keeps waiting while GitHub still reports the pre-push head, even if the base await fixture.coordinator.close(); await fixture.runner.close(); }); +it('pushes nothing when shutdown begins while the push reads the remote, before this coordinator is closed', async () => { + let closingAtBoundary: boolean | null = null; + const fixture = await rebaseFixture('feature\n', undefined, { duringPushRead(_service, coordinator) { + // Server shutdown step 1 stops runner admission; the coordinator's own close waits for the HTTP drain (step 2). + coordinator.runner.rejectAdmission(); + closingAtBoundary = coordinator.runner.closing; + // The coordinator itself is still open: a new preparation would be refused only as already running. + expect(() => coordinator.assertStartable()).toThrow(/already running/); + } }); + expect(closingAtBoundary).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'The server is shutting down.' }); + expect(fixture.remote().head).not.toBe(fixture.rebased); + // Shutdown starts no new GitHub read: the marker waits for startup. + expect(fixture.branchReads()).toBe(0); + expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('pushes nothing when a cancel is committed while the push reads the remote, and closes the task once the outcome is read', async () => { const fixture = await rebaseFixture('feature\n', undefined, { duringPushRead(service, coordinator) { // The production route: the coordinator cancels the task and aborts this preparation after the cancel commits. diff --git a/test/runner-branch-push.test.ts b/test/runner-branch-push.test.ts index cf09289b..c3095ac3 100644 --- a/test/runner-branch-push.test.ts +++ b/test/runner-branch-push.test.ts @@ -333,8 +333,10 @@ describe('GitBranchPusher', () => { mkdirSync(bin); // Git's place on PATH: other commands succeed at once with no output; `cat-file` leaves a process in a new session // holding its output. + // Each cat-file call leaves its own process, so every PID is appended and every one is killed afterwards. const escape = `const c = require('node:child_process').spawn('sleep', ['60'], { detached: true, stdio: ['ignore', 'inherit', 'inherit'] });` - + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(c.pid)); c.unref();`; + + `require('node:fs').appendFileSync(${JSON.stringify(pidFile)}, c.pid + '\\n'); c.unref();`; + const escaped = () => { try { return readFileSync(pidFile, 'utf8').split('\n').filter(Boolean).map(Number); } catch { return []; } }; writeFileSync(join(bin, 'git'), `#!/bin/sh\ncase " $* " in *" cat-file "*) exec ${JSON.stringify(process.execPath)} -e ${JSON.stringify(escape)} ;; esac\nexit 0\n`, { mode: 0o755 }); try { const began = performance.now(); @@ -343,8 +345,10 @@ describe('GitBranchPusher', () => { // A push checks its head with cat-file before anything is written: that read is bounded the same way. await expect(instance.push(IDENTITY, { head: s.base, branch: BRANCH })).rejects.toThrow(/outside its group still holds its output/); expect(performance.now() - began).toBeLessThan(30_000); + // One holder per cat-file call: both are recorded, so the cleanup below reaches both. + expect(escaped()).toHaveLength(2); } finally { - try { process.kill(Number(readFileSync(pidFile, 'utf8')), 'SIGKILL'); } catch { /* already gone */ } + for (const pid of escaped()) try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } } }, 40_000); From 14c29421400ea3763c616aad046cfc75740d914c Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 07:57:18 -0700 Subject: [PATCH 081/163] Answer merge-job clicks from durable records after failed reads and unsaved outcomes (F6b) A fulfilled guarded merge whose outcome could not be saved leaves its attempt submitting: the job now ends submitted, not merged or queued. An admission load or trust read that fails after a resend of the same click was admitted answers that job, and a trust read's own timeout or gh answer survives a shutdown that started while gh stopped. Co-Authored-By: Claude Opus 5.5 --- runner/merge-job.ts | 13 ++++++-- test/merge-job.test.ts | 35 ++++++++++++++++++++++ test/runner-publishing.test.ts | 54 ++++++++++++++++++++++++++++++++++ web/server.ts | 15 +++++++--- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 728c5d08..e250c17c 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -77,7 +77,12 @@ export class MergeJobCoordinator { // job's admission, runs in one turn. A load stopped by shutdown decides nothing, as above. let reviewToken: string; try { reviewToken = (await this.service.load({ signal })).token; } - catch (error) { if (this.#closing || signal?.aborted) throw new ShuttingDownError(); throw error; } + catch (error) { + // A resend of this click may have been admitted while this load was pending: its job is this click's answer. + if (store.getMergeJob(identity, actionId)) return replay(); + if (this.#closing || signal?.aborted) throw new ShuttingDownError(); + throw error; + } // A resend of this click, or shutdown, may have arrived while the review loaded. if (store.getMergeJob(identity, actionId)) return replay(); if (this.#closing) throw new ShuttingDownError(); @@ -173,8 +178,12 @@ export class MergeJobCoordinator { // The job's signal stops the guarded merge as shutdown or a deadline stops a click: before GitHub is asked, nothing // is applied; after, the outcome is unknown. const merged = await this.merges.merge(view.token, mergeId, signal); + // A fulfilled merge does not prove its outcome was saved: when saving it fails, the attempt stays submitting and + // reconciles from GitHub. Only this job's own attempt, in a settled state, ends the job merged or queued. const attempt = store.getMergeAttempt(identity); - this.#finish(job.id, attempt?.kind === 'queue' && attempt.state !== 'merged' ? 'queued' : 'merged', null, merged.result.url || null); + if (attempt?.actionId !== mergeId || (attempt.state !== 'merged' && attempt.state !== 'queued')) + return this.#finish(job.id, 'submitted', 'GitHub accepted the merge, but its outcome was not saved; it reconciles from GitHub.'); + this.#finish(job.id, attempt.state, null, merged.result.url || attempt.url || null); } catch (error) { const reason = error instanceof Error ? error.message : String(error); // Once this job's merge attempt exists, GitHub was asked: its recorded state is the answer, whatever else (a cancel, diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index d3f3ce1d..602aadd7 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -327,6 +327,20 @@ it('ends queued when the merge queue accepted the head', async () => { expect(f.store.getTask(identity).status).toBe('in review'); }); +it.each([false, true])('ends submitted, not merged or queued, when GitHub accepted the merge but saving its outcome failed (queue: %s)', async queue => { + const f = setup({ queue }); + // MergeCoordinator resolves after GitHub accepted the command even when saving the outcome fails (test/merge.test.ts). + const fail = vi.fn(() => { throw new Error('local refresh failed'); }); + if (queue) f.store.queueMergeAttempt = fail; else f.store.finishMergeAttempt = fail; + await f.coordinator.start('review-token', randomUUID()); + const job = await settled(f.coordinator); + expect(fail).toHaveBeenCalledTimes(1); + expect(f.gh.merged).toEqual([oid(2)]); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ actionId: job.mergeActionId, state: 'submitting' }); + expect(job).toMatchObject({ state: 'submitted', reason: expect.stringContaining('reconciles from GitHub') }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + it('leaves the task approved, merge blocked when a retried job is queued', async () => { const f = setup({ queue: true, checks: n => (n === 1 ? [{ context: 'build', appId: null, state: 'failure' }] : []) }); await f.coordinator.start('review-token', randomUUID()); @@ -592,6 +606,27 @@ it('gives a resend of the same click, during the first send\'s review load, the await settled(f.coordinator); }); +it('answers the resent click\'s job when the first send\'s admission load fails after the resend was admitted', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const first = f.coordinator.start('review-token', id); + await held.loading; + const second = await f.coordinator.start('review-token', id); // Its own load is not held: it admits the job. + expect(f.store.getMergeJob(identity, id)).not.toBeNull(); + held.fail(new Error('History worker timed out.')); + expect((await first).id).toBe(second.id); + await settled(f.coordinator); +}); + +it('answers the resent click\'s job when shutdown stops the first send\'s admission load after the resend was admitted', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never), request = new AbortController(); + const first = f.coordinator.start('review-token', id, () => null, request.signal); + await held.loading; + const second = await f.coordinator.start('review-token', id); + f.coordinator.stop(); + request.abort(new Error('Request cancelled during shutdown.')); + expect((await first).id).toBe(second.id); +}); + it('admits nothing and saves nothing when shutdown starts during the admission load', async () => { const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); const starting = f.coordinator.start('review-token', id); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index d350f388..295963e6 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1899,6 +1899,60 @@ describe('the one-click merge job (F6b of #22)', () => { try { expect(reopened.getMergeJob(served.identity, id)).toBeNull(); } finally { reopened.close(); } }, 60_000); + it('answers a merge click whose trust read fails with the job a resend of the same click saved meanwhile', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, hold = false, fail!: (error: Error) => void, reading!: () => void; + const held = new Promise(resolve => { reading = resolve; }); + const gh = gateway(() => storeRef!, () => []); + const gate: IssueTrustGateway = { ...issueGateway, issueAccess(number) { + // The first click's read is held until it fails; the resend's read (and the publish's before) answers at once. + if (hold) { hold = false; reading(); return new Promise((_resolve, reject) => { fail = reject; }); } + return Promise.resolve({ number, authorLogin: 'member', collaborator: true }); + } }; + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + hold = true; + const first = merge(served.app, token, id); + await held; + const second = await merge(served.app, token, id); + expect(second).toMatchObject({ status: 200, body: { job: { id } } }); + expect(served.store.getMergeJob(served.identity, id)).toMatchObject({ id }); + fail(new Error('GitHub returned HTTP 502')); + expect(await first).toMatchObject({ status: 200, body: { job: { id } } }); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); + }, 60_000); + + it.each([ + ['timed out', () => new RemoteTimeout('Issue retrieval timed out.'), 503, 'Issue trust could not be verified: Issue retrieval timed out.'], + ['gh answered', () => new CommandFailed('gh failed (exit 1): HTTP 404: Not Found', '', ''), 502, 'Issue trust could not be verified: gh failed (exit 1): HTTP 404: Not Found'], + ] as const)('keeps a merge click\'s trust read failure (%s) when shutdown starts while gh stops, saving nothing', async (_name, failure, status, error) => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, hang = false, fail!: (error: Error) => void, reading!: () => void; + const held = new Promise(resolve => { reading = resolve; }); + const gh = gateway(() => storeRef!, () => []); + const gate: IssueTrustGateway = { ...issueGateway, issueAccess(number) { + if (!hang) return Promise.resolve({ number, authorLogin: 'member', collaborator: true }); + reading(); + return new Promise((_resolve, reject) => { fail = reject; }); + } }; + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; + hang = true; + const clicked = merge(served.app, token, id); + await held; + // Shutdown's first step runs at once; the read then ends with its own failure, which happened first. + const closed = served.close(); + fail(failure()); + expect(await clicked).toMatchObject({ status, body: { error } }); + await closed; + const reopened = new Store(database); + try { expect(reopened.getMergeJob(served.identity, id)).toBeNull(); } finally { reopened.close(); } + }, 60_000); + it('answers 503 when shutdown stops the reload after a merge job was saved, keeping the saved job for a resend', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, hanging = false; diff --git a/web/server.ts b/web/server.ts index 471bf498..108457d4 100644 --- a/web/server.ts +++ b/web/server.ts @@ -727,11 +727,18 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge let untrusted: string|null=null; if(config.github&&!mergeJobs.saved(input.actionId)) { if(stopping||runner?.closing)throw new ShuttingDownError(); - let access: IssueAccess; - // A read stopped by shutdown decides nothing: 503, and the browser resends the same click. + let access: IssueAccess|null=null; try { access=await readIssueAccess(requestAbort.signal); } - catch (error) { if(stopping||runner?.closing||requestAbort.signal.aborted)throw new ShuttingDownError(); throw error; } - try { requireTrustedIssue(access); } catch (error) { if(!(error instanceof GuardRefusal))throw error; untrusted=error.message; } + catch (error) { + // A resend of this click may have saved its job while this read ran: that job is the answer (start replays it). + if(!mergeJobs.saved(input.actionId)) { + // The read's own timeout, or gh's answer, came first: a shutdown that started while gh stopped does not + // replace it. Otherwise a read stopped by shutdown decides nothing: 503, and the browser resends the click. + if(!timedOut(error)&&!ghAnswered(error)&&(stopping||runner?.closing||requestAbort.signal.aborted))throw new ShuttingDownError(); + throw error; + } + } + if(access) try { requireTrustedIssue(access); } catch (error) { if(!(error instanceof GuardRefusal))throw error; untrusted=error.message; } } const job=await mergeJobs.start(input.token,input.actionId,()=>untrusted??(runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) ?'The runner or publisher is busy; the merge cannot start yet.':null),requestAbort.signal); From fbde7d4d761f4c2314b52b7b41effe86b9d0a025 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 07:57:18 -0700 Subject: [PATCH 082/163] Keep a retry's key past an earlier job and newer job observations past held reviews (F6b) A key made while an earlier job is shown stays until its own job ends or a newer job is the latest, so an unanswered retry is resent with the same key. A full review response older than the poll's record of the same job keeps that record. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 6 +-- test/browser/review.spec.ts | 58 ++++++++++++++++++++++++++++ web/public/app.js | 48 ++++++++++++++++++----- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index d0c489e6..339ec90d 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -30,10 +30,10 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again | +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. If a resend of the same click was admitted while this load or the click's trust read was pending, a failure of either answers that resend's job. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again. A trust read that timed out, or that gh answered, keeps that answer even when shutdown started while gh stopped | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | -| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head (job: `merged` or `queued`) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | +| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it (job: `merged` or `queued`; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. @@ -75,7 +75,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record of the same job (by its `updatedAt`) keeps that newer record, so a slow **Refresh** does not restart following a job the poll saw end. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. ## Tests diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 7e3a5b5c..d553e422 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -726,6 +726,64 @@ test('starts a new action once another job became the latest after a retained ke expect(clicks[1]).not.toBe(clicks[0]); }); +test('resends the same action for Retry merge after a retry failed while an earlier job is shown (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const blocked={id:'0f6b0000-0000-4000-8000-0000000000a1',state:'blocked',step:null,reason:'Required check failed: ci.',checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + // Every retry answers 503: nothing was applied, so the click is unresolved and its key must be resent. + const clicks:string[]=[]; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); + await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});}); + // An earlier job ended blocked: the review offers Retry merge, and keeps showing that job. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,job:blocked,ready:true,action:'retry'};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Merge blocked. The review server is shutting down.'); + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect.poll(()=>clicks.length).toBe(2); + // The earlier job is not this click's: the unresolved click is resent with its own key. + expect(clicks[1]).toBe(clicks[0]); +}); + +test('keeps a merge-job outcome a poll saw while an older Refresh response was held (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const running={id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',reason:null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const merged={...running,state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',updatedAt:'2026-10-11T00:05:00Z'}; + let ended=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; + const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); + await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?merged:running})});}); + // Every review response was read while the job ran; the Refresh response is held until the poll has seen it end. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:false,action:null,job:running}; + if(hold){hold=false;holding();await released;} + await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toBeVisible(); + hold=true; + const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await held; + // Selecting an item while Refresh waits renders the merge control, which schedules a poll; that poll sees the job end. + ended=true; + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:10_000}); + const seen=polls; + release();await refreshed; + // The older Refresh response does not restore the running job: no poll restarts (the poll interval is 2 s), and the + // page still shows the outcome. + await page.waitForTimeout(3_000); + expect(polls).toBe(seen); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveCount(0); +}); + test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; diff --git a/web/public/app.js b/web/public/app.js index 930fb699..8423bdb5 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -145,11 +145,13 @@ async function refresh() { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + if (newerJob) data = withMergeJobObservation(data, newerJob); snippetSelection = null; render(); if (newerQueue) showMergeQueueStatus(newerQueue); + if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); setPlanStatus(planStatusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { if (generation !== reviewGeneration) return; @@ -171,9 +173,11 @@ function renderMerge() { if (!merge?.available) return; // Once the attempt this retained key started has ended, a retry is a new action and needs a new key. if (mergeAction && merge.queue?.actionId === mergeAction.actionId && ["merged", "removed", "failed"].includes(merge.queue.state)) mergeAction = null; - // Likewise once the merge job this key started has ended (a job is saved under its click's action ID), or another job is - // the latest: one job runs at a time, so this key's job has ended or never started. Either way a click is a new action. - if (mergeAction && merge.job && !(merge.job.id === mergeAction.actionId && merge.job.state === "running")) mergeAction = null; + // Likewise once the merge job this key started has ended (a job is saved under its click's action ID), or a job newer + // than the one shown when the key was made is the latest: one job runs at a time, so this key's job has ended or never + // started. Either way a click is a new action. The job shown when the key was made says nothing about its click. + if (mergeAction && merge.job && (merge.job.id === mergeAction.actionId ? merge.job.state !== "running" : merge.job.id !== mergeAction.jobBefore)) + mergeAction = null; // With merge jobs (their status carries `job`), a click's answer arrives while the control may hold focus, so it is // marked, never disabled; activation is ignored while the merge is not ready. if ("job" in merge) { @@ -234,6 +238,8 @@ async function pollMergeQueue(generation) { const update = await api("/api/merge"); if (generation !== mergeGeneration || !data?.merge?.available) return; if (data.merge.job?.state === "running") { + // A full review response read before this observation must not restore an older record of this job. + mergeObservationGeneration++; // A merge job (F6b): follow its step. Once it ends, update only the merge control, as the merge-queue poll does: // a background reload would erase the person's selection and input; Refresh shows the outcome. const job = update.job; @@ -322,10 +328,12 @@ async function act(command) { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); data = newerQueue ? withMergeQueue(updated, newerQueue) : updated; + if (newerJob) data = withMergeJobObservation(data, newerJob); render(); if (newerQueue) showMergeQueueStatus(newerQueue); + if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); return true; } catch (error) { if (generation !== reviewGeneration) return false; @@ -616,7 +624,7 @@ $("merge").onclick = async () => { else $("merge").disabled = true; try { rememberDraft(); - mergeAction ??= { actionId: crypto.randomUUID(), token: data.token }; + mergeAction ??= { actionId: crypto.randomUUID(), token: data.token, jobBefore: data.merge.job?.id ?? null }; const updated = await api("/api/action", { action: "merge", token: mergeAction.token, actionId: mergeAction.actionId }); // The composer stayed editable while the request was in flight: keep what the person typed meanwhile. rememberDraft(); @@ -924,6 +932,22 @@ function preserveSettledQuestionAnswers(updated) { : note; }) }; } +/** A poll's record of the job a full response also carries, when the poll saw it later than that response did. */ +function newerMergeJob(updated, observationOwner) { + const newer = data?.merge?.job, older = updated.merge?.job; + return observationOwner !== mergeObservationGeneration && newer && older && newer.id === older.id && newer.updatedAt > older.updatedAt + ? newer + : null; +} +function mergeJobBanner(job) { + return job.state === "running" ? mergeJobText(job) : `${mergeJobText(job)} Refresh to see the result.`; +} +/** The view with a newer observation of its job: an ended job asks for a refresh, as the job poll shows it. */ +function withMergeJobObservation(view, job) { + if (job.state === "running") return { ...view, merge: { ...view.merge, job } }; + const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); + return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message: mergeJobBanner(job) }, ...blockers] } }; +} function newerMergeQueue(updated, observationOwner) { const newer = data?.merge?.queue, older = updated.merge?.queue; const fullResponseIsTerminal = ["merged", "removed", "failed"].includes(older?.state); @@ -1346,10 +1370,12 @@ async function importPlan(event) { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + if (newerJob) data = withMergeJobObservation(data, newerJob); render(); if (newerQueue) showMergeQueueStatus(newerQueue); + if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); if ($("plan-file").files[0] === file) $("plan-file").value = ""; renderPlanFile(); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); @@ -1532,10 +1558,12 @@ async function applyPlanAuthor(index) { mergePollTimer = null; mergePollState = null; mergePollDelay = 2000; - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + if (newerJob) data = withMergeJobObservation(data, newerJob); render(); if (newerQueue) showMergeQueueStatus(newerQueue); + if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { request.observationError = `Revision r${revision} was applied, but the current plan could not reload. ${error.message}`; @@ -1794,10 +1822,12 @@ $("plans-refresh").onclick = async () => { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + if (newerJob) data = withMergeJobObservation(data, newerJob); render(); if (newerQueue) showMergeQueueStatus(newerQueue); + if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { if (generation !== planOperationGeneration || sharedGeneration !== reviewGeneration) return; From 7ffd08422d972c86d5f7ecb015026ada7f71e13d Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 07:57:35 -0700 Subject: [PATCH 083/163] Scope the shutdown comment on the push failure path to its own read Co-Authored-By: Claude Opus 5.5 --- runner/pre-merge.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 771f6c4f..837ac56f 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -336,9 +336,8 @@ export class PreMergeCoordinator { } }, signal); } catch (error) { if (error instanceof BranchPushRefused) return { result: 'refused', stateVersion: owned(this.#finishPush(marker)) }; - // Shutdown (from its first step, when the runner stops admission): no new GitHub read may start; startup settles - // the marker. Otherwise one read, within what is left of - // the preparation's deadline with Git's settlement reserve kept inside it (except for a process that stays in + // Shutdown (from its first step, when the runner stops admission): this failure path starts no settlement read; + // startup settles the marker. Otherwise one read, within what is left of the preparation's deadline with Git's settlement reserve kept inside it (except for a process that stays in // Git's group after Git exits, which is waited for; see #settlePush), settles it; a cancel does not stop that // read. When the preparation's own deadline ended the push, less than that reserve is left, so no read starts and // the marker stays, as for an unreadable branch. From 0eb8a217273c28d18ef4e35d12e4734380802016 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 08:45:28 -0700 Subject: [PATCH 084/163] Log a merge job that could not be ended, and document that it stays running until restart (F6b) Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 1 + runner/merge-job.ts | 9 +++++++-- test/merge-job.test.ts | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 339ec90d..a6425de5 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -63,6 +63,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | | Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | +| Ending the job fails | if the write that ends a job fails (a store error, not shutdown), the server logs "Could not end merge job …; it shows as running until the server restarts". The job stays `running` on disk: the page keeps following it, and a new click is refused while it runs. The next start settles it as below | | Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed or the review changed while GitHub was asked). A `prepare-merge` action and a push marker are settled as before | ## Storage diff --git a/runner/merge-job.ts b/runner/merge-job.ts index e250c17c..dc775d6f 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -139,7 +139,12 @@ export class MergeJobCoordinator { #finish(id: string, state: Exclude, reason: string | null = null, url: string | null = null): void { try { this.settle(() => this.service.store.finishMergeJob(this.service.config.identity, id, { state, reason, url })); } - catch { /* Startup settles a job that could not be ended now. */ } + catch (error) { this.#unended(id, error); } + } + + /** Startup settles a job that could not be ended now; until then it shows as running and a new click is refused. */ + #unended(id: string, error: unknown): void { + if (!this.#closing) console.error(`Could not end merge job ${id}; it shows as running until the server restarts: ${(error as Error)?.message ?? String(error)}`); } async #run(job: MergeJob, signal: AbortSignal): Promise { @@ -219,7 +224,7 @@ export class MergeJobCoordinator { #block(job: MergeJob, reason: string, whileReady = false): void { const readyFor = whileReady ? { commandPolicyDigest: this.service.commandPolicyDigest() } : undefined; try { this.settle(() => this.service.store.blockMergeJob(this.service.config.identity, job.id, reason, readyFor)); } - catch { /* Startup settles a job that could not be ended now. */ } + catch (error) { this.#unended(job.id, error); } } async #awaitChecks(job: MergeJob, signal: AbortSignal): Promise { diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 602aadd7..0e1fb621 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -341,6 +341,24 @@ it.each([false, true])('ends submitted, not merged or queued, when GitHub accept expect(f.store.getTask(identity).status).toBe('in review'); }); +it.each(['finish', 'block'] as const)('reports a job it could not end (%s), which stays running and refuses a new click until startup settles it', async kind => { + const f = setup(kind === 'block' ? { checks: () => [{ context: 'build', appId: null, state: 'failure' }] } : {}); + const fail = vi.fn(() => { throw new Error('disk full'); }); + // The finish case ends `submitted` (its attempt's outcome was not saved either), so the task stays in review and a new + // click reaches the running-job check. + if (kind === 'block') f.store.blockMergeJob = fail; + else { f.store.finishMergeJob = fail; f.store.finishMergeAttempt = () => { throw new Error('local refresh failed'); }; } + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const started = await f.coordinator.start('review-token', randomUUID()); + await f.coordinator.current; + expect(fail).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`Could not end merge job ${started.id}; it shows as running until the server restarts: disk full`))); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running' }); + await expect(f.coordinator.start('review-token', randomUUID())).rejects.toThrow(/already running/); + } finally { logged.mockRestore(); } +}); + it('leaves the task approved, merge blocked when a retried job is queued', async () => { const f = setup({ queue: true, checks: n => (n === 1 ? [{ context: 'build', appId: null, state: 'failure' }] : []) }); await f.coordinator.start('review-token', randomUUID()); From 45bff7a0fb4a7de1b39289d7eac1aafac9570f82 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 08:48:57 -0700 Subject: [PATCH 085/163] Never reject a merge job's run, log quoted, and test the restart that settles an unended job (F6b) Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- runner/merge-job.ts | 13 ++++++++----- test/merge-job.test.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index a6425de5..f265debe 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -63,7 +63,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | | Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | -| Ending the job fails | if the write that ends a job fails (a store error, not shutdown), the server logs "Could not end merge job …; it shows as running until the server restarts". The job stays `running` on disk: the page keeps following it, and a new click is refused while it runs. The next start settles it as below | +| Ending the job fails | if the write that ends a job fails, or reading the task after the job failed does, the server logs "Could not end merge job …; it shows as running until the server restarts" with the store's message. The job stays `running` on disk: the page keeps following it, and a new click is refused while it runs. The next start settles it as below | | Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed or the review changed while GitHub was asked). A `prepare-merge` action and a push marker are settled as before | ## Storage diff --git a/runner/merge-job.ts b/runner/merge-job.ts index dc775d6f..9e2e9763 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -96,10 +96,13 @@ export class MergeJobCoordinator { const abort = new AbortController(); this.#abort = abort; const run = Promise.resolve().then(() => this.#run(job, abort.signal)).catch(error => { // Anything unexpected ends the job, unless shutdown stopped it (startup then settles it) or the task was cancelled - // (a cancel during one of the job's own review loads, which await a history worker since #170). - if (abort.signal.aborted && this.#closing) return; - if ((abort.signal.reason as { code?: string } | undefined)?.code === 'CANCELLED' || this.#cancelled()) return this.#end(job, abort.signal); - this.#finish(job.id, 'failed', error instanceof Error ? error.message : String(error)); + // (a cancel during one of the job's own review loads, which await a history worker since #170). Never rejects: if + // even reading the task fails here, the job stays running for startup, as when ending it fails. + try { + if (abort.signal.aborted && this.#closing) return; + if ((abort.signal.reason as { code?: string } | undefined)?.code === 'CANCELLED' || this.#cancelled()) return this.#end(job, abort.signal); + this.#finish(job.id, 'failed', error instanceof Error ? error.message : String(error)); + } catch (unended) { this.#unended(job.id, unended); } }).finally(() => { if (this.#active === run) { this.#active = null; this.#abort = null; } }); this.#active = run; return mergeJobView(job); @@ -144,7 +147,7 @@ export class MergeJobCoordinator { /** Startup settles a job that could not be ended now; until then it shows as running and a new click is refused. */ #unended(id: string, error: unknown): void { - if (!this.#closing) console.error(`Could not end merge job ${id}; it shows as running until the server restarts: ${(error as Error)?.message ?? String(error)}`); + console.error(`Could not end merge job ${id}; it shows as running until the server restarts: ${JSON.stringify((error as Error)?.message ?? String(error))}`); } async #run(job: MergeJob, signal: AbortSignal): Promise { diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 0e1fb621..708d5547 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -344,6 +344,7 @@ it.each([false, true])('ends submitted, not merged or queued, when GitHub accept it.each(['finish', 'block'] as const)('reports a job it could not end (%s), which stays running and refuses a new click until startup settles it', async kind => { const f = setup(kind === 'block' ? { checks: () => [{ context: 'build', appId: null, state: 'failure' }] } : {}); const fail = vi.fn(() => { throw new Error('disk full'); }); + const { blockMergeJob, finishMergeJob } = f.store; // The finish case ends `submitted` (its attempt's outcome was not saved either), so the task stays in review and a new // click reaches the running-job check. if (kind === 'block') f.store.blockMergeJob = fail; @@ -353,12 +354,36 @@ it.each(['finish', 'block'] as const)('reports a job it could not end (%s), whic const started = await f.coordinator.start('review-token', randomUUID()); await f.coordinator.current; expect(fail).toHaveBeenCalledTimes(1); - expect(logged).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`Could not end merge job ${started.id}; it shows as running until the server restarts: disk full`))); + expect(logged).toHaveBeenCalledWith(`Could not end merge job ${started.id}; it shows as running until the server restarts: "disk full"`); expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running' }); await expect(f.coordinator.start('review-token', randomUUID())).rejects.toThrow(/already running/); + // The next start settles it (runner/production.ts), once the store writes again. + Object.assign(f.store, { blockMergeJob, finishMergeJob }); + f.store.settleInterruptedMergeJobs(identity); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: kind === 'block' ? 'failed' : 'submitted' }); } finally { logged.mockRestore(); } }); +it('keeps a job running and reports it when even reading the task fails after the job failed', async () => { + const f = setup(); + const admission = holdNextLoad(f.review), run = holdNextLoad(f.review); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const getTask = f.store.getTask.bind(f.store); + try { + const starting = f.coordinator.start('review-token', randomUUID()); + await admission.loading; admission.release(); + const started = await starting; + await run.loading; + f.store.getTask = () => { throw new Error('database is locked'); }; + run.fail(new Error('History worker crashed.')); + // The job's promise settles without rejecting, so the server's `.finally` and `close()` see no rejection. + await expect(f.coordinator.current).resolves.toBeUndefined(); + f.store.getTask = getTask; + expect(logged).toHaveBeenCalledWith(`Could not end merge job ${started.id}; it shows as running until the server restarts: "database is locked"`); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running' }); + } finally { f.store.getTask = getTask; logged.mockRestore(); } +}); + it('leaves the task approved, merge blocked when a retried job is queued', async () => { const f = setup({ queue: true, checks: n => (n === 1 ? [{ context: 'build', appId: null, state: 'failure' }] : []) }); await f.coordinator.start('review-token', randomUUID()); From cffdf4c7083334c4204353ea9559dbe0895d7327 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 09:13:04 -0700 Subject: [PATCH 086/163] Log, not reject, when following a settled merge job fails; test the shutdown log The server's `.finally` after a merge job read the task; a throw there rejected a new promise unhandled and exited the server. Catch and log it. Add a regression for a job whose ending write fails during shutdown, and state in merge-handoff.md that the log is written then too. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- test/merge-job.test.ts | 24 +++++++++++++++++- test/runner-publishing.test.ts | 37 ++++++++++++++++++++++++++++ web/server.ts | 7 +++++- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index f265debe..1a42e243 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -63,7 +63,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | | Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | -| Ending the job fails | if the write that ends a job fails, or reading the task after the job failed does, the server logs "Could not end merge job …; it shows as running until the server restarts" with the store's message. The job stays `running` on disk: the page keeps following it, and a new click is refused while it runs. The next start settles it as below | +| Ending the job fails | if the write that ends a job fails, or reading the task after the job failed does, the server logs, also during shutdown (after GitHub was asked to merge), "Could not end merge job …; it shows as running until the server restarts" with the store's message. The job stays `running` on disk: the page keeps following it, and a new click is refused while it runs. The next start settles it as below | | Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed or the review changed while GitHub was asked). A `prepare-merge` action and a push marker are settled as before | ## Storage diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 708d5547..fa769595 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -376,7 +376,8 @@ it('keeps a job running and reports it when even reading the task fails after th await run.loading; f.store.getTask = () => { throw new Error('database is locked'); }; run.fail(new Error('History worker crashed.')); - // The job's promise settles without rejecting, so the server's `.finally` and `close()` see no rejection. + // The job's promise settles without rejecting, so `close()` and the server's `.finally` (whose callback catches its own + // errors) see no rejection. await expect(f.coordinator.current).resolves.toBeUndefined(); f.store.getTask = getTask; expect(logged).toHaveBeenCalledWith(`Could not end merge job ${started.id}; it shows as running until the server restarts: "database is locked"`); @@ -497,6 +498,27 @@ it('ends blocked when GitHub refuses the merge while shutdown stops the job', as expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); }); +it.each(['submitted', 'blocked'] as const)('reports a job it could not end during shutdown (%s), after GitHub was asked to merge', async kind => { + // Shutdown's first step (stop) has run, but GitHub was already asked, so the job still ends through its ending write. + let coordinatorRef: MergeJobCoordinator | null = null; + const f = setup({ merge: kind === 'blocked' + ? async () => { coordinatorRef!.stop(); throw new MergeSubmissionError('Base branch was modified.', 'refused'); } + : signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })) }); + coordinatorRef = f.coordinator; + const fail = vi.fn(() => { throw new Error('disk full'); }); + const { blockMergeJob, finishMergeJob } = f.store; + if (kind === 'blocked') f.store.blockMergeJob = fail; else f.store.finishMergeJob = fail; + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const started = await f.coordinator.start('review-token', randomUUID()); + if (kind === 'submitted') { await vi.waitFor(() => expect(f.gh.merged).toHaveLength(1)); await f.coordinator.close(); } + else await f.coordinator.current; + expect(fail).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalledWith(`Could not end merge job ${started.id}; it shows as running until the server restarts: "disk full"`); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running' }); + } finally { Object.assign(f.store, { blockMergeJob, finishMergeJob }); logged.mockRestore(); } +}); + it('settles a job a crash left in its merge step from its merge attempt at startup', async () => { for (const [settle, expected, status] of [ [() => undefined, 'submitted', 'in review'], diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 295963e6..9f99dbb3 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -2147,4 +2147,41 @@ describe('the one-click merge job (F6b of #22)', () => { await vi.waitFor(() => expect(w.github.prs[0]!.open).toBe(false), { timeout: 20_000 }); expect(gh.merged).toEqual([]); }, 60_000); + + it('logs, and does not reject, when reading the task fails as the server follows a settled merge job', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, release!: () => void; + const answered = new Promise(resolve => { release = resolve; }); + const base = gateway(() => storeRef!, () => []); + // GitHub answers the merge only once the click was answered, so the job settles after the click's own review reload. + const gh: MergeGateway = { inspect: base.inspect, merge: async () => { await answered; return { url: 'https://github.com/acme/app/pull/100' }; } }; + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const unhandled: unknown[] = [], onUnhandled = (reason: unknown) => { unhandled.push(reason); }; + process.on('unhandledRejection', onUnhandled); + const logged = vi.spyOn(console, 'error'); + // The first task read after the job's ending write fails: that is the server's `.finally` following the job. + let failNext = false; + const getTask = served.store.getTask.bind(served.store), finishMergeJob = served.store.finishMergeJob.bind(served.store); + served.store.finishMergeJob = ((...args: Parameters) => { const ended = finishMergeJob(...args); failNext = true; return ended; }) as Store['finishMergeJob']; + served.store.getTask = ((identity: PlanIdentity) => { + if (failNext) { failNext = false; throw new Error('database is locked'); } + return getTask(identity); + }) as Store['getTask']; + try { + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + release(); + await vi.waitFor(() => expect(served.store.latestMergeJob(served.identity)).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); + // The server's callback runs a few microtasks after the ending write; an unhandled rejection is reported after them. + await new Promise(resolve => setTimeout(resolve, 50)); + expect(failNext).toBe(false); + expect(unhandled).toEqual([]); + expect(logged).toHaveBeenCalledWith('Could not close the cancelled task\'s pull requests after its merge job: "database is locked"'); + } finally { + process.off('unhandledRejection', onUnhandled); + served.store.getTask = getTask; served.store.finishMergeJob = finishMergeJob; logged.mockRestore(); + } + }, 60_000); }); diff --git a/web/server.ts b/web/server.ts index 108457d4..69854ed9 100644 --- a/web/server.ts +++ b/web/server.ts @@ -743,7 +743,12 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge const job=await mergeJobs.start(input.token,input.actionId,()=>untrusted??(runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) ?'The runner or publisher is busy; the merge cannot start yet.':null),requestAbort.signal); const running=mergeJobs.current; - if(running)void running.finally(()=>{ if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); }); + // The job's run never rejects, but this callback's own promise would: it must not throw (an unhandled rejection + // exits the server), so a failed read or close start is logged instead. + if(running)void running.finally(()=>{ + try { if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); } + catch (error) { console.error(`Could not close the cancelled task's pull requests after its merge job: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } + }); // The job is saved, so its click is settled. The reload awaits a history worker (#170) while the job's preparation // writes, so it can fail without anything being wrong with the job: answer the job, and the page follows it and // asks for a refresh (as after a direct merge whose reload fails). A reload stopped by shutdown answers 503, and From 715329b71c3d618e366b8c7516c787182ed04800 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 09:59:26 -0700 Subject: [PATCH 087/163] Test that the wait between required-check inspections doubles to its cap Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index fa769595..34f80945 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -737,3 +737,19 @@ it('ends the job cancelled when the task is cancelled during the job\'s own revi expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); expect(f.gh.merged).toEqual([]); }); +it('doubles the wait between required-check inspections from the first delay up to the maximum', async () => { + // Required checks stay pending long enough for the wait to reach its maximum; only the sleeps between inspections count. + const f = setup({ checks: n => (n <= 6 ? [pending()] : [passing()]) }); + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 60_000, firstDelayMs: 3, maxDelayMs: 20 }); jobs.push(coordinator); + const waits: number[] = []; + const timer = setTimeout, spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler: () => void, ms?: number, ...args: unknown[]) => { + // Record the wait only while the job is between inspections of its required checks. + if (coordinator.latest()?.step === 'checks') waits.push(ms ?? 0); + return timer(handler, ms, ...args); + }) as typeof setTimeout); + try { + await coordinator.start('review-token', randomUUID()); + expect(await settled(coordinator)).toMatchObject({ state: 'merged' }); + } finally { spy.mockRestore(); } + expect(waits).toEqual([3, 6, 12, 20, 20, 20]); +}); From f684da432871122f007701be060441e6433ce0c6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 09:59:34 -0700 Subject: [PATCH 088/163] Describe the merge_jobs table as stored: id, plan_key and a JSON record Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 1a42e243..78d53cf8 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -68,7 +68,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t ## Storage -Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click (`action_id`, task key, `state`, `step`, `reason`, required-check names and states, the merge result URL, times). A refused click's row is marked `refused`: it is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. +Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. ## API and browser From b721ca6b485d4d6cbbe4cf3f888c6cceaf54c75f Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 10:01:37 -0700 Subject: [PATCH 089/163] Scan from the original base for a pre-v19 opening, never another branch's check No record shows which base branch a check recorded before schema v19 scanned: a publish can record its check and stop before opening, so the task's oldest clear check may belong to another base branch. Start the legacy scan at the task's original base instead, which covers every base commit, and drop the now-unused Store.oldestClearAlreadyFixed. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/guarded-merge.md | 2 +- docs/implementation/merge-handoff.md | 2 +- runner/merge.ts | 7 ++++--- runner/store.ts | 4 ---- test/merge-task-pr.test.ts | 31 ++++++++++++++++++++++++++-- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index 7596dfea..e619d1c9 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check from schema v19; for an earlier first opening, the task's oldest clear check stands in) when that check scanned from the task's original base, and otherwise at the task's original base, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check from schema v19) when that check scanned from the task's original base, and otherwise, including a first opening recorded before v19, at the task's original base, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge call aborted at its 14-second deadline settles by 14.4 s. *Later (#48, #170):* a click can also be waiting on its issue-trust read, whose stop wait is 0.75 s, or on a review load, whose history worker gets 1 s after the abort before it is terminated; so it settles about 0.75 s after its deadline, or about 1 s or a little more after it when a review load was running, past the 14.5-second shutdown drain (traced, not tested); see [merge-queue.md](merge-queue.md). Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 78d53cf8..f39eaf95 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -53,7 +53,7 @@ Moving the task to **approved, merge blocked** advances its state version, so th ## The already-fixed range -A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. The scan must cover every base commit after the task's original base (the base of its first snapshot), since a rebase can bring any of them under the pull request. Each opening records the pre-PR check it followed (schema v19). That check of the task's **first opening into the current base branch** scanned the branch from its snapshot's base to the branch's tip then, and found nothing. When it scanned from the task's original base, each merge inspection scans from that tip to the branch's tip now. A later rebase onto the branch can bring in any commit after the original base: those up to that tip were scanned by that check, and later ones are in the merge's scan, so the scan covers them whichever of the task's openings into that branch the merge follows (after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base). A newer check is not a safe start: it scanned only from a rebased base, and a compare range excludes its own start commit. An opening into another base branch, left by a change of the `github.baseBranch` setting, says nothing about this branch and is not used. A first opening recorded before schema v19 has no check; the task's oldest clear check stands in. When there is no such check, or it scanned from a rebased base (the base branch setting changed after a rebase), the scan starts at the task's original base. If that start is not on the base branch (for example, it was force-pushed), the answer is unknown and merging stays blocked. The check reads at most 250 base commits: if more landed since the scan's start, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. The status shown while a merge attempt is submitting, queued or merged scans from the pull request's base, as before: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. The scan must cover every base commit after the task's original base (the base of its first snapshot), since a rebase can bring any of them under the pull request. Each opening records the pre-PR check it followed (schema v19). That check of the task's **first opening into the current base branch** scanned the branch from its snapshot's base to the branch's tip then, and found nothing. When it scanned from the task's original base, each merge inspection scans from that tip to the branch's tip now. A later rebase onto the branch can bring in any commit after the original base: those up to that tip were scanned by that check, and later ones are in the merge's scan, so the scan covers them whichever of the task's openings into that branch the merge follows (after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base). A newer check is not a safe start: it scanned only from a rebased base, and a compare range excludes its own start commit. An opening into another base branch, left by a change of the `github.baseBranch` setting, says nothing about this branch and is not used. A first opening recorded before schema v19 has no check, and no record shows which base branch an older check scanned (a publish can record its check and stop before opening), so no other check stands in. Then, and when the first opening's check scanned from a rebased base (the base branch setting changed after a rebase), the scan starts at the task's original base. If that start is not on the base branch (for example, it was force-pushed), the answer is unknown and merging stays blocked. The check reads at most 250 base commits: if more landed since the scan's start, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. The status shown while a merge attempt is submitting, queued or merged scans from the pull request's base, as before: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. ## Cancel, shutdown and restart diff --git a/runner/merge.ts b/runner/merge.ts index ae2d03bc..f7103482 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -130,14 +130,15 @@ export class MergeCoordinator { * nothing; when that base is the task's original base, the scan starts at that tip: commits a later rebase onto the * branch brings in up to that tip were scanned by that check, and later ones are in this scan, whichever of the * task's openings the merge follows. An opening into another base branch says nothing about this one. A first - * opening recorded before schema v19 has no check: the task's oldest clear check stands in. Otherwise (no such check, or one that scanned from a rebased base), the scan starts at the - * task's original base. The task's own commits are excluded. + * opening recorded before schema v19 has no check, and no record shows which base branch an older check scanned (a + * publish may record a check and stop before opening), so no other check stands in. Then, and when the check scanned + * from a rebased base, the scan starts at the task's original base. The task's own commits are excluded. */ #alreadyFixedRange(here: TaskPullRequest[], base: string): { alreadyFixedSince: string; ownCommits: string[] } { const { store, config } = this.service, identity = config.identity; const original = store.firstSnapshot(identity).base; const first = here.find(pr => pr.base === base); - const check = first?.checkId ? store.getAlreadyFixed(identity, first.checkId) : store.oldestClearAlreadyFixed(identity); + const check = first?.checkId ? store.getAlreadyFixed(identity, first.checkId) : null; const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); if (check?.result.outcome !== 'clear') return { alreadyFixedSince: original, ownCommits }; let scannedFrom: string | null = null; diff --git a/runner/store.ts b/runner/store.ts index 8b501f7f..c4feca19 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2356,10 +2356,6 @@ export class Store { * which did not record its own check. A compare range excludes its start commit, so only a scan from this check's tip * onward is provably continuous with what was scanned before. */ - oldestClearAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { - const row = this.#get(`SELECT * FROM already_fixed_checks WHERE plan_key=? AND outcome='clear' ORDER BY rowid ASC LIMIT 1`, identityKey(identity)); - return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; - } getAlreadyFixed(identity: PlanIdentity, id: string): AlreadyFixedCheck | null { const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? AND id=?', identityKey(identity), id); return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index 8f47b42e..9d22bd3d 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -436,7 +436,7 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(9) }); }); - it('scans from the oldest clear check for a PR opened before schema v19, whatever later checks found', async () => { + it('scans from the task\'s original base for a PR opened before schema v19, whatever later checks found', async () => { const root = mkdtempSync(join(tmpdir(), 'codeboost-v19-')); roots.push(root); const path = join(root, 'state.sqlite'); const { store } = published(path); @@ -459,7 +459,34 @@ describe('the merge gate with a runner block (#121)', () => { const gh = github(reopened); const merges = new MergeCoordinator(service(reopened), gh.client, undefined, undefined, PUBLISHED); await merges.status(undefined, true); - expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); + // No record shows which base branch an older check scanned, so none stands in: the scan covers every base commit. + expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(1) }); + }); + + it('scans from the task\'s original base, not another base branch\'s check, for a PR opened before schema v19', async () => { + const root = mkdtempSync(join(tmpdir(), 'codeboost-v19-')); roots.push(root); + const path = join(root, 'state.sqlite'); + // PR 7 went into main after a clear check that scanned main from the original base oid(1) to oid(9). github.baseBranch + // then became release: the person closed PR 7 and published again, into release, after a check that scanned release + // to oid(29). Both openings were recorded before v19, so neither names its check; main's oid(9) says nothing about + // release. + const { store } = published(path); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(29) } }); + const release = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'release', headBranch: BRANCH, headSha: oid(2), draft: false }); + opened(store, release.openingId, 8, false); + toReview(store); + store.close(); stores.splice(stores.indexOf(store), 1); + const raw = new DatabaseSync(path); raw.exec('UPDATE task_pull_requests SET check_id=NULL'); raw.close(); + const reopened = new Store(path); stores.push(reopened); + expect(reopened.taskPullRequests(identity).map(pr => [pr.base, pr.checkId])).toEqual([['main', null], ['release', null]]); + const gh = github(reopened); + await new MergeCoordinator(service(reopened), gh.client, undefined, undefined, { ...PUBLISHED, baseBranch: 'release' }).status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(1) }); }); it('scans from the first opening into the current base branch after the base branch setting changed', async () => { From d1f99fc02b3f944532f2aff35ae7791b5eb6d6bf Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 10:03:00 -0700 Subject: [PATCH 090/163] Pin that publish is refused while a merge job runs, and document why Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- test/runner-publishing.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index f39eaf95..abe48310 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -68,7 +68,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t ## Storage -Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. +Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. So no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. ## API and browser diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 9f99dbb3..e3451f72 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1817,7 +1817,7 @@ describe('the one-click merge job (F6b of #22)', () => { }; const mergeState = async (app: App) => (await fetch(`${new URL(app.url).origin}/api/merge`, { headers: { 'x-codeboost-token': app.token } })).json() as Promise>; - it('offers Merge PR while only blockers the job resolves remain, then waits for checks and merges, refusing prepare-merge meanwhile', async () => { + it('offers Merge PR while only blockers the job resolves remain, then waits for checks and merges, refusing prepare-merge and publish meanwhile', async () => { const w = world(); mergeable(w); let checks: RequiredCheck[] = [{ context: 'ci', appId: null, state: 'pending' }], storeRef: Store | null = null; const gh = gateway(() => storeRef!, () => checks); @@ -1835,6 +1835,9 @@ describe('the one-click merge job (F6b of #22)', () => { expect(started.body.merge).toMatchObject({ ready: false, job: { state: 'running' } }); await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks', checks: [{ context: 'ci', state: 'pending' }] }), { timeout: 10_000 }); expect(await act(served.app, 'prepare-merge')).toMatchObject({ status: 409, body: { error: expect.stringMatching(/merge is running/) } }); + // A publish cannot begin a refresh of the PR the job waits on: publishing refuses a task in review. + expect(await act(served.app, 'publish')).toMatchObject({ status: 409, body: { error: expect.stringMatching(/The task is in review/) } }); + expect(served.store.taskPullRequests(served.identity).every(pr => pr.refresh === null)).toBe(true); checks = [{ context: 'ci', appId: null, state: 'success' }]; await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); expect(gh.merged).toEqual([served.store.getSnapshot(served.identity).head]); From 94c35573c5dca4283780c02efa741f6ea923f80a Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 10:27:27 -0700 Subject: [PATCH 091/163] Refuse start and resume while a merge job runs, and state what plan import 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 --- docs/implementation/merge-handoff.md | 4 +- test/runner-publishing.test.ts | 97 ++++++++++++++++++++++++++++ web/server.ts | 3 + 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index abe48310..f0e07908 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -68,7 +68,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t ## Storage -Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. So no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. +Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. The runner's `start` and `resume` actions are refused too ("A merge is running for this task; wait for it to end or cancel the task."), saved and replayed like other refusals, and neither is offered: a run would move the task's head, and the publish at its end would push it. So no run starts, and no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. Plan import stays allowed during a job, as at any time in review. It changes only the plan, never the branch or the pull request; the readiness the job prepared is then for an older review, so the job's next inspection ends it `failed` ("Pre-merge preparation has not completed for the current review."), or the guarded merge refuses, and nothing is merged. ## API and browser @@ -80,7 +80,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting, GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` refusal, cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting, GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. ## Check this document with a reader diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index e3451f72..c445a46f 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1844,6 +1844,103 @@ describe('the one-click merge job (F6b of #22)', () => { expect(served.store.getTask(served.identity).status).toBe('merged'); }, 60_000); + it('refuses start and resume while a merge job waits, even after a new plan revision is imported and approved; the import only ends the job failed', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null; + const base = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }]); + // The job's fresh inspections after its first are held until the test releases them (or the job's signal stops them), + // so the job is still waiting for checks at every step below, whatever the machine's speed. + let fresh = 0, release!: () => void; + const released = new Promise(resolve => { release = resolve; }); + const gh: MergeGateway = { merge: base.merge, inspect: async request => { + if (request?.fresh && ++fresh > 1) await new Promise((resolve, reject) => { + void released.then(resolve); + request.signal?.addEventListener('abort', () => reject(request.signal!.reason), { once: true }); + }); + return base.inspect(request); + } }; + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + // The first inspection has answered (its pending check is recorded) and the second is held. + await vi.waitFor(async () => { + expect((await mergeState(served.app)).job).toMatchObject({ state: 'running', step: 'checks', checks: [{ context: 'ci', state: 'pending' }] }); + expect(fresh).toBe(2); + }, { timeout: 10_000 }); + // As a person would: import a new revision of the plan (plan import has no task-status guard), then approve it all. + const plan = served.store.getPlan(served.identity); + const imported = await fetch(`${new URL(served.app.url).origin}/api/plan/import`, { method: 'POST', headers: { 'x-codeboost-token': served.app.token, 'content-type': 'application/json' }, + body: JSON.stringify({ source: JSON.stringify(plan), format: 'json', expectedRevision: plan.revision, actionId: randomUUID() }) }); + expect(imported.status).toBe(200); + expect(served.store.getPlan(served.identity).revision).toBe(plan.revision + 1); + await acceptAndApprove(served.app.service); + expect(served.store.unapprovedExecutionItems(served.identity, plan.revision + 1)).toEqual([]); + expect(served.store.getTask(served.identity).status).toBe('in review'); + const calls = [...w.github.calls], branchHead = w.github.head(served.branch), attempts = served.store.recentAttempts(served.identity, 50).length; + // Neither is offered, and both are refused, while the job runs. + expect(await view(served.app)).toMatchObject({ startable: false, resumable: false }); + for (const action of ['start', 'resume']) { + const id = randomUUID(), refused = await act(served.app, action, id); + expect(refused).toMatchObject({ status: 409, body: { error: 'A merge is running for this task; wait for it to end or cancel the task.' } }); + // Saved like other refusals: the same action ID replays it. + expect(await act(served.app, action, id)).toMatchObject({ status: 409, body: { error: refused.body.error } }); + } + expect((await mergeState(served.app)).job).toMatchObject({ state: 'running', step: 'checks' }); + expect(fresh).toBe(2); + // What the import itself does to the job: the readiness it prepared is for the old review, so its next inspection + // ends it failed, and nothing is merged, run, pushed or published. + release(); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'failed', + reason: 'Pre-merge preparation has not completed for the current review. Prepare the merge again.' }), { timeout: 10_000 }); + expect(served.store.getTask(served.identity).status).toBe('in review'); + expect(served.store.recentAttempts(served.identity, 50)).toHaveLength(attempts); + expect(served.app.publishing!.busy(served.identity)).toBe(false); + expect(w.github.calls).toEqual(calls); + expect(w.github.head(served.branch)).toBe(branchHead); + expect(base.merged).toEqual([]); + }, 60_000); + + it('refuses start while a merge job prepares, even after a new plan revision is imported and approved', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, release!: () => void, preparing = false; + const held = new Promise(resolve => { release = resolve; }); + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, mergeGateway: gh, runnerPublished: true, + // A preparation held until the test releases it; it then fails, as a real one does once the review changed under it. + preMerge: service => ({ + get active() { return preparing; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, + cancelTask: () => 'closed' as const, close: async () => undefined, + start: async (expected: { actionId: string }) => { + preparing = true; + await held; + preparing = false; + const identity = service.config.identity, snapshot = service.store.getSnapshot(identity); + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'The review changed during preparation.' }, null); + }, + } as unknown as PreMergeCoordinator) }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(async () => { expect(preparing).toBe(true); expect((await mergeState(served.app)).job).toMatchObject({ state: 'running', step: 'preparing' }); }, { timeout: 10_000 }); + const plan = served.store.getPlan(served.identity); + const imported = await fetch(`${new URL(served.app.url).origin}/api/plan/import`, { method: 'POST', headers: { 'x-codeboost-token': served.app.token, 'content-type': 'application/json' }, + body: JSON.stringify({ source: JSON.stringify(plan), format: 'json', expectedRevision: plan.revision, actionId: randomUUID() }) }); + expect(imported.status).toBe(200); + await acceptAndApprove(served.app.service); + const calls = [...w.github.calls], attempts = served.store.recentAttempts(served.identity, 50).length; + expect(await view(served.app)).toMatchObject({ startable: false }); + expect(await act(served.app, 'start')).toMatchObject({ status: 409, body: { error: 'A merge is running for this task; wait for it to end or cancel the task.' } }); + expect(served.store.getTask(served.identity).status).toBe('in review'); + expect(served.store.recentAttempts(served.identity, 50)).toHaveLength(attempts); + release(); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'failed' }), { timeout: 10_000 }); + expect(w.github.calls).toEqual(calls); + expect(gh.merged).toEqual([]); + }, 60_000); + it('admits issue trust before a merge job starts: an untrusted click is refused and replayed, a failed read saves nothing', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, access: 'untrusted' | 'unreadable' | 'trusted' = 'trusted', reads = 0, prepared = 0; diff --git a/web/server.ts b/web/server.ts index 69854ed9..a21e28d3 100644 --- a/web/server.ts +++ b/web/server.ts @@ -220,6 +220,9 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if (executor.busy(identity)) throw new GuardRefusal('An earlier run of this task is still finishing; try again when that run has ended.'); // A publish reads the task head and pushes it; a new item must not move it meanwhile. if (publishing?.busy(identity)) throw new GuardRefusal('A pull request is being published for this task; try again when it has finished.'); + // A merge job prepares, waits for checks and merges the PR's head. A run would move that head, and its end publishes + // (pushes the head, refreshes the PR) under the job. Before owed work too: settling it ends in the same publish. + if (mergeJobs?.active) throw new GuardRefusal('A merge is running for this task; wait for it to end or cancel the task.'); // Recovery owns this claim for Resume even when owed work will settle instead of admitting an item. Start must not // consume it and strand the task behind the resulting human gate. if (action === 'start' && task.requeuePending) throw new GuardRefusal('Recovery left this task to requeue; it cannot start until Resume resolves that claim.'); From 25444dd8cf108e0f20f5eaebece94cc6ab1b0f89 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 10:27:27 -0700 Subject: [PATCH 092/163] Describe getAlreadyFixed as the look-up by check ID it is Co-Authored-By: Claude Opus 5.5 --- runner/store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/runner/store.ts b/runner/store.ts index c4feca19..dd2b018b 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2352,9 +2352,9 @@ export class Store { return this.#db.prepare('SELECT * FROM task_pull_requests WHERE plan_key=? ORDER BY rowid').all(key).map(row => this.#pullRequestRecord(row)); } /** - * The task's first already-fixed check that found nothing: the scan start for an opening recorded before schema v19, - * which did not record its own check. A compare range excludes its start commit, so only a scan from this check's tip - * onward is provably continuous with what was scanned before. + * The task's already-fixed check with this ID, or null if it has none with that ID. The guarded merge reads the check the + * task's first opening into the base branch recorded (`task_pull_requests.check_id`, schema v19) to decide where its + * already-fixed scan starts. */ getAlreadyFixed(identity: PlanIdentity, id: string): AlreadyFixedCheck | null { const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? AND id=?', identityKey(identity), id); From d2de1b306d36711a133f958d6d21fea93942d35d Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 10:50:44 -0700 Subject: [PATCH 093/163] Keep the Merge button pending across renders, and pin every full-response job guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/implementation/merge-handoff.md | 4 +- test/browser/review.spec.ts | 126 ++++++++++++++++++++++++++- web/public/app.js | 15 ++++ 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index f0e07908..9757b3fa 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -75,8 +75,8 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - `POST /api/action` with `merge` returns at once when the server runs merge jobs (a runner and a pull request published by it, which requires preparation): the reloaded review with the job's latest record as `job`, or, if that reload failed, `{ job, mergeRefreshRequired: true }`. The job may already have moved on or ended by then; the page follows a running job and, for an ended one, asks for a refresh. - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. -- While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record of the same job (by its `updatedAt`) keeps that newer record, so a slow **Refresh** does not restart following a job the poll saw end. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. +- While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record of the same job (by its `updatedAt`) keeps that newer record, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. ## Tests diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index d553e422..8401f1cb 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -8,7 +8,7 @@ import { request as httpRequest, type IncomingMessage } from 'node:http'; import { createDemo } from '../../scripts/demo.ts'; import { choiceKeys } from '../../core/approvals.ts'; import { ReviewService } from '../../runner/review.ts'; -import { startServer } from '../../web/server.ts'; +import { startServer, type PlanningDeps } from '../../web/server.ts'; import type { MergeGateway, MergeQueueGateway } from '../../github/merge.ts'; import { fixtureGit } from '../fixtures/git.ts'; let root: string, app: Awaited>; @@ -784,6 +784,126 @@ test('keeps a merge-job outcome a poll saw while an older Refresh response was h await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveCount(0); }); +/** A runner server whose every full review shows a running merge job (each was read while it ran); the job poll reports + * it merged once `ended` is set. Used to hold one full response while a poll sees the job end. */ +async function mergeJobOutcomeRace(page:import('@playwright/test').Page,planning?:PlanningDeps){ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway,undefined,undefined,undefined,planning); + 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}); + const running={id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',reason:null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const merged={...running,state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',updatedAt:'2026-10-11T00:05:00Z'}; + const race={ended:false,polls:0,hold:false,config,release:()=>{},held:Promise.resolve()}; + let holding!:()=>void;race.held=new Promise(resolve=>{holding=resolve;}); + const released=new Promise(resolve=>{race.release=resolve;}); + await page.route('**/api/merge',route=>{race.polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:race.ended?merged:running})});}); + // A full response (review or action) read while the job ran. The one marked held waits, and the job ends meanwhile. + const answer=async(route:import('@playwright/test').Route)=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:false,action:null,job:running}; + if(race.hold){race.hold=false;race.ended=true;holding();await released;} + await route.fulfill({response,json:body});}; + await page.route('**/api/review',answer); + await page.route('**/api/action',answer); + await page.goto(app.url); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toBeVisible(); + /** The poll saw the job end while the full response was held; release it and show it does not restore the running job. */ + const settle=async(response:Promise)=>{ + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:10_000}); + const seen=race.polls; + race.release();await response; + // No poll restarts (the poll interval is 2 s), and the page still shows the outcome. + await page.waitForTimeout(3_000); + expect(race.polls).toBe(seen); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveCount(0); + }; + return {race,settle}; +} + +test('keeps a merge-job outcome a poll saw while an older review action response was held (F6b)',async({page})=>{ + const {race,settle}=await mergeJobOutcomeRace(page); + await page.getByRole('button',{name:'Request change',exact:true}).click(); + await page.getByLabel('Change to request').fill('Keep the merge outcome.'); + race.hold=true; + const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Save change request',exact:true}).click();await race.held; + // Selecting an item while the action waits renders the merge control, which schedules a poll; that poll sees the job end. + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await settle(answered); + await page.getByRole('button',{name:/P1 Bound exponential retries/}).click(); + await expect(page.getByText('Keep the merge outcome.',{exact:true})).toBeVisible(); +}); + +test('keeps a merge-job outcome a poll saw while an older Plans refresh response was held (F6b)',async({page})=>{ + const {race,settle}=await mergeJobOutcomeRace(page); + await page.getByRole('link',{name:'Plans',exact:true}).click(); + race.hold=true; + const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#plans-refresh').click();await race.held; + await page.getByRole('link',{name:'Review',exact:true}).click(); + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await settle(refreshed); + await expect(page.locator('#plans-status')).toHaveText('✓ Revision r1 is current.'); +}); + +test('keeps a merge-job outcome a poll saw while an older plan-import reload was held (F6b)',async({page})=>{ + const {race,settle}=await mergeJobOutcomeRace(page); + const current=app.service.store.getPlan(race.config.identity),next={...structuredClone(current),revision:current.revision+1,summary:'Make retry limits visible to operators'}; + await page.getByRole('link',{name:'Plans',exact:true}).click(); + await page.getByLabel('Plan file').setInputFiles({name:'plan.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(next))}); + race.hold=true; + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + // The import's success renders the merge control, which schedules a poll; the poll sees the job end during the reload. + await page.getByRole('button',{name:'Import next revision'}).click();await race.held; + await settle(reloaded); + await expect(page.locator('#plans-status')).toHaveText('✓ Revision r2 is current.'); +}); + +test('keeps a merge-job outcome a poll saw while an older Apply draft reload was held (F6b)',async({page})=>{ + let plan:unknown; + const planning:PlanningDeps={provider:{invoke:async()=>JSON.stringify(plan)},describe:()=>({repo:{name:'retry-service',baseRef:'main'},issue:{number:3,title:'Retries',body:'',comments:[]},approvedLessons:[],validate:()=>undefined})}; + const {race,settle}=await mergeJobOutcomeRace(page,planning); + const current=app.service.store.getPlan(race.config.identity); + plan={...structuredClone(current),revision:current.revision+1,summary:'Make retry limits visible to operators'}; + await page.getByRole('link',{name:'Plans',exact:true}).click(); + await page.getByRole('button',{name:'Draft next revision'}).click(); + const apply=page.getByRole('button',{name:'Apply draft',exact:true}); + await expect(apply).toBeVisible(); + race.hold=true; + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + // Apply's success renders the merge control, which schedules a poll; the poll sees the job end during the reload. + await apply.click();await race.held; + await settle(reloaded); + await expect(page.locator('#plans-status')).toHaveText('✓ Revision r2 is current.'); +}); + +test('keeps the Merge button pending while its click is in flight, across a render (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const running={id:'0f6b0000-0000-4000-8000-0000000000d1',state:'running',step:'preparing',reason:null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + let release!:()=>void,arrived!:()=>void;const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{arrived=resolve;}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + arrived();await released; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:running})})); + // A runner server reports merging as a job (its merge status carries `job`). + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const button=page.locator('#merge'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click();await held; + await expect(button).toHaveAttribute('aria-disabled','true');await expect(button).toHaveText('Requesting merge…'); + // Selecting an item renders the page, merge control included, while the click's request is still in flight. + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.getByRole('button',{name:/P2 Document retry behavior/})).toHaveAttribute('aria-current','true'); + await expect(button).toHaveAttribute('aria-disabled','true');await expect(button).toHaveText('Requesting merge…'); + await expect(button).toHaveJSProperty('disabled',false); + release(); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); +}); + test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; @@ -860,6 +980,8 @@ test('follows a started merge job when the server could not reload the review af await expect(page.locator('#banner')).toContainText('Preparing the merge'); await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:15000}); expect(polls).toBeGreaterThanOrEqual(2); + // Give a background reload started by the job's end time to reach the route before counting. + await page.waitForTimeout(2500); expect(reviews).toBe(reloadsBefore); expect(await page.locator('#items button').count()).toBe(items); }); @@ -901,6 +1023,8 @@ test('follows a one-click merge job from preparation through the required-check // The job's end updates only the merge control (AGENTS.md, Async review UI): no background reload erases the // person's selection or input. await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + // Give a background reload started by the job's end time to reach the route before counting. + await page.waitForTimeout(2500); expect(reviews).toBe(reloadsBeforeEnd); await expect(button).toHaveAttribute('aria-disabled','true');await expect(button).toHaveJSProperty('disabled',false); expect(polls).toBe(3); diff --git a/web/public/app.js b/web/public/app.js index 8423bdb5..ac17f1f4 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -36,6 +36,9 @@ let snippetSelection = null; // Kept until the server answers, so a resend after a lost response replays the same click instead of merging twice. // The key travels with the exact request it was made for: a later Refresh changes data.token, not this request. let mergeAction = null; +// True while a Merge PR click's request is in flight: a render meanwhile (selecting an item, a plan reload) keeps the +// control marked and labelled as pending instead of offering it again from `merge.ready`. +let mergeClickPending = false; const statusClass = (text) => text.startsWith("✓") ? "good" @@ -188,6 +191,14 @@ function renderMerge() { $("merge").disabled = !merge.ready; $("merge").removeAttribute("aria-disabled"); } + if (mergeClickPending) { + if ("job" in merge) $("merge").setAttribute("aria-disabled", "true"); + else $("merge").disabled = true; + $("merge").textContent = "Requesting merge…"; + $("merge-details").textContent = merge.queue ? "Merge status" : "Review blockers"; + scheduleMergePoll(); + return; + } const job = merge.job; if (job?.state === "running") { // Keep the control focusable while its job runs; repeat activation is ignored because the merge is not ready. @@ -622,10 +633,13 @@ $("merge").onclick = async () => { // With merge jobs, the click returns at once and the focused control must keep focus: mark it, do not disable it. if (data.merge && "job" in data.merge) $("merge").setAttribute("aria-disabled", "true"); else $("merge").disabled = true; + mergeClickPending = true; + $("merge").textContent = "Requesting merge…"; try { rememberDraft(); mergeAction ??= { actionId: crypto.randomUUID(), token: data.token, jobBefore: data.merge.job?.id ?? null }; const updated = await api("/api/action", { action: "merge", token: mergeAction.token, actionId: mergeAction.actionId }); + mergeClickPending = false; // The composer stayed editable while the request was in flight: keep what the person typed meanwhile. rememberDraft(); mergeAction = null; @@ -657,6 +671,7 @@ $("merge").onclick = async () => { render(); $("banner").textContent = `${queued ? "Merge queued" : "Merge submitted"}. ${updated.mergeResult.url}`; } catch (error) { + mergeClickPending = false; rememberDraft(); // Only a parsed, definite server answer resolves this click. No response, an unreadable body, 503 (nothing // applied) or an admitted attempt with an unknown outcome keeps the key until that attempt ends. From c1a329d5b7ecee9e4e3cd0597ff09699fec22ed5 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 10:52:39 -0700 Subject: [PATCH 094/163] Count neutral and skipped required checks as passing, and read a check'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 --- docs/implementation/merge-handoff.md | 2 +- github/merge.ts | 48 +++++++++++++++++-------- test/merge.test.ts | 52 ++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 9757b3fa..8f2ab4ec 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it (job: `merged` or `queued`; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. ## Outcomes diff --git a/github/merge.ts b/github/merge.ts index b4adece7..d5c8e009 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -150,6 +150,33 @@ function timestamp(value: unknown, label: string): string { } /** GitHub CLI adapter. All arguments are literal argv; no shell is involved. */ +/** + * The state GitHub gives one observed required check. A check run that completed SUCCESS, NEUTRAL or SKIPPED passes, as + * GitHub treats those conclusions for required checks; a commit status passes only with SUCCESS. + */ +function observedCheckState(check: Record): 'success' | 'pending' | 'failure' { + if (typeof check.name === 'string') { + const status = String(check.status ?? '').toUpperCase(); + const conclusion = String(check.conclusion ?? '').toUpperCase(); + if (['QUEUED','IN_PROGRESS','WAITING','PENDING','REQUESTED'].includes(status)) return 'pending'; + if (status === 'COMPLETED') return ['SUCCESS','NEUTRAL','SKIPPED'].includes(conclusion) ? 'success' : 'failure'; + return 'failure'; + } + const status = String(check.state ?? '').toUpperCase(); + return status === 'SUCCESS' ? 'success' : ['PENDING','EXPECTED'].includes(status) ? 'pending' : 'failure'; +} +/** + * When an observed check run or status started, to pick the latest of several with one name. gh reports a run that has + * not started yet with no time or the zero time (year 1); such a run was queued after every started one, so it counts + * as the latest. + */ +function observedCheckStart(check: Record): number { + const at = typeof check.startedAt === 'string' ? Date.parse(check.startedAt) : NaN; + return Number.isFinite(at) && new Date(at).getUTCFullYear() > 1 ? at : Number.POSITIVE_INFINITY; +} +/** Among runs with the same start, the one that passes least counts, so a tie never reads as passing. */ +const checkStateRank = { failure: 0, pending: 1, success: 2 } as const; + export class GhMergeGateway implements MergeGateway, MergeQueueGateway { readonly config: GhMergeConfig; readonly run: RunGh; @@ -366,24 +393,17 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { } } const requiredChecks = [...requirements.values()].map(required => { - const match = observed.find(check => { + const matches = observed.filter(check => { const context = typeof check.name === 'string' ? check.name : check.context; const app = check.app && typeof check.app === 'object' ? (check.app as { databaseId?: unknown }).databaseId : null; return context === required.context && (required.appId === null || app === required.appId); }); - if (!match) return { ...required, state: 'missing' as const }; - let state: RequiredCheck['state']; - if (typeof match.name === 'string') { - const status = String(match.status ?? '').toUpperCase(); - const conclusion = String(match.conclusion ?? '').toUpperCase(); - if (['QUEUED','IN_PROGRESS','WAITING','PENDING','REQUESTED'].includes(status)) state = 'pending'; - else if (status === 'COMPLETED') state = conclusion === 'SUCCESS' ? 'success' : 'failure'; - else state = 'failure'; - } else { - const status = String(match.state ?? '').toUpperCase(); - state = status === 'SUCCESS' ? 'success' : ['PENDING','EXPECTED'].includes(status) ? 'pending' : 'failure'; - } - return { ...required, state }; + if (!matches.length) return { ...required, state: 'missing' as const }; + // A re-run adds a newer run (or status) with the same name; GitHub counts the latest one, so an older failure + // does not outrank a newer pending or passing run. + const latest = matches.map(check => ({ state: observedCheckState(check), at: observedCheckStart(check) })) + .reduce((best, next) => next.at > best.at || (next.at === best.at && checkStateRank[next.state] < checkStateRank[best.state]) ? next : best); + return { ...required, state: latest.state }; }); return { rulesKnown, atomicBaseGuard, mergeQueue, requiredChecks }; } diff --git a/test/merge.test.ts b/test/merge.test.ts index 1508c100..91f1c440 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -940,6 +940,58 @@ it('parses required checks from both rule sources and pins the gh merge head', a expect(pullReads).toBe(2); }); +/** Inspects a pull request whose one required check, `test` from any app, has these observed runs and statuses. */ +async function requiredTestState(statusCheckRollup: unknown[]) { + const run = async (args: readonly string[]) => { + const joined = args.join(' '); + if (joined.startsWith('pr view 7')) return JSON.stringify({ number: 7, isDraft: false, baseRefName: 'main', baseRefOid: sha('a'), headRefName: 'feature', headRefOid: sha('b'), state: 'OPEN', mergeable: 'MERGEABLE', statusCheckRollup }); + if (joined.includes('/rules/branches/')) return JSON.stringify([[{ type: 'required_status_checks', parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: 'test', integration_id: null }] } }]]); + if (/branches\/main$/.test(joined)) return JSON.stringify({ protected: false }); + if (joined.endsWith('/protection')) throw new Error('gh: Branch not protected (HTTP 404)'); + { const fixed = alreadyFixedReads(args); if (fixed !== null) return fixed; } + throw new Error(`Unexpected gh call: ${joined}`); + }; + const state = await new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21 }, run).inspect(); + expect(state.rulesKnown).toBe(true); + return state.requiredChecks.map(check => check.state); +} + +it.each([ + ['SUCCESS', 'success'], ['NEUTRAL', 'success'], ['SKIPPED', 'success'], + ['FAILURE', 'failure'], ['CANCELLED', 'failure'], ['TIMED_OUT', 'failure'], ['ACTION_REQUIRED', 'failure'], ['STARTUP_FAILURE', 'failure'], ['STALE', 'failure'], +] as const)('reads a required check run that completed %s as %s, as GitHub does for required checks', async (conclusion, expected) => { + expect(await requiredTestState([{ name: 'test', status: 'COMPLETED', conclusion, startedAt: '2026-10-11T00:00:00Z' }])).toEqual([expected]); +}); + +it.each([ + ['a newer pending re-run over an older failure', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z', completedAt: '2026-10-11T00:01:00Z' }, + { name: 'test', status: 'IN_PROGRESS', conclusion: '', startedAt: '2026-10-11T00:05:00Z', completedAt: '0001-01-01T00:00:00Z' }, + ], 'pending'], + ['a newer passing re-run over an older failure', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'success'], + ['a newer failure over an older pass, listed first', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, + ], 'failure'], + ['a queued re-run that has not started (zero time) over an older failure', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'QUEUED', conclusion: '', startedAt: '0001-01-01T00:00:00Z' }, + ], 'pending'], + ['the run that passes least when two started at once', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + ], 'failure'], + ['a newer commit status over an older failed one', [ + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'success'], +] as const)('counts the latest observation of a required check: %s', async (_case, rollup, expected) => { + expect(await requiredTestState([...rollup])).toEqual([expected]); +}); + it('rejects an unsupported runtime merge method', () => { expect(() => new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21, method: 'typo' as 'merge' })).toThrow(/merge method/i); }); From de892bfec29d1830d969794bbb3a477aa86964d2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 11:07:59 -0700 Subject: [PATCH 095/163] Keep a GitHub failure that precedes the required-check wait limit 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 --- docs/implementation/merge-handoff.md | 4 ++-- runner/merge-job.ts | 4 +++- test/merge-job.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 8f2ab4ec..991af37c 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it (job: `merged` or `queued`; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. ## Outcomes @@ -80,7 +80,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting, GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. ## Check this document with a reader diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 9e2e9763..11f3eee8 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -248,7 +248,9 @@ export class MergeJobCoordinator { try { status = await this.merges.status(undefined, true, AbortSignal.any([signal, limit])); } catch (error) { if (signal.aborted) return { kind: 'cancelled' }; - if (limit.aborted) return timedOut(); + // Only the limit's own abort is the wait's timeout: every layer below rejects with that reason, unchanged. A + // failure that came first (a GitHub stage failing while its stopped peers settle past the limit) keeps its error. + if (limit.aborted && error === limit.reason) return timedOut(); return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }; } const verdict = classify(status); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 34f80945..5aa0117a 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -658,6 +658,29 @@ it('starts no inspection after the wait limit and stops one that runs into it', expect(f.gh.merged).toEqual([]); }); +it('keeps a GitHub failure that came before the wait limit, even when the inspection settles after it', async () => { + let inspections = 0, abortedFirst: unknown = null; + const f = setup({ checks: () => [pending()] }); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + if (++inspections < 2) return inspect(request); + // A stage fails at once; its stopped peers settle only after the wait's limit, and the gateway then reports the + // stage's own error (github/merge.ts inspect), not the abort. + const failure = new Error('gh: HTTP 502 from the rules read'); + await new Promise(resolve => request!.signal!.addEventListener('abort', resolve, { once: true })); + abortedFirst = request!.signal!.reason; + throw failure; + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 300, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + await coordinator.start('review-token', randomUUID()); + const done = await settled(coordinator); + // The interleaving happened: the wait's limit (a timeout, not a cancel) fired before the failed inspection settled. + expect((abortedFirst as Error | null)?.name).toBe('TimeoutError'); + expect(done).toMatchObject({ state: 'failed', reason: 'gh: HTTP 502 from the rules read' }); + expect(f.store.getTask(identity).status).toBe('in review'); + expect(f.gh.merged).toEqual([]); +}); + // Since #170 a click's admission awaits the review load; these pin what may happen while it does. it('gives a resend of the same click, during the first send\'s review load, the same job', async () => { const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); From fbfe4cee27b1e401ca628755983c5c3158fd43b9 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 11:19:08 -0700 Subject: [PATCH 096/163] Order merge jobs by a per-task sequence when keeping a poll's newer record 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 --- docs/implementation/merge-handoff.md | 4 +-- runner/store.ts | 14 ++++++-- test/browser/review.spec.ts | 49 ++++++++++++++++++++++++++++ test/merge-job.test.ts | 12 +++++++ web/public/app.js | 21 ++++++++---- 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 991af37c..d9fe6fda 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -68,7 +68,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t ## Storage -Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. The runner's `start` and `resume` actions are refused too ("A merge is running for this task; wait for it to end or cancel the task."), saved and replayed like other refusals, and neither is offered: a run would move the task's head, and the publish at its end would push it. So no run starts, and no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. Plan import stays allowed during a job, as at any time in review. It changes only the plan, never the branch or the pull request; the readiness the job prepared is then for an older review, so the job's next inspection ends it `failed` ("Pre-merge preparation has not completed for the current review."), or the guarded merge refuses, and nothing is merged. +Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, `sequence` (the record's place among the task's jobs and refused clicks, from 1, so the browser orders jobs without comparing clock times), start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. The runner's `start` and `resume` actions are refused too ("A merge is running for this task; wait for it to end or cancel the task."), saved and replayed like other refusals, and neither is offered: a run would move the task's head, and the publish at its end would push it. So no run starts, and no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. Plan import stays allowed during a job, as at any time in review. It changes only the plan, never the branch or the pull request; the readiness the job prepared is then for an older review, so the job's next inspection ends it `failed` ("Pre-merge preparation has not completed for the current review."), or the guarded merge refuses, and nothing is merged. ## API and browser @@ -76,7 +76,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record of the same job (by its `updatedAt`) keeps that newer record, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. ## Tests diff --git a/runner/store.ts b/runner/store.ts index dd2b018b..a8d112dc 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -149,6 +149,11 @@ export interface MergeJob { /** Required checks that have not succeeded yet, as the last inspection read them. */ checks: { context: string; state: string }[]; url: string | null; + /** + * The job's place among the task's jobs and refused clicks, starting at 1: a later click has a higher number. The + * browser orders records of different jobs by it, since wall-clock times can step back. + */ + sequence: number; startedAt: string; updatedAt: string; } @@ -2140,7 +2145,7 @@ export class Store { if (this.runningMergeJob(identity)) throw new GuardRefusal('A merge is already running for this task.'); const now = input.now ?? new Date().toISOString(); const job: MergeJob = { id: input.actionId, mergeActionId: null, token: input.token, state: 'running', step: 'preparing', reason: null, checks: [], - url: null, startedAt: now, updatedAt: now }; + url: null, sequence: this.#nextMergeJobSequence(key), startedAt: now, updatedAt: now }; this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); return { job, resent: false }; }); @@ -2163,11 +2168,16 @@ export class Store { } const now = new Date().toISOString(); const job: MergeJob = { id: input.actionId, mergeActionId: null, token: input.token, state: 'failed', step: null, - reason: input.reason.slice(0, 2_000), checks: [], url: null, startedAt: now, updatedAt: now, refused: true }; + reason: input.reason.slice(0, 2_000), checks: [], url: null, sequence: this.#nextMergeJobSequence(key), startedAt: now, updatedAt: now, refused: true }; this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); return job; }); } + /** One more than the task's highest job sequence; rows in `merge_jobs` are never deleted, so it only grows. */ + #nextMergeJobSequence(key: string): number { + const row = this.#get(`SELECT COALESCE(MAX(json_extract(data,'$.sequence')),0) AS n FROM merge_jobs WHERE plan_key=?`, key) as { n: number }; + return row.n + 1; + } /** Move a running job to another step or record what it waits for. A job no longer running is left unchanged. */ updateMergeJob(identity: PlanIdentity, id: string, patch: { step?: MergeJobStep; reason?: string | null; checks?: { context: string; state: string }[]; mergeActionId?: string }): MergeJob | null { diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 8401f1cb..269c2031 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -784,6 +784,55 @@ test('keeps a merge-job outcome a poll saw while an older Refresh response was h await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveCount(0); }); +test('keeps a newer merge job a poll saw while a held Refresh response shows an earlier job ended (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + // Job A runs in this tab. Another tab's click starts job B after A ends; B's record is later than any of A's (sequence), + // while its clock times are earlier, as after a clock step back. + const jobA={id:'0f6b0000-0000-4000-8000-0000000000e1',state:'running',step:'preparing',reason:null,checks:[],url:null,sequence:1,startedAt:'2026-10-11T00:10:00Z',updatedAt:'2026-10-11T00:10:00Z'}; + const blockedA={...jobA,state:'blocked',step:null,reason:'Required check failed: ci.',updatedAt:'2026-10-11T00:20:00Z'}; + const jobB={...jobA,id:'0f6b0000-0000-4000-8000-0000000000e2',step:'checks',sequence:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + let started=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; + const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); + await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started?jobB:jobA})});}); + // The page loads with A running. The held Refresh response was read after A ended (blocked, Retry offered) and before B + // started; it is answered only after the poll has seen B running. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + if(hold){hold=false;body.merge={...body.merge,ready:true,action:'retry',job:blockedA};holding();await released;} + else body.merge={...body.merge,ready:false,action:null,job:jobA}; + await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toBeVisible(); + hold=true; + const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await held; + // Selecting an item while Refresh waits renders the merge control, which schedules a poll; that poll sees job B running. + started=true; + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toBeVisible({timeout:10_000}); + await expect(page.locator('#banner')).toHaveText('Waiting for required checks.'); + release(); + const answer=await (await refreshed).json(); + // The interleaving happened: the Refresh answer showed job A ended with Retry offered, after the poll saw B. + expect(answer.merge).toMatchObject({ready:true,action:'retry',job:{id:blockedA.id,state:'blocked'}}); + // The older Refresh response does not restore job A: B stays followed, not ready, and its poll keeps running (2 s). + const seen=polls; + await page.waitForTimeout(3_000); + expect(polls).toBeGreaterThan(seen); + const merge=page.locator('#merge'); + await expect(merge).toHaveText('Waiting for checks…'); + await expect(merge).toHaveAttribute('aria-disabled','true'); + await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toHaveCount(0); + // Not ready while B runs: activating the control sends no merge request. + let actions=0,dialogs=0;page.on('request',request=>{if(request.url().endsWith('/api/action'))actions++;}); + page.on('dialog',dialog=>{dialogs++;void dialog.accept();}); + await merge.dispatchEvent('click');await page.waitForTimeout(500); + expect(dialogs).toBe(0);expect(actions).toBe(0); + await expect(page.locator('#banner')).toHaveText('Waiting for required checks.'); +}); + /** A runner server whose every full review shows a running merge job (each was read while it ran); the job poll reports * it merged once `ended` is set. Used to hold one full response while a poll sees the job end. */ async function mergeJobOutcomeRace(page:import('@playwright/test').Page,planning?:PlanningDeps){ diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 5aa0117a..45bd8495 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -681,6 +681,18 @@ it('keeps a GitHub failure that came before the wait limit, even when the inspec expect(f.gh.merged).toEqual([]); }); +it('numbers each job and refused click after the task\'s earlier ones, so the browser can order jobs without clocks', async () => { + const f = setup({ prepare: () => 'failed' }); + const first = await f.coordinator.start('review-token', randomUUID()); + await settled(f.coordinator); + const refusal = f.store.recordMergeJobRefusal(identity, { actionId: randomUUID(), token: 'review-token', reason: 'Refused.' }); + // A later click keeps a higher number even when the clock stepped back. + const second = f.store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token', now: '2000-01-01T00:00:00.000Z' }).job; + expect([first.sequence, refusal.sequence, second.sequence]).toEqual([1, 2, 3]); + expect(f.coordinator.latest()).toMatchObject({ id: second.id, sequence: 3 }); + f.store.finishMergeJob(identity, second.id, { state: 'failed' }); +}); + // Since #170 a click's admission awaits the review load; these pin what may happen while it does. it('gives a resend of the same click, during the first send\'s review load, the same job', async () => { const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); diff --git a/web/public/app.js b/web/public/app.js index ac17f1f4..3130018d 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -947,19 +947,28 @@ function preserveSettledQuestionAnswers(updated) { : note; }) }; } -/** A poll's record of the job a full response also carries, when the poll saw it later than that response did. */ +/** + * A poll's record of the task's latest job, when the poll saw it later than a full response read before it. Records of + * one job order by update time; a later job (another tab's click) outranks any record of an earlier one, by the + * server's per-task job sequence, never by clock time. + */ function newerMergeJob(updated, observationOwner) { const newer = data?.merge?.job, older = updated.merge?.job; - return observationOwner !== mergeObservationGeneration && newer && older && newer.id === older.id && newer.updatedAt > older.updatedAt - ? newer - : null; + if (observationOwner === mergeObservationGeneration || !newer || !older) return null; + const later = newer.id === older.id ? newer.updatedAt > older.updatedAt + : Number.isSafeInteger(newer.sequence) && Number.isSafeInteger(older.sequence) && newer.sequence > older.sequence; + return later ? newer : null; } function mergeJobBanner(job) { return job.state === "running" ? mergeJobText(job) : `${mergeJobText(job)} Refresh to see the result.`; } -/** The view with a newer observation of its job: an ended job asks for a refresh, as the job poll shows it. */ +/** + * The view with a newer observation of its job, as the job poll shows it: a running job is not ready to merge (the + * full response may offer Retry for an earlier job), and an ended job asks for a refresh. + */ function withMergeJobObservation(view, job) { - if (job.state === "running") return { ...view, merge: { ...view.merge, job } }; + if (job.state === "running") return { ...view, merge: { ...view.merge, ready: false, action: null, job, + blockers: view.merge.blockers.filter(blocker => blocker.code !== "job-refresh") } }; const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message: mergeJobBanner(job) }, ...blockers] } }; } From 105ea74039126ee6f604161bffcee5b979b323a6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 11:58:23 -0700 Subject: [PATCH 097/163] Show a merge job's outcome, not the blockers it resolved, once it ends 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 --- docs/implementation/merge-handoff.md | 4 +- test/browser/review.spec.ts | 78 ++++++++++++++++++++++++++-- web/public/app.js | 51 ++++++++++++++---- 3 files changed, 117 insertions(+), 16 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index d9fe6fda..a285f96c 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -76,11 +76,11 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive; after **Refresh**, the page offers **Retry merge** when the job is `blocked`. The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result" and only the blockers the job does not resolve: the review the page holds was read before or during the job, so it still lists preparation, a moved base, `cmd:` checks, required checks and mergeability, which the job has since settled, and those are left out. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome and drops the blockers the job resolved. ## Check this document with a reader diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 269c2031..f53cea31 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -46,7 +46,8 @@ test('mints a new merge key for a retry once the attempt started by a retained k await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}keys.push(body.actionId); if(keys.length===1){await route.abort('failed');return;} await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Merge is blocked.'})});}); - await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept()); + const confirms:string[]=[]; + await openReview(page, app.url);page.on('dialog',dialog=>{confirms.push(dialog.message());void dialog.accept();}); await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>keys.length).toBe(1);await expect(page.locator('#banner')).toContainText('Merge blocked'); // The lost click did reach GitHub (its response was lost), and that attempt later ends as refused. const expected={...view.expected,reviewVersion:view.expected.reviewVersion!};const attempt=app.service.store.beginMergeAttempt(config.identity,expected,view.snapshot.head,null,'direct',keys[0]); @@ -54,6 +55,8 @@ test('mints a new merge key for a retry once the attempt started by a retained k await refreshReview(page);await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toBeEnabled(); await page.getByRole('button',{name:'Retry merge',exact:true}).click();await expect.poll(()=>keys.length).toBe(2); expect(keys[1]).not.toBe(keys[0]); + // A direct merge's retry merges the exact reviewed head again (without a runner, nothing prepares or rebases it). + expect(confirms).toEqual(['Merge this reviewed pull request?','Retry merging this exact reviewed head?']); }); test('answers 503 when the merge deadline stops a click, so the browser may resend its key',async()=>{ test.setTimeout(60_000); @@ -634,6 +637,66 @@ test('hides Retry until a timed-out invocation has actually settled',async({page /** Whether the element is drawn with the muted surface background a control that cannot act uses (poll it: buttons may animate their background). */ const looksInactive=(locator:import('@playwright/test').Locator)=>locator.evaluate(el=>{const probe=document.createElement('div');probe.style.background='var(--surface)';document.body.append(probe); const surface=getComputedStyle(probe).backgroundColor;probe.remove();return getComputedStyle(el).backgroundColor===surface;}); +/** The blockers a runner server's review lists while a merge job prepares (runner/merge.ts): preparation has not completed + * for the review, and a required check is pending. The job resolves both. */ +const jobResolvedBlockers=[{code:'preparation',message:'Pre-merge preparation has not completed for the current review. Prepare the merge again.'},{code:'check',message:'ci is pending.'}]; +/** The merge control and its dialog once the page saw its job end: the outcome, the refresh request, and only `others`. */ +async function expectEndedMergeJob(page:import('@playwright/test').Page,label:string,ended:string,others:string[]=[]){ + await expect(page.locator('#merge')).toHaveText(label); + await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#merge-details')).toHaveText('Merge status'); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog h2')).toHaveText('Merge status'); + await expect(page.locator('dialog li')).toHaveText([ended,...others]); + await page.keyboard.press('Escape'); +} +test('shows only the outcome once a polled merge job ends, not the blockers it resolved (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[])=>({id:'0f6b0000-0000-4000-8000-0000000000f1',state,step,reason:null,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)];let polls=0; + // As a runner server answers: Merge PR is offered while only job-resolved blockers remain, and the click's review, + // reloaded while the job prepares, still lists them. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing'); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]},job:running})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The page follows the job on the click's review, which lists the job-resolved blockers. + expect((await (await answered).json()).merge.blockers.map((blocker:{code:string})=>blocker.code)).toEqual(['preparation','check']); + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toBeVisible({timeout:10_000}); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:15_000}); + await expectEndedMergeJob(page,'Merged','Merged. Refresh to see the result.'); +}); +test('shows only the outcome when the click is answered with an ended job, not the blockers it resolved (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const reason='Pre-merge preparation has not completed for the current review.'; + const failed={id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + // A plan import during preparation made an approval stale, so the job failed before the click was answered. The click's + // review lists that unrelated blocker beside the job-resolved ones it was reloaded with. + const approval={code:'approval',message:'P1 is pending.'};let polls=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:failed,blockers:[...review.merge.blockers,approval,...jobResolvedBlockers]},job:failed})});}); + await page.route('**/api/merge',async route=>{polls++;await route.continue();}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + const ended=`Merge stopped. ${reason} Merge again when ready. Refresh to see the result.`; + await expect(page.locator('#banner')).toHaveText(ended); + await expectEndedMergeJob(page,'Merge stopped',ended,[approval.message]); + expect(polls).toBe(0); +}); test('names a failed merge-job poll as a merge-status failure (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; @@ -739,7 +802,8 @@ test('resends the same action for Retry merge after a retry failed while an earl // An earlier job ended blocked: the review offers Retry merge, and keeps showing that job. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); body.merge={...body.merge,job:blocked,ready:true,action:'retry'};await route.fulfill({response,json:body});}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const confirms:string[]=[]; + await page.goto(app.url);page.on('dialog',dialog=>{confirms.push(dialog.message());void dialog.accept();}); await page.getByRole('button',{name:'Retry merge',exact:true}).click(); await expect(page.locator('#banner')).toContainText('Merge blocked. The review server is shutting down.'); const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); @@ -748,6 +812,9 @@ test('resends the same action for Retry merge after a retry failed while an earl await expect.poll(()=>clicks.length).toBe(2); // The earlier job is not this click's: the unresolved click is resent with its own key. expect(clicks[1]).toBe(clicks[0]); + // A job's retry prepares again and may rebase onto a new head, so it does not promise the exact reviewed head. + const retry='Retry the merge? It prepares the merge again, which may rebase onto the current base and need a new review.'; + expect(confirms).toEqual([retry,retry]); }); test('keeps a merge-job outcome a poll saw while an older Refresh response was held (F6b)',async({page})=>{ @@ -760,9 +827,10 @@ test('keeps a merge-job outcome a poll saw while an older Refresh response was h let ended=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?merged:running})});}); - // Every review response was read while the job ran; the Refresh response is held until the poll has seen it end. + // Every review response was read while the job ran; the Refresh response is held until the poll has seen it end. As a + // runner server's review read during preparation does, each lists the blockers the job resolves. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:false,action:null,job:running}; + body.merge={...body.merge,ready:false,action:null,job:running,blockers:[...body.merge.blockers,...jobResolvedBlockers]}; if(hold){hold=false;holding();await released;} await route.fulfill({response,json:body});}); await page.goto(app.url); @@ -782,6 +850,8 @@ test('keeps a merge-job outcome a poll saw while an older Refresh response was h expect(polls).toBe(seen); await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveCount(0); + // The held response's blockers were the job's own: the control and dialog show only the outcome. + await expectEndedMergeJob(page,'Merged','Merged. Refresh to see the result.'); }); test('keeps a newer merge job a poll saw while a held Refresh response shows an earlier job ended (F6b)',async({page})=>{ diff --git a/web/public/app.js b/web/public/app.js index 3130018d..e6f35383 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -209,7 +209,11 @@ function renderMerge() { scheduleMergePoll(); return; } - $("merge").textContent = merge.action === "retry" + // The page saw its job end and has not reloaded: name the outcome, not the blockers the job already resolved. + const jobEnded = merge.blockers.some(blocker => blocker.code === "job-refresh"); + $("merge").textContent = jobEnded + ? endedMergeJobLabel(job) + : merge.action === "retry" ? "Retry merge" : merge.queue?.state === "submitting" ? "Submitting…" @@ -220,7 +224,7 @@ function renderMerge() { : merge.ready ? "Merge PR" : `${merge.blockers.length} blocker${merge.blockers.length === 1 ? "" : "s"}`; - $("merge-details").textContent = merge.queue ? "Merge status" : "Review blockers"; + $("merge-details").textContent = merge.queue || jobEnded ? "Merge status" : "Review blockers"; scheduleMergePoll(); } function scheduleMergePoll() { @@ -263,8 +267,7 @@ async function pollMergeQueue(generation) { return; } const ended = `${job ? mergeJobText(job) : "The merge job ended."} Refresh to see the result.`; - const blockers = data.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); - data = { ...data, merge: { ...data.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message: ended }, ...blockers] } }; + data = withEndedMergeJob(data, job, ended); $("banner").textContent = ended; renderMerge(); return; @@ -295,6 +298,25 @@ function mergeJobText(job) { }[job.state] ?? job.state; } const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge", "job-refresh"]); +/** + * Blockers a merge job settles itself (runner/merge-job.ts JOB_RESOLVES: it prepares, rebases, pushes, runs `cmd:` checks + * and waits for required checks), plus mergeability, which its own inspections read again. A review read before or during + * the job still lists them; once the job has ended they describe a state the job already moved past. + */ +const mergeJobResolvedBlockers = new Set(["preparation", "base", "acceptance", "check", "mergeable"]); +/** + * The view once the page learns its merge job ended, without a reload: the job's outcome and "Refresh to see the + * result", plus only the blockers the job did not resolve. The control reads the outcome, as it does after a queue merge. + */ +function withEndedMergeJob(view, job, message) { + const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code) && !mergeJobResolvedBlockers.has(blocker.code)); + return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message }, ...blockers] } }; +} +/** The merge control's label for a job the page saw end: its outcome, as the control reads a queue merge's. */ +function endedMergeJobLabel(job) { + return { merged: "Merged", queued: "Merge queued", submitted: "Merge submitted", blocked: "Merge blocked", "review-required": "Review needed", + failed: "Merge stopped", cancelled: "Merge cancelled" }[job?.state] ?? "Merge ended"; +} function withMergeQueue(view, queue) { const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); if (queue?.state === "merged") @@ -618,12 +640,23 @@ $("merge-details").onclick = () => { ? `
    ${job.checks.map(check => `
  • ${esc(check.context)}: ${esc(check.state)}
  • `).join("")}
` : ""}`); return; } + const jobEnded = data.merge.blockers.some(blocker => blocker.code === "job-refresh"); showDialog( - `

${queue ? "Merge status" : "Merge blockers"}

${queue ? `

${esc(queue.state)} · reviewed head ${esc(queue.reviewedHead.slice(0, 12))}

${queue.phase ? `

GitHub phase: ${esc(queue.phase)}${queue.position === null ? "" : ` · position ${queue.position}`}

` : ""}${queue.reason ? `

${esc(queue.reason)}

` : ""}${queue.observationError ? `

${esc(queue.observationError)}

` : ""}` : ""}
    ${data.merge.blockers.map((blocker) => `
  • ${esc(blocker.message)}
  • `).join("")}
`, + `

${queue || jobEnded ? "Merge status" : "Merge blockers"}

${queue ? `

${esc(queue.state)} · reviewed head ${esc(queue.reviewedHead.slice(0, 12))}

${queue.phase ? `

GitHub phase: ${esc(queue.phase)}${queue.position === null ? "" : ` · position ${queue.position}`}

` : ""}${queue.reason ? `

${esc(queue.reason)}

` : ""}${queue.observationError ? `

${esc(queue.observationError)}

` : ""}` : ""}
    ${data.merge.blockers.map((blocker) => `
  • ${esc(blocker.message)}
  • `).join("")}
`, ); }; +/** + * The merge click's confirmation. A direct retry merges the exact reviewed head again; a merge job's retry prepares + * again first, and a rebase there makes a new head and snapshot (docs/implementation/merge-handoff.md, Outcomes). + */ +function mergeConfirmText(merge) { + if (merge.action !== "retry") return "Merge this reviewed pull request?"; + return "job" in merge + ? "Retry the merge? It prepares the merge again, which may rebase onto the current base and need a new review." + : "Retry merging this exact reviewed head?"; +} $("merge").onclick = async () => { - if (busy || planImportPending || !data?.merge?.ready || !window.confirm(data.merge.action === "retry" ? "Retry merging this exact reviewed head?" : "Merge this reviewed pull request?")) return; + if (busy || planImportPending || !data?.merge?.ready || !window.confirm(mergeConfirmText(data.merge))) return; supersedePlanRefresh(); const busyOwner = beginBusy("write"); reviewGeneration++; @@ -653,8 +686,7 @@ $("merge").onclick = async () => { // The job had already ended when the click was answered: nothing to follow, so say how to see its result, as the // job poll does when a job ends. const ended = `${mergeJobText(updated.job)} Refresh to see the result.`; - const blockers = data.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); - data = { ...data, merge: { ...data.merge, ready: false, action: null, blockers: [{ code: "job-refresh", message: ended }, ...blockers] } }; + data = withEndedMergeJob(data, updated.job, ended); render(); $("banner").textContent = ended; return; @@ -969,8 +1001,7 @@ function mergeJobBanner(job) { function withMergeJobObservation(view, job) { if (job.state === "running") return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: view.merge.blockers.filter(blocker => blocker.code !== "job-refresh") } }; - const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); - return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message: mergeJobBanner(job) }, ...blockers] } }; + return withEndedMergeJob(view, job, mergeJobBanner(job)); } function newerMergeQueue(updated, observationOwner) { const newer = data?.merge?.queue, older = updated.merge?.queue; From a76b453bfb4215643fabf5b2c36fd3e07dfbe68d Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 12:24:04 -0700 Subject: [PATCH 098/163] Drop job-settled blockers only after a job reached the merge, from the 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 --- docs/implementation/merge-handoff.md | 5 +++-- runner/merge-job.ts | 17 ++++++++++---- test/browser/review.spec.ts | 33 +++++++++++++++++----------- test/merge-job.test.ts | 15 ++++++++++++- web/public/app.js | 16 +++++++------- 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index a285f96c..fa9d58ba 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -73,14 +73,15 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## API and browser - `POST /api/action` with `merge` returns at once when the server runs merge jobs (a runner and a pull request published by it, which requires preparation): the reloaded review with the job's latest record as `job`, or, if that reload failed, `{ job, mergeRefreshRequired: true }`. The job may already have moved on or ended by then; the page follows a running job and, for an ended one, asks for a refresh. +- With merge jobs, the merge status in the review (`GET /api/review` and the click's answer) carries `job`, the task's latest job, and `settledBeforeJobMerge`: the blocker codes settled once a job asks GitHub to merge. It is built from the server's job-resolved blockers (preparation, base, acceptance, check) plus `mergeable`, and the page uses it rather than a copy of its own. - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result" and only the blockers the job does not resolve: the review the page holds was read before or during the job, so it still lists preparation, a moved base, `cmd:` checks, required checks and mergeability, which the job has since settled, and those are left out. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome and drops the blockers the job resolved. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. ## Check this document with a reader diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 11f3eee8..89588720 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -267,15 +267,23 @@ export class MergeJobCoordinator { } /** Blockers the merge job resolves itself: it prepares (rebase, push, cmd: checks) and waits for required checks. */ -const JOB_RESOLVES = new Set(['preparation', 'base', 'acceptance', 'check']); +export const JOB_RESOLVES: ReadonlySet = new Set(['preparation', 'base', 'acceptance', 'check']); + +/** + * Blockers settled by the time a job asks GitHub to merge: the job's resolved blockers, and mergeability, since the + * guarded merge starts only after `classify` found no blocker at all. A job that ended `merged`, `queued` or `submitted` + * got that far; the browser drops these from the review it read before or during that job. Any other outcome settled + * none of them for certain. + */ +export const SETTLED_BEFORE_JOB_MERGE: readonly string[] = Object.freeze([...JOB_RESOLVES, 'mergeable']); /** * The merge status the browser sees when merging runs as a job: ready when only blockers the job resolves remain (or * GitHub is still computing mergeability), and never while a job or merge attempt is in flight. After a blocked job the - * action is a retry. The latest job rides along. + * action is a retry. The latest job rides along, with the blockers a job that reached the merge has settled. */ export function withMergeJob(status: T, job: MergeJobView | null, taskStatus: string): T & { job: MergeJobView | null } { + remote: { mergeable: string } | null; queue: { state: string } | null }>(status: T, job: MergeJobView | null, taskStatus: string): T & { job: MergeJobView | null; settledBeforeJobMerge: readonly string[] } { const running = job?.state === 'running'; const attempt = status.queue?.state === 'submitting' || status.queue?.state === 'queued' || status.queue?.state === 'merged'; const computing = status.remote?.mergeable === 'UNKNOWN'; @@ -286,7 +294,8 @@ export function withMergeJob>; @@ -661,10 +662,10 @@ test('shows only the outcome once a polled merge job ends, not the blockers it r // reloaded while the job prepares, still lists them. await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing'); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]};await route.fulfill({response,json:body});}); + body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); @@ -674,27 +675,33 @@ test('shows only the outcome once a polled merge job ends, not the blockers it r await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:15_000}); await expectEndedMergeJob(page,'Merged','Merged. Refresh to see the result.'); }); -test('shows only the outcome when the click is answered with an ended job, not the blockers it resolved (F6b)',async({page})=>{ +test('keeps every blocker when the click is answered with a job that ended without merging (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const reason='Pre-merge preparation has not completed for the current review.'; - const failed={id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; - // A plan import during preparation made an approval stale, so the job failed before the click was answered. The click's - // review lists that unrelated blocker beside the job-resolved ones it was reloaded with. - const approval={code:'approval',message:'P1 is pending.'};let polls=0; + // A collaborator pushed while the job waited: its inspection listed the moved head first, so the job failed with that + // reason alone (classify reports the first blocker). The same inspection found a merge conflict, and a required check + // is still pending; the job settled neither. + const head={code:'head',message:'The pull request head moved after review.'}; + const conflict={code:'mergeable',message:'GitHub reports a merge conflict.'}; + const failed={id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason:head.message,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + let polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:failed,blockers:[...review.merge.blockers,approval,...jobResolvedBlockers]},job:failed})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:failed,blockers:[...review.merge.blockers,head,conflict,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:failed})});}); await page.route('**/api/merge',async route=>{polls++;await route.continue();}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]};await route.fulfill({response,json:body});}); + body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); - const ended=`Merge stopped. ${reason} Merge again when ready. Refresh to see the result.`; + // The answer names the conflict and the check among the codes a job settles before it merges; this job never merged. + const settled=(await (await answered).json()).merge.settledBeforeJobMerge; + expect(settled).toEqual(expect.arrayContaining(['mergeable','check','preparation'])); + const ended=`Merge stopped. ${head.message} Merge again when ready. Refresh to see the result.`; await expect(page.locator('#banner')).toHaveText(ended); - await expectEndedMergeJob(page,'Merge stopped',ended,[approval.message]); + await expectEndedMergeJob(page,'Merge stopped',ended,[head.message,conflict.message,...jobResolvedBlockers.map(blocker=>blocker.message)]); expect(polls).toBe(0); }); test('names a failed merge-job poll as a merge-status failure (F6b)',async({page})=>{ @@ -830,7 +837,7 @@ test('keeps a merge-job outcome a poll saw while an older Refresh response was h // Every review response was read while the job ran; the Refresh response is held until the poll has seen it end. As a // runner server's review read during preparation does, each lists the blockers the job resolves. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:false,action:null,job:running,blockers:[...body.merge.blockers,...jobResolvedBlockers]}; + body.merge={...body.merge,ready:false,action:null,job:running,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE}; if(hold){hold=false;holding();await released;} await route.fulfill({response,json:body});}); await page.goto(app.url); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 45bd8495..47971633 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; import { GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; -import { MergeJobCoordinator, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; +import { JOB_RESOLVES, MergeJobCoordinator, SETTLED_BEFORE_JOB_MERGE, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; import type { PreMergeCoordinator, PreMergeResult } from '../runner/pre-merge.ts'; import type { ReviewService } from '../runner/review.ts'; import { MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type RemoteMergeState, type RequiredCheck } from '../github/merge.ts'; @@ -470,6 +470,19 @@ it('offers Merge PR while only blockers the job resolves remain, and a retry aft expect(withMergeJob(status(['mergeable'], { remote: { mergeable: 'CONFLICTING' } }), null, 'in review')).toMatchObject({ ready: false }); }); +it('sends the blockers a job settles before it merges: the ones it resolves, plus mergeability', () => { + const status = (codes: string[], mergeable = 'MERGEABLE') => + ({ ready: false, action: null, blockers: codes.map(code => ({ code, message: code })), remote: { mergeable }, queue: null }) as Parameters[0]; + const field = withMergeJob(status([]), null, 'in review').settledBeforeJobMerge; + // Pinned to the job's own set: a code added to or removed from JOB_RESOLVES, or from the field, fails here. + expect(field).toEqual(['preparation', 'base', 'acceptance', 'check', 'mergeable']); + expect(field).toEqual([...JOB_RESOLVES, 'mergeable']); + expect(SETTLED_BEFORE_JOB_MERGE).toBe(field); + // Every code but mergeability is one Merge PR is offered over, because the job resolves it. + for (const code of field.filter(code => code !== 'mergeable')) + expect(withMergeJob(status([code]), null, 'in review')).toMatchObject({ ready: true, settledBeforeJobMerge: field }); +}); + it('ends as failed, not blocked, when checks fail on a head that moved while waiting', async () => { const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }], change: () => ({ head: oid(40) }) }); await f.coordinator.start('review-token', randomUUID()); diff --git a/web/public/app.js b/web/public/app.js index e6f35383..4cb3f664 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -298,18 +298,18 @@ function mergeJobText(job) { }[job.state] ?? job.state; } const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge", "job-refresh"]); -/** - * Blockers a merge job settles itself (runner/merge-job.ts JOB_RESOLVES: it prepares, rebases, pushes, runs `cmd:` checks - * and waits for required checks), plus mergeability, which its own inspections read again. A review read before or during - * the job still lists them; once the job has ended they describe a state the job already moved past. - */ -const mergeJobResolvedBlockers = new Set(["preparation", "base", "acceptance", "check", "mergeable"]); +/** Outcomes of a job that asked GitHub to merge, which it does only once no blocker remains. */ +const mergeJobReachedMerge = new Set(["merged", "queued", "submitted"]); /** * The view once the page learns its merge job ended, without a reload: the job's outcome and "Refresh to see the - * result", plus only the blockers the job did not resolve. The control reads the outcome, as it does after a queue merge. + * result". A job that reached the merge (merged, queued or submitted) settled the blockers the server lists in + * `settledBeforeJobMerge` (preparation, a moved base, `cmd:` checks, required checks, mergeability), so a review read + * before or during it no longer lists them. Any other outcome keeps every blocker the page last read: a blocked job's + * failing check still fails, and Refresh shows what still applies. The control reads the outcome, as after a queue merge. */ function withEndedMergeJob(view, job, message) { - const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code) && !mergeJobResolvedBlockers.has(blocker.code)); + const settled = new Set(mergeJobReachedMerge.has(job.state) ? view.merge.settledBeforeJobMerge ?? [] : []); + const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code) && !settled.has(blocker.code)); return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message }, ...blockers] } }; } /** The merge control's label for a job the page saw end: its outcome, as the control reads a queue merge's. */ From 0857fa2cb427c6d31a971ed5e70945db5c091604 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 12:40:31 -0700 Subject: [PATCH 099/163] Pin the served settled-codes field on a runner server, not on a body the browser test made Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 6 +++--- test/runner-publishing.test.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 0feb6245..da971ae1 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -696,9 +696,9 @@ test('keeps every blocker when the click is answered with a job that ended witho await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); - // The answer names the conflict and the check among the codes a job settles before it merges; this job never merged. - const settled=(await (await answered).json()).merge.settledBeforeJobMerge; - expect(settled).toEqual(expect.arrayContaining(['mergeable','check','preparation'])); + // This job never merged, so the codes it would have settled (served by the review, pinned in test/runner-publishing.test.ts) + // stay listed. + await answered; const ended=`Merge stopped. ${head.message} Merge again when ready. Refresh to see the result.`; await expect(page.locator('#banner')).toHaveText(ended); await expectEndedMergeJob(page,'Merge stopped',ended,[head.message,conflict.message,...jobResolvedBlockers.map(blocker=>blocker.message)]); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index c445a46f..a53475ef 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -24,7 +24,7 @@ import type { PlanIdentity } from '../core/identity.ts'; import { approveItem } from '../core/approvals.ts'; import { GhIssueGateway, type IssueTrustGateway } from '../github/issues.ts'; import type { PreMergeCoordinator } from '../runner/pre-merge.ts'; -import { MergeJobCoordinator, type MergeJobTiming } from '../runner/merge-job.ts'; +import { MergeJobCoordinator, SETTLED_BEFORE_JOB_MERGE, type MergeJobTiming } from '../runner/merge-job.ts'; import type { RemoteMergeState, RequiredCheck } from '../github/merge.ts'; import { RemoteTimeout } from '../github/timeout.ts'; import { CommandFailed } from '../github/run-with-input.ts'; @@ -1828,6 +1828,8 @@ describe('the one-click merge job (F6b of #22)', () => { const before = await review(served.app); // Not prepared yet, and a required check pends: both are the job's to resolve. expect(before.merge.blockers.map((blocker: { code: string }) => blocker.code)).toEqual(expect.arrayContaining(['preparation', 'check'])); + // A runner server sends the codes a job settles before it merges, which the page drops once a job reached the merge. + expect(before.merge.settledBeforeJobMerge).toEqual([...SETTLED_BEFORE_JOB_MERGE]); expect(before.merge).toMatchObject({ ready: true, action: 'merge', job: null }); const started = await merge(served.app, before.token); expect(started.status).toBe(200); From 9d46828640be4ea7299b85cdedb199996bbfcbfa Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 13:44:43 -0700 Subject: [PATCH 100/163] Word merge-job messages as what the person does, and follow another tab's job - With merge jobs, a moved base and missing preparation read as what Merge PR does (it rebases and prepares), in the served status and in a failed job's reason, instead of asking for a Rebase or Prepare the screen does not offer. - A blocked job's reason ends with its next step: wait after the 30-minute timeout, fix after a failing check or GitHub's refusal. - Failed reasons no longer say "merge again"; the page adds it once. - The running job's dialog names the step as the Merge control does. - A refused merge click carries the task's latest job; a page with an older review (a second tab) follows a later running job instead of asking for a refresh. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 15 ++++++--- runner/merge-job.ts | 47 ++++++++++++++++++++-------- runner/store.ts | 19 ++++++----- test/browser/review.spec.ts | 41 +++++++++++++++++++++++- test/merge-job.test.ts | 28 ++++++++++++----- test/runner-publishing.test.ts | 11 ++++++- web/public/app.js | 33 ++++++++++++++----- web/server.ts | 12 +++++-- 8 files changed, 162 insertions(+), 44 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index fa9d58ba..a4881011 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -37,6 +37,10 @@ Without a runner, **Merge PR** works as before. A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. + +With merge jobs, the merge status words the blockers the job resolves as what merging does, because the screen offers no Rebase or Prepare control: a moved base reads as above, and preparation reads "The merge has not been prepared for the current review; merging prepares it." The reason a job ends `failed` with uses the same wording. Without a runner, the guarded merge's own wording is unchanged. + ## Outcomes | Job state | Task status afterwards | What the person does | @@ -44,9 +48,9 @@ A required check run passes when it completed with success, neutral or skipped, | `merged` | merged | nothing | | `queued` | unchanged (in review, or approved, merge blocked on a retry) while GitHub's queue holds it | nothing | | `submitted` | unchanged | refresh; the merge attempt settles from GitHub | -| `blocked` | approved, merge blocked | fix the check or GitHub's reason, then **Retry merge**. After GitHub refused a merge, a retry whose preparation rebases makes a new snapshot, and that needs every item approved again (the replacement-snapshot rule of `merge-queue.md`): the items show stale, the retry ends `review-required`, and the merge status asks for every item to be approved again. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | +| `blocked` | approved, merge blocked | what the reason's last sentence says. After the 30-minute wait ran out, nothing failed: "Wait for the checks to pass, then Retry merge." (or wait for GitHub to determine mergeability). After a failing check or GitHub's refusal: "Fix it, then Retry merge." ("Fix them" for several failing checks). After GitHub refused a merge, a retry whose preparation rebases makes a new snapshot, and that needs every item approved again (the replacement-snapshot rule of `merge-queue.md`): the items show stale, the retry ends `review-required`, and the merge status asks for every item to be approved again. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | | `review-required` | unchanged | review the refreshed pull request, then merge again | -| `failed` | unchanged | read the reason, then merge again | +| `failed` | unchanged | do what the reason asks first, if anything (for example, review the new head), then merge again. The reason never says "merge again": the page adds "Merge again when ready." once | | `cancelled` | cancelled | nothing | Moving the task to **approved, merge blocked** advances its state version, so the next job prepares again from the start. @@ -68,20 +72,21 @@ A rebase moves the pull request's base. The guarded merge would then scan only t ## Storage -Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, `sequence` (the record's place among the task's jobs and refused clicks, from 1, so the browser orders jobs without comparing clock times), start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. The runner's `start` and `resume` actions are refused too ("A merge is running for this task; wait for it to end or cancel the task."), saved and replayed like other refusals, and neither is offered: a run would move the task's head, and the publish at its end would push it. So no run starts, and no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. Plan import stays allowed during a job, as at any time in review. It changes only the plan, never the branch or the pull request; the readiness the job prepared is then for an older review, so the job's next inspection ends it `failed` ("Pre-merge preparation has not completed for the current review."), or the guarded merge refuses, and nothing is merged. +Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, `sequence` (the record's place among the task's jobs and refused clicks, from 1, so the browser orders jobs without comparing clock times), start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. The runner's `start` and `resume` actions are refused too ("A merge is running for this task; wait for it to end or cancel the task."), saved and replayed like other refusals, and neither is offered: a run would move the task's head, and the publish at its end would push it. So no run starts, and no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. Plan import stays allowed during a job, as at any time in review. It changes only the plan, never the branch or the pull request; the readiness the job prepared is then for an older review, so the job's next inspection ends it `failed` ("The merge has not been prepared for the current review; merging prepares it."), or the guarded merge refuses, and nothing is merged. ## API and browser - `POST /api/action` with `merge` returns at once when the server runs merge jobs (a runner and a pull request published by it, which requires preparation): the reloaded review with the job's latest record as `job`, or, if that reload failed, `{ job, mergeRefreshRequired: true }`. The job may already have moved on or ended by then; the page follows a running job and, for an ended one, asks for a refresh. - With merge jobs, the merge status in the review (`GET /api/review` and the click's answer) carries `job`, the task's latest job, and `settledBeforeJobMerge`: the blocker codes settled once a job asks GitHub to merge. It is built from the server's job-resolved blockers (preparation, base, acceptance, check) plus `mergeable`, and the page uses it rather than a copy of its own. - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. +- A refused merge click (409, new or replayed) carries `job`, the task's latest job, as `GET /api/merge` would answer it. A page that holds an older review, such as a second tab, may click Merge PR while another tab's job runs; it is refused ("A merge is already running for this task."). When the carried job is running and later than the one the page shows, the page follows it as its poll would: the control shows the job's step, and the banner reads the refusal, then the step. The refusal is a definite answer, so the click's key is dropped, as for any other refusal; any other refusal still asks for a refresh. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, and which blockers keep Merge PR available (including Retry merge after `blocked`). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route, its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. ## Check this document with a reader diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 89588720..fed3a7f6 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -3,7 +3,7 @@ import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, settleWith, type S import { MergeOutcomeUnknown, type MergeCoordinator, type MergeStatus } from './merge.ts'; import type { PreMergeCoordinator } from './pre-merge.ts'; import type { ReviewService } from './review.ts'; -import type { MergeJob, MergeJobState } from './store.ts'; +import { FIX_THEN_RETRY, type MergeJob, type MergeJobState } from './store.ts'; /** How long a job waits for GitHub's required checks on the pushed head (docs/implementation/merge-handoff.md). */ export const REQUIRED_CHECK_WAIT_MS = 30 * 60_000; @@ -19,7 +19,7 @@ export interface MergeJobTiming { checkWaitMs?: number; firstDelayMs?: number; m /** Why the wait for required checks ended. */ type CheckVerdict = { kind: 'ready' } | { kind: 'wait'; checks: { context: string; state: string }[] } - | { kind: 'blocked'; reason: string } | { kind: 'failed'; reason: string } | { kind: 'cancelled' }; + | { kind: 'blocked'; reason: string; next: string } | { kind: 'failed'; reason: string } | { kind: 'cancelled' }; /** * The one-click merge (F6b of #22): preparation, the wait for GitHub's required checks on the pushed head, then the @@ -175,7 +175,7 @@ export class MergeJobCoordinator { const verdict = await this.#awaitChecks(job, signal); if (verdict.kind === 'cancelled') return this.#end(job, signal); // A failing or slow check blocks only while the readiness it was read for still holds (in the block's transaction). - if (verdict.kind === 'blocked') return this.#block(job, verdict.reason, true); + if (verdict.kind === 'blocked') return this.#block(job, verdict.reason, verdict.next, true); if (verdict.kind === 'failed') return this.#finish(job.id, 'failed', verdict.reason); // 3. Merge through the guarded merge, which re-validates everything and reruns the already-fixed check. // The merge's action ID is saved first: after a restart, the attempt carrying it gives the job's outcome. @@ -200,8 +200,8 @@ export class MergeJobCoordinator { if (attempt?.actionId === mergeId) { // A refusal because the head changed needs a fresh review, not a retry: the task is not "approved" any more. if ((attempt.state === 'failed' || attempt.state === 'removed') && attempt.requiresFreshReview) - return this.#finish(job.id, 'failed', `GitHub refused the merge: ${attempt.reason ?? reason} Review the new head, then merge again.`); - if (attempt.state === 'failed' || attempt.state === 'removed') return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`); + return this.#finish(job.id, 'failed', `GitHub refused the merge: ${attempt.reason ?? reason} Review the new head first.`); + if (attempt.state === 'failed' || attempt.state === 'removed') return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`, FIX_THEN_RETRY); if (attempt.state === 'merged') return this.#finish(job.id, 'merged', null, attempt.url); if (attempt.state === 'queued') return this.#finish(job.id, 'queued', null, attempt.url); return this.#finish(job.id, 'submitted', reason); @@ -223,10 +223,13 @@ export class MergeJobCoordinator { this.#finish(job.id, 'cancelled', 'Task cancelled.'); } - /** Approved, merge blocked: the task's next merge prepares again from the start. One Store transaction. */ - #block(job: MergeJob, reason: string, whileReady = false): void { + /** + * Approved, merge blocked: the task's next merge prepares again from the start. One Store transaction. `next` is the + * person's next step, saved after the reason only when the job ends blocked. + */ + #block(job: MergeJob, reason: string, next: string, whileReady = false): void { const readyFor = whileReady ? { commandPolicyDigest: this.service.commandPolicyDigest() } : undefined; - try { this.settle(() => this.service.store.blockMergeJob(this.service.config.identity, job.id, reason, readyFor)); } + try { this.settle(() => this.service.store.blockMergeJob(this.service.config.identity, job.id, reason, readyFor, next)); } catch (error) { this.#unended(job.id, error); } } @@ -234,8 +237,10 @@ export class MergeJobCoordinator { const { store, config } = this.service, identity = config.identity; const until = performance.now() + this.checkWaitMs; let delay = this.firstDelayMs, waiting: { context: string; state: string }[] = []; + // Nothing failed: the person waits for the checks (or GitHub) rather than fixing anything. const timedOut = (): CheckVerdict => ({ kind: 'blocked', reason: `Required checks did not pass within ${Math.round(this.checkWaitMs / 60_000)} minutes: ${ - waiting.length ? waiting.map(check => `${check.context} is ${check.state}`).join(', ') : 'GitHub has not determined mergeability'}.` }); + waiting.length ? waiting.map(check => `${check.context} is ${check.state}`).join(', ') : 'GitHub has not determined mergeability'}.`, + next: waiting.length ? 'Wait for the checks to pass, then Retry merge.' : 'Wait for GitHub to determine mergeability, then Retry merge.' }); for (;;) { if (signal.aborted) return { kind: 'cancelled' }; const task = store.getTask(identity); @@ -269,6 +274,20 @@ export class MergeJobCoordinator { /** Blockers the merge job resolves itself: it prepares (rebase, push, cmd: checks) and waits for required checks. */ export const JOB_RESOLVES: ReadonlySet = new Set(['preparation', 'base', 'acceptance', 'check']); +/** + * How a job-resolved blocker reads when merging runs as a job. The guarded merge's own wording asks the person to rebase + * or prepare, but the job's screen offers neither: merging does both (docs/implementation/merge-handoff.md). Used for + * the merge status the browser sees and for the reason a job ends `failed` with, so both say the same. + */ +const JOB_WORDING: Readonly> = Object.freeze({ + base: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.', + preparation: 'The merge has not been prepared for the current review; merging prepares it.', +}); +export function inJobTerms(blocker: T): T { + const message = JOB_WORDING[blocker.code]; + return message === undefined ? blocker : { ...blocker, message }; +} + /** * Blockers settled by the time a job asks GitHub to merge: the job's resolved blockers, and mergeability, since the * guarded merge starts only after `classify` found no blocker at all. A job that ended `merged`, `queued` or `submitted` @@ -294,7 +313,8 @@ export function withMergeJob blocker.code !== 'check' && !(blocker.code === 'mergeable' && computing)); - if (other.length) return { kind: 'failed', reason: other[0]!.message }; + if (other.length) return { kind: 'failed', reason: inJobTerms(other[0]!).message }; const failing = status.remote.requiredChecks.filter(check => check.state === 'failure'); - if (failing.length) return { kind: 'blocked', reason: `Required check${failing.length === 1 ? '' : 's'} failed: ${failing.map(check => check.context).join(', ')}.` }; + if (failing.length) return { kind: 'blocked', reason: `Required check${failing.length === 1 ? '' : 's'} failed: ${failing.map(check => check.context).join(', ')}.`, + next: failing.length === 1 ? FIX_THEN_RETRY : 'Fix them, then Retry merge.' }; const waiting = status.remote.requiredChecks.filter(check => check.state !== 'success') .map(check => ({ context: check.context, state: check.state })); if (waiting.length || computing) return { kind: 'wait', checks: waiting }; - return status.ready ? { kind: 'ready' } : { kind: 'failed', reason: status.blockers[0]?.message ?? 'Merge is blocked.' }; + return status.ready ? { kind: 'ready' } : { kind: 'failed', reason: status.blockers[0] ? inJobTerms(status.blockers[0]).message : 'Merge is blocked.' }; } function sleep(ms: number, signal: AbortSignal): Promise { diff --git a/runner/store.ts b/runner/store.ts index a8d112dc..0b8a7412 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -88,6 +88,8 @@ export interface PreMergeReadiness { stateVersion: number; reviewVersion: number; snapshotId: string; base: string; head: string; commandPolicyDigest: string; } export const MAX_REWRITE_LINEAGE_ROWS = 1_000; +/** The next step after a failing required check or GitHub's refusal blocks a merge job (docs/implementation/merge-handoff.md). */ +export const FIX_THEN_RETRY = 'Fix it, then Retry merge.'; /** What a completed `prepare-merge` action replays after its background coordinator settles. */ export function preMergeActionResponse(result: PreMergeActionResult) { return { outcome: result.state, base: result.base, head: result.head, checked: result.checked, reason: result.reason }; @@ -2188,9 +2190,10 @@ export class Store { * so the job and the task cannot disagree after a crash. A task that moved meanwhile keeps its status. * With `readyFor`, the block applies only while the preparation's readiness still holds (checked in the same * transaction): a check result that arrived after a review edit or another task change says nothing about the - * changed review, so the job then ends `failed` and the task keeps its status. + * changed review, so the job then ends `failed` and the task keeps its status. `next`, the person's next step, follows + * the reason only when the job ends `blocked`: a `failed` end says why, and the page adds "Merge again when ready." */ - blockMergeJob(identity: PlanIdentity, id: string, reason: string, readyFor?: { commandPolicyDigest: string }): boolean { + blockMergeJob(identity: PlanIdentity, id: string, reason: string, readyFor?: { commandPolicyDigest: string }, next?: string): boolean { const key = identityKey(identity); return this.#transaction(() => { const job = this.getMergeJob(identity, id); @@ -2203,7 +2206,7 @@ export class Store { if (!this.preMergeReady(identity, { stateVersion: task.state_version as number, reviewVersion: this.#current(key).review_version as number, snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, commandPolicyDigest: readyFor.commandPolicyDigest })) return this.finishMergeJob(identity, id, { state: 'failed', - reason: 'The review or task changed while waiting for required checks; the preparation no longer applies. Merge again.' }); + reason: 'The review or task changed while waiting for required checks; the preparation no longer applies.' }); } // GitHub refused the job's own merge attempt: the block applies only to the review that attempt was admitted for. // A review edit, new snapshot or plan revision while GitHub was asked leaves the changed task as it is. @@ -2212,14 +2215,14 @@ export class Store { const plan = this.#current(key); if (plan.revision !== attempt.revision || plan.snapshot_id !== attempt.snapshotId || plan.review_version !== attempt.reviewVersion) return this.finishMergeJob(identity, id, { state: 'failed', - reason: `${reason} The review changed while GitHub was asked to merge; review it, then merge again.` }); + reason: `${reason} The review changed while GitHub was asked to merge; review it first.` }); } if (task.status === 'in review' && !this.#activeAttempt(key) && !this.#activeMerge(key) && task.rebase_in_progress === null && (task.push_in_progress === null || task.push_in_progress === undefined)) { this.#run('UPDATE tasks SET status=? WHERE plan_key=?', 'approved but merge blocked', key); this.#touch(key); } - return this.finishMergeJob(identity, id, { state: 'blocked', reason }); + return this.finishMergeJob(identity, id, { state: 'blocked', reason: next ? `${reason} ${next}` : reason }); }); } /** End a running job. Returns false when it had already ended. */ @@ -2264,7 +2267,7 @@ export class Store { if (!running) return; const attempt = running.mergeActionId ? this.getMergeAttempt(identity) : null; if (!attempt || attempt.actionId !== running.mergeActionId) { - this.finishMergeJob(identity, running.id, { state: 'failed', reason: 'The server restarted before the merge finished. Merge again.' }); + this.finishMergeJob(identity, running.id, { state: 'failed', reason: 'The server restarted before the merge finished.' }); return; } if (attempt.state === 'merged' || attempt.state === 'queued') { @@ -2279,10 +2282,10 @@ export class Store { if (attempt.requiresFreshReview) { // The head changed: the task needs a fresh review, so it is not moved to approved, merge blocked. this.finishMergeJob(identity, running.id, { state: 'failed', - reason: `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'} Review the new head, then merge again.` }); + reason: `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'} Review the new head first.` }); return; } - this.blockMergeJob(identity, running.id, `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'}`); + this.blockMergeJob(identity, running.id, `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'}`, undefined, FIX_THEN_RETRY); } /** Startup recovery makes an interrupted preparation definite and replayable before new work is admitted. */ settleInterruptedPreMergeActions(identity: PlanIdentity): void { diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index da971ae1..35492390 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -640,7 +640,7 @@ const looksInactive=(locator:import('@playwright/test').Locator)=>locator.evalua const surface=getComputedStyle(probe).backgroundColor;probe.remove();return getComputedStyle(el).backgroundColor===surface;}); /** The blockers a runner server's review lists while a merge job prepares (runner/merge.ts): preparation has not completed * for the review, and a required check is pending. The job resolves both. */ -const jobResolvedBlockers=[{code:'preparation',message:'Pre-merge preparation has not completed for the current review. Prepare the merge again.'},{code:'check',message:'ci is pending.'}]; +const jobResolvedBlockers=[{code:'preparation',message:'The merge has not been prepared for the current review; merging prepares it.'},{code:'check',message:'ci is pending.'}]; /** The merge control and its dialog once the page saw its job end: the outcome, the refresh request, and only `others`. */ async function expectEndedMergeJob(page:import('@playwright/test').Page,label:string,ended:string,others:string[]=[]){ await expect(page.locator('#merge')).toHaveText(label); @@ -1057,6 +1057,43 @@ test('keeps focus on the Merge button when a merge-job click is refused (F6b)',a await page.waitForTimeout(1000); expect(mergeClicks).toBe(1); }); +test('follows the running merge job when a click from an older review is refused because another tab started it (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[])=>({id:'0f6b0000-0000-4000-8000-0000000000b1',state,step,reason:null,checks,url:null,sequence:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)];let polls=0,mergeClicks=0; + // This tab read its review before another tab's click started a job; the server refuses this click and, as + // web/server.ts does, carries the task's running job. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; + await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'A merge is already running for this task.',job:job('running','checks',[{context:'ci',state:'pending'}])})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The page follows the other tab's job, as its poll would, rather than asking for a refresh. + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#banner')).toHaveText('A merge is already running for this task. Waiting for required checks: ci is pending.'); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:15_000}); + expect(polls).toBeGreaterThanOrEqual(2); + expect(mergeClicks).toBe(1); +}); +test('states the next step a blocked merge job names, once (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + // The reason runner/merge-job.ts saves when the 30-minute wait ends: nothing failed, so the step is to wait. + const reason='Required checks did not pass within 30 minutes: ci is pending. Wait for the checks to pass, then Retry merge.'; + const blocked={id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:blocked,mergeRefreshRequired:true})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toHaveText(`Approved, merge blocked. ${reason} Refresh to see the result.`); +}); test('asks for a refresh when the click is answered with a merge job that has already ended (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; @@ -1141,6 +1178,8 @@ test('follows a one-click merge job from preparation through the required-check // A repeat activation while the job runs starts nothing. await button.press('Enter'); await page.getByRole('button',{name:'Merge status',exact:true}).click(); + // The dialog names the step as the control does, not the job's raw step value. + await expect(page.locator('dialog strong')).toHaveText('Waiting for checks…'); await expect(page.locator('dialog')).toContainText('ci: pending'); await page.keyboard.press('Escape'); await expect(page.getByRole('button',{name:'Merging…',exact:true})).toHaveAttribute('aria-disabled','true'); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 47971633..ef8f1522 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -149,6 +149,8 @@ it('ends approved, merge blocked when required checks do not pass in time', asyn const done = await settled(f.coordinator); expect(done.state).toBe('blocked'); expect(done.reason).toMatch(/did not pass within .*build is pending, deploy is missing/); + // Nothing failed, so the next step is to wait, not to fix anything (merge-handoff.md, Outcomes). + expect(done.reason).toMatch(/deploy is missing\. Wait for the checks to pass, then Retry merge\.$/); expect(f.gh.merged).toEqual([]); expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); }); @@ -156,7 +158,7 @@ it('ends approved, merge blocked when required checks do not pass in time', asyn it('stops at once when a required check fails, without waiting for the time limit', async () => { const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }] }); await f.coordinator.start('review-token', randomUUID()); - expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: 'Required check failed: build.' }); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: 'Required check failed: build. Fix it, then Retry merge.' }); expect(f.gh.inspections()).toBe(1); expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); }); @@ -173,7 +175,8 @@ it('ends failed, without merging, when GitHub cannot be read while waiting for c it('ends failed, leaving the task in review, when GitHub refuses because the head changed', async () => { const f = setup({ merge: async () => { throw new MergeSubmissionError('Head branch was modified. Review and try the merge again.', 'refused'); } }); await f.coordinator.start('review-token', randomUUID()); - expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/^GitHub refused the merge: Head branch was modified.*Review the new head/) }); + // The page adds "Merge again when ready." to a failed job's reason, so the reason never says it too. + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/^GitHub refused the merge: Head branch was modified.*Review the new head first\.$/) }); expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed', requiresFreshReview: true }); expect(f.store.getTask(identity).status).toBe('in review'); }); @@ -188,7 +191,7 @@ it('ends failed, leaving the task in review, when the review changed while GitHu } }); storeRef = f.store; await f.coordinator.start('review-token', randomUUID()); - expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge/) }); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge; review it first\.$/) }); expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed' }); expect(f.store.getTask(identity).status).toBe('in review'); }); @@ -202,7 +205,7 @@ it('ends failed, leaving the task in review, when a failing check arrives after } }); storeRef = f.store; await f.coordinator.start('review-token', randomUUID()); - expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed while waiting for required checks/) }); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed while waiting for required checks; the preparation no longer applies\.$/) }); expect(f.store.getTask(identity).status).toBe('in review'); expect(f.gh.merged).toEqual([]); }); @@ -226,14 +229,16 @@ it('ends for review or as failed when preparation does, and merges nothing', asy it('ends as failed, for a person, when another blocker appears while waiting', async () => { const f = setup({ change: () => ({ base: oid(30) }) }); await f.coordinator.start('review-token', randomUUID()); - expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/base branch moved/) }); + // Merge PR rebases and prepares itself, and this screen offers no Rebase: the reason says what merging again does. + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', + reason: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.' }); expect(f.gh.merged).toEqual([]); }); it('ends approved, merge blocked when GitHub refuses the merge', async () => { const f = setup({ merge: async () => { throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); } }); await f.coordinator.start('review-token', randomUUID()); - expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge: .*expected/) }); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge: .*expected\. Fix it, then Retry merge\.$/) }); expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); }); @@ -312,7 +317,7 @@ it('leaves a job running on shutdown, and startup ends it as failed', async () = await f.coordinator.close(); expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); f.store.settleInterruptedMergeJobs(identity); - expect(f.coordinator.latest()).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); + expect(f.coordinator.latest()).toMatchObject({ state: 'failed', reason: 'The server restarted before the merge finished.' }); // Not a definite refusal: the route answers 503 and the browser keeps the click's key (nothing was saved). const id = randomUUID(); await expect(f.coordinator.start('review-token', id)).rejects.toThrow(ShuttingDownError); @@ -456,6 +461,11 @@ it('offers Merge PR while only blockers the job resolves remain, and a retry aft expect(withMergeJob(status(['preparation', 'base', 'acceptance', 'check']), null, 'in review')).toMatchObject({ ready: true, action: 'merge', job: null }); expect(withMergeJob(status(['mergeable'], { remote: { mergeable: 'UNKNOWN' } }), null, 'in review')).toMatchObject({ ready: true }); expect(withMergeJob(status(['mergeable']), null, 'in review')).toMatchObject({ ready: false, action: null }); + // The blockers the job resolves read as what merging does: no Rebase or Prepare control is on the screen. + expect(withMergeJob(status(['base', 'preparation', 'head']), null, 'in review').blockers).toEqual([ + { code: 'base', message: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.' }, + { code: 'preparation', message: 'The merge has not been prepared for the current review; merging prepares it.' }, + { code: 'head', message: 'head' }]); for (const code of ['approval', 'scope', 'unplanned', 'head', 'already-fixed', 'rules', 'base-guard', 'task']) expect(withMergeJob(status(['preparation', code]), null, 'in review')).toMatchObject({ ready: false, action: null }); expect(withMergeJob(status([]), running, 'in review')).toMatchObject({ ready: false, job: running }); @@ -556,6 +566,10 @@ it('settles a job a crash left in its merge step from its merge attempt at start f.store.settleInterruptedMergeJobs(identity); expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: expected }); expect(f.store.getTask(identity).status).toBe(status); + // A blocked job's reason ends with its next step; a failed one's never says "merge again": the page adds that once. + const reason = f.store.getMergeJob(identity, job.id)!.reason ?? ''; + if (expected === 'blocked') expect(reason).toBe('GitHub refused the merge: Base branch was modified. Fix it, then Retry merge.'); + if (expected === 'failed') expect(reason).not.toMatch(/merge again/i); } }); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index a53475ef..b05fca96 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1828,6 +1828,9 @@ describe('the one-click merge job (F6b of #22)', () => { const before = await review(served.app); // Not prepared yet, and a required check pends: both are the job's to resolve. expect(before.merge.blockers.map((blocker: { code: string }) => blocker.code)).toEqual(expect.arrayContaining(['preparation', 'check'])); + // Worded as what Merge PR does: the screen has no Prepare control. + expect(before.merge.blockers.find((blocker: { code: string }) => blocker.code === 'preparation').message) + .toBe('The merge has not been prepared for the current review; merging prepares it.'); // A runner server sends the codes a job settles before it merges, which the page drops once a job reached the merge. expect(before.merge.settledBeforeJobMerge).toEqual([...SETTLED_BEFORE_JOB_MERGE]); expect(before.merge).toMatchObject({ ready: true, action: 'merge', job: null }); @@ -1836,6 +1839,12 @@ describe('the one-click merge job (F6b of #22)', () => { expect(started.body.job).toMatchObject({ state: 'running' }); expect(started.body.merge).toMatchObject({ ready: false, job: { state: 'running' } }); await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks', checks: [{ context: 'ci', state: 'pending' }] }), { timeout: 10_000 }); + // A second tab still holds the review read before the click: its click is refused, and the refusal carries the + // running job so that page follows it. A resend replays the refusal with the task's latest job, still never as a job. + const secondTab = randomUUID(), refused = await merge(served.app, before.token, secondTab); + expect(refused).toMatchObject({ status: 409, body: { error: 'A merge is already running for this task.', job: { id: started.body.job.id, state: 'running', step: 'checks' } } }); + expect(await merge(served.app, before.token, secondTab)).toMatchObject({ status: 409, body: { error: refused.body.error, job: { id: started.body.job.id } } }); + expect((await mergeState(served.app)).job).toMatchObject({ id: started.body.job.id }); expect(await act(served.app, 'prepare-merge')).toMatchObject({ status: 409, body: { error: expect.stringMatching(/merge is running/) } }); // A publish cannot begin a refresh of the PR the job waits on: publishing refuses a task in review. expect(await act(served.app, 'publish')).toMatchObject({ status: 409, body: { error: expect.stringMatching(/The task is in review/) } }); @@ -1895,7 +1904,7 @@ describe('the one-click merge job (F6b of #22)', () => { // ends it failed, and nothing is merged, run, pushed or published. release(); await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'failed', - reason: 'Pre-merge preparation has not completed for the current review. Prepare the merge again.' }), { timeout: 10_000 }); + reason: 'The merge has not been prepared for the current review; merging prepares it.' }), { timeout: 10_000 }); expect(served.store.getTask(served.identity).status).toBe('in review'); expect(served.store.recentAttempts(served.identity, 50)).toHaveLength(attempts); expect(served.app.publishing!.busy(served.identity)).toBe(false); diff --git a/web/public/app.js b/web/public/app.js index 4cb3f664..0ec26947 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -68,7 +68,8 @@ async function api(path, body) { body: body ? JSON.stringify(body) : undefined, }); const value = await response.json(); - if (!response.ok) throw Object.assign(new Error(value.error || "Request failed."), { status: response.status, outcomeUnknown: value.outcomeUnknown === true }); + if (!response.ok) throw Object.assign(new Error(value.error || "Request failed."), { status: response.status, outcomeUnknown: value.outcomeUnknown === true, + ...(value.job && typeof value.job === "object" ? { job: value.job } : {}) }); return value; } // Before the first review load no item is selected; text typed then is kept under "" until render() selects an item. @@ -203,7 +204,7 @@ function renderMerge() { if (job?.state === "running") { // Keep the control focusable while its job runs; repeat activation is ignored because the merge is not ready. $("merge").setAttribute("aria-disabled", "true"); - $("merge").textContent = { preparing: "Preparing…", checks: "Waiting for checks…", merging: "Merging…" }[job.step] ?? "Merging…"; + $("merge").textContent = mergeJobStepLabel(job); $("merge-details").hidden = false; $("merge-details").textContent = "Merge status"; scheduleMergePoll(); @@ -292,11 +293,17 @@ function mergeJobText(job) { return { merged: "Merged.", queued: "Merge queued. Waiting for GitHub to confirm the outcome.", submitted: `Merge submitted; GitHub's outcome is not confirmed yet. ${job.reason ?? ""}`.trim(), - blocked: `Approved, merge blocked. ${job.reason ?? ""} Fix it, then Retry merge.`.trim(), + // The server's reason ends with the next step, which depends on the cause (a timeout waits; a failing check is fixed). + blocked: `Approved, merge blocked. ${job.reason ?? "Retry merge when ready."}`, "review-required": `Review needed before merging. ${job.reason ?? ""}`.trim(), - failed: `Merge stopped. ${job.reason ?? ""} Merge again when ready.`.trim(), cancelled: "Merge cancelled.", + // A failed job's reason says why (and anything to do first); the next step is always to merge again, said once here. + failed: `Merge stopped.${job.reason ? ` ${job.reason}` : ""} Merge again when ready.`, cancelled: "Merge cancelled.", }[job.state] ?? job.state; } +/** The running job's step, as the Merge control and its status dialog name it. */ +function mergeJobStepLabel(job) { + return { preparing: "Preparing…", checks: "Waiting for checks…", merging: "Merging…" }[job.step] ?? "Merging…"; +} const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge", "job-refresh"]); /** Outcomes of a job that asked GitHub to merge, which it does only once no blocker remains. */ const mergeJobReachedMerge = new Set(["merged", "queued", "submitted"]); @@ -636,7 +643,7 @@ $("merge-details").onclick = () => { if (!data?.merge?.available) return; const queue = data.merge.queue, job = data.merge.job; if (job?.state === "running") { - showDialog(`

Merge status

${esc(job.step ?? job.state)}

${esc(mergeJobText(job))}

${job.checks.length + showDialog(`

Merge status

${esc(mergeJobStepLabel(job))}

${esc(mergeJobText(job))}

${job.checks.length ? `
    ${job.checks.map(check => `
  • ${esc(check.context)}: ${esc(check.state)}
  • `).join("")}
` : ""}`); return; } @@ -708,6 +715,14 @@ $("merge").onclick = async () => { // Only a parsed, definite server answer resolves this click. No response, an unreadable body, 503 (nothing // applied) or an admitted attempt with an unknown outcome keeps the key until that attempt ends. if (typeof error.status === "number" && error.status !== 503 && !error.outcomeUnknown) mergeAction = null; + // A merge-job refusal carries the task's latest job. When that is a running job later than the one this page shows + // (another tab's click started it), follow it as the poll would, instead of asking for a refresh. + if (error.job?.state === "running" && data.merge && "job" in data.merge && laterMergeJob(error.job, data.merge.job)) { + data = withMergeJobObservation(data, error.job); + render(); + $("banner").textContent = `${error.message} ${mergeJobText(error.job)}`; + return; + } data = { ...data, merge: { ...data.merge, ready: false, blockers: [{ code: "stale-merge", message: `${error.message} Refresh before trying again.` }] } }; render(); $("banner").textContent = `Merge blocked. ${error.message}`; @@ -987,9 +1002,13 @@ function preserveSettledQuestionAnswers(updated) { function newerMergeJob(updated, observationOwner) { const newer = data?.merge?.job, older = updated.merge?.job; if (observationOwner === mergeObservationGeneration || !newer || !older) return null; - const later = newer.id === older.id ? newer.updatedAt > older.updatedAt + return laterMergeJob(newer, older) ? newer : null; +} +/** Whether `newer` is a later record than `older` (none counts as earliest): by update time within a job, else by sequence. */ +function laterMergeJob(newer, older) { + if (!older) return true; + return newer.id === older.id ? newer.updatedAt > older.updatedAt : Number.isSafeInteger(newer.sequence) && Number.isSafeInteger(older.sequence) && newer.sequence > older.sequence; - return later ? newer : null; } function mergeJobBanner(job) { return job.state === "running" ? mergeJobText(job) : `${mergeJobText(job)} Refresh to see the result.`; diff --git a/web/server.ts b/web/server.ts index a21e28d3..36c2ce4a 100644 --- a/web/server.ts +++ b/web/server.ts @@ -743,8 +743,16 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge } if(access) try { requireTrustedIssue(access); } catch (error) { if(!(error instanceof GuardRefusal))throw error; untrusted=error.message; } } - const job=await mergeJobs.start(input.token,input.actionId,()=>untrusted??(runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) - ?'The runner or publisher is busy; the merge cannot start yet.':null),requestAbort.signal); + let job: Awaited>; + try { + job=await mergeJobs.start(input.token,input.actionId,()=>untrusted??(runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) + ?'The runner or publisher is busy; the merge cannot start yet.':null),requestAbort.signal); + } catch (error) { + // A refusal (new or replayed) carries the task's latest job, so a page holding an older review, such as a + // second tab, follows the job another click started. A saved refusal is still never shown as a job. + if(error instanceof GuardRefusal) { json(409,{error:error.message,job:mergeJobs.latest()}); return; } + throw error; + } const running=mergeJobs.current; // The job's run never rejects, but this callback's own promise would: it must not throw (an unhandled rejection // exits the server), so a failed read or close start is logged instead. From f83b6570db808d069046b5224e8aa7d75ac26ede Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 14:43:11 -0700 Subject: [PATCH 101/163] Word a merge job's guarded-merge refusal and an unsettled push in job terms The guarded merge's refusal now carries its blocker (MergeBlocked), so a job that fails there words the reason as what merging does; the direct path keeps its own text. The unsettled-push preparation blocker keeps its detail in job mode instead of reading as an unprepared merge. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- runner/merge-job.ts | 9 ++++++--- runner/merge.ts | 28 +++++++++++++++++++------- test/merge-job.test.ts | 30 ++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index a4881011..c6819c9d 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -39,7 +39,7 @@ A required check run passes when it completed with success, neutral or skipped, A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. -With merge jobs, the merge status words the blockers the job resolves as what merging does, because the screen offers no Rebase or Prepare control: a moved base reads as above, and preparation reads "The merge has not been prepared for the current review; merging prepares it." The reason a job ends `failed` with uses the same wording. Without a runner, the guarded merge's own wording is unchanged. +With merge jobs, the merge status words the blockers the job resolves as what merging does, because the screen offers no Rebase or Prepare control: a moved base reads as above, and preparation reads "The merge has not been prepared for the current review; merging prepares it." A push of the rewritten head whose outcome is not known yet is a preparation blocker too, and keeps its own detail: "A push of the rewritten head has not settled; merging reads its outcome and prepares again." The reason a job ends `failed` with uses the same wording, both when the job's own inspection finds the blocker and when the guarded merge's inspections do (for example, the base moved after the job's last inspection): the guarded merge's refusal carries the blocker, and the job words it. Without a runner, and for a direct merge, the guarded merge's own wording is unchanged. ## Outcomes diff --git a/runner/merge-job.ts b/runner/merge-job.ts index fed3a7f6..72527952 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, settleWith, type ShutdownCapability } from './lifecycle.ts'; -import { MergeOutcomeUnknown, type MergeCoordinator, type MergeStatus } from './merge.ts'; +import { MergeBlocked, MergeOutcomeUnknown, PUSH_UNSETTLED, type MergeCoordinator, type MergeStatus } from './merge.ts'; import type { PreMergeCoordinator } from './pre-merge.ts'; import type { ReviewService } from './review.ts'; import { FIX_THEN_RETRY, type MergeJob, type MergeJobState } from './store.ts'; @@ -209,7 +209,8 @@ export class MergeJobCoordinator { if (error instanceof MergeOutcomeUnknown) return this.#finish(job.id, 'submitted', reason); // A cancel or shutdown, whichever way the merge reported it (stopped, or refused because the task was cancelled). if (signal.aborted || this.#cancelled()) return this.#end(job, signal); - this.#finish(job.id, 'failed', reason); + // The guarded merge's own refusal asks the person to rebase or prepare; the job's reason says what merging does. + this.#finish(job.id, 'failed', error instanceof MergeBlocked && error.blocker ? `${error.prefix}${inJobTerms(error.blocker).message}` : reason); } } @@ -283,8 +284,10 @@ const JOB_WORDING: Readonly> = Object.freeze({ base: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.', preparation: 'The merge has not been prepared for the current review; merging prepares it.', }); +/** An unsettled push is a preparation blocker too, but its own: merging reads the push's outcome before it prepares. */ +const PUSH_UNSETTLED_JOB = 'A push of the rewritten head has not settled; merging reads its outcome and prepares again.'; export function inJobTerms(blocker: T): T { - const message = JOB_WORDING[blocker.code]; + const message = blocker.code === 'preparation' && blocker.message === PUSH_UNSETTLED ? PUSH_UNSETTLED_JOB : JOB_WORDING[blocker.code]; return message === undefined ? blocker : { ...blocker, message }; } diff --git a/runner/merge.ts b/runner/merge.ts index f7103482..38c6a215 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -20,6 +20,20 @@ export class MergeNotApplied extends Error {} class CommittedFailure extends Error {} /** The click was admitted but GitHub's outcome is unknown; its attempt stays in flight, so the key must be kept. */ export class MergeOutcomeUnknown extends Error {} +/** + * Validation refused because a blocker remains. Its message is the guarded merge's own wording; the blocker rides along + * so a merge job can word its reason as what merging does (runner/merge-job.ts `inJobTerms`). + */ +export class MergeBlocked extends Error { + readonly prefix: string; + readonly blocker: MergeBlocker | undefined; + constructor(prefix: string, blocker: MergeBlocker | undefined) { + super(`${prefix}${blocker?.message ?? 'Merge is blocked.'}`); + this.prefix = prefix; this.blocker = blocker; + } +} +/** The preparation blocker's message while a push of the rewritten head has not settled. */ +export const PUSH_UNSETTLED = 'A push of the rewritten head has not settled. Prepare the merge again to read its outcome.'; export interface MergeQueueStatus { kind: MergeAttempt['kind']; state: MergeAttempt['state']; reviewedHead: string; url: string | null; reason: string | null; phase: MergeAttempt['phase']; position: number | null; occurredAt: string | null; retryable: boolean; @@ -232,7 +246,7 @@ export class MergeCoordinator { const task = this.service.store.getTask(this.service.config.identity); if (task.status !== 'merged' && !MERGEABLE_STATUSES.includes(task.status)) blockers.push({ code: 'task', message: `The task is ${task.status}; merge it from review.` }); if (task.pushInProgress) blockers.push({ code: 'preparation', - message: 'A push of the rewritten head has not settled. Prepare the merge again to read its outcome.' }); + message: PUSH_UNSETTLED }); else if (this.published?.requiresPreparation && view.expected.reviewVersion !== undefined && !this.service.store.preMergeReady(this.service.config.identity, { stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion, @@ -466,7 +480,7 @@ export class MergeCoordinator { // final PR inspection; a synchronous local trust check then closes the admission window without another await. const authorization = this.#authorize ? await this.#authorize(signal) : null; const { status, resolved } = await this.#statusForMerge(view, signal); - if (!status.ready) throw new Error(status.blockers[0]?.message ?? 'Merge is blocked.'); + if (!status.ready) throw new MergeBlocked('', status.blockers[0]); view = await this.service.load({ signal }); if (view.token !== token) throw new Error('Review changed during merge validation. Refresh before merging.'); const { status: finalStatus, resolved: finalTarget } = await this.#statusForMerge(view, signal); @@ -476,7 +490,7 @@ export class MergeCoordinator { if (finalStatus.remote.base !== status.remote.base || finalStatus.remote.head !== status.remote.head || !samePullRequest({ status: finalStatus, resolved: finalTarget })) throw new Error('The pull request changed during merge validation. Refresh before merging.'); if (finalStatus.remote.mergeQueue !== status.remote.mergeQueue) throw new Error('Merge-queue requirements changed during validation. Refresh before merging.'); - if (!finalStatus.ready) throw new Error(`Merge requirements changed during validation. ${finalStatus.blockers[0]!.message}`); + if (!finalStatus.ready) throw new MergeBlocked('Merge requirements changed during validation. ', finalStatus.blockers[0]); let commandStatus = finalStatus; let queueWatermark: string | null = null; if (status.remote.mergeQueue) { @@ -487,7 +501,7 @@ export class MergeCoordinator { commandStatus = command.status; if (commandStatus.remote.base !== finalStatus.remote.base || commandStatus.remote.head !== finalStatus.remote.head || commandStatus.remote.mergeQueue !== finalStatus.remote.mergeQueue || !samePullRequest(command)) throw new Error('Merge-queue requirements changed after queue correlation. Refresh before merging.'); - if (!commandStatus.ready) throw new Error(`Merge requirements changed after queue correlation. ${commandStatus.blockers[0]!.message}`); + if (!commandStatus.ready) throw new MergeBlocked('Merge requirements changed after queue correlation. ', commandStatus.blockers[0]); } if (authorization) { await authorization.refresh(); @@ -496,7 +510,7 @@ export class MergeCoordinator { || authorized.status.remote.mergeQueue !== commandStatus.remote.mergeQueue || !samePullRequest(authorized)) throw new Error('The pull request changed during merge validation. Refresh before merging.'); if (!authorized.status.ready) - throw new Error(`Merge requirements changed during authorization validation. ${authorized.status.blockers[0]!.message}`); + throw new MergeBlocked('Merge requirements changed during authorization validation. ', authorized.status.blockers[0]); commandStatus = authorized.status; } if (commandStatus.remote.mergeQueue) { @@ -510,7 +524,7 @@ export class MergeCoordinator { || boundary.status.remote.mergeQueue !== commandStatus.remote.mergeQueue || !samePullRequest(boundary)) throw new Error('The pull request changed after queue correlation. Refresh before merging.'); if (!boundary.status.ready) - throw new Error(`Merge requirements changed after queue correlation. ${boundary.status.blockers[0]!.message}`); + throw new MergeBlocked('Merge requirements changed after queue correlation. ', boundary.status.blockers[0]); commandStatus = boundary.status; } // The final review load awaits its history worker, so the pull request is inspected once more after it: the last @@ -522,7 +536,7 @@ export class MergeCoordinator { if (last.status.remote.base !== commandStatus.remote.base || last.status.remote.head !== commandStatus.remote.head || last.status.remote.mergeQueue !== commandStatus.remote.mergeQueue || !samePullRequest(last)) throw new Error('The pull request changed during merge validation. Refresh before merging.'); - if (!last.status.ready) throw new Error(`Merge requirements changed during validation. ${last.status.blockers[0]!.message}`); + if (!last.status.ready) throw new MergeBlocked('Merge requirements changed during validation. ', last.status.blockers[0]); commandStatus = last.status; authorization?.validate(); if (this.service.store && this.service.config) { diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index ef8f1522..a312c710 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -815,3 +815,33 @@ it('doubles the wait between required-check inspections from the first delay up } finally { spy.mockRestore(); } expect(waits).toEqual([3, 6, 12, 20, 20, 20]); }); + +it('words the guarded merge\'s refusal as what merging does when the base moves after the job\'s last ready inspection', async () => { + // Inspection 1 is the job's wait (ready); a push to the base lands before inspection 2, the guarded merge's first. + const f = setup({ change: n => (n >= 2 ? { base: oid(5) } : {}) }); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + // The interleaving happened: the wait's one inspection was ready, and the guarded merge's first (the moved base) refused. + expect(f.gh.inspections()).toBe(2); + expect(f.store.getMergeAttempt(identity)).toBeNull(); + expect(done).toMatchObject({ state: 'failed', + reason: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.' }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); + // The direct path keeps the guarded merge's own wording. + await expect(f.merges.merge('review-token', randomUUID())).rejects.toThrow('The base branch moved. Rebase and review the resulting snapshot.'); +}); + +it('keeps the unsettled push in the job\'s wording of the preparation blocker', async () => { + const store = publishedTask(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // A preparation rebased and began pushing; the push's outcome is not known yet (as test/merge-task-pr.test.ts sets up). + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + store.beginPrePush(identity, reviewed(), store.getTask(identity).stateVersion, { branch: BRANCH, from: oid(2), to: oid(12) }); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + const status = await merges.status(); + expect(status.blockers.filter(blocker => blocker.code === 'preparation')).toEqual([{ code: 'preparation', + message: 'A push of the rewritten head has not settled. Prepare the merge again to read its outcome.' }]); + expect(withMergeJob(status as Parameters[0], null, 'in review').blockers.filter(blocker => blocker.code === 'preparation')).toEqual([{ code: 'preparation', + message: 'A push of the rewritten head has not settled; merging reads its outcome and prepares again.' }]); +}); From 3a2251a051f60da297c7a273934b64fb0f834f9d Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 14:43:11 -0700 Subject: [PATCH 102/163] Test that a refused merge click follows only a running, later job The server attaches the task's latest job to every merge-job refusal; an ended job, or a running record no later than the one shown, asks for a refresh. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 35492390..26f8a929 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1079,6 +1079,36 @@ test('follows the running merge job when a click from an older review is refused expect(polls).toBeGreaterThanOrEqual(2); expect(mergeClicks).toBe(1); }); +// A refused click's job is followed only when it is running and later than the job this page shows (web/public/app.js): +// the server attaches the task's latest job to every merge-job refusal (web/server.ts), so an ended or older one comes too. +const shownJob={id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:'Required check failed: ci.',checks:[],url:null,sequence:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:05Z'}; +for(const refusal of [ + {name:'an ended job',shown:null,button:'Merge PR',job:{id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.',checks:[],url:null,sequence:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}}, + {name:'an older running job',shown:shownJob,button:'Retry merge',job:{id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}],url:null,sequence:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:09Z'}}, + {name:'a running record no later than the job shown',shown:shownJob,button:'Retry merge',job:{...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}]}}, +]){ + test(`asks for a refresh, not following it, when a refused merge click carries ${refusal.name} (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + let polls=0,mergeClicks=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; + await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Stale review state. Refresh before merging.',job:refusal.job})});}); + await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:refusal.job})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:true,action:refusal.shown?'retry':'merge',job:refusal.shown,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:refusal.button,exact:true}).click(); + await expect(page.locator('#banner')).toHaveText('Merge blocked. Stale review state. Refresh before merging.'); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog')).toContainText('Stale review state. Refresh before merging. Refresh before trying again.'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(2500); // Past the first poll delay: the refused click's job is not followed. + expect(polls).toBe(0); + expect(mergeClicks).toBe(1); + }); +} test('states the next step a blocked merge job names, once (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; From 536a18dda2686c44b24a871e66310b02d2384691 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 14:59:09 -0700 Subject: [PATCH 103/163] Require both a check run and a commit status that share a required check's name GitHub requires both to pass when a required name has both. Take the latest observation of each kind and let the one that passes least decide, so a newer passing status no longer hides a failed or pending check run. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- github/merge.ts | 13 +++++--- test/merge.test.ts | 46 ++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index c6819c9d..b79a0978 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it (job: `merged` or `queued`; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. diff --git a/github/merge.ts b/github/merge.ts index d5c8e009..3b3d87c9 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -400,10 +400,15 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { }); if (!matches.length) return { ...required, state: 'missing' as const }; // A re-run adds a newer run (or status) with the same name; GitHub counts the latest one, so an older failure - // does not outrank a newer pending or passing run. - const latest = matches.map(check => ({ state: observedCheckState(check), at: observedCheckStart(check) })) - .reduce((best, next) => next.at > best.at || (next.at === best.at && checkStateRank[next.state] < checkStateRank[best.state]) ? next : best); - return { ...required, state: latest.state }; + // does not outrank a newer pending or passing run. A check run and a commit status with the same name are not + // re-runs of each other: GitHub requires both to pass. So the latest of each kind counts, and the one that passes + // least decides. + const latestOfKind = (kind: Record[]) => kind.length === 0 ? null + : kind.map(check => ({ state: observedCheckState(check), at: observedCheckStart(check) })) + .reduce((best, next) => next.at > best.at || (next.at === best.at && checkStateRank[next.state] < checkStateRank[best.state]) ? next : best).state; + const states = [latestOfKind(matches.filter(check => typeof check.name === 'string')), latestOfKind(matches.filter(check => typeof check.name !== 'string'))] + .filter(state => state !== null); + return { ...required, state: states.reduce((worst, next) => checkStateRank[next] < checkStateRank[worst] ? next : worst) }; }); return { rulesKnown, atomicBaseGuard, mergeQueue, requiredChecks }; } diff --git a/test/merge.test.ts b/test/merge.test.ts index 91f1c440..7a887c97 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -941,11 +941,11 @@ it('parses required checks from both rule sources and pins the gh merge head', a }); /** Inspects a pull request whose one required check, `test` from any app, has these observed runs and statuses. */ -async function requiredTestState(statusCheckRollup: unknown[]) { +async function requiredTestState(statusCheckRollup: unknown[], appId: number | null = null) { const run = async (args: readonly string[]) => { const joined = args.join(' '); if (joined.startsWith('pr view 7')) return JSON.stringify({ number: 7, isDraft: false, baseRefName: 'main', baseRefOid: sha('a'), headRefName: 'feature', headRefOid: sha('b'), state: 'OPEN', mergeable: 'MERGEABLE', statusCheckRollup }); - if (joined.includes('/rules/branches/')) return JSON.stringify([[{ type: 'required_status_checks', parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: 'test', integration_id: null }] } }]]); + if (joined.includes('/rules/branches/')) return JSON.stringify([[{ type: 'required_status_checks', parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: 'test', integration_id: appId }] } }]]); if (/branches\/main$/.test(joined)) return JSON.stringify({ protected: false }); if (joined.endsWith('/protection')) throw new Error('gh: Branch not protected (HTTP 404)'); { const fixed = alreadyFixedReads(args); if (fixed !== null) return fixed; } @@ -992,6 +992,48 @@ it.each([ expect(await requiredTestState([...rollup])).toEqual([expected]); }); +// GitHub: "If a check and a commit status have the same name, both must pass when that name is required." The latest +// of each kind counts, and the one that passes least decides; a newer observation of one kind never replaces the other. +it.each([ + ['a newer passing status does not hide a failed check run', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a newer passing status does not hide a pending check run', [ + { name: 'test', status: 'IN_PROGRESS', conclusion: '', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'pending'], + ['a newer passing check run does not hide a failed status', [ + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a failure outranks a pending of the other kind', [ + { name: 'test', status: 'QUEUED', conclusion: '', startedAt: '0001-01-01T00:00:00Z' }, + { context: 'test', state: 'ERROR', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['both kinds passing', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'success'], + ['each kind re-run: the latest of each passes', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:01:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:02:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:03:00Z' }, + ], 'success'], + ['only a check run', [{ name: 'test', status: 'IN_PROGRESS', conclusion: '', startedAt: '2026-10-11T00:00:00Z' }], 'pending'], + ['only a commit status', [{ context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }], 'failure'], +] as const)('requires both a check run and a commit status with the required name: %s', async (_case, rollup, expected) => { + expect(await requiredTestState([...rollup])).toEqual([expected]); +}); + +it('matches a requirement bound to an app only against that app\'s check runs, never a commit status', async () => { + expect(await requiredTestState([ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z', app: { databaseId: 10 } }, + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 10)).toEqual(['success']); +}); + it('rejects an unsupported runtime merge method', () => { expect(() => new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21, method: 'typo' as 'merge' })).toThrow(/merge method/i); }); From 3fbf1cdd4ac55cfe0a2c1c4ef4b5eb71ab673ffd Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 15:25:23 -0700 Subject: [PATCH 104/163] Order records of one merge job by a durable revision, not its update time Two saves can share an update time, and the clock can step back between them, so a held Refresh response's running record could look as late as the outcome a poll saw and restore the running job. Every saved change to a job now adds one to its revision, and the browser compares revisions within a job (sequence across jobs, as before). Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 4 +-- runner/store.ts | 12 +++++-- test/browser/review.spec.ts | 49 +++++++++++++++------------- test/merge-job.test.ts | 35 ++++++++++++++++++++ web/public/app.js | 9 +++-- 5 files changed, 78 insertions(+), 31 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index b79a0978..c0c50194 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -72,7 +72,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t ## Storage -Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, `sequence` (the record's place among the task's jobs and refused clicks, from 1, so the browser orders jobs without comparing clock times), start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. The runner's `start` and `resume` actions are refused too ("A merge is running for this task; wait for it to end or cancel the task."), saved and replayed like other refusals, and neither is offered: a run would move the task's head, and the publish at its end would push it. So no run starts, and no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. Plan import stays allowed during a job, as at any time in review. It changes only the plan, never the branch or the pull request; the readiness the job prepared is then for an older review, so the job's next inspection ends it `failed` ("The merge has not been prepared for the current review; merging prepares it."), or the guarded merge refuses, and nothing is merged. +Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, `sequence` (the record's place among the task's jobs and refused clicks, from 1, so the browser orders jobs without comparing clock times), `revision` (the record's version, from 1; every saved change to the job adds one, so the browser orders records of one job without comparing clock times, which can tie or step back), start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. The runner's `start` and `resume` actions are refused too ("A merge is running for this task; wait for it to end or cancel the task."), saved and replayed like other refusals, and neither is offered: a run would move the task's head, and the publish at its end would push it. So no run starts, and no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. Plan import stays allowed during a job, as at any time in review. It changes only the plan, never the branch or the pull request; the readiness the job prepared is then for an older review, so the job's next inspection ends it `failed` ("The merge has not been prepared for the current review; merging prepares it."), or the guarded merge refuses, and nothing is merged. ## API and browser @@ -82,7 +82,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - A refused merge click (409, new or replayed) carries `job`, the task's latest job, as `GET /api/merge` would answer it. A page that holds an older review, such as a second tab, may click Merge PR while another tab's job runs; it is refused ("A merge is already running for this task."). When the carried job is running and later than the one the page shows, the page follows it as its poll would: the control shows the job's step, and the banner reads the refusal, then the step. The refusal is a definite answer, so the click's key is dropped, as for any other refusal; any other refusal still asks for a refresh. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `revision`, never its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. ## Tests diff --git a/runner/store.ts b/runner/store.ts index 0b8a7412..b98c6444 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -156,6 +156,11 @@ export interface MergeJob { * browser orders records of different jobs by it, since wall-clock times can step back. */ sequence: number; + /** + * The record's version, from 1: every saved change to the job adds one. The browser orders records of the same job by + * it, since two saves can share an update time or the clock can step back between them. + */ + revision: number; startedAt: string; updatedAt: string; } @@ -2147,7 +2152,7 @@ export class Store { if (this.runningMergeJob(identity)) throw new GuardRefusal('A merge is already running for this task.'); const now = input.now ?? new Date().toISOString(); const job: MergeJob = { id: input.actionId, mergeActionId: null, token: input.token, state: 'running', step: 'preparing', reason: null, checks: [], - url: null, sequence: this.#nextMergeJobSequence(key), startedAt: now, updatedAt: now }; + url: null, sequence: this.#nextMergeJobSequence(key), revision: 1, startedAt: now, updatedAt: now }; this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); return { job, resent: false }; }); @@ -2170,7 +2175,7 @@ export class Store { } const now = new Date().toISOString(); const job: MergeJob = { id: input.actionId, mergeActionId: null, token: input.token, state: 'failed', step: null, - reason: input.reason.slice(0, 2_000), checks: [], url: null, sequence: this.#nextMergeJobSequence(key), startedAt: now, updatedAt: now, refused: true }; + reason: input.reason.slice(0, 2_000), checks: [], url: null, sequence: this.#nextMergeJobSequence(key), revision: 1, startedAt: now, updatedAt: now, refused: true }; this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); return job; }); @@ -2237,7 +2242,8 @@ export class Store { if (!row) throw new Error('Unknown merge job.'); const next = change(decode(row.data)); if (!next) return null; - const job = { ...next, updatedAt: new Date().toISOString() }; + // Each saved change is strictly later than the one before, whatever the clock says. + const job = { ...next, revision: (Number.isSafeInteger(next.revision) ? next.revision : 1) + 1, updatedAt: new Date().toISOString() }; this.#run('UPDATE merge_jobs SET data=? WHERE id=?', encode(job), id); return job; }); diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 26f8a929..bd22cdcc 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -656,7 +656,7 @@ test('shows only the outcome once a polled merge job ends, not the blockers it r const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[])=>({id:'0f6b0000-0000-4000-8000-0000000000f1',state,step,reason:null,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[])=>({id:'0f6b0000-0000-4000-8000-0000000000f1',state,step,reason:null,checks,url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)];let polls=0; // As a runner server answers: Merge PR is offered while only job-resolved blockers remain, and the click's review, // reloaded while the job prepares, still lists them. @@ -685,7 +685,7 @@ test('keeps every blocker when the click is answered with a job that ended witho // is still pending; the job settled neither. const head={code:'head',message:'The pull request head moved after review.'}; const conflict={code:'mergeable',message:'GitHub reports a merge conflict.'}; - const failed={id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason:head.message,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const failed={id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason:head.message,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; let polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); @@ -709,7 +709,7 @@ test('names a failed merge-job poll as a merge-status failure (F6b)',async({page const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const running={id:'0f6b0000-0000-4000-8000-000000000001',state:'running',step:'checks',reason:null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const running={id:'0f6b0000-0000-4000-8000-000000000001',state:'running',step:'checks',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); @@ -726,7 +726,7 @@ test('keeps a draft typed while a merge-job click is in flight (F6b)',async({pag const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); let release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await held;const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing'); @@ -749,7 +749,7 @@ test('starts a new action for Retry merge once the job its retained key started const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); const clicks:string[]=[];let first:string|null=null; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} @@ -775,7 +775,7 @@ test('starts a new action once another job became the latest after a retained ke const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); const clicks:string[]=[];let first:string|null=null; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} @@ -801,7 +801,7 @@ test('resends the same action for Retry merge after a retry failed while an earl const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const blocked={id:'0f6b0000-0000-4000-8000-0000000000a1',state:'blocked',step:null,reason:'Required check failed: ci.',checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const blocked={id:'0f6b0000-0000-4000-8000-0000000000a1',state:'blocked',step:null,reason:'Required check failed: ci.',checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; // Every retry answers 503: nothing was applied, so the click is unresolved and its key must be resent. const clicks:string[]=[]; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); @@ -824,13 +824,16 @@ test('resends the same action for Retry merge after a retry failed while an earl expect(confirms).toEqual([retry,retry]); }); -test('keeps a merge-job outcome a poll saw while an older Refresh response was held (F6b)',async({page})=>{ +// The server orders records of one job by revision, never clock time: the held running record's update time may equal or +// follow the outcome's (two saves in one clock tick, or a clock step back between them). +for(const clock of [{name:'',runningAt:'2026-10-11T00:00:00Z'},{name:' with the same update time',runningAt:'2026-10-11T00:05:00Z'},{name:' with a later update time',runningAt:'2026-10-11T00:09:00Z'}]) +test(`keeps a merge-job outcome a poll saw while an older Refresh response was held${clock.name} (F6b)`,async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const running={id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',reason:null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; - const merged={...running,state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',updatedAt:'2026-10-11T00:05:00Z'}; + const running={id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:clock.runningAt}; + const merged={...running,state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:2,updatedAt:'2026-10-11T00:05:00Z'}; let ended=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?merged:running})});}); @@ -868,8 +871,8 @@ test('keeps a newer merge job a poll saw while a held Refresh response shows an 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}); // Job A runs in this tab. Another tab's click starts job B after A ends; B's record is later than any of A's (sequence), // while its clock times are earlier, as after a clock step back. - const jobA={id:'0f6b0000-0000-4000-8000-0000000000e1',state:'running',step:'preparing',reason:null,checks:[],url:null,sequence:1,startedAt:'2026-10-11T00:10:00Z',updatedAt:'2026-10-11T00:10:00Z'}; - const blockedA={...jobA,state:'blocked',step:null,reason:'Required check failed: ci.',updatedAt:'2026-10-11T00:20:00Z'}; + const jobA={id:'0f6b0000-0000-4000-8000-0000000000e1',state:'running',step:'preparing',reason:null,checks:[],url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:10:00Z',updatedAt:'2026-10-11T00:10:00Z'}; + const blockedA={...jobA,state:'blocked',step:null,reason:'Required check failed: ci.',revision:2,updatedAt:'2026-10-11T00:20:00Z'}; const jobB={...jobA,id:'0f6b0000-0000-4000-8000-0000000000e2',step:'checks',sequence:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; let started=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); @@ -917,8 +920,8 @@ async function mergeJobOutcomeRace(page:import('@playwright/test').Page,planning const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=await startServer(config,0,undefined,gateway,undefined,undefined,undefined,planning); 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}); - const running={id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',reason:null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; - const merged={...running,state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',updatedAt:'2026-10-11T00:05:00Z'}; + const running={id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const merged={...running,state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:2,updatedAt:'2026-10-11T00:05:00Z'}; const race={ended:false,polls:0,hold:false,config,release:()=>{},held:Promise.resolve()}; let holding!:()=>void;race.held=new Promise(resolve=>{holding=resolve;}); const released=new Promise(resolve=>{race.release=resolve;}); @@ -1008,7 +1011,7 @@ test('keeps the Merge button pending while its click is in flight, across a rend const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const running={id:'0f6b0000-0000-4000-8000-0000000000d1',state:'running',step:'preparing',reason:null,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const running={id:'0f6b0000-0000-4000-8000-0000000000d1',state:'running',step:'preparing',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; let release!:()=>void,arrived!:()=>void;const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{arrived=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); @@ -1062,7 +1065,7 @@ test('follows the running merge job when a click from an older review is refused const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[])=>({id:'0f6b0000-0000-4000-8000-0000000000b1',state,step,reason:null,checks,url:null,sequence:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[])=>({id:'0f6b0000-0000-4000-8000-0000000000b1',state,step,reason:null,checks,url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)];let polls=0,mergeClicks=0; // This tab read its review before another tab's click started a job; the server refuses this click and, as // web/server.ts does, carries the task's running job. @@ -1081,10 +1084,10 @@ test('follows the running merge job when a click from an older review is refused }); // A refused click's job is followed only when it is running and later than the job this page shows (web/public/app.js): // the server attaches the task's latest job to every merge-job refusal (web/server.ts), so an ended or older one comes too. -const shownJob={id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:'Required check failed: ci.',checks:[],url:null,sequence:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:05Z'}; +const shownJob={id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:'Required check failed: ci.',checks:[],url:null,sequence:2,revision:3,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:05Z'}; for(const refusal of [ - {name:'an ended job',shown:null,button:'Merge PR',job:{id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.',checks:[],url:null,sequence:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}}, - {name:'an older running job',shown:shownJob,button:'Retry merge',job:{id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}],url:null,sequence:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:09Z'}}, + {name:'an ended job',shown:null,button:'Merge PR',job:{id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.',checks:[],url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}}, + {name:'an older running job',shown:shownJob,button:'Retry merge',job:{id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}],url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:09Z'}}, {name:'a running record no later than the job shown',shown:shownJob,button:'Retry merge',job:{...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}]}}, ]){ test(`asks for a refresh, not following it, when a refused merge click carries ${refusal.name} (F6b)`,async({page})=>{ @@ -1116,7 +1119,7 @@ test('states the next step a blocked merge job names, once (F6b)',async({page})= 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}); // The reason runner/merge-job.ts saves when the 30-minute wait ends: nothing failed, so the step is to wait. const reason='Required checks did not pass within 30 minutes: ci is pending. Wait for the checks to pass, then Retry merge.'; - const blocked={id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason,checks:[],url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const blocked={id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:blocked,mergeRefreshRequired:true})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); @@ -1129,7 +1132,7 @@ test('asks for a refresh when the click is answered with a merge job that has al const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); let polls=0; // The job failed before the click was answered (its review load failed as the click's reload did). await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} @@ -1154,7 +1157,7 @@ test('follows a started merge job when the server could not reload the review af const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)]; let polls=0,reviews=0; // The click's job started, but its review reload failed: the server answers only the job and asks for a refresh. @@ -1184,7 +1187,7 @@ test('follows a one-click merge job from preparation through the required-check const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('running','merging'),job('merged',null)]; let polls=0,reviews=0,mergeClicks=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index a312c710..f5ccf8e2 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -720,6 +720,41 @@ it('numbers each job and refused click after the task\'s earlier ones, so the br f.store.finishMergeJob(identity, second.id, { state: 'failed' }); }); +it('makes every saved change to a job a later revision, even when the clock ties or steps back', () => { + const store = publishedTask(); + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const at = (time: string) => vi.setSystemTime(new Date(time)); + at('2026-10-11T00:00:00.000Z'); + const refusal = store.recordMergeJobRefusal(identity, { actionId: randomUUID(), token: 'review-token', reason: 'Refused.' }); + expect(refusal.revision).toBe(1); + const id = randomUUID(), records = [store.beginMergeJob(identity, { actionId: id, token: 'review-token' }).job]; + // Same clock tick as admission, then the same tick again, then a clock step back, then the block that ends it. + records.push(store.updateMergeJob(identity, id, { step: 'checks' })!); + records.push(store.updateMergeJob(identity, id, { checks: [{ context: 'ci', state: 'pending' }] })!); + at('2026-10-10T23:00:00.000Z'); + records.push(store.updateMergeJob(identity, id, { mergeActionId: randomUUID() })!); + at('2026-10-10T22:00:00.000Z'); + expect(store.blockMergeJob(identity, id, 'Required check failed: ci.')).toBe(true); + records.push(store.getMergeJob(identity, id)!); + // The interleaving happened: update times tied and then stepped back. + expect(records.map(job => job.updatedAt)).toEqual(['2026-10-11T00:00:00.000Z', '2026-10-11T00:00:00.000Z', '2026-10-11T00:00:00.000Z', + '2026-10-10T23:00:00.000Z', '2026-10-10T22:00:00.000Z']); + expect(records.map(job => job.revision)).toEqual([1, 2, 3, 4, 5]); + expect(records.at(-1)!.state).toBe('blocked'); + // Startup settlement and finishing are saved changes too. + const next = randomUUID(); + const begun = store.beginMergeJob(identity, { actionId: next, token: 'review-token' }).job; + store.settleInterruptedMergeJobs(identity); + const settledJob = store.getMergeJob(identity, next)!; + expect(settledJob.updatedAt).toBe(begun.updatedAt); + expect([begun.revision, settledJob.revision]).toEqual([1, 2]); + // An ended job is left unchanged: no save, no new revision. + expect(store.finishMergeJob(identity, next, { state: 'failed' })).toBe(false); + expect(store.getMergeJob(identity, next)!.revision).toBe(2); + } finally { vi.useRealTimers(); } +}); + // Since #170 a click's admission awaits the review load; these pin what may happen while it does. it('gives a resend of the same click, during the first send\'s review load, the same job', async () => { const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); diff --git a/web/public/app.js b/web/public/app.js index 0ec26947..979b9342 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -996,7 +996,7 @@ function preserveSettledQuestionAnswers(updated) { } /** * A poll's record of the task's latest job, when the poll saw it later than a full response read before it. Records of - * one job order by update time; a later job (another tab's click) outranks any record of an earlier one, by the + * one job order by the server's per-job revision; a later job (another tab's click) outranks any record of an earlier one, by the * server's per-task job sequence, never by clock time. */ function newerMergeJob(updated, observationOwner) { @@ -1004,10 +1004,13 @@ function newerMergeJob(updated, observationOwner) { if (observationOwner === mergeObservationGeneration || !newer || !older) return null; return laterMergeJob(newer, older) ? newer : null; } -/** Whether `newer` is a later record than `older` (none counts as earliest): by update time within a job, else by sequence. */ +/** + * Whether `newer` is a later record than `older` (none counts as earliest): by revision within a job, else by sequence. + * Never by clock time: two saves can share an update time, and the clock can step back between them. + */ function laterMergeJob(newer, older) { if (!older) return true; - return newer.id === older.id ? newer.updatedAt > older.updatedAt + return newer.id === older.id ? Number.isSafeInteger(newer.revision) && Number.isSafeInteger(older.revision) && newer.revision > older.revision : Number.isSafeInteger(newer.sequence) && Number.isSafeInteger(older.sequence) && newer.sequence > older.sequence; } function mergeJobBanner(job) { From fc7834cc6a5762d73980ed409354522d7dfd26da Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 15:29:14 -0700 Subject: [PATCH 105/163] End a merge job the poll no longer reports without a TypeError When /api/merge answered job: null while the page showed a running job, withEndedMergeJob read job.state and threw, so the banner showed "Could not refresh merge status" and polling repeated forever. Treat a null job as not known to have reached the merge: show the ended message, keep every blocker, and stop polling. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 29 +++++++++++++++++++++++++++++ web/public/app.js | 5 +++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index bd22cdcc..27c3fb89 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -721,6 +721,35 @@ test('names a failed merge-job poll as a merge-status failure (F6b)',async({page await expect(page.locator('#banner')).toHaveText(/^Could not refresh merge status\. GitHub returned HTTP 502\./,{timeout:15_000}); }); +test('ends a running merge job the poll no longer reports, keeping every blocker (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const running={id:'0f6b0000-0000-4000-8000-0000000000f3',state:'running',step:'checks',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); + // The job is no longer reported (no job record and no merge queue): the page cannot tell whether it reached the merge. + let polls=0; + await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:null})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + 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:'Waiting for checks…',exact:true})).toBeVisible({timeout:10_000}); + const ended='The merge job ended. Refresh to see the result.'; + await expect(page.locator('#banner')).toHaveText(ended,{timeout:15_000}); + await expect(page.getByText('Could not refresh merge status')).toHaveCount(0); + // A job not known to have merged settled nothing, so every blocker the page last read stays listed. + await expectEndedMergeJob(page,'Merge ended',ended,jobResolvedBlockers.map(blocker=>blocker.message)); + const seen=polls;expect(seen).toBe(1); + // Past the 2 s poll delay (and its first back-off), no further status poll runs. + await page.waitForTimeout(5_000); + expect(polls).toBe(seen); + await expect(page.locator('#banner')).toHaveText(ended); +}); + test('keeps a draft typed while a merge-job click is in flight (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; diff --git a/web/public/app.js b/web/public/app.js index 979b9342..3198a782 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -312,10 +312,11 @@ const mergeJobReachedMerge = new Set(["merged", "queued", "submitted"]); * result". A job that reached the merge (merged, queued or submitted) settled the blockers the server lists in * `settledBeforeJobMerge` (preparation, a moved base, `cmd:` checks, required checks, mergeability), so a review read * before or during it no longer lists them. Any other outcome keeps every blocker the page last read: a blocked job's - * failing check still fails, and Refresh shows what still applies. The control reads the outcome, as after a queue merge. + * failing check still fails, and Refresh shows what still applies. A job the poll no longer reports (`job` null) is + * not known to have reached the merge, so it also keeps every blocker. The control reads the outcome, as after a queue merge. */ function withEndedMergeJob(view, job, message) { - const settled = new Set(mergeJobReachedMerge.has(job.state) ? view.merge.settledBeforeJobMerge ?? [] : []); + const settled = new Set(mergeJobReachedMerge.has(job?.state) ? view.merge.settledBeforeJobMerge ?? [] : []); const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code) && !settled.has(blocker.code)); return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message }, ...blockers] } }; } From d49aeb745dacb371893dcb3c5b690cda3cd69687 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 17:25:50 -0700 Subject: [PATCH 106/163] Decide merge job endings from cancel, attempt state and the wait clock - Check a committed cancel before shutdown when the job's work rejects, so a cancel followed by shutdown during the job's own review load ends it `cancelled` instead of leaving it running for startup to fail. - End the job from its own merge attempt after a fulfilled guarded merge with the same mapping as a rejected one, so a queue poll that records `removed` or `failed` before the job resumes blocks it (and the task) rather than reporting `submitted`. - Treat a ready inspection that finishes after the check-wait limit, before its timer runs, as the wait timing out; failing checks still decide. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 8 +-- runner/merge-job.ts | 55 ++++++++++------ test/merge-job.test.ts | 93 ++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 24 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index c0c50194..93d283b4 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -33,9 +33,9 @@ Without a runner, **Merge PR** works as before. | Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. If a resend of the same click was admitted while this load or the click's trust read was pending, a failure of either answers that resend's job. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again. A trust read that timed out, or that gh answered, keeps that answer even when shutdown started while gh stopped | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | -| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it (job: `merged` or `queued`; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | +| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed before the job read it, the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, counts as the wait timing out too; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. @@ -66,7 +66,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | -| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | +| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk, unless the task was cancelled before the job stopped: then it ends `cancelled`, also when its stopped work (such as one of its own review loads) settles only after shutdown began. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | | Ending the job fails | if the write that ends a job fails, or reading the task after the job failed does, the server logs, also during shutdown (after GitHub was asked to merge), "Could not end merge job …; it shows as running until the server restarts" with the store's message. The job stays `running` on disk: the page keeps following it, and a new click is refused while it runs. The next start settles it as below | | Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed or the review changed while GitHub was asked). A `prepare-merge` action and a push marker are settled as before | @@ -86,7 +86,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Further regressions cover a cancel, then shutdown, before the job's own review load rejects (the job ends `cancelled`), a queue poll that records `removed` or `failed` after the guarded merge saved `queued` and before the job resumes (the job ends `blocked`), and an inspection that finds the checks passing, or one failing, but finishes after the wait limit before its timer runs (a timeout, or the failing check). Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. ## Check this document with a reader diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 72527952..30ae197b 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -3,7 +3,7 @@ import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, settleWith, type S import { MergeBlocked, MergeOutcomeUnknown, PUSH_UNSETTLED, type MergeCoordinator, type MergeStatus } from './merge.ts'; import type { PreMergeCoordinator } from './pre-merge.ts'; import type { ReviewService } from './review.ts'; -import { FIX_THEN_RETRY, type MergeJob, type MergeJobState } from './store.ts'; +import { FIX_THEN_RETRY, type MergeAttempt, type MergeJob, type MergeJobState } from './store.ts'; /** How long a job waits for GitHub's required checks on the pushed head (docs/implementation/merge-handoff.md). */ export const REQUIRED_CHECK_WAIT_MS = 30 * 60_000; @@ -99,8 +99,10 @@ export class MergeJobCoordinator { // (a cancel during one of the job's own review loads, which await a history worker since #170). Never rejects: if // even reading the task fails here, the job stays running for startup, as when ending it fails. try { - if (abort.signal.aborted && this.#closing) return; + // A cancel is checked before shutdown: a task cancelled before shutdown began ends the job `cancelled` even when + // the stopped work rejects only after shutdown began (the store's write gate closes after the job is awaited). if ((abort.signal.reason as { code?: string } | undefined)?.code === 'CANCELLED' || this.#cancelled()) return this.#end(job, abort.signal); + if (abort.signal.aborted && this.#closing) return; this.#finish(job.id, 'failed', error instanceof Error ? error.message : String(error)); } catch (unended) { this.#unended(job.id, unended); } }).finally(() => { if (this.#active === run) { this.#active = null; this.#abort = null; } }); @@ -186,26 +188,19 @@ export class MergeJobCoordinator { // The job's signal stops the guarded merge as shutdown or a deadline stops a click: before GitHub is asked, nothing // is applied; after, the outcome is unknown. const merged = await this.merges.merge(view.token, mergeId, signal); - // A fulfilled merge does not prove its outcome was saved: when saving it fails, the attempt stays submitting and - // reconciles from GitHub. Only this job's own attempt, in a settled state, ends the job merged or queued. + // A fulfilled merge does not prove its outcome: when saving it fails, the attempt stays submitting and reconciles + // from GitHub; a queue poll may also have ended the attempt (removed, failed or merged) after it was saved queued + // and before this job resumed. This job's own attempt decides, as when the merge rejects. const attempt = store.getMergeAttempt(identity); - if (attempt?.actionId !== mergeId || (attempt.state !== 'merged' && attempt.state !== 'queued')) + if (attempt?.actionId !== mergeId || attempt.state === 'submitting') return this.#finish(job.id, 'submitted', 'GitHub accepted the merge, but its outcome was not saved; it reconciles from GitHub.'); - this.#finish(job.id, attempt.state, null, merged.result.url || attempt.url || null); + return this.#endAsAttempt(job, attempt, 'no reason given.', merged.result.url || attempt.url || null); } catch (error) { const reason = error instanceof Error ? error.message : String(error); // Once this job's merge attempt exists, GitHub was asked: its recorded state is the answer, whatever else (a cancel, // shutdown) happened meanwhile. A refusal blocks (design: "GitHub refused the merge"); anything else is unknown. const attempt = store.getMergeAttempt(identity); - if (attempt?.actionId === mergeId) { - // A refusal because the head changed needs a fresh review, not a retry: the task is not "approved" any more. - if ((attempt.state === 'failed' || attempt.state === 'removed') && attempt.requiresFreshReview) - return this.#finish(job.id, 'failed', `GitHub refused the merge: ${attempt.reason ?? reason} Review the new head first.`); - if (attempt.state === 'failed' || attempt.state === 'removed') return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`, FIX_THEN_RETRY); - if (attempt.state === 'merged') return this.#finish(job.id, 'merged', null, attempt.url); - if (attempt.state === 'queued') return this.#finish(job.id, 'queued', null, attempt.url); - return this.#finish(job.id, 'submitted', reason); - } + if (attempt?.actionId === mergeId) return this.#endAsAttempt(job, attempt, reason, attempt.url); if (error instanceof MergeOutcomeUnknown) return this.#finish(job.id, 'submitted', reason); // A cancel or shutdown, whichever way the merge reported it (stopped, or refused because the task was cancelled). if (signal.aborted || this.#cancelled()) return this.#end(job, signal); @@ -214,6 +209,21 @@ export class MergeJobCoordinator { } } + /** + * End the job as its own merge attempt records GitHub's answer, after the guarded merge fulfilled or rejected: one + * mapping for both, and for every state the attempt can reach. `reason` stands in when a refusal saved none. + */ + #endAsAttempt(job: MergeJob, attempt: MergeAttempt, reason: string, url: string | null): void { + if (attempt.state === 'merged' || attempt.state === 'queued') return this.#finish(job.id, attempt.state, null, url); + if (attempt.state === 'failed' || attempt.state === 'removed') { + // A refusal because the head changed needs a fresh review, not a retry: the task is not "approved" any more. + if (attempt.requiresFreshReview) + return this.#finish(job.id, 'failed', `GitHub refused the merge: ${attempt.reason ?? reason} Review the new head first.`); + return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`, FIX_THEN_RETRY); + } + this.#finish(job.id, 'submitted', reason); + } + #cancelled(): boolean { const task = this.service.store.getTask(this.service.config.identity); return task.status === 'cancelled' || task.cancelRequested !== null; @@ -237,11 +247,13 @@ export class MergeJobCoordinator { async #awaitChecks(job: MergeJob, signal: AbortSignal): Promise { const { store, config } = this.service, identity = config.identity; const until = performance.now() + this.checkWaitMs; - let delay = this.firstDelayMs, waiting: { context: string; state: string }[] = []; - // Nothing failed: the person waits for the checks (or GitHub) rather than fixing anything. + let delay = this.firstDelayMs, waiting: { context: string; state: string }[] = [], waited = false; + // Nothing failed: the person waits for the checks (or GitHub) rather than fixing anything. Before any inspection + // finished within the limit, nothing was seen pending or computing. const timedOut = (): CheckVerdict => ({ kind: 'blocked', reason: `Required checks did not pass within ${Math.round(this.checkWaitMs / 60_000)} minutes: ${ - waiting.length ? waiting.map(check => `${check.context} is ${check.state}`).join(', ') : 'GitHub has not determined mergeability'}.`, - next: waiting.length ? 'Wait for the checks to pass, then Retry merge.' : 'Wait for GitHub to determine mergeability, then Retry merge.' }); + waiting.length ? waiting.map(check => `${check.context} is ${check.state}`).join(', ') + : waited ? 'GitHub has not determined mergeability' : 'no inspection of them finished in time'}.`, + next: waiting.length || !waited ? 'Wait for the checks to pass, then Retry merge.' : 'Wait for GitHub to determine mergeability, then Retry merge.' }); for (;;) { if (signal.aborted) return { kind: 'cancelled' }; const task = store.getTask(identity); @@ -260,8 +272,11 @@ export class MergeJobCoordinator { return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }; } const verdict = classify(status); + // Only checks seen passing within the limit count. Work at the end of an inspection can cross the limit before + // its timer runs, so a ready answer is checked against the clock too; a failing check or other blocker stands. + if (verdict.kind === 'ready' && performance.now() >= until) return timedOut(); if (verdict.kind !== 'wait') return verdict; - waiting = verdict.checks; + waiting = verdict.checks; waited = true; this.settle(() => store.updateMergeJob(identity, job.id, { checks: waiting })); const remaining = until - performance.now(); if (remaining <= 0) return timedOut(); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index f5ccf8e2..fb8c90a2 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -880,3 +880,96 @@ it('keeps the unsettled push in the job\'s wording of the preparation blocker', expect(withMergeJob(status as Parameters[0], null, 'in review').blockers.filter(blocker => blocker.code === 'preparation')).toEqual([{ code: 'preparation', message: 'A push of the rewritten head has not settled; merging reads its outcome and prepares again.' }]); }); + +it('ends the job cancelled when its own review load rejects only after the task was cancelled and shutdown began', async () => { + const f = setup(), id = randomUUID(); + // The admission's load passes; the job's own first load is held, and rejects only when the test says so (not on abort). + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + let reject!: (error: unknown) => void, started!: (signal?: AbortSignal) => void; + const loading = new Promise(resolve => { started = resolve; }); + (f.review.load as unknown as ReturnType).mockImplementationOnce((options?: { signal?: AbortSignal }) => { + started(options?.signal); + return new Promise((_resolve, fail) => { reject = fail; }); + }); + await f.coordinator.start('review-token', id); + const signal = (await loading)!; + // As the cancel route does: the task's cancel commits, then the job is told. Then shutdown begins. + f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID()); + f.coordinator.cancel(); + f.coordinator.stop(); + // The interleaving happened: the cancel aborted the job first (shutdown's abort came second and changed nothing), + // and the load is still pending when shutdown has begun. + expect((signal.reason as { code?: string }).code).toBe('CANCELLED'); + expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); + reject(signal.reason); + await f.coordinator.close(); + expect(f.coordinator.latest()).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); + expect(f.gh.merged).toEqual([]); +}); + +it.each(['removed', 'failed'] as const)('ends the job from its attempt when a queue poll records %s after the merge queued and before the job resumed', async state => { + const f = setup({ queue: true }); + const inspectQueue = f.gh.client as unknown as { inspectQueue: ReturnType }; + inspectQueue.inspectQueue.mockImplementationOnce(async (head: string) => state === 'removed' + ? { state: 'removed', reviewedHead: head, removedAt: '2026-10-11T00:01:00Z', reason: 'The merge queue removed it: checks failed.' } + : { state: 'failed', reviewedHead: head, entryId: 'E', reason: 'The merge queue failed it: checks failed.' }); + // The guarded merge settles (attempt saved queued); a queue poll then ends the attempt before the job resumes. + const merge = f.merges.merge.bind(f.merges); + let queuedBeforePoll: string | undefined; + f.merges.merge = (async (...args: Parameters) => { + const result = await merge(...args); + queuedBeforePoll = f.store.getMergeAttempt(identity)?.state; + await f.merges.pollQueue(); + return result; + }) as typeof merge; + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + // The interleaving happened: the merge saved queued, and the poll ended this job's attempt before the job read it. + expect(queuedBeforePoll).toBe('queued'); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ actionId: done.mergeActionId, state, requiresFreshReview: false }); + expect(done).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge: The merge queue .* it: checks failed\. Fix it, then Retry merge\.$/) }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('times out, without merging, when a ready inspection finishes after the wait limit before its timer runs', async () => { + const f = setup({ checks: n => (n === 1 ? [pending()] : [passing()]) }); + let offset = 0, limitAbortedAtReady: boolean | null = null; + const now = performance.now.bind(performance); + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now() + offset); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + const status = await inspect(request); + // The second inspection (all checks passing) ends after the limit: the clock crosses it, but the limit's timer has + // not run (it counts real time, far from the limit). + if ((inspect as ReturnType).mock.calls.length === 2) { offset += 120_000; limitAbortedAtReady = request?.signal?.aborted ?? null; } + return status; + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 60_000, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + try { + await coordinator.start('review-token', randomUUID()); + const done = await settled(coordinator); + // The interleaving happened: the ready inspection's signal had not been stopped by the limit when it answered. + expect(limitAbortedAtReady).toBe(false); + expect(done).toMatchObject({ state: 'blocked', reason: 'Required checks did not pass within 1 minutes: ci is pending. Wait for the checks to pass, then Retry merge.' }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + } finally { clock.mockRestore(); } +}); + +it('keeps a failing check found by an inspection that finishes after the wait limit', async () => { + const f = setup({ checks: n => (n === 1 ? [pending()] : [{ context: 'build', appId: null, state: 'failure' }]) }); + let offset = 0; + const now = performance.now.bind(performance); + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now() + offset); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + const status = await inspect(request); + if ((inspect as ReturnType).mock.calls.length === 2) offset += 120_000; + return status; + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 60_000, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + try { + await coordinator.start('review-token', randomUUID()); + expect(await settled(coordinator)).toMatchObject({ state: 'blocked', reason: 'Required check failed: build. Fix it, then Retry merge.' }); + } finally { clock.mockRestore(); } +}); From cd787341a1bf701840d04ad7c28effeca14d8edc Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 17:41:38 -0700 Subject: [PATCH 107/163] Say the checks passed after the limit when a late ready inspection times out Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- runner/merge-job.ts | 6 +++++- test/merge-job.test.ts | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 93d283b4..70d75a67 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed before the job read it, the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, counts as the wait timing out too; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 30ae197b..c295261a 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -274,7 +274,11 @@ export class MergeJobCoordinator { const verdict = classify(status); // Only checks seen passing within the limit count. Work at the end of an inspection can cross the limit before // its timer runs, so a ready answer is checked against the clock too; a failing check or other blocker stands. - if (verdict.kind === 'ready' && performance.now() >= until) return timedOut(); + // The checks did pass, so the reason says so rather than naming the last wait, and the record drops its waiting list. + if (verdict.kind === 'ready' && performance.now() >= until) { + this.settle(() => store.updateMergeJob(identity, job.id, { checks: [] })); + return { kind: 'blocked', reason: `Required checks passed only after the ${Math.round(this.checkWaitMs / 60_000)}-minute limit.`, next: 'Retry merge.' }; + } if (verdict.kind !== 'wait') return verdict; waiting = verdict.checks; waited = true; this.settle(() => store.updateMergeJob(identity, job.id, { checks: waiting })); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index fb8c90a2..ba5d66a3 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -950,7 +950,8 @@ it('times out, without merging, when a ready inspection finishes after the wait const done = await settled(coordinator); // The interleaving happened: the ready inspection's signal had not been stopped by the limit when it answered. expect(limitAbortedAtReady).toBe(false); - expect(done).toMatchObject({ state: 'blocked', reason: 'Required checks did not pass within 1 minutes: ci is pending. Wait for the checks to pass, then Retry merge.' }); + // GitHub reported the checks passing, so neither the reason nor the record names the earlier pending check. + expect(done).toMatchObject({ state: 'blocked', reason: 'Required checks passed only after the 1-minute limit. Retry merge.', checks: [] }); expect(f.gh.merged).toEqual([]); expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); } finally { clock.mockRestore(); } From 0ef10402ee43c29bae6d60ec39657d9efc3ecfdd Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 18:29:51 -0700 Subject: [PATCH 108/163] Tighten merge job cancel, readiness and startup settlement tests The cancel test now waits far longer than it allows, so only the job being told can end the wait, and checks no inspection ran after it. The readiness test asserts every inspection read one task version instead of only that one was captured. The startup test no longer claims an ordering that correctness does not need. Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 24 +++++++++++++++--------- test/runner-production.test.ts | 4 +++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index ba5d66a3..657cb059 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -95,11 +95,11 @@ function github(store: Store, options: { checks?: (n: number) => RequiredCheck[] }; return { client, merged, inspections: () => inspections }; } -function setup(options: Parameters[1] & { prepare?: Parameters[1] } = {}) { +function setup(options: Parameters[1] & { prepare?: Parameters[1]; timing?: typeof TIMING } = {}) { const store = publishedTask(), review = service(store), gh = github(store, options); const merges = new MergeCoordinator(review, gh.client, undefined, undefined, PUBLISHED); const prep = preparation(store, options.prepare); - const coordinator = new MergeJobCoordinator(review, prep.fake, merges, undefined, TIMING); jobs.push(coordinator); + const coordinator = new MergeJobCoordinator(review, prep.fake, merges, undefined, options.timing ?? TIMING); jobs.push(coordinator); return { store, review, gh, merges, prep, coordinator }; } const settled = async (coordinator: MergeJobCoordinator) => { await coordinator.current; return coordinator.latest()!; }; @@ -250,24 +250,30 @@ it('ends submitted when GitHub\'s merge outcome is unknown; the attempt stays in }); it('stops waiting at once when the task is cancelled', async () => { - const f = setup({ checks: () => [pending()] }); + // The first wait between inspections is far longer than this test may wait: only the job being told can end it. + const f = setup({ checks: () => [pending()], timing: { checkWaitMs: 600_000, firstDelayMs: 300_000, maxDelayMs: 300_000 } }); await f.coordinator.start('review-token', randomUUID()); - await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + await vi.waitFor(() => expect(f.coordinator.latest()?.checks).toEqual([{ context: 'ci', state: 'pending' }])); + const inspected = f.gh.inspections(); // As the cancel route does: the task's cancel commits first, then the job is told. expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); f.coordinator.cancel(); - expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled' }); + const late = new Promise<'still waiting'>(resolve => setTimeout(() => resolve('still waiting'), 500).unref()); + expect(await Promise.race([settled(f.coordinator), late])).toMatchObject({ state: 'cancelled' }); + expect(f.gh.inspections()).toBe(inspected); expect(f.gh.merged).toEqual([]); }); it('keeps the readiness preparation recorded valid while the job records its progress', async () => { - let readyAt: number | null = null; - const f = setup({ checks: n => { if (n === 1) readyAt = f.store.getTask(identity).stateVersion; return n <= 3 ? [pending()] : [passing()]; } }); + const versions: number[] = []; + const f = setup({ checks: n => { versions.push(f.store.getTask(identity).stateVersion); return n <= 3 ? [pending()] : [passing()]; } }); await f.coordinator.start('review-token', randomUUID()); expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); - // The guarded merge admitted the readiness bound at preparation: the job's own writes never advanced the task. + // Every inspection, from the one after preparation through the guarded merge's own, read the same task version: + // the job's three progress writes between them never advanced the task, so the guarded merge admitted that readiness. + expect(versions.length).toBeGreaterThanOrEqual(4); + expect(new Set(versions).size).toBe(1); expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'merged' }); - expect(readyAt).not.toBeNull(); }); it('replays a resent click, refuses a second click while a job runs, and replays a refusal for a stale token', async () => { diff --git a/test/runner-production.test.ts b/test/runner-production.test.ts index f53602bd..b30ec341 100644 --- a/test/runner-production.test.ts +++ b/test/runner-production.test.ts @@ -122,7 +122,9 @@ describe('runner startup', () => { expect(await assembly.sources.vendor(service.config.identity)).toBe('claude'); expect(() => assembly.sources.planContext({ ...service.config.identity, planId: 'other' })).toThrow(/only its configured plan/); }); - it('fails a merge job an earlier process left running before it assembles anything (F6b)', async () => { + // Where in startup this runs does not matter: the server builds its merge job coordinator, and listens, only after + // setUpRunner resolves (web/server.ts), so no new job can start or be shown before the leftover one has ended. + it('fails a merge job an earlier process left running, during runner startup (F6b)', async () => { const { root, service } = fixture(), calls: string[] = []; const actionId = randomUUID(); service.store.beginMergeJob(service.config.identity, { actionId, token: 'review-token' }); From 084b5df0164ae097c9f0e190e1dae9b07c0333f6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 18:48:33 -0700 Subject: [PATCH 109/163] Put the gh adapter's doc comment back on its class; say when shutdown stops a job's merge Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- github/merge.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 70d75a67..374bed82 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -66,7 +66,7 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | | Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | | Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | -| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk, unless the task was cancelled before the job stopped: then it ends `cancelled`, also when its stopped work (such as one of its own review loads) settles only after shutdown began. A guarded merge in progress stops as a click's does: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | +| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk, unless the task was cancelled before the job stopped: then it ends `cancelled`, also when its stopped work (such as one of its own review loads) settles only after shutdown began. A guarded merge in progress is stopped in that first step too (a direct click's merge gets the drain period instead), and its outcome is recorded as a click's is: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | | Ending the job fails | if the write that ends a job fails, or reading the task after the job failed does, the server logs, also during shutdown (after GitHub was asked to merge), "Could not end merge job …; it shows as running until the server restarts" with the store's message. The job stays `running` on disk: the page keeps following it, and a new click is refused while it runs. The next start settles it as below | | Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed or the review changed while GitHub was asked). A `prepare-merge` action and a push marker are settled as before | diff --git a/github/merge.ts b/github/merge.ts index 3b3d87c9..ed5effb4 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -149,7 +149,6 @@ function timestamp(value: unknown, label: string): string { return value; } -/** GitHub CLI adapter. All arguments are literal argv; no shell is involved. */ /** * The state GitHub gives one observed required check. A check run that completed SUCCESS, NEUTRAL or SKIPPED passes, as * GitHub treats those conclusions for required checks; a commit status passes only with SUCCESS. @@ -177,6 +176,7 @@ function observedCheckStart(check: Record): number { /** Among runs with the same start, the one that passes least counts, so a tie never reads as passing. */ const checkStateRank = { failure: 0, pending: 1, success: 2 } as const; +/** GitHub CLI adapter. All arguments are literal argv; no shell is involved. */ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { readonly config: GhMergeConfig; readonly run: RunGh; From 674b9862234bea0a713e1f6e9045013478fa7612 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 19:12:09 -0700 Subject: [PATCH 110/163] Test that a job stopped before the guarded merge asks GitHub nothing Covers MergeCoordinator.merge's pre-aborted caller signal check: the job's step-3 review load finishes after shutdown aborted its signal, so merge() is entered with an already-aborted signal and must not reach gh. Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 657cb059..f48701ab 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -517,6 +517,30 @@ it('asks GitHub to merge nothing once stopped, even if the checks pass meanwhile expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); }); +it('asks GitHub to merge nothing when shutdown stopped the job before the guarded merge was entered', async () => { + const f = setup(); + // The step-3 review load (the job's step is `merging`) finishes even though shutdown aborted its signal, so the job + // reaches the guarded merge with a signal that is already aborted. + let release!: () => void, loaded!: () => void; + const gate = new Promise(resolve => { release = resolve; }), loading = new Promise(resolve => { loaded = resolve; }); + const load = f.review.load as unknown as ReturnType, view = load.getMockImplementation()!; + load.mockImplementation(async (...args: unknown[]) => { + if (f.coordinator.latest()?.step === 'merging') { loaded(); await gate; } + return view(...args); + }); + const entered: boolean[] = [], merge = f.merges.merge.bind(f.merges); + f.merges.merge = ((token, actionId, caller) => { entered.push(caller?.aborted ?? false); return merge(token, actionId, caller); }) as typeof f.merges.merge; + const started = await f.coordinator.start('review-token', randomUUID()); + await loading; + f.coordinator.stop(); + release(); + await f.coordinator.current; + expect(entered).toEqual([true]); // The guarded merge was entered, with the job's signal already aborted. + expect(f.gh.merged).toEqual([]); + expect(f.store.getMergeAttempt(identity)).toBeNull(); // No attempt was begun. + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running', step: 'merging' }); +}); + it('ends blocked when GitHub refuses the merge while shutdown stops the job', async () => { let coordinatorRef: MergeJobCoordinator | null = null; const f = setup({ merge: async () => { coordinatorRef!.stop(); throw new MergeSubmissionError('Base branch was modified.', 'refused'); } }); From d4405446e0926779f393a238c89f787dfd0edf5b Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 19:13:03 -0700 Subject: [PATCH 111/163] Save the store's running-job refusal so a resend replays it When a job's ending write failed, the coordinator holds no job in memory but Store.beginMergeJob still refuses a new click because the job shows as running. start() now saves that definite refusal under the click's action ID, as it does for its own admission refusals, so a resend replays it instead of re-checking. Also types the step-3 load wrapper in the pre-aborted merge test. Co-Authored-By: Claude Opus 5.5 --- runner/merge-job.ts | 12 ++++++++++-- test/merge-job.test.ts | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/runner/merge-job.ts b/runner/merge-job.ts index c295261a..b8095a2b 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, settleWith, type ShutdownCapability } from './lifecycle.ts'; +import { ActionIdReused, GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, settleWith, type ShutdownCapability } from './lifecycle.ts'; import { MergeBlocked, MergeOutcomeUnknown, PUSH_UNSETTLED, type MergeCoordinator, type MergeStatus } from './merge.ts'; import type { PreMergeCoordinator } from './pre-merge.ts'; import type { ReviewService } from './review.ts'; @@ -92,7 +92,15 @@ export class MergeJobCoordinator { if (typeof token === 'string' && token) store.recordMergeJobRefusal(identity, { actionId, token, reason: refusal }); throw new GuardRefusal(refusal); } - const { job } = store.beginMergeJob(identity, { actionId, token: token as string }); + let job: MergeJob; + try { ({ job } = store.beginMergeJob(identity, { actionId, token: token as string })); } + catch (error) { + // The store's own refusal (a job still running on disk after its ending write failed, or a closed task) is + // definite too: saved and replayed like the refusals above. + if (error instanceof GuardRefusal && !(error instanceof ActionIdReused)) + store.recordMergeJobRefusal(identity, { actionId, token: token as string, reason: error.message }); + throw error; + } const abort = new AbortController(); this.#abort = abort; const run = Promise.resolve().then(() => this.#run(job, abort.signal)).catch(error => { // Anything unexpected ends the job, unless shutdown stopped it (startup then settles it) or the task was cancelled diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index f48701ab..c3195770 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -352,7 +352,7 @@ it.each([false, true])('ends submitted, not merged or queued, when GitHub accept expect(f.store.getTask(identity).status).toBe('in review'); }); -it.each(['finish', 'block'] as const)('reports a job it could not end (%s), which stays running and refuses a new click until startup settles it', async kind => { +it.each(['finish', 'block'] as const)('reports a job it could not end (%s), which stays running and refuses a new click (saved, so a resend replays it) until startup settles it', async kind => { const f = setup(kind === 'block' ? { checks: () => [{ context: 'build', appId: null, state: 'failure' }] } : {}); const fail = vi.fn(() => { throw new Error('disk full'); }); const { blockMergeJob, finishMergeJob } = f.store; @@ -367,11 +367,20 @@ it.each(['finish', 'block'] as const)('reports a job it could not end (%s), whic expect(fail).toHaveBeenCalledTimes(1); expect(logged).toHaveBeenCalledWith(`Could not end merge job ${started.id}; it shows as running until the server restarts: "disk full"`); expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running' }); - await expect(f.coordinator.start('review-token', randomUUID())).rejects.toThrow(/already running/); + // The coordinator has no job in memory; the store's running record refuses the click, and that refusal is saved. + const refused = randomUUID(); + expect(f.coordinator.active).toBe(false); + await expect(f.coordinator.start('review-token', refused)).rejects.toThrow('A merge is already running for this task.'); + expect(f.store.getMergeJob(identity, refused)).toMatchObject({ state: 'failed', refused: true, reason: 'A merge is already running for this task.' }); // The next start settles it (runner/production.ts), once the store writes again. Object.assign(f.store, { blockMergeJob, finishMergeJob }); f.store.settleInterruptedMergeJobs(identity); expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: kind === 'block' ? 'failed' : 'submitted' }); + // A resend of the refused click replays its saved refusal (stale now, for that click only) and starts nothing. + const loads = (f.review.load as unknown as ReturnType).mock.calls.length; + await expect(f.coordinator.start('review-token', refused)).rejects.toThrow('A merge is already running for this task.'); + expect((f.review.load as unknown as ReturnType).mock.calls.length).toBe(loads); + expect(f.coordinator.active).toBe(false); } finally { logged.mockRestore(); } }); @@ -523,7 +532,7 @@ it('asks GitHub to merge nothing when shutdown stopped the job before the guarde // reaches the guarded merge with a signal that is already aborted. let release!: () => void, loaded!: () => void; const gate = new Promise(resolve => { release = resolve; }), loading = new Promise(resolve => { loaded = resolve; }); - const load = f.review.load as unknown as ReturnType, view = load.getMockImplementation()!; + const load = f.review.load as unknown as ReturnType, view = load.getMockImplementation() as (...args: unknown[]) => unknown; load.mockImplementation(async (...args: unknown[]) => { if (f.coordinator.latest()?.step === 'merging') { loaded(); await gate; } return view(...args); From d3cbaa8137af2d132d2d1d93f67dd7f69b32c124 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 22:13:10 -0700 Subject: [PATCH 112/163] Follow a job's queued attempt, and the later job a resent click's answer carries A merge job polled to its end in `queued` now follows the attempt GitHub's queue holds (the poll's `queue`, matched by the job's mergeActionId), so a page that started without a queue sees the merge or removal without Refresh. A resent click whose answer replays its ended job follows the later job the reloaded review carries (by sequence, or revision within one job), rather than showing the replayed outcome while that job runs. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 6 +-- test/browser/review.spec.ts | 77 ++++++++++++++++++++++++++++ web/public/app.js | 33 +++++++++--- 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 374bed82..942b0b94 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -76,17 +76,17 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## API and browser -- `POST /api/action` with `merge` returns at once when the server runs merge jobs (a runner and a pull request published by it, which requires preparation): the reloaded review with the job's latest record as `job`, or, if that reload failed, `{ job, mergeRefreshRequired: true }`. The job may already have moved on or ended by then; the page follows a running job and, for an ended one, asks for a refresh. +- `POST /api/action` with `merge` returns at once when the server runs merge jobs (a runner and a pull request published by it, which requires preparation): the reloaded review with the job's latest record as `job`, or, if that reload failed, `{ job, mergeRefreshRequired: true }`. The job may already have moved on or ended by then; the page follows a running job and, for an ended one, asks for a refresh. A resend of a click whose job has ended replays that job while a later one (another tab's retry) may run: the reloaded review then carries the later job, and the page follows the later of the two records (by `sequence`, or `revision` within one job), so it follows the running job rather than showing the replayed outcome. - With merge jobs, the merge status in the review (`GET /api/review` and the click's answer) carries `job`, the task's latest job, and `settledBeforeJobMerge`: the blocker codes settled once a job asks GitHub to merge. It is built from the server's job-resolved blockers (preparation, base, acceptance, check) plus `mergeable`, and the page uses it rather than a copy of its own. - `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. - A refused merge click (409, new or replayed) carries `job`, the task's latest job, as `GET /api/merge` would answer it. A page that holds an older review, such as a second tab, may click Merge PR while another tab's job runs; it is refused ("A merge is already running for this task."). When the carried job is running and later than the one the page shows, the page follows it as its poll would: the control shows the job's step, and the banner reads the refusal, then the step. The refusal is a definite answer, so the click's key is dropped, as for any other refusal; any other refusal still asks for a refresh. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `revision`, never its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `revision`, never its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. A job that ends `queued` left its merge attempt in GitHub's queue, and the poll that sees it end answers that attempt as `queue` (its `actionId` is the job's `mergeActionId`). The page then follows the attempt as it follows a queue merge, also when its review had no queue: the control reads **Merge queued** while GitHub's queue holds it, and the queue poll continues until GitHub merges or removes it, which the control and banner then read (**Merged** and "GitHub confirmed the reviewed head was merged.", or the removal) without a refresh. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Further regressions cover a cancel, then shutdown, before the job's own review load rejects (the job ends `cancelled`), a queue poll that records `removed` or `failed` after the guarded merge saved `queued` and before the job resumes (the job ends `blocked`), and an inspection that finds the checks passing, or one failing, but finishes after the wait limit before its timer runs (a timeout, or the failing check). Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Further regressions cover a cancel, then shutdown, before the job's own review load rejects (the job ends `cancelled`), a queue poll that records `removed` or `failed` after the guarded merge saved `queued` and before the job resumes (the job ends `blocked`), and an inspection that finds the checks passing, or one failing, but finishes after the wait limit before its timer runs (a timeout, or the failing check). Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a job the poll sees end `queued` is followed through GitHub's queue to merged without a refresh, from a review with no queue, that a resent click whose answer replays its ended job follows the later running job the reloaded review carries, that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. ## Check this document with a reader diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 27c3fb89..b0d66856 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1263,3 +1263,80 @@ test('follows a one-click merge job from preparation through the required-check await page.locator('#review-link').click();await reloaded; expect(reviews).toBeGreaterThan(reloadsBeforeEnd); }); +test('follows the merge queue a polled job ended in, from a review with no queue, to merged without Refresh (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-0000000000e9'; + // Shapes as runner/merge-job.ts (mergeJobView) and runner/merge.ts (#queueStatus) answer them: the job's attempt carries + // the job's merge action ID. + const job=(state:string,step:string|null,revision:number)=>({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId:step==='preparing'?null:mergeActionId,state,step,reason:null,checks:[],url:null,sequence:1,revision,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:state==='merged'?'2026-10-11T00:10:00Z':null,actionId:mergeActionId,retryable:false}); + // The job ends queued; GitHub's queue merges two polls later. + const steps=[{queue:null,job:job('running','merging',2)},{queue:queue('queued'),job:job('queued',null,3)},{queue:queue('queued'),job:job('queued',null,3)},{queue:queue('merged'),job:job('queued',null,3)}]; + const answered:Array<{queue:unknown;job:{state:string}}>=[];let reviews=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing',1); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,queue:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(answered.length,steps.length-1)]!;answered.push(next);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); + await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:true,action:'merge',queue:null,job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const clicked=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The page started from a review with no queue, and the click's answer has none either. + expect((await (await clicked).json()).merge.queue).toBeNull(); + const reviewsAfterClick=reviews; + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:20_000}); + // The interleaving: a poll answered the ended `queued` job with its queue still active, and only a later poll saw the merge. + expect(answered.map(answer=>`${answer.job.state}/${(answer.queue as {state:string}|null)?.state??'none'}`).slice(0,4)).toEqual(['running/none','queued/queued','queued/queued','queued/merged']); + await expect(page.locator('#banner')).toHaveText('GitHub confirmed the reviewed head was merged.'); + await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); + // As after a queue merge: the merge status names the confirmed merge, and the blockers the job settled are gone. + await page.locator('#merge-details').click(); + await expect(page.locator('dialog h2')).toHaveText('Merge status'); + await expect(page.locator('dialog li')).toHaveText(['GitHub confirmed that the reviewed head was merged.']); + await page.keyboard.press('Escape'); + expect(reviews).toBe(reviewsAfterClick); // No Refresh, and no background reload. +}); +test('follows a later running merge job when a resent click replays its own ended job (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const record=(id:string,sequence:number,state:string,step:string|null,reason:string|null=null)=>({id,mergeActionId:null,state,step,reason,checks:step==='checks'?[{context:'ci',state:'pending'}]:[],url:null,sequence,revision:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + // The review shows an earlier job that ended blocked, so it offers Retry merge. + const shown=record('0f6b0000-0000-4000-8000-0000000000d0',1,'blocked',null,'Required check failed: ci.'); + const clicks:string[]=[];let jobB:ReturnType|null=null,polls=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); + // The first answer is lost before the server admitted the click; the server then saves its job A, which ends blocked, + // and another tab's retry starts job B. + if(clicks.length===1){await route.abort('failed');return;} + const jobA=record(body.actionId,2,'blocked',null,'Required check failed: ci.');jobB=record('0f6b0000-0000-4000-8000-0000000000d3',3,'running','checks'); + // As web/server.ts answers a resend: the replayed job A, with the review reloaded meanwhile, whose job is the latest (B). + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:jobB},job:jobA})});}); + await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:jobB})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,job:shown,ready:true,action:'retry'};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>void dialog.accept()); + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Merge blocked.'); + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + const resent=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + const answer=await (await resent).json(); + // The interleaving: the resend carried the first click's key, and its answer replayed that click's ended job A beside + // the later running job B. + expect(clicks).toHaveLength(2);expect(clicks[1]).toBe(clicks[0]); + expect(answer.job).toMatchObject({id:clicks[0],state:'blocked',sequence:2}); + expect(answer.merge.job).toMatchObject({state:'running',sequence:3}); + // The page follows B, not A's outcome. + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#banner')).toHaveText('Waiting for required checks: ci is pending.'); + await expect.poll(()=>polls,{timeout:10_000}).toBeGreaterThan(0); + await expect(page.locator('#merge')).toHaveText('Waiting for checks…'); + await expect(page.locator('#banner')).not.toContainText('Merge blocked'); +}); diff --git a/web/public/app.js b/web/public/app.js index 3198a782..793a6c05 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -270,6 +270,12 @@ async function pollMergeQueue(generation) { const ended = `${job ? mergeJobText(job) : "The merge job ended."} Refresh to see the result.`; data = withEndedMergeJob(data, job, ended); $("banner").textContent = ended; + // A job that ended `queued` left its attempt in GitHub's queue: follow it, as after a click answered with it. + const followed = withEndedJobQueue(data, job, update.queue); + if (followed !== data) { + data = followed; + showMergeQueueStatus(update.queue); + } renderMerge(); return; } @@ -335,6 +341,16 @@ function withMergeQueue(view, queue) { blockers.unshift(...view.merge.blockers.filter(blocker => blocker.code === "queue-active")); return { ...view, merge: { ...view.merge, ready: false, action: null, queue, blockers } }; } +/** + * The view once a job is seen ended `queued`, with the merge attempt `queue` the same poll answered. The job's attempt + * (its action ID is the job's `mergeActionId`) is followed as the queue poll follows one: while GitHub's queue holds it, + * the control keeps the job's outcome and the queue poll continues; once it is merged, removed or failed, the control and + * banner read that, as they do for a queue merge. Any other job or attempt leaves the view unchanged. + */ +function withEndedJobQueue(view, job, queue) { + if (job?.state !== "queued" || !queue?.actionId || queue.actionId !== job.mergeActionId) return view; + return ["submitting", "queued"].includes(queue.state) ? { ...view, merge: { ...view.merge, queue } } : withMergeQueue(view, queue); +} function showMergeQueueStatus(queue) { if (queue?.state === "merged") { $("banner").textContent = "GitHub confirmed the reviewed head was merged."; @@ -686,21 +702,24 @@ $("merge").onclick = async () => { mergeAction = null; if (updated.job) { // A merge job runs in the background (F6b); the page follows it. When the server could not reload the review after - // starting the job, it answers only the job: keep the page's review and follow the job on it. + // starting the job, it answers only the job: keep the page's review and follow the job on it. A resend can replay + // this click's ended job while a later one (another tab's retry) runs: the reloaded review then carries that later + // job, and the page follows the later record (by sequence, or revision within one job). + const job = !updated.mergeRefreshRequired && updated.merge?.job && laterMergeJob(updated.merge.job, updated.job) ? updated.merge.job : updated.job; data = updated.mergeRefreshRequired - ? { ...data, merge: { ...data.merge, ready: false, action: null, job: updated.job } } - : { ...updated, merge: { ...updated.merge, job: updated.job } }; - if (updated.job.state !== "running") { + ? { ...data, merge: { ...data.merge, ready: false, action: null, job } } + : { ...updated, merge: { ...updated.merge, job } }; + if (job.state !== "running") { // The job had already ended when the click was answered: nothing to follow, so say how to see its result, as the // job poll does when a job ends. - const ended = `${mergeJobText(updated.job)} Refresh to see the result.`; - data = withEndedMergeJob(data, updated.job, ended); + const ended = `${mergeJobText(job)} Refresh to see the result.`; + data = withEndedMergeJob(data, job, ended); render(); $("banner").textContent = ended; return; } render(); - $("banner").textContent = mergeJobText(updated.job); + $("banner").textContent = mergeJobText(job); return; } const queued = updated.mergeQueue?.state === "queued" || updated.mergeQueue?.state === "submitting"; From bd77939b15ed31fb9944ed6ffd29a6f9be136f8e Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 23:00:14 -0700 Subject: [PATCH 113/163] Build F6b browser fixtures' job records with every field the server sends The F6b browser tests built merge job records without `sequence` and/or `mergeActionId`, unlike mergeJobView, so the page's laterMergeJob treated cross-job comparisons as "not later". A single mergeJobRecord helper now builds each record with every MergeJobView field (typed against it), and the tests that show several jobs give each its own sequence; a poll follows the clicked job instead of an unrelated one. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 85 ++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index b0d66856..b18307f6 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -9,7 +9,7 @@ import { createDemo } from '../../scripts/demo.ts'; import { choiceKeys } from '../../core/approvals.ts'; import { ReviewService } from '../../runner/review.ts'; import { startServer, type PlanningDeps } from '../../web/server.ts'; -import { SETTLED_BEFORE_JOB_MERGE } from '../../runner/merge-job.ts'; +import { SETTLED_BEFORE_JOB_MERGE, type MergeJobView } from '../../runner/merge-job.ts'; import type { MergeGateway, MergeQueueGateway } from '../../github/merge.ts'; import { fixtureGit } from '../fixtures/git.ts'; let root: string, app: Awaited>; @@ -641,6 +641,14 @@ const looksInactive=(locator:import('@playwright/test').Locator)=>locator.evalua /** The blockers a runner server's review lists while a merge job prepares (runner/merge.ts): preparation has not completed * for the review, and a required check is pending. The job resolves both. */ const jobResolvedBlockers=[{code:'preparation',message:'The merge has not been prepared for the current review; merging prepares it.'},{code:'check',message:'ci is pending.'}]; +/** A merge job record with every field the server sends (runner/merge-job.ts mergeJobView: the store's record without its + * token), `fields` overriding the defaults. A job saves its merge action ID before the guarded merge, so one in that step or + * past it (merged, queued, submitted) carries one by default. */ +const mergeJobRecord=(fields:Partial={}):MergeJobView=>{ + const state=fields.state??'running',step=fields.step===undefined?(state==='running'?'preparing':null):fields.step; + const mergeActionId=step==='merging'||['merged','queued','submitted'].includes(state)?'0f6b0000-0000-4000-8000-00000000ac01':null; + return {id:'0f6b0000-0000-4000-8000-000000000000',mergeActionId,reason:null,checks:[],url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z',...fields,state,step}; +}; /** The merge control and its dialog once the page saw its job end: the outcome, the refresh request, and only `others`. */ async function expectEndedMergeJob(page:import('@playwright/test').Page,label:string,ended:string,others:string[]=[]){ await expect(page.locator('#merge')).toHaveText(label); @@ -656,7 +664,7 @@ test('shows only the outcome once a polled merge job ends, not the blockers it r const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[])=>({id:'0f6b0000-0000-4000-8000-0000000000f1',state,step,reason:null,checks,url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[])=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f1',state,step,checks}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)];let polls=0; // As a runner server answers: Merge PR is offered while only job-resolved blockers remain, and the click's review, // reloaded while the job prepares, still lists them. @@ -685,7 +693,7 @@ test('keeps every blocker when the click is answered with a job that ended witho // is still pending; the job settled neither. const head={code:'head',message:'The pull request head moved after review.'}; const conflict={code:'mergeable',message:'GitHub reports a merge conflict.'}; - const failed={id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason:head.message,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const failed=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason:head.message}); let polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); @@ -709,7 +717,7 @@ test('names a failed merge-job poll as a merge-status failure (F6b)',async({page const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const running={id:'0f6b0000-0000-4000-8000-000000000001',state:'running',step:'checks',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-000000000001',state:'running',step:'checks'}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); @@ -726,7 +734,7 @@ test('ends a running merge job the poll no longer reports, keeping every blocker const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const running={id:'0f6b0000-0000-4000-8000-0000000000f3',state:'running',step:'checks',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f3',state:'running',step:'checks'}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); @@ -755,12 +763,12 @@ test('keeps a draft typed while a merge-job click is in flight (F6b)',async({pag const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); - let release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?'Required check failed: ci.':null}); + let started:MergeJobView|null=null,release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - await held;const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing'); + await held;const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing');started=running; await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); - await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:job('x','running','preparing')})})); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); @@ -778,13 +786,13 @@ test('starts a new action for Retry merge once the job its retained key started const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); - const clicks:string[]=[];let first:string|null=null; + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?'Required check failed: ci.':null}); + const clicks:string[]=[];let first:string|null=null,started:MergeJobView|null=null; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing'); + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing',2);started=running; await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); - await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:job('x','running','preparing')})})); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); // The first click's job was saved and has since ended blocked: the review offers Retry merge. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); body.merge={...body.merge,job:first?job(first,'blocked',null):null,...(first?{ready:true,action:'retry'}:{})};await route.fulfill({response,json:body});}); @@ -804,16 +812,16 @@ test('starts a new action once another job became the latest after a retained ke const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:string,step:string|null)=>({id,state,step,reason:state==='blocked'?'Required check failed: ci.':null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); - const clicks:string[]=[];let first:string|null=null; + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?'Required check failed: ci.':null}); + const clicks:string[]=[];let first:string|null=null,started:MergeJobView|null=null; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing'); + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing',3);started=running; await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); - await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:job('x','running','preparing')})})); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); // The first click's job ended, then another client's job ran and ended blocked: the latest job is not this key's. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,job:first?job('0f6b0000-0000-4000-8000-0000000000b2','blocked',null):null,...(first?{ready:true,action:'retry'}:{})};await route.fulfill({response,json:body});}); + body.merge={...body.merge,job:first?job('0f6b0000-0000-4000-8000-0000000000b2','blocked',null,2):null,...(first?{ready:true,action:'retry'}:{})};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toContainText('Merge blocked.'); @@ -830,7 +838,7 @@ test('resends the same action for Retry merge after a retry failed while an earl const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const blocked={id:'0f6b0000-0000-4000-8000-0000000000a1',state:'blocked',step:null,reason:'Required check failed: ci.',checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000a1',state:'blocked',step:null,reason:'Required check failed: ci.'}); // Every retry answers 503: nothing was applied, so the click is unresolved and its key must be resent. const clicks:string[]=[]; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); @@ -861,8 +869,8 @@ test(`keeps a merge-job outcome a poll saw while an older Refresh response was h const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const running={id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:clock.runningAt}; - const merged={...running,state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:2,updatedAt:'2026-10-11T00:05:00Z'}; + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',updatedAt:clock.runningAt}); + const merged=mergeJobRecord({...running,mergeActionId:'0f6b0000-0000-4000-8000-00000000ac01',state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:2,updatedAt:'2026-10-11T00:05:00Z'}); let ended=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?merged:running})});}); @@ -900,9 +908,9 @@ test('keeps a newer merge job a poll saw while a held Refresh response shows an 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}); // Job A runs in this tab. Another tab's click starts job B after A ends; B's record is later than any of A's (sequence), // while its clock times are earlier, as after a clock step back. - const jobA={id:'0f6b0000-0000-4000-8000-0000000000e1',state:'running',step:'preparing',reason:null,checks:[],url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:10:00Z',updatedAt:'2026-10-11T00:10:00Z'}; - const blockedA={...jobA,state:'blocked',step:null,reason:'Required check failed: ci.',revision:2,updatedAt:'2026-10-11T00:20:00Z'}; - const jobB={...jobA,id:'0f6b0000-0000-4000-8000-0000000000e2',step:'checks',sequence:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const jobA=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',state:'running',step:'preparing',sequence:1,startedAt:'2026-10-11T00:10:00Z',updatedAt:'2026-10-11T00:10:00Z'}); + const blockedA=mergeJobRecord({...jobA,state:'blocked',step:null,reason:'Required check failed: ci.',revision:2,updatedAt:'2026-10-11T00:20:00Z'}); + const jobB=mergeJobRecord({...jobA,id:'0f6b0000-0000-4000-8000-0000000000e2',step:'checks',sequence:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); let started=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started?jobB:jobA})});}); @@ -949,8 +957,8 @@ async function mergeJobOutcomeRace(page:import('@playwright/test').Page,planning const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=await startServer(config,0,undefined,gateway,undefined,undefined,undefined,planning); 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}); - const running={id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; - const merged={...running,state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:2,updatedAt:'2026-10-11T00:05:00Z'}; + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing'}); + const merged=mergeJobRecord({...running,mergeActionId:'0f6b0000-0000-4000-8000-00000000ac01',state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:2,updatedAt:'2026-10-11T00:05:00Z'}); const race={ended:false,polls:0,hold:false,config,release:()=>{},held:Promise.resolve()}; let holding!:()=>void;race.held=new Promise(resolve=>{holding=resolve;}); const released=new Promise(resolve=>{race.release=resolve;}); @@ -1040,7 +1048,7 @@ test('keeps the Merge button pending while its click is in flight, across a rend const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const running={id:'0f6b0000-0000-4000-8000-0000000000d1',state:'running',step:'preparing',reason:null,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000d1',state:'running',step:'preparing'}); let release!:()=>void,arrived!:()=>void;const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{arrived=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); @@ -1094,7 +1102,7 @@ test('follows the running merge job when a click from an older review is refused const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[])=>({id:'0f6b0000-0000-4000-8000-0000000000b1',state,step,reason:null,checks,url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[])=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000b1',state,step,checks}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)];let polls=0,mergeClicks=0; // This tab read its review before another tab's click started a job; the server refuses this click and, as // web/server.ts does, carries the task's running job. @@ -1113,11 +1121,11 @@ test('follows the running merge job when a click from an older review is refused }); // A refused click's job is followed only when it is running and later than the job this page shows (web/public/app.js): // the server attaches the task's latest job to every merge-job refusal (web/server.ts), so an ended or older one comes too. -const shownJob={id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:'Required check failed: ci.',checks:[],url:null,sequence:2,revision:3,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:05Z'}; +const shownJob=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:'Required check failed: ci.',sequence:2,revision:3,updatedAt:'2026-10-11T00:00:05Z'}); for(const refusal of [ - {name:'an ended job',shown:null,button:'Merge PR',job:{id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.',checks:[],url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}}, - {name:'an older running job',shown:shownJob,button:'Retry merge',job:{id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}],url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:09Z'}}, - {name:'a running record no later than the job shown',shown:shownJob,button:'Retry merge',job:{...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}]}}, + {name:'an ended job',shown:null,button:'Merge PR',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.'})}, + {name:'an older running job',shown:shownJob,button:'Retry merge',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'checks',checks:[{context:'ci',state:'pending'}],updatedAt:'2026-10-11T00:00:09Z'})}, + {name:'a running record no later than the job shown',shown:shownJob,button:'Retry merge',job:mergeJobRecord({...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}]})}, ]){ test(`asks for a refresh, not following it, when a refused merge click carries ${refusal.name} (F6b)`,async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); @@ -1148,7 +1156,7 @@ test('states the next step a blocked merge job names, once (F6b)',async({page})= 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}); // The reason runner/merge-job.ts saves when the 30-minute wait ends: nothing failed, so the step is to wait. const reason='Required checks did not pass within 30 minutes: ci is pending. Wait for the checks to pass, then Retry merge.'; - const blocked={id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason,checks:[],url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}; + const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:blocked,mergeRefreshRequired:true})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); @@ -1161,7 +1169,7 @@ test('asks for a refresh when the click is answered with a merge job that has al const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[],reason:string|null=null)=>mergeJobRecord({state,step,checks,reason}); let polls=0; // The job failed before the click was answered (its review load failed as the click's reload did). await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} @@ -1186,7 +1194,7 @@ test('follows a started merge job when the server could not reload the review af const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[],reason:string|null=null)=>mergeJobRecord({state,step,checks,reason}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)]; let polls=0,reviews=0; // The click's job started, but its review reload failed: the server answers only the job and asks for a refresh. @@ -1216,7 +1224,7 @@ test('follows a one-click merge job from preparation through the required-check const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:string,step:string|null,checks:Array<{context:string;state:string}>=[],reason:string|null=null)=>({id:'0f6b0000-0000-4000-8000-000000000000',state,step,reason,checks,url:null,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[],reason:string|null=null)=>mergeJobRecord({state,step,checks,reason}); const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('running','merging'),job('merged',null)]; let polls=0,reviews=0,mergeClicks=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; @@ -1271,7 +1279,7 @@ test('follows the merge queue a polled job ended in, from a review with no queue const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-0000000000e9'; // Shapes as runner/merge-job.ts (mergeJobView) and runner/merge.ts (#queueStatus) answer them: the job's attempt carries // the job's merge action ID. - const job=(state:string,step:string|null,revision:number)=>({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId:step==='preparing'?null:mergeActionId,state,step,reason:null,checks:[],url:null,sequence:1,revision,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],revision:number)=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId:step==='preparing'?null:mergeActionId,state,step,revision}); const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:state==='merged'?'2026-10-11T00:10:00Z':null,actionId:mergeActionId,retryable:false}); // The job ends queued; GitHub's queue merges two polls later. const steps=[{queue:null,job:job('running','merging',2)},{queue:queue('queued'),job:job('queued',null,3)},{queue:queue('queued'),job:job('queued',null,3)},{queue:queue('merged'),job:job('queued',null,3)}]; @@ -1305,7 +1313,7 @@ test('follows a later running merge job when a resent click replays its own ende const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const record=(id:string,sequence:number,state:string,step:string|null,reason:string|null=null)=>({id,mergeActionId:null,state,step,reason,checks:step==='checks'?[{context:'ci',state:'pending'}]:[],url:null,sequence,revision:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const record=(id:string,sequence:number,state:MergeJobView['state'],step:MergeJobView['step'],reason:string|null=null)=>mergeJobRecord({id,sequence,state,step,reason,checks:step==='checks'?[{context:'ci',state:'pending'}]:[],revision:2}); // The review shows an earlier job that ended blocked, so it offers Retry merge. const shown=record('0f6b0000-0000-4000-8000-0000000000d0',1,'blocked',null,'Required check failed: ci.'); const clicks:string[]=[];let jobB:ReturnType|null=null,polls=0; @@ -1340,3 +1348,4 @@ test('follows a later running merge job when a resent click replays its own ende await expect(page.locator('#merge')).toHaveText('Waiting for checks…'); await expect(page.locator('#banner')).not.toContainText('Merge blocked'); }); + From f8996615952d72089c87ca2f6400500eed4337ab Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 23:00:23 -0700 Subject: [PATCH 114/163] Keep the attempt a polled job ended queued in when a held full response arrives A full response (Refresh, a review action, Refresh plan, plan import or Apply) read while the job was merging kept the poll's newer `queued` job but took the response's own queue (none, or another attempt's), so the control went back to "Merge queued" and polling stopped after the poll had already shown Merged. The shared newerMergeObservations helper now also keeps the attempt the poll followed for that job (actionId === job.mergeActionId) and presents it as the poll did, at all five full-response sites. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- test/browser/review.spec.ts | 64 ++++++++++++++++++++++++++++ web/public/app.js | 63 ++++++++++++++++----------- 3 files changed, 103 insertions(+), 26 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 942b0b94..fc248aa5 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -82,7 +82,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - A refused merge click (409, new or replayed) carries `job`, the task's latest job, as `GET /api/merge` would answer it. A page that holds an older review, such as a second tab, may click Merge PR while another tab's job runs; it is refused ("A merge is already running for this task."). When the carried job is running and later than the one the page shows, the page follows it as its poll would: the control shows the job's step, and the banner reads the refusal, then the step. The refusal is a definite answer, so the click's key is dropped, as for any other refusal; any other refusal still asks for a refresh. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `revision`, never its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. A job that ends `queued` left its merge attempt in GitHub's queue, and the poll that sees it end answers that attempt as `queue` (its `actionId` is the job's `mergeActionId`). The page then follows the attempt as it follows a queue merge, also when its review had no queue: the control reads **Merge queued** while GitHub's queue holds it, and the queue poll continues until GitHub merges or removes it, which the control and banner then read (**Merged** and "GitHub confirmed the reviewed head was merged.", or the removal) without a refresh. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `revision`, never its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A kept job that ended `queued` also keeps the merge attempt the poll followed for it (the attempt whose `actionId` is the job's `mergeActionId`), whatever queue the full response holds, and shows it as the poll did: **Merged** or a removal stays, and while GitHub's queue holds it the control reads **Merge queued** and the queue poll continues. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. A job that ends `queued` left its merge attempt in GitHub's queue, and the poll that sees it end answers that attempt as `queue` (its `actionId` is the job's `mergeActionId`). The page then follows the attempt as it follows a queue merge, also when its review had no queue: the control reads **Merge queued** while GitHub's queue holds it, and the queue poll continues until GitHub merges or removes it, which the control and banner then read (**Merged** and "GitHub confirmed the reviewed head was merged.", or the removal) without a refresh. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. ## Tests diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index b18307f6..4aa6dfe2 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1349,3 +1349,67 @@ test('follows a later running merge job when a resent click replays its own ende await expect(page.locator('#banner')).not.toContainText('Merge blocked'); }); +/** A runner server whose every full review shows its merge job running in the guarded merge (`merging`), with no merge + * attempt (each was read before the job asked GitHub). The job poll answers `answer(polls)`, in the shapes + * runner/merge-job.ts (mergeJobView) and runner/merge.ts (#queueStatus) send: the job's attempt carries its merge action ID. + * A full review marked `race.hold` waits until `race.release()`. */ +async function endedJobQueueServer(page:import('@playwright/test').Page,answer:(polls:number,parts:{job:(state:MergeJobView['state'],revision:number)=>MergeJobView;queue:(state:string,actionId?:string)=>{state:string}})=>{queue:{state:string}|null;job:MergeJobView}){ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-0000000000e9'; + const jobRecord=(state:MergeJobView['state'],revision:number)=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId,state,step:state==='running'?'merging':null,revision}); + const queueRecord=(state:string,actionId=mergeActionId)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:state==='removed'?'Required check failed.':null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:state==='merged'||state==='removed'?'2026-10-11T00:10:00Z':null,actionId,retryable:state==='removed'}); + const race={answered:[] as string[],hold:false,release:()=>{},held:Promise.resolve()}; + let holding!:()=>void;race.held=new Promise(resolve=>{holding=resolve;}); + const released=new Promise(resolve=>{race.release=resolve;}); + await page.route('**/api/merge',async route=>{const next=answer(race.answered.length,{job:jobRecord,queue:queueRecord}); + race.answered.push(`${next.job.state}/${next.queue?.state??'none'}`);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:false,action:null,queue:null,job:jobRecord('running',2),blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE}; + if(race.hold){race.hold=false;holding();await released;} + await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.getByRole('button',{name:'Merging…',exact:true})).toBeVisible(); + return race; +} + +for(const variant of [{name:'merged',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.'},{name:'still queued',label:'Merge queued',banner:'Merge queued at position 1. Waiting for GitHub.'}]) +test(`keeps the merge attempt a polled job ended queued in, ${variant.name}, while an older Refresh response was held (F6b)`,async({page})=>{ + let ended=false; + // Before the job ends the poll sees it merging; then it ends `queued` with its attempt queued, and GitHub's queue + // merges it on the next poll (or still holds it). + const race=await endedJobQueueServer(page,(polls,{job,queue})=>!ended?{queue:null,job:job('running',2)} + :{queue:queue(variant.name==='merged'&&race.answered.filter(answer=>answer.startsWith('queued')).length>0?'merged':'queued'),job:job('queued',3)}); + race.hold=true; + const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await race.held; + // Selecting an item while Refresh waits renders the merge control, which schedules a poll; it sees the job end. + ended=true; + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.locator('#merge')).toHaveText(variant.label,{timeout:15_000}); + await expect(page.locator('#banner')).toHaveText(variant.banner); + // The interleaving: before the held Refresh response was released, the poll saw the job end queued and followed its attempt. + const afterEnd=race.answered.filter(answer=>answer!=='running/none'); + if(variant.name==='merged')expect(afterEnd).toEqual(['queued/queued','queued/merged']); + else{expect(afterEnd.length).toBeGreaterThan(0);expect([...new Set(afterEnd)]).toEqual(['queued/queued']);} + const seen=race.answered.length; + race.release(); + const answer=await (await refreshed).json(); + // The held response was read while the job was merging, before it had an attempt. + expect(answer.merge).toMatchObject({queue:null,job:{state:'running',step:'merging'}}); + await page.waitForTimeout(3_000); + if(variant.name==='merged'){ + // Merged survives, and no poll restarts (the poll interval is 2 s). + expect(race.answered.length).toBe(seen); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog li')).toHaveText(['GitHub confirmed that the reviewed head was merged.']); + await page.keyboard.press('Escape'); + }else{ + // The attempt is still followed: the queue poll continues. + expect(race.answered.length).toBeGreaterThan(seen); + } + await expect(page.locator('#merge')).toHaveText(variant.label); + await expect(page.locator('#banner')).toHaveText(variant.banner); +}); diff --git a/web/public/app.js b/web/public/app.js index 793a6c05..b26a93e3 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -149,13 +149,11 @@ async function refresh() { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); - data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); - if (newerJob) data = withMergeJobObservation(data, newerJob); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(withNewerMergeObservations(updated, newer)); snippetSelection = null; render(); - if (newerQueue) showMergeQueueStatus(newerQueue); - if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); + showNewerMergeObservations(newer); setPlanStatus(planStatusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { if (generation !== reviewGeneration) return; @@ -385,12 +383,10 @@ async function act(command) { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); - data = newerQueue ? withMergeQueue(updated, newerQueue) : updated; - if (newerJob) data = withMergeJobObservation(data, newerJob); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = withNewerMergeObservations(updated, newer); render(); - if (newerQueue) showMergeQueueStatus(newerQueue); - if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); + showNewerMergeObservations(newer); return true; } catch (error) { if (generation !== reviewGeneration) return false; @@ -1045,6 +1041,29 @@ function withMergeJobObservation(view, job) { blockers: view.merge.blockers.filter(blocker => blocker.code !== "job-refresh") } }; return withEndedMergeJob(view, job, mergeJobBanner(job)); } +/** + * The poll's observations that a full response read before them must keep: the merge attempt (`queue`), the task's latest + * job (`job`), and, for a kept job that ended `queued`, the attempt the poll followed for it (`jobQueue`, whose action ID + * is the job's `mergeActionId`). The full response's own queue may be none or another attempt's, so `queue` alone would + * drop the attempt the poll saw merged, removed or failed, or still queued and polled. + */ +function newerMergeObservations(updated, observationOwner) { + const queue = newerMergeQueue(updated, observationOwner), job = newerMergeJob(updated, observationOwner), followed = data?.merge?.queue; + const jobQueue = job?.state === "queued" && followed?.actionId && followed.actionId === job.mergeActionId ? followed : null; + return { queue, job, jobQueue }; +} +/** The full response `view` with the newer observations kept, as the poll presented them. */ +function withNewerMergeObservations(view, { queue, job, jobQueue }) { + let kept = queue ? withMergeQueue(view, queue) : view; + if (job) kept = withMergeJobObservation(kept, job); + return jobQueue ? withEndedJobQueue(kept, job, jobQueue) : kept; +} +/** The banner for the newer observations kept, as the poll showed them; call after render. */ +function showNewerMergeObservations({ queue, job, jobQueue }) { + if (queue) showMergeQueueStatus(queue); + if (job) $("banner").textContent = mergeJobBanner(job); + if (jobQueue) showMergeQueueStatus(jobQueue); +} function newerMergeQueue(updated, observationOwner) { const newer = data?.merge?.queue, older = updated.merge?.queue; const fullResponseIsTerminal = ["merged", "removed", "failed"].includes(older?.state); @@ -1467,12 +1486,10 @@ async function importPlan(event) { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); - data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); - if (newerJob) data = withMergeJobObservation(data, newerJob); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(withNewerMergeObservations(updated, newer)); render(); - if (newerQueue) showMergeQueueStatus(newerQueue); - if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); + showNewerMergeObservations(newer); if ($("plan-file").files[0] === file) $("plan-file").value = ""; renderPlanFile(); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); @@ -1655,12 +1672,10 @@ async function applyPlanAuthor(index) { mergePollTimer = null; mergePollState = null; mergePollDelay = 2000; - const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); - data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); - if (newerJob) data = withMergeJobObservation(data, newerJob); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(withNewerMergeObservations(updated, newer)); render(); - if (newerQueue) showMergeQueueStatus(newerQueue); - if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); + showNewerMergeObservations(newer); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { request.observationError = `Revision r${revision} was applied, but the current plan could not reload. ${error.message}`; @@ -1919,12 +1934,10 @@ $("plans-refresh").onclick = async () => { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner), newerJob = newerMergeJob(updated, mergeObservationOwner); - data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); - if (newerJob) data = withMergeJobObservation(data, newerJob); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(withNewerMergeObservations(updated, newer)); render(); - if (newerQueue) showMergeQueueStatus(newerQueue); - if (newerJob) $("banner").textContent = mergeJobBanner(newerJob); + showNewerMergeObservations(newer); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { if (generation !== planOperationGeneration || sharedGeneration !== reviewGeneration) return; From 7fcf94e0dc838097814977580e272152e727b4d8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 23:00:24 -0700 Subject: [PATCH 115/163] Test that a job's queue follow ignores another attempt and shows an ended one at once Covers two claims of withEndedJobQueue: a poll whose attempt is not the job's (another action ID) leaves the job's outcome alone and is not polled, and a poll that sees the job end queued with its attempt already merged or removed shows that outcome at once and stops polling. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 4aa6dfe2..1b7ec743 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1413,3 +1413,34 @@ test(`keeps the merge attempt a polled job ended queued in, ${variant.name}, whi await expect(page.locator('#merge')).toHaveText(variant.label); await expect(page.locator('#banner')).toHaveText(variant.banner); }); + +test('does not follow another attempt the poll answers beside a job that ended queued (F6b)',async({page})=>{ + // The job ended queued, but the poll's attempt is another one (its action ID is not the job's merge action ID). + const race=await endedJobQueueServer(page,(polls,{job,queue})=>polls===0?{queue:null,job:job('running',2)}:{queue:queue('queued','0f6b0000-0000-4000-8000-0000000000ee'),job:job('queued',3)}); + await expect(page.locator('#merge')).toHaveText('Merge queued',{timeout:15_000}); + const ended='Merge queued. Waiting for GitHub to confirm the outcome. Refresh to see the result.'; + await expect(page.locator('#banner')).toHaveText(ended); + expect(race.answered).toEqual(['running/none','queued/queued']); + // The view is the job's outcome alone: the other attempt's queue is not shown or polled. + await page.waitForTimeout(3_000); + expect(race.answered).toHaveLength(2); + await expect(page.locator('#banner')).toHaveText(ended); + await expectEndedMergeJob(page,'Merge queued',ended); +}); + +for(const variant of [{state:'merged',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.',status:'GitHub confirmed that the reviewed head was merged.'}, + {state:'removed',label:'1 blocker',banner:'Removed from merge queue. Required check failed. Refresh to verify retry readiness.',status:'Required check failed. Refresh to verify retry readiness.'}]) +test(`shows at once a job's attempt already ${variant.state} when the poll sees the job end queued (F6b)`,async({page})=>{ + // One poll answers the job ended queued and its attempt already ended. + const race=await endedJobQueueServer(page,(polls,{job,queue})=>polls===0?{queue:null,job:job('running',2)}:{queue:queue(variant.state),job:job('queued',3)}); + await expect(page.locator('#merge')).toHaveText(variant.label,{timeout:15_000}); + expect(race.answered).toEqual(['running/none',`queued/${variant.state}`]); + await expect(page.locator('#banner')).toHaveText(variant.banner); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog h2')).toHaveText('Merge status'); + await expect(page.locator('dialog li')).toHaveText([variant.status]); + await page.keyboard.press('Escape'); + // Polling stops (the poll interval is 2 s). + await page.waitForTimeout(3_000); + expect(race.answered).toHaveLength(2); +}); From 84c4ab1020dab335cbc66c5d60224497de3aa972 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 23:18:18 -0700 Subject: [PATCH 116/163] Give F6b fixture job records increasing revisions along the job's course Every saved change adds one to a job's revision (runner/store.ts), so a later record of one job with another state or step carries a higher one. The mergeJobRecord helper now defaults the revision by the job's course (preparing 1, checks 2, merging 3, ended 4); fixtures that pass an explicit revision, including the one that sends an older record on purpose, keep it. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 1b7ec743..2ea4b700 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -643,11 +643,14 @@ const looksInactive=(locator:import('@playwright/test').Locator)=>locator.evalua const jobResolvedBlockers=[{code:'preparation',message:'The merge has not been prepared for the current review; merging prepares it.'},{code:'check',message:'ci is pending.'}]; /** A merge job record with every field the server sends (runner/merge-job.ts mergeJobView: the store's record without its * token), `fields` overriding the defaults. A job saves its merge action ID before the guarded merge, so one in that step or - * past it (merged, queued, submitted) carries one by default. */ + * past it (merged, queued, submitted) carries one by default. Every saved change adds one to a job's revision + * (runner/store.ts), so by default a record later in the job's course (preparing, checks, merging, then ended) carries a + * higher revision than an earlier one; a test that sends an older record on purpose passes its revision. */ const mergeJobRecord=(fields:Partial={}):MergeJobView=>{ const state=fields.state??'running',step=fields.step===undefined?(state==='running'?'preparing':null):fields.step; const mergeActionId=step==='merging'||['merged','queued','submitted'].includes(state)?'0f6b0000-0000-4000-8000-00000000ac01':null; - return {id:'0f6b0000-0000-4000-8000-000000000000',mergeActionId,reason:null,checks:[],url:null,sequence:1,revision:1,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z',...fields,state,step}; + const revision=state==='running'?({preparing:1,checks:2,merging:3} as Record)[step??'']??1:4; + return {id:'0f6b0000-0000-4000-8000-000000000000',mergeActionId,reason:null,checks:[],url:null,sequence:1,revision,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z',...fields,state,step}; }; /** The merge control and its dialog once the page saw its job end: the outcome, the refresh request, and only `others`. */ async function expectEndedMergeJob(page:import('@playwright/test').Page,label:string,ended:string,others:string[]=[]){ From 835d0a4f430924503ff5ac01f98aaa6c52f1bc8d Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 23:41:36 -0700 Subject: [PATCH 117/163] Show a job's own attempt when the merge click's answer carries it ended A click answered with a job that ended `queued` dropped the attempt the reloaded review carried: another client's poll may already have seen it merged, removed or failed, yet the control read "Merge queued" and, being terminal, nothing polled it until Refresh. The click, the job poll and a newer observation kept over a full response now present an ended job and its own attempt (actionId === job.mergeActionId) through one helper pair, withEndedJobAndAttempt / showEndedJobAndAttempt, so the attempt's status takes precedence in the control and the banner, and an active attempt is polled. Tests cover the click answered with the job's attempt merged, removed and still queued, and a held Plans refresh response beside the existing Refresh cases. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- test/browser/review.spec.ts | 66 +++++++++++++++++++++++++--- web/public/app.js | 65 ++++++++++++++++----------- 3 files changed, 101 insertions(+), 32 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index fc248aa5..2f9d1205 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -82,7 +82,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - A refused merge click (409, new or replayed) carries `job`, the task's latest job, as `GET /api/merge` would answer it. A page that holds an older review, such as a second tab, may click Merge PR while another tab's job runs; it is refused ("A merge is already running for this task."). When the carried job is running and later than the one the page shows, the page follows it as its poll would: the control shows the job's step, and the banner reads the refusal, then the step. The refusal is a definite answer, so the click's key is dropped, as for any other refusal; any other refusal still asks for a refresh. - Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. - While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). -- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `revision`, never its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A kept job that ended `queued` also keeps the merge attempt the poll followed for it (the attempt whose `actionId` is the job's `mergeActionId`), whatever queue the full response holds, and shows it as the poll did: **Merged** or a removal stays, and while GitHub's queue holds it the control reads **Merge queued** and the queue poll continues. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. A job that ends `queued` left its merge attempt in GitHub's queue, and the poll that sees it end answers that attempt as `queue` (its `actionId` is the job's `mergeActionId`). The page then follows the attempt as it follows a queue merge, also when its review had no queue: the control reads **Merge queued** while GitHub's queue holds it, and the queue poll continues until GitHub merges or removes it, which the control and banner then read (**Merged** and "GitHub confirmed the reviewed head was merged.", or the removal) without a refresh. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `revision`, never its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A kept job that ended `queued` also keeps the merge attempt the poll followed for it (the attempt whose `actionId` is the job's `mergeActionId`), whatever queue the full response holds, and shows it as the poll did: **Merged** or a removal stays, and while GitHub's queue holds it the control reads **Merge queued** and the queue poll continues. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. A job that ends `queued` left its merge attempt in GitHub's queue, and the poll that sees it end answers that attempt as `queue` (its `actionId` is the job's `mergeActionId`). The page then follows the attempt as it follows a queue merge, also when its review had no queue: the control reads **Merge queued** while GitHub's queue holds it, and the queue poll continues until GitHub merges or removes it, which the control and banner then read (**Merged** and "GitHub confirmed the reviewed head was merged.", or the removal) without a refresh. The click's answer is read the same way: when it carries a job that ended `queued` and that job's own attempt (another client's poll may have seen the attempt merged, removed or failed before the click's review reload read it), the control and banner read the attempt (**Merged**, or the removal, not **Merge queued**), and an attempt GitHub's queue still holds is polled until it ends. The job poll, the click's answer and a newer record kept over a full response present an ended job and its own attempt through one rule, in which the attempt's status takes precedence over the job's outcome in both the control and the banner. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. ## Tests diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 2ea4b700..8edd41b1 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1378,22 +1378,25 @@ async function endedJobQueueServer(page:import('@playwright/test').Page,answer:( return race; } -for(const variant of [{name:'merged',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.'},{name:'still queued',label:'Merge queued',banner:'Merge queued at position 1. Waiting for GitHub.'}]) -test(`keeps the merge attempt a polled job ended queued in, ${variant.name}, while an older Refresh response was held (F6b)`,async({page})=>{ +for(const variant of [{name:'merged',site:'Refresh',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.'},{name:'still queued',site:'Refresh',label:'Merge queued',banner:'Merge queued at position 1. Waiting for GitHub.'}, + {name:'merged',site:'Plans refresh',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.'}]) +test(`keeps the merge attempt a polled job ended queued in, ${variant.name}, while an older ${variant.site} response was held (F6b)`,async({page})=>{ let ended=false; // Before the job ends the poll sees it merging; then it ends `queued` with its attempt queued, and GitHub's queue // merges it on the next poll (or still holds it). const race=await endedJobQueueServer(page,(polls,{job,queue})=>!ended?{queue:null,job:job('running',2)} :{queue:queue(variant.name==='merged'&&race.answered.filter(answer=>answer.startsWith('queued')).length>0?'merged':'queued'),job:job('queued',3)}); race.hold=true; + if(variant.site==='Plans refresh')await page.getByRole('link',{name:'Plans',exact:true}).click(); const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); - await page.locator('#review-link').click();await race.held; - // Selecting an item while Refresh waits renders the merge control, which schedules a poll; it sees the job end. + if(variant.site==='Plans refresh'){await page.locator('#plans-refresh').click();await race.held;await page.getByRole('link',{name:'Review',exact:true}).click();} + else{await page.locator('#review-link').click();await race.held;} + // Selecting an item while the reload waits renders the merge control, which schedules a poll; it sees the job end. ended=true; await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); await expect(page.locator('#merge')).toHaveText(variant.label,{timeout:15_000}); await expect(page.locator('#banner')).toHaveText(variant.banner); - // The interleaving: before the held Refresh response was released, the poll saw the job end queued and followed its attempt. + // The interleaving: before the held response was released, the poll saw the job end queued and followed its attempt. const afterEnd=race.answered.filter(answer=>answer!=='running/none'); if(variant.name==='merged')expect(afterEnd).toEqual(['queued/queued','queued/merged']); else{expect(afterEnd.length).toBeGreaterThan(0);expect([...new Set(afterEnd)]).toEqual(['queued/queued']);} @@ -1447,3 +1450,56 @@ test(`shows at once a job's attempt already ${variant.state} when the poll sees await page.waitForTimeout(3_000); expect(race.answered).toHaveLength(2); }); + +for(const variant of [{state:'merged',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.',status:'GitHub confirmed that the reviewed head was merged.'}, + {state:'removed',label:'1 blocker',banner:'Removed from merge queue. Required check failed. Refresh to verify retry readiness.',status:'Required check failed. Refresh to verify retry readiness.'}, + {state:'queued',label:'Merge queued',banner:'Merge queued at position 1. Waiting for GitHub.',status:'Merge queued. Waiting for GitHub to confirm the outcome. Refresh to see the result.'}]) +test(`shows the job's own attempt, ${variant.state}, when the click is answered with a job that ended queued (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-0000000000e9'; + // Shapes as runner/merge-job.ts (mergeJobView) and runner/merge.ts (#queueStatus, #attemptBlocker) send them. + const queued=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId,state:'queued',step:null}); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:state==='removed'?'Required check failed.':null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:state==='merged'||state==='removed'?'2026-10-11T00:10:00Z':null,actionId:mergeActionId,retryable:state==='removed'}); + const attemptBlocker={merged:[{code:'queue-merged',message:'GitHub confirmed that the reviewed head was merged.'}],queued:[{code:'queue-active',message:'The reviewed head is queued. Waiting for GitHub to confirm the outcome.'}]}[variant.state]??[]; + const polls:string[]=[]; + // Another client's poll saw the job end queued, and its attempt reach `variant.state`, before this click's review reload + // read them: the click's answer carries both. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,queue:queue(variant.state),job:queued,blockers:[...attemptBlocker,...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:queued})});}); + // A later poll sees GitHub merge an attempt that was still queued. + await page.route('**/api/merge',async route=>{const next={queue:queue(polls.length===0&&variant.state==='queued'?'queued':'merged'),job:queued};polls.push(`${next.job.state}/${next.queue.state}`); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:true,action:'merge',queue:null,job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const clicked=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The interleaving: the click's answer already holds the ended job and its attempt in `variant.state`; this page had polled nothing. + const answer=await (await clicked).json(); + expect(answer.job).toMatchObject({state:'queued',mergeActionId}); + expect(answer.merge.queue).toMatchObject({state:variant.state,actionId:mergeActionId}); + expect(polls).toEqual([]); + await expect(page.locator('#merge')).toHaveText(variant.label); + await expect(page.locator('#banner')).toHaveText(variant.banner); + await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog h2')).toHaveText('Merge status'); + await expect(page.locator('dialog li')).toHaveText([variant.status]); + await page.keyboard.press('Escape'); + if(variant.state==='queued'){ + // The attempt is still in GitHub's queue: the queue poll follows it until GitHub merges it. + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:15_000}); + expect(polls.slice(0,2)).toEqual(['queued/queued','queued/merged']); + await expect(page.locator('#banner')).toHaveText('GitHub confirmed the reviewed head was merged.'); + }else{ + // An ended attempt is not polled (the poll interval is 2 s), so its outcome stays without a Refresh. + await page.waitForTimeout(3_000); + expect(polls).toEqual([]); + await expect(page.locator('#merge')).toHaveText(variant.label); + await expect(page.locator('#banner')).toHaveText(variant.banner); + } +}); diff --git a/web/public/app.js b/web/public/app.js index b26a93e3..0bd783aa 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -265,15 +265,9 @@ async function pollMergeQueue(generation) { renderMerge(); return; } - const ended = `${job ? mergeJobText(job) : "The merge job ended."} Refresh to see the result.`; - data = withEndedMergeJob(data, job, ended); - $("banner").textContent = ended; // A job that ended `queued` left its attempt in GitHub's queue: follow it, as after a click answered with it. - const followed = withEndedJobQueue(data, job, update.queue); - if (followed !== data) { - data = followed; - showMergeQueueStatus(update.queue); - } + data = withEndedJobAndAttempt(data, job, update.queue); + showEndedJobAndAttempt(job, update.queue); renderMerge(); return; } @@ -346,9 +340,30 @@ function withMergeQueue(view, queue) { * banner read that, as they do for a queue merge. Any other job or attempt leaves the view unchanged. */ function withEndedJobQueue(view, job, queue) { - if (job?.state !== "queued" || !queue?.actionId || queue.actionId !== job.mergeActionId) return view; + if (!isEndedJobQueue(job, queue)) return view; return ["submitting", "queued"].includes(queue.state) ? { ...view, merge: { ...view.merge, queue } } : withMergeQueue(view, queue); } +/** Whether `queue` is the merge attempt a job that ended `queued` left in GitHub's queue (its action ID is the job's `mergeActionId`). */ +function isEndedJobQueue(job, queue) { + return job?.state === "queued" && Boolean(queue?.actionId) && queue.actionId === job.mergeActionId; +} +/** The banner text for a job the page saw end (`job` null: the poll no longer reports it). */ +function endedMergeJobText(job) { + return `${job ? mergeJobText(job) : "The merge job ended."} Refresh to see the result.`; +} +/** + * The view once the page learns its job ended, with the job's own attempt when `queue` is it. The job poll, the click's + * answer and a newer observation kept over a full response all present an ended job through this and + * showEndedJobAndAttempt, so an attempt GitHub already merged, removed or failed keeps its outcome and an active one is polled. + */ +function withEndedJobAndAttempt(view, job, queue) { + return withEndedJobQueue(withEndedMergeJob(view, job, endedMergeJobText(job)), job, queue); +} +/** The banner for withEndedJobAndAttempt: the job's outcome, then its own attempt's status, which takes precedence. */ +function showEndedJobAndAttempt(job, queue) { + $("banner").textContent = endedMergeJobText(job); + if (isEndedJobQueue(job, queue)) showMergeQueueStatus(queue); +} function showMergeQueueStatus(queue) { if (queue?.state === "merged") { $("banner").textContent = "GitHub confirmed the reviewed head was merged."; @@ -706,12 +721,14 @@ $("merge").onclick = async () => { ? { ...data, merge: { ...data.merge, ready: false, action: null, job } } : { ...updated, merge: { ...updated.merge, job } }; if (job.state !== "running") { - // The job had already ended when the click was answered: nothing to follow, so say how to see its result, as the - // job poll does when a job ends. - const ended = `${mergeJobText(job)} Refresh to see the result.`; - data = withEndedMergeJob(data, job, ended); + // The job had already ended when the click was answered: say how to see its result, as the job poll does when a + // job ends. A job that ended `queued` is shown with its own attempt, as the poll shows it: another client's poll + // may have seen that attempt merged, removed or failed before this click's review reload read it, or it is still + // queued and the queue poll follows it. + const queue = data.merge.queue; + data = withEndedJobAndAttempt(data, job, queue); render(); - $("banner").textContent = ended; + showEndedJobAndAttempt(job, queue); return; } render(); @@ -1029,17 +1046,14 @@ function laterMergeJob(newer, older) { return newer.id === older.id ? Number.isSafeInteger(newer.revision) && Number.isSafeInteger(older.revision) && newer.revision > older.revision : Number.isSafeInteger(newer.sequence) && Number.isSafeInteger(older.sequence) && newer.sequence > older.sequence; } -function mergeJobBanner(job) { - return job.state === "running" ? mergeJobText(job) : `${mergeJobText(job)} Refresh to see the result.`; -} /** * The view with a newer observation of its job, as the job poll shows it: a running job is not ready to merge (the - * full response may offer Retry for an earlier job), and an ended job asks for a refresh. + * full response may offer Retry for an earlier job), and an ended job asks for a refresh, with its own attempt `queue`. */ -function withMergeJobObservation(view, job) { +function withMergeJobObservation(view, job, queue = null) { if (job.state === "running") return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: view.merge.blockers.filter(blocker => blocker.code !== "job-refresh") } }; - return withEndedMergeJob(view, job, mergeJobBanner(job)); + return withEndedJobAndAttempt(view, job, queue); } /** * The poll's observations that a full response read before them must keep: the merge attempt (`queue`), the task's latest @@ -1049,20 +1063,19 @@ function withMergeJobObservation(view, job) { */ function newerMergeObservations(updated, observationOwner) { const queue = newerMergeQueue(updated, observationOwner), job = newerMergeJob(updated, observationOwner), followed = data?.merge?.queue; - const jobQueue = job?.state === "queued" && followed?.actionId && followed.actionId === job.mergeActionId ? followed : null; + const jobQueue = isEndedJobQueue(job, followed) ? followed : null; return { queue, job, jobQueue }; } /** The full response `view` with the newer observations kept, as the poll presented them. */ function withNewerMergeObservations(view, { queue, job, jobQueue }) { - let kept = queue ? withMergeQueue(view, queue) : view; - if (job) kept = withMergeJobObservation(kept, job); - return jobQueue ? withEndedJobQueue(kept, job, jobQueue) : kept; + const kept = queue ? withMergeQueue(view, queue) : view; + return job ? withMergeJobObservation(kept, job, jobQueue) : kept; } /** The banner for the newer observations kept, as the poll showed them; call after render. */ function showNewerMergeObservations({ queue, job, jobQueue }) { if (queue) showMergeQueueStatus(queue); - if (job) $("banner").textContent = mergeJobBanner(job); - if (jobQueue) showMergeQueueStatus(jobQueue); + if (job?.state === "running") $("banner").textContent = mergeJobText(job); + else if (job) showEndedJobAndAttempt(job, jobQueue); } function newerMergeQueue(updated, observationOwner) { const newer = data?.merge?.queue, older = updated.merge?.queue; From d490184b0d93f53c37cd7102efae93b6dc36108c Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 00:18:36 -0700 Subject: [PATCH 118/163] Give merge-job test records production revision sequences The mergeJobRecord helper now follows runner/store.ts #changeMergeJob and runner/merge-job.ts: preparing 1, checks 2 with no waiting checks or 3 after the first wait, merging 4, ended 5. Explicit fixtures that used impossible revisions (merging or blocked at 2, merged at 2, a waiting checks record at 2) now carry possible ones, and every later record of one job has a higher revision; the "no later than" refusal case sends an older wait (3) than the shown blocked record (4). Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 8edd41b1..30875357 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -644,12 +644,14 @@ const jobResolvedBlockers=[{code:'preparation',message:'The merge has not been p /** A merge job record with every field the server sends (runner/merge-job.ts mergeJobView: the store's record without its * token), `fields` overriding the defaults. A job saves its merge action ID before the guarded merge, so one in that step or * past it (merged, queued, submitted) carries one by default. Every saved change adds one to a job's revision - * (runner/store.ts), so by default a record later in the job's course (preparing, checks, merging, then ended) carries a - * higher revision than an earlier one; a test that sends an older record on purpose passes its revision. */ + * (runner/store.ts #changeMergeJob): the job is saved at 1 while it prepares, moves to checks at 2 with no waiting checks, + * saves each inspection that finds checks waiting (runner/merge-job.ts), then moves to merging and ends with one more + * save each. By default a record follows the course preparing 1, checks 2 (none waiting) or 3 (first wait), merging 4, + * ended 5; a test that sends a further inspection, a shorter course, or an older record on purpose passes its revision. */ const mergeJobRecord=(fields:Partial={}):MergeJobView=>{ const state=fields.state??'running',step=fields.step===undefined?(state==='running'?'preparing':null):fields.step; const mergeActionId=step==='merging'||['merged','queued','submitted'].includes(state)?'0f6b0000-0000-4000-8000-00000000ac01':null; - const revision=state==='running'?({preparing:1,checks:2,merging:3} as Record)[step??'']??1:4; + const revision=state==='running'?(step==='checks'?((fields.checks??[]).length?3:2):({preparing:1,merging:4} as Record)[step??'']??1):5; return {id:'0f6b0000-0000-4000-8000-000000000000',mergeActionId,reason:null,checks:[],url:null,sequence:1,revision,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z',...fields,state,step}; }; /** The merge control and its dialog once the page saw its job end: the outcome, the refresh request, and only `others`. */ @@ -873,7 +875,7 @@ test(`keeps a merge-job outcome a poll saw while an older Refresh response was h app=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}); const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',updatedAt:clock.runningAt}); - const merged=mergeJobRecord({...running,mergeActionId:'0f6b0000-0000-4000-8000-00000000ac01',state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:2,updatedAt:'2026-10-11T00:05:00Z'}); + const merged=mergeJobRecord({...running,mergeActionId:'0f6b0000-0000-4000-8000-00000000ac01',state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:5,updatedAt:'2026-10-11T00:05:00Z'}); let ended=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?merged:running})});}); @@ -912,8 +914,8 @@ test('keeps a newer merge job a poll saw while a held Refresh response shows an // Job A runs in this tab. Another tab's click starts job B after A ends; B's record is later than any of A's (sequence), // while its clock times are earlier, as after a clock step back. const jobA=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',state:'running',step:'preparing',sequence:1,startedAt:'2026-10-11T00:10:00Z',updatedAt:'2026-10-11T00:10:00Z'}); - const blockedA=mergeJobRecord({...jobA,state:'blocked',step:null,reason:'Required check failed: ci.',revision:2,updatedAt:'2026-10-11T00:20:00Z'}); - const jobB=mergeJobRecord({...jobA,id:'0f6b0000-0000-4000-8000-0000000000e2',step:'checks',sequence:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + const blockedA=mergeJobRecord({...jobA,state:'blocked',step:null,reason:'Required check failed: ci.',revision:3,updatedAt:'2026-10-11T00:20:00Z'}); + const jobB=mergeJobRecord({...jobA,id:'0f6b0000-0000-4000-8000-0000000000e2',step:'checks',sequence:2,revision:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); let started=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started?jobB:jobA})});}); @@ -961,7 +963,7 @@ async function mergeJobOutcomeRace(page:import('@playwright/test').Page,planning app=await startServer(config,0,undefined,gateway,undefined,undefined,undefined,planning); 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}); const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing'}); - const merged=mergeJobRecord({...running,mergeActionId:'0f6b0000-0000-4000-8000-00000000ac01',state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:2,updatedAt:'2026-10-11T00:05:00Z'}); + const merged=mergeJobRecord({...running,mergeActionId:'0f6b0000-0000-4000-8000-00000000ac01',state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:5,updatedAt:'2026-10-11T00:05:00Z'}); const race={ended:false,polls:0,hold:false,config,release:()=>{},held:Promise.resolve()}; let holding!:()=>void;race.held=new Promise(resolve=>{holding=resolve;}); const released=new Promise(resolve=>{race.release=resolve;}); @@ -1124,11 +1126,11 @@ test('follows the running merge job when a click from an older review is refused }); // A refused click's job is followed only when it is running and later than the job this page shows (web/public/app.js): // the server attaches the task's latest job to every merge-job refusal (web/server.ts), so an ended or older one comes too. -const shownJob=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:'Required check failed: ci.',sequence:2,revision:3,updatedAt:'2026-10-11T00:00:05Z'}); +const shownJob=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:'Required check failed: ci.',sequence:2,revision:4,updatedAt:'2026-10-11T00:00:05Z'}); for(const refusal of [ {name:'an ended job',shown:null,button:'Merge PR',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.'})}, {name:'an older running job',shown:shownJob,button:'Retry merge',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'checks',checks:[{context:'ci',state:'pending'}],updatedAt:'2026-10-11T00:00:09Z'})}, - {name:'a running record no later than the job shown',shown:shownJob,button:'Retry merge',job:mergeJobRecord({...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}]})}, + {name:'a running record no later than the job shown',shown:shownJob,button:'Retry merge',job:mergeJobRecord({...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}],revision:3})}, ]){ test(`asks for a refresh, not following it, when a refused merge click carries ${refusal.name} (F6b)`,async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); @@ -1285,7 +1287,7 @@ test('follows the merge queue a polled job ended in, from a review with no queue const job=(state:MergeJobView['state'],step:MergeJobView['step'],revision:number)=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId:step==='preparing'?null:mergeActionId,state,step,revision}); const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:state==='merged'?'2026-10-11T00:10:00Z':null,actionId:mergeActionId,retryable:false}); // The job ends queued; GitHub's queue merges two polls later. - const steps=[{queue:null,job:job('running','merging',2)},{queue:queue('queued'),job:job('queued',null,3)},{queue:queue('queued'),job:job('queued',null,3)},{queue:queue('merged'),job:job('queued',null,3)}]; + const steps=[{queue:null,job:job('running','merging',4)},{queue:queue('queued'),job:job('queued',null,5)},{queue:queue('queued'),job:job('queued',null,5)},{queue:queue('merged'),job:job('queued',null,5)}]; const answered:Array<{queue:unknown;job:{state:string}}>=[];let reviews=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing',1); @@ -1316,7 +1318,7 @@ test('follows a later running merge job when a resent click replays its own ende const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const record=(id:string,sequence:number,state:MergeJobView['state'],step:MergeJobView['step'],reason:string|null=null)=>mergeJobRecord({id,sequence,state,step,reason,checks:step==='checks'?[{context:'ci',state:'pending'}]:[],revision:2}); + const record=(id:string,sequence:number,state:MergeJobView['state'],step:MergeJobView['step'],reason:string|null=null)=>mergeJobRecord({id,sequence,state,step,reason,checks:step==='checks'?[{context:'ci',state:'pending'}]:[]}); // The review shows an earlier job that ended blocked, so it offers Retry merge. const shown=record('0f6b0000-0000-4000-8000-0000000000d0',1,'blocked',null,'Required check failed: ci.'); const clicks:string[]=[];let jobB:ReturnType|null=null,polls=0; @@ -1384,8 +1386,8 @@ test(`keeps the merge attempt a polled job ended queued in, ${variant.name}, whi let ended=false; // Before the job ends the poll sees it merging; then it ends `queued` with its attempt queued, and GitHub's queue // merges it on the next poll (or still holds it). - const race=await endedJobQueueServer(page,(polls,{job,queue})=>!ended?{queue:null,job:job('running',2)} - :{queue:queue(variant.name==='merged'&&race.answered.filter(answer=>answer.startsWith('queued')).length>0?'merged':'queued'),job:job('queued',3)}); + const race=await endedJobQueueServer(page,(polls,{job,queue})=>!ended?{queue:null,job:job('running',4)} + :{queue:queue(variant.name==='merged'&&race.answered.filter(answer=>answer.startsWith('queued')).length>0?'merged':'queued'),job:job('queued',5)}); race.hold=true; if(variant.site==='Plans refresh')await page.getByRole('link',{name:'Plans',exact:true}).click(); const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); @@ -1422,7 +1424,7 @@ test(`keeps the merge attempt a polled job ended queued in, ${variant.name}, whi test('does not follow another attempt the poll answers beside a job that ended queued (F6b)',async({page})=>{ // The job ended queued, but the poll's attempt is another one (its action ID is not the job's merge action ID). - const race=await endedJobQueueServer(page,(polls,{job,queue})=>polls===0?{queue:null,job:job('running',2)}:{queue:queue('queued','0f6b0000-0000-4000-8000-0000000000ee'),job:job('queued',3)}); + const race=await endedJobQueueServer(page,(polls,{job,queue})=>polls===0?{queue:null,job:job('running',4)}:{queue:queue('queued','0f6b0000-0000-4000-8000-0000000000ee'),job:job('queued',5)}); await expect(page.locator('#merge')).toHaveText('Merge queued',{timeout:15_000}); const ended='Merge queued. Waiting for GitHub to confirm the outcome. Refresh to see the result.'; await expect(page.locator('#banner')).toHaveText(ended); @@ -1438,7 +1440,7 @@ for(const variant of [{state:'merged',label:'Merged',banner:'GitHub confirmed th {state:'removed',label:'1 blocker',banner:'Removed from merge queue. Required check failed. Refresh to verify retry readiness.',status:'Required check failed. Refresh to verify retry readiness.'}]) test(`shows at once a job's attempt already ${variant.state} when the poll sees the job end queued (F6b)`,async({page})=>{ // One poll answers the job ended queued and its attempt already ended. - const race=await endedJobQueueServer(page,(polls,{job,queue})=>polls===0?{queue:null,job:job('running',2)}:{queue:queue(variant.state),job:job('queued',3)}); + const race=await endedJobQueueServer(page,(polls,{job,queue})=>polls===0?{queue:null,job:job('running',4)}:{queue:queue(variant.state),job:job('queued',5)}); await expect(page.locator('#merge')).toHaveText(variant.label,{timeout:15_000}); expect(race.answered).toEqual(['running/none',`queued/${variant.state}`]); await expect(page.locator('#banner')).toHaveText(variant.banner); From c4414d3e0d49ade1020e293b975945f65e134efd Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 00:48:10 -0700 Subject: [PATCH 119/163] Serve the queue-handoff tests' review a merging record at the revision the server saves it Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 30875357..ea6aee8f 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1372,7 +1372,7 @@ async function endedJobQueueServer(page:import('@playwright/test').Page,answer:( await page.route('**/api/merge',async route=>{const next=answer(race.answered.length,{job:jobRecord,queue:queueRecord}); race.answered.push(`${next.job.state}/${next.queue?.state??'none'}`);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:false,action:null,queue:null,job:jobRecord('running',2),blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE}; + body.merge={...body.merge,ready:false,action:null,queue:null,job:jobRecord('running',4),blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE}; if(race.hold){race.hold=false;holding();await released;} await route.fulfill({response,json:body});}); await page.goto(app.url); From 51a9b3c495d929f381df4032587408eacf3c649a Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 01:14:23 -0700 Subject: [PATCH 120/163] Give the 30-minute-timeout fixture the revision its waits would reach Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index ea6aee8f..ff79f571 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1161,7 +1161,9 @@ test('states the next step a blocked merge job names, once (F6b)',async({page})= 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}); // The reason runner/merge-job.ts saves when the 30-minute wait ends: nothing failed, so the step is to wait. const reason='Required checks did not pass within 30 minutes: ci is pending. Wait for the checks to pass, then Retry merge.'; - const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason}); + // Every inspection that finds checks waiting saves the job again; 30 minutes of waits (10 s doubling to 60 s) is about 33 + // inspections after preparing (1) and the checks step (2), so the ended record is about revision 36. + const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason,revision:36}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:blocked,mergeRefreshRequired:true})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); From fea2785cc20e8336b599a19cf5ec5778fc4d8928 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 01:34:55 -0700 Subject: [PATCH 121/163] Test the two merge-job timeout wordings that name no waiting check Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index c3195770..1c632f8a 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -724,6 +724,39 @@ it('starts no inspection after the wait limit and stops one that runs into it', expect(f.gh.merged).toEqual([]); }); +it('ends blocked, waiting for GitHub, when mergeability is still being computed at the wait limit', async () => { + // No required checks wait: only GitHub's mergeability is unknown at every inspection. + const f = setup({ change: () => ({ mergeable: 'UNKNOWN' }), timing: { checkWaitMs: 300, firstDelayMs: 5, maxDelayMs: 5 } }); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + expect(f.gh.inspections()).toBeGreaterThan(1); + // A 300 ms limit rounds to 0 minutes in the reason. + expect(done).toMatchObject({ state: 'blocked', checks: [], + reason: 'Required checks did not pass within 0 minutes: GitHub has not determined mergeability. Wait for GitHub to determine mergeability, then Retry merge.' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + expect(f.gh.merged).toEqual([]); +}); + +it('ends blocked, waiting for the checks, when no inspection answers before the wait limit', async () => { + let inspections = 0; + const f = setup({ checks: () => [pending()] }); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + inspections++; + // GitHub never answers; only the wait's limit ends the read. + await new Promise((_resolve, reject) => request!.signal!.addEventListener('abort', () => reject(request!.signal!.reason), { once: true })); + return inspect(request); + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 300, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + await coordinator.start('review-token', randomUUID()); + const done = await settled(coordinator); + expect(inspections).toBe(1); + expect(done).toMatchObject({ state: 'blocked', checks: [], + reason: 'Required checks did not pass within 0 minutes: no inspection of them finished in time. Wait for the checks to pass, then Retry merge.' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + expect(f.gh.merged).toEqual([]); +}); + it('keeps a GitHub failure that came before the wait limit, even when the inspection settles after it', async () => { let inspections = 0, abortedFirst: unknown = null; const f = setup({ checks: () => [pending()] }); From 42d4f67398f03aefa88944dfcff2a35826c086af Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 01:44:39 -0700 Subject: [PATCH 122/163] Give the 30-minute-timeout fixture the highest revision its waits can reach Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index ff79f571..79de0d81 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1161,9 +1161,10 @@ test('states the next step a blocked merge job names, once (F6b)',async({page})= 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}); // The reason runner/merge-job.ts saves when the 30-minute wait ends: nothing failed, so the step is to wait. const reason='Required checks did not pass within 30 minutes: ci is pending. Wait for the checks to pass, then Retry merge.'; - // Every inspection that finds checks waiting saves the job again; 30 minutes of waits (10 s doubling to 60 s) is about 33 - // inspections after preparing (1) and the checks step (2), so the ended record is about revision 36. - const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason,revision:36}); + // The highest revision such a record can reach: preparing saves 1 and the checks step 2. Each inspection that finds checks + // waiting saves once; with the 30-minute limit and waits of 10 s doubling to 60 s, inspections start at most at 0, 10, 30 + // and 70 s, then every 60 s from 130 to 1750 s: 32 inspections, revisions 3 to 34. The timeout's block saves 35. + const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason,revision:35}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:blocked,mergeRefreshRequired:true})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); From ca91302954b186afbd957e634f8b83ff7b615342 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 01:44:39 -0700 Subject: [PATCH 123/163] Give blocked merge-job fixtures the reason production saves, with its next step Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 79de0d81..e64eb41b 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -654,6 +654,9 @@ const mergeJobRecord=(fields:Partial={}):MergeJobView=>{ const revision=state==='running'?(step==='checks'?((fields.checks??[]).length?3:2):({preparing:1,merging:4} as Record)[step??'']??1):5; return {id:'0f6b0000-0000-4000-8000-000000000000',mergeActionId,reason:null,checks:[],url:null,sequence:1,revision,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z',...fields,state,step}; }; +/** A blocked job's reason as production saves it: one failing required check (runner/merge-job.ts classify), then the + * next step that blocking appends (FIX_THEN_RETRY in runner/store.ts). */ +const CHECK_FAILED_BLOCK='Required check failed: ci. Fix it, then Retry merge.'; /** The merge control and its dialog once the page saw its job end: the outcome, the refresh request, and only `others`. */ async function expectEndedMergeJob(page:import('@playwright/test').Page,label:string,ended:string,others:string[]=[]){ await expect(page.locator('#merge')).toHaveText(label); @@ -768,7 +771,7 @@ test('keeps a draft typed while a merge-job click is in flight (F6b)',async({pag const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?'Required check failed: ci.':null}); + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?CHECK_FAILED_BLOCK:null}); let started:MergeJobView|null=null,release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await held;const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing');started=running; @@ -791,7 +794,7 @@ test('starts a new action for Retry merge once the job its retained key started const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?'Required check failed: ci.':null}); + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?CHECK_FAILED_BLOCK:null}); const clicks:string[]=[];let first:string|null=null,started:MergeJobView|null=null; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} @@ -817,7 +820,7 @@ test('starts a new action once another job became the latest after a retained ke const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?'Required check failed: ci.':null}); + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?CHECK_FAILED_BLOCK:null}); const clicks:string[]=[];let first:string|null=null,started:MergeJobView|null=null; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} @@ -843,7 +846,7 @@ test('resends the same action for Retry merge after a retry failed while an earl const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000a1',state:'blocked',step:null,reason:'Required check failed: ci.'}); + const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000a1',state:'blocked',step:null,reason:CHECK_FAILED_BLOCK}); // Every retry answers 503: nothing was applied, so the click is unresolved and its key must be resent. const clicks:string[]=[]; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); @@ -914,7 +917,7 @@ test('keeps a newer merge job a poll saw while a held Refresh response shows an // Job A runs in this tab. Another tab's click starts job B after A ends; B's record is later than any of A's (sequence), // while its clock times are earlier, as after a clock step back. const jobA=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',state:'running',step:'preparing',sequence:1,startedAt:'2026-10-11T00:10:00Z',updatedAt:'2026-10-11T00:10:00Z'}); - const blockedA=mergeJobRecord({...jobA,state:'blocked',step:null,reason:'Required check failed: ci.',revision:3,updatedAt:'2026-10-11T00:20:00Z'}); + const blockedA=mergeJobRecord({...jobA,state:'blocked',step:null,reason:CHECK_FAILED_BLOCK,revision:3,updatedAt:'2026-10-11T00:20:00Z'}); const jobB=mergeJobRecord({...jobA,id:'0f6b0000-0000-4000-8000-0000000000e2',step:'checks',sequence:2,revision:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); let started=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); @@ -1126,7 +1129,7 @@ test('follows the running merge job when a click from an older review is refused }); // A refused click's job is followed only when it is running and later than the job this page shows (web/public/app.js): // the server attaches the task's latest job to every merge-job refusal (web/server.ts), so an ended or older one comes too. -const shownJob=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:'Required check failed: ci.',sequence:2,revision:4,updatedAt:'2026-10-11T00:00:05Z'}); +const shownJob=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:CHECK_FAILED_BLOCK,sequence:2,revision:4,updatedAt:'2026-10-11T00:00:05Z'}); for(const refusal of [ {name:'an ended job',shown:null,button:'Merge PR',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.'})}, {name:'an older running job',shown:shownJob,button:'Retry merge',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'checks',checks:[{context:'ci',state:'pending'}],updatedAt:'2026-10-11T00:00:09Z'})}, @@ -1323,13 +1326,13 @@ test('follows a later running merge job when a resent click replays its own ende 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}); const record=(id:string,sequence:number,state:MergeJobView['state'],step:MergeJobView['step'],reason:string|null=null)=>mergeJobRecord({id,sequence,state,step,reason,checks:step==='checks'?[{context:'ci',state:'pending'}]:[]}); // The review shows an earlier job that ended blocked, so it offers Retry merge. - const shown=record('0f6b0000-0000-4000-8000-0000000000d0',1,'blocked',null,'Required check failed: ci.'); + const shown=record('0f6b0000-0000-4000-8000-0000000000d0',1,'blocked',null,CHECK_FAILED_BLOCK); const clicks:string[]=[];let jobB:ReturnType|null=null,polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); // The first answer is lost before the server admitted the click; the server then saves its job A, which ends blocked, // and another tab's retry starts job B. if(clicks.length===1){await route.abort('failed');return;} - const jobA=record(body.actionId,2,'blocked',null,'Required check failed: ci.');jobB=record('0f6b0000-0000-4000-8000-0000000000d3',3,'running','checks'); + const jobA=record(body.actionId,2,'blocked',null,CHECK_FAILED_BLOCK);jobB=record('0f6b0000-0000-4000-8000-0000000000d3',3,'running','checks'); // As web/server.ts answers a resend: the replayed job A, with the review reloaded meanwhile, whose job is the latest (B). const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:jobB},job:jobA})});}); From 371fd3ec48aeb720623e8d2f0bf2637e9843e260 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 02:22:05 -0700 Subject: [PATCH 124/163] Test the merge route's busy refusal while a preparation's command check holds the runner Co-Authored-By: Claude Opus 5.5 --- test/runner-publishing.test.ts | 54 +++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index b05fca96..d2e1e5df 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -9,7 +9,7 @@ import { startServer } from '../web/server.ts'; import { Store, type PublishRecord } from '../runner/store.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import type { ReviewService } from '../runner/review.ts'; -import type { PreparedAttempt, RunnerDeps } from '../runner/coordinator.ts'; +import type { PreparedAttempt, RunnerCoordinator, RunnerDeps } from '../runner/coordinator.ts'; import { SafetyFindings, executionDeps, type ExecutionSources, type TaskWorkspace } from '../runner/execution.ts'; import { GitBranchPusher } from '../runner/branch-push.ts'; import { PullRequestPublisher } from '../runner/publish.ts'; @@ -236,7 +236,9 @@ function world(): World { * earlier process would have left it. */ async function serve(w: World, options: { before?: (service: ReviewService) => void | Promise; onPushSpawn?: (n: number, app: () => App, close: () => Promise) => void; demo?: boolean; startup?: boolean; settleMs?: number; env?: NodeJS.ProcessEnv; hold?: Promise; shortRetryMs?: number; findingSource?: FindingSource; - issueGateway?: IssueTrustGateway; preMerge?: (service: ReviewService) => PreMergeCoordinator; mergeGateway?: MergeGateway; mergeJobTiming?: MergeJobTiming; + issueGateway?: IssueTrustGateway; preMerge?: (service: ReviewService, runner: RunnerCoordinator) => PreMergeCoordinator; mergeGateway?: MergeGateway; mergeJobTiming?: MergeJobTiming; + /** The runner also admits command-check attempts, as pre-merge preparation starts them (held by `hold` too). */ + checks?: boolean; /** A runner block and no github.pullRequest: merging targets the PR the runner published, after preparation. */ runnerPublished?: boolean } = {}) { let app: App | undefined, spawns = 0, closing: Promise | undefined; @@ -283,12 +285,12 @@ async function serve(w: World, options: { before?: (service: ReviewService) => v }; const deps: RunnerDeps = source ? executionDeps(service.store, workspace, launch, sources, OWNER, findings) - : { runnerOwner: OWNER, kinds: ['execute'], prepare: async () => prepared, cleanupPreparation: async () => undefined, + : { runnerOwner: OWNER, kinds: options.checks ? ['execute', 'check'] : ['execute'], prepare: async () => prepared, cleanupPreparation: async () => undefined, start: input => ({ attemptId: input.attemptId, cancel: () => undefined, // `hold`: the attempt keeps running until the test releases it. settled: (options.hold ?? Promise.resolve()).then(() => ({ attemptId: input.attemptId, context: input.context, exitCode: 0, signal: null, stdout: '', stderr: '' })) }), validate: () => ({ head: service.store.getSnapshot(service.config.identity).head, unchanged: true, inScope: [], outOfScope: [] }) }; - return { deps, sources, findings, publisher, ...(options.preMerge ? { preMerge: () => options.preMerge!(service) } : {}), + return { deps, sources, findings, publisher, ...(options.preMerge ? { preMerge: (runner: RunnerCoordinator) => options.preMerge!(service, runner) } : {}), ...(options.env ? { env: options.env } : {}), ...(options.shortRetryMs ? { shortRetryMs: options.shortRetryMs } : {}), recovery: { finalized: [], requeue: [], removedDirectories: [], unknownEntries: [], unmatchedStorage: [], repairedMerges: [] } }; }, options.mergeJobTiming ? { mergeJobTiming: options.mergeJobTiming } : {}); @@ -1986,6 +1988,50 @@ describe('the one-click merge job (F6b of #22)', () => { expect(prepared).toBe(1); }, 60_000); + it('refuses a merge click while a preparation\'s command check holds the runner, and the resend replays the refusal', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null, preparing = false; + const hold = Promise.withResolvers(); + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, mergeGateway: gh, + runnerPublished: true, checks: true, hold: hold.promise, + // A preparation started by prepare-merge while the task is in review: it runs a command-check attempt through the + // real runner (held until the test releases it) and waits for it to settle, as production's does. + preMerge: (service, runner) => ({ + get active() { return preparing; }, get last() { return null; }, settlePendingPush: async () => null, + cancelTask: () => 'closed' as const, close: async () => undefined, + assertStartable() { if (preparing) throw new GuardRefusal('Pre-merge preparation is already running.'); }, + start: async (expected: { actionId: string }) => { + preparing = true; + try { + const identity = service.config.identity; + runner.start(identity, { expectedStateVersion: service.store.getTask(identity).stateVersion, kind: 'check', + item: service.store.getPlan(identity).items[0]!.id, deadline: Date.now() + 60_000, expectedContext: service.store.currentContext(identity) }); + await runner.settled(identity); + const snapshot = service.store.getSnapshot(identity); + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'The task or review changed during preparation. Reload first.' }, null); + } finally { preparing = false; } + }, + } as unknown as PreMergeCoordinator) }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect(served.store.getTask(served.identity).status).toBe('in review'); + expect(await act(served.app, 'prepare-merge')).toMatchObject({ status: 200 }); + await vi.waitFor(() => expect(served.app.runner!.isActive(served.identity)).toBe(true), { timeout: 10_000 }); + const { token } = await review(served.app), id = randomUUID(), busy = 'The runner or publisher is busy; the merge cannot start yet.'; + expect(await merge(served.app, token, id)).toMatchObject({ status: 409, body: { error: busy, job: null } }); + // No job started: the click's refusal is saved instead. + expect(served.store.getMergeJob(served.identity, id)).toMatchObject({ refused: true, reason: busy }); + expect((await mergeState(served.app)).job).toBeNull(); + hold.resolve(); + await vi.waitFor(() => { expect(preparing).toBe(false); expect(served.app.runner!.isActive(served.identity)).toBe(false); }, { timeout: 10_000 }); + // The resend replays the saved refusal, though the runner is idle now. + expect(await merge(served.app, token, id)).toMatchObject({ status: 409, body: { error: busy, job: null } }); + expect((await mergeState(served.app)).job).toBeNull(); + expect(gh.merged).toEqual([]); + }, 60_000); + it('answers 503 and saves nothing when shutdown stops a merge click\'s trust read', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, hang = false, reading = false; From a4570138e756da7f4b027c2d202aa177fa5cac45 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 02:22:10 -0700 Subject: [PATCH 125/163] Test the plural wording when several required checks fail Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 1c632f8a..63a407ce 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -163,6 +163,14 @@ it('stops at once when a required check fails, without waiting for the time limi expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); }); +it('names every failing required check, and the plural next step, when several fail', async () => { + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }, pending('lint'), { context: 'deploy', appId: null, state: 'failure' }] }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: 'Required checks failed: build, deploy. Fix them, then Retry merge.' }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + it('ends failed, without merging, when GitHub cannot be read while waiting for checks', async () => { const f = setup({ checks: () => [pending()], unreadable: n => n === 2 }); await f.coordinator.start('review-token', randomUUID()); From b4e6f4bf4c446826601b5fbe06165da22f6b6061 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 02:22:10 -0700 Subject: [PATCH 126/163] Assert the exact reason of every merge job startup settles Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 63a407ce..1167c67f 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -590,18 +590,23 @@ it.each(['submitted', 'blocked'] as const)('reports a job it could not end durin }); it('settles a job a crash left in its merge step from its merge attempt at startup', async () => { - for (const [settle, expected, status] of [ - [() => undefined, 'submitted', 'in review'], - [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'merged', url: 'https://github.com/owner/repo/pull/7' }), 'merged', 'merged'], - [(store: Store, id: string) => store.queueMergeAttempt(identity, id, 'https://github.com/owner/repo/pull/7'), 'queued', 'in review'], - [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Head branch was modified.', requiresFreshReview: true }), 'failed', 'in review'], + for (const [settle, expected, status, outcome] of [ + [() => undefined, 'submitted', 'in review', { reason: 'The server restarted while GitHub\'s merge outcome was unknown; it reconciles from GitHub.', url: null }], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'merged', url: 'https://github.com/owner/repo/pull/7' }), 'merged', 'merged', + { reason: null, url: 'https://github.com/owner/repo/pull/7' }], + [(store: Store, id: string) => store.queueMergeAttempt(identity, id, 'https://github.com/owner/repo/pull/7'), 'queued', 'in review', + { reason: null, url: 'https://github.com/owner/repo/pull/7' }], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Head branch was modified.', requiresFreshReview: true }), 'failed', 'in review', + { reason: 'GitHub refused the merge: Head branch was modified. Review the new head first.', url: null }], // GitHub refused while a person changed the review: the refusal says nothing about the changed review. [(store: Store, id: string) => { const snapshot = store.getSnapshot(identity); store.addReviewNote(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, 'P1', 'change', 'Changed during the merge.'); store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }); - }, 'failed', 'in review'], - [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }), 'blocked', 'approved but merge blocked'], + }, 'failed', 'in review', + { reason: 'GitHub refused the merge: Base branch was modified. The review changed while GitHub was asked to merge; review it first.', url: null }], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }), 'blocked', 'approved but merge blocked', + { reason: 'GitHub refused the merge: Base branch was modified. Fix it, then Retry merge.', url: null }], ] as const) { // GitHub never answers: the job is in its merge step when the process "crashes". const f = setup({ merge: signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })) }); @@ -611,7 +616,7 @@ it('settles a job a crash left in its merge step from its merge attempt at start expect(job).toMatchObject({ step: 'merging', mergeActionId: attempt.actionId }); settle(f.store, attempt.id); f.store.settleInterruptedMergeJobs(identity); - expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: expected }); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: expected, ...outcome }); expect(f.store.getTask(identity).status).toBe(status); // A blocked job's reason ends with its next step; a failed one's never says "merge again": the page adds that once. const reason = f.store.getMergeJob(identity, job.id)!.reason ?? ''; @@ -668,7 +673,7 @@ it('decides a retried job only from its own merge attempt, never from an earlier f.store.beginMergeJob(identity, { actionId: crashed, token: 'review-token' }); f.store.updateMergeJob(identity, crashed, { step: 'merging', mergeActionId: randomUUID() }); f.store.settleInterruptedMergeJobs(identity); - expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/server restarted/) }); + expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: 'The server restarted before the merge finished.' }); }); it('refuses, saves and replays a click on a task that is not mergeable or is being cancelled', async () => { From 09a31922409c8fa55861b7789655c59d5eb9d6f1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 02:31:50 -0700 Subject: [PATCH 127/163] Use texts production sends in merge-job and history-failure fixtures Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 6 +++--- test/merge-job.test.ts | 6 +++--- test/runner-publishing.test.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index e64eb41b..fcc607ef 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -699,8 +699,8 @@ test('keeps every blocker when the click is answered with a job that ended witho // A collaborator pushed while the job waited: its inspection listed the moved head first, so the job failed with that // reason alone (classify reports the first blocker). The same inspection found a merge conflict, and a required check // is still pending; the job settled neither. - const head={code:'head',message:'The pull request head moved after review.'}; - const conflict={code:'mergeable',message:'GitHub reports a merge conflict.'}; + const head={code:'head',message:'The pull request head moved. Refresh the review.'}; + const conflict={code:'mergeable',message:'The pull request has merge conflicts.'}; const failed=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason:head.message}); let polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} @@ -1184,7 +1184,7 @@ test('asks for a refresh when the click is answered with a merge job that has al let polls=0; // The job failed before the click was answered (its review load failed as the click's reload did). await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:job('failed',null,[],'History worker timed out.'),mergeRefreshRequired:true})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:job('failed',null,[],'History work exceeded its overall deadline.'),mergeRefreshRequired:true})});}); await page.route('**/api/merge',async route=>{polls++;await route.continue();}); // A runner server reports merging as a job (its merge status carries `job`), as production does. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 1167c67f..5e8f08f2 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -859,7 +859,7 @@ it('answers the resent click\'s job when the first send\'s admission load fails await held.loading; const second = await f.coordinator.start('review-token', id); // Its own load is not held: it admits the job. expect(f.store.getMergeJob(identity, id)).not.toBeNull(); - held.fail(new Error('History worker timed out.')); + held.fail(new Error('History work exceeded its overall deadline.')); expect((await first).id).toBe(second.id); await settled(f.coordinator); }); @@ -898,8 +898,8 @@ it('saves nothing when the admission load fails for another reason, so the click const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); const starting = f.coordinator.start('review-token', id); await held.loading; - held.fail(new Error('History worker timed out.')); - await expect(starting).rejects.toThrow('History worker timed out.'); + held.fail(new Error('History work exceeded its overall deadline.')); + await expect(starting).rejects.toThrow('History work exceeded its overall deadline.'); expect(f.store.getMergeJob(identity, id)).toBeNull(); // The same click, sent again, starts the job. expect(await f.coordinator.start('review-token', id)).toMatchObject({ state: 'running' }); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index d2e1e5df..5185ad67 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -2146,7 +2146,7 @@ describe('the one-click merge job (F6b of #22)', () => { let failed = false; service.load = async (options?: Parameters[0]) => { const signal = options?.signal; - if (signal && !failed && requestSignals.has(signal)) { failed = true; throw new Error('History worker timed out.'); } + if (signal && !failed && requestSignals.has(signal)) { failed = true; throw new Error('History work exceeded its overall deadline.'); } if (signal) requestSignals.add(signal); return realLoad(options); }; From c4acbf55a94d91c8b14cf91bf6fe9a9b3ded0280 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 02:31:50 -0700 Subject: [PATCH 128/163] Test the control label and banner of each ended merge job, and the merging step's banner Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index fcc607ef..2b3be5aa 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -720,6 +720,52 @@ test('keeps every blocker when the click is answered with a job that ended witho await expectEndedMergeJob(page,'Merge stopped',ended,[head.message,conflict.message,...jobResolvedBlockers.map(blocker=>blocker.message)]); expect(polls).toBe(0); }); +/** Ended jobs with the course production runs before each outcome and the reason it saves (runner/merge-job.ts). */ +const endedMergeJobs:{state:MergeJobView['state'];course:MergeJobView[];reason:string;label:string;banner:string}[]=[ + // GitHub refused the job's merge: the job blocks with the refusal and FIX_THEN_RETRY (#endAsAttempt). + {state:'blocked',course:[mergeJobRecord({step:'merging'})],reason:'GitHub refused the merge: Required status check "ci" is expected. Fix it, then Retry merge.',label:'Merge blocked', + banner:'Approved, merge blocked. GitHub refused the merge: Required status check "ci" is expected. Fix it, then Retry merge.'}, + // The preparation's rebase made an approval stale (runner/pre-merge.ts): the job ends from its preparing step. + {state:'review-required',course:[],reason:'P1 requires refreshed attribution or approval.',label:'Review needed', + banner:'Review needed before merging. P1 requires refreshed attribution or approval.'}, + // GitHub accepted the merge, but saving its outcome failed. + {state:'submitted',course:[mergeJobRecord({step:'merging'})],reason:'GitHub accepted the merge, but its outcome was not saved; it reconciles from GitHub.',label:'Merge submitted', + banner:'Merge submitted; GitHub\'s outcome is not confirmed yet. GitHub accepted the merge, but its outcome was not saved; it reconciles from GitHub.'}, + // The task was cancelled while the job waited for checks. + {state:'cancelled',course:[mergeJobRecord({step:'checks',checks:[{context:'ci',state:'pending'}]})],reason:'Task cancelled.',label:'Merge cancelled',banner:'Merge cancelled.'}, +]; +for(const variant of endedMergeJobs)test(`names a merge job that ended ${variant.state} on its control and banner (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + // Each saved change adds one to the revision: preparing 1, then the course, then the end. A job past preparing has + // checked (revision 2) before it waits (3) or merges (4); the course's records carry those defaults. + const lastRevision=variant.course.at(-1)?.revision??1; + const ended=mergeJobRecord({state:variant.state,step:null,reason:variant.reason,revision:lastRevision+1, + mergeActionId:variant.course.some(job=>job.step==='merging')?'0f6b0000-0000-4000-8000-00000000ac01':null}); + const running=mergeJobRecord({step:'preparing'}); + let polls=0,released=false; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); + // The merging step is held until the test has read its banner. + await page.route('**/api/merge',async route=>{const next=polls{const response=await route.fetch(),body=await response.json(); + body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + if(variant.course.some(job=>job.step==='merging')){ + await expect(page.getByRole('button',{name:'Merging…',exact:true})).toHaveAttribute('aria-disabled','true',{timeout:10_000}); + await expect(page.locator('#banner')).toHaveText('Merging the reviewed head.'); + released=true; + } + const banner=`${variant.banner} Refresh to see the result.`; + await expect(page.locator('#banner')).toHaveText(banner,{timeout:15_000}); + // Only a job that asked GitHub to merge and was accepted (submitted) settled the blockers it resolves. + await expectEndedMergeJob(page,variant.label,banner,variant.state==='submitted'?[]:jobResolvedBlockers.map(blocker=>blocker.message)); +}); test('names a failed merge-job poll as a merge-status failure (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; From d1c0959c1a28bba845eac696ce55b180cbd731f6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 03:15:40 -0700 Subject: [PATCH 129/163] Test that merging clears listed checks and the exact prepare-merge refusal Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 17 +++++++++++++++++ test/runner-publishing.test.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 5e8f08f2..4ba735be 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -143,6 +143,23 @@ it('waits while a required check is pending, then merges once it passes', async expect(f.gh.merged).toEqual([oid(2)]); }); +it('clears the pending checks it listed when it moves to merging', async () => { + let passes = false, release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const f = setup({ checks: () => [passes ? passing() : pending()], + merge: async () => { await held; return { url: 'https://github.com/owner/repo/pull/7' }; } }); + const job = await f.coordinator.start('review-token', randomUUID()); + try { + // The saved record lists the pending check while the job waits on it. + await vi.waitFor(() => expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ step: 'checks', checks: [{ context: 'ci', state: 'pending' }] })); + // The check passes; the guarded merge is held, so the saved record is read at the merging step. + passes = true; + await vi.waitFor(() => expect(f.gh.merged).toEqual([oid(2)])); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'running', step: 'merging', checks: [] }); + } finally { release(); } + expect(await settled(f.coordinator)).toMatchObject({ id: job.id, state: 'merged', checks: [] }); +}); + it('ends approved, merge blocked when required checks do not pass in time', async () => { const f = setup({ checks: () => [pending('build'), { context: 'deploy', appId: null, state: 'missing' }] }); await f.coordinator.start('review-token', randomUUID()); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 5185ad67..340cbc65 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1847,7 +1847,7 @@ describe('the one-click merge job (F6b of #22)', () => { expect(refused).toMatchObject({ status: 409, body: { error: 'A merge is already running for this task.', job: { id: started.body.job.id, state: 'running', step: 'checks' } } }); expect(await merge(served.app, before.token, secondTab)).toMatchObject({ status: 409, body: { error: refused.body.error, job: { id: started.body.job.id } } }); expect((await mergeState(served.app)).job).toMatchObject({ id: started.body.job.id }); - expect(await act(served.app, 'prepare-merge')).toMatchObject({ status: 409, body: { error: expect.stringMatching(/merge is running/) } }); + expect(await act(served.app, 'prepare-merge')).toMatchObject({ status: 409, body: { error: 'A merge is running for this task; it prepares the merge itself.' } }); // A publish cannot begin a refresh of the PR the job waits on: publishing refuses a task in review. expect(await act(served.app, 'publish')).toMatchObject({ status: 409, body: { error: expect.stringMatching(/The task is in review/) } }); expect(served.store.taskPullRequests(served.identity).every(pr => pr.refresh === null)).toBe(true); From 32ff4ec664419be0a521d9afb9dcb879b22248f5 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 03:40:58 -0700 Subject: [PATCH 130/163] Pin the merge job's shutdown handling around its first load and preparation A job stopped by shutdown during its own first review load or its preparation stays running with no ending write, and a first load that still resolves after shutdown starts no preparation and saves no prepare-merge placeholder. Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 4ba735be..9b33213d 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -1076,3 +1076,72 @@ it('keeps a failing check found by an inspection that finishes after the wait li expect(await settled(coordinator)).toMatchObject({ state: 'blocked', reason: 'Required check failed: build. Fix it, then Retry merge.' }); } finally { clock.mockRestore(); } }); + +it('leaves the job running, with no ending write, when shutdown stops the job\'s own first review load', async () => { + const f = setup(), id = randomUUID(); + // The admission's load passes; the job's own first load is held and rejects on the shutdown abort, as a stopped load does. + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + const held = holdNextLoad(f.review as never); + const finish = vi.spyOn(f.store, 'finishMergeJob'); + const started = await f.coordinator.start('review-token', id); + const signal = (await held.loading)!; + f.coordinator.stop(); + expect(signal.aborted).toBe(true); // The load was pending when shutdown aborted it. + await f.coordinator.current; + expect(finish).not.toHaveBeenCalled(); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running', step: 'preparing', reason: null }); + expect(f.prep.calls).toEqual([]); +}); + +it.each(['rejects', 'resolves failed'] as const)('leaves the job running, with no ending write, when its preparation %s after shutdown stopped it', async ending => { + const f = setup(); + // The preparation is held until shutdown aborts it, then rejects or resolves `failed` with shutdown's reason. + let started!: () => void; + const preparing = new Promise(resolve => { started = resolve; }); + const stopped: { end?: () => void } = {}; + (f.prep.fake as unknown as { start: (expected: { actionId: string }) => Promise }).start = expected => { + f.prep.calls.push(expected.actionId); started(); + return new Promise((resolve, reject) => { + stopped.end = () => { + const snapshot = f.store.getSnapshot(identity); + if (ending === 'rejects') reject(new Error('Server shutdown.')); + else resolve({ state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'Server shutdown.' }); + }; + }); + }; + const finish = vi.spyOn(f.store, 'finishMergeJob'), update = vi.spyOn(f.store, 'updateMergeJob'); + const job = await f.coordinator.start('review-token', randomUUID()); + await preparing; + f.coordinator.stop(); + stopped.end!(); + await f.coordinator.current; + expect(f.prep.calls).toHaveLength(1); + expect(finish).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'running', step: 'preparing', reason: null }); + expect(f.gh.merged).toEqual([]); +}); + +it('starts no preparation when shutdown began while the job\'s first review load was finishing and the load still resolved', async () => { + const f = setup(); + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + // The job's first load ignores the abort and resolves when released (a load past its last abort check). + let release!: () => void, started!: (signal?: AbortSignal) => void; + const loading = new Promise(resolve => { started = resolve; }); + (f.review.load as unknown as ReturnType).mockImplementationOnce((options?: { signal?: AbortSignal }) => { + started(options?.signal); + return new Promise(resolve => { release = () => resolve((f.review as never as { view: () => unknown }).view()); }); + }); + const userAction = vi.spyOn(f.store, 'userAction'), finish = vi.spyOn(f.store, 'finishMergeJob'); + const job = await f.coordinator.start('review-token', randomUUID()); + const signal = (await loading)!; + f.coordinator.stop(); + // The interleaving happened: the signal was aborted while the load was still pending, and the load then resolved. + expect(signal.aborted).toBe(true); + release(); + await f.coordinator.current; + expect(f.prep.calls).toEqual([]); + expect(userAction.mock.calls.filter(([, action]) => (action as { kind: string }).kind === 'prepare-merge')).toEqual([]); + expect(finish).not.toHaveBeenCalled(); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'running', step: 'preparing', reason: null }); +}); From 2d65a23143dce2d8d637f82be5f2a5619fc71c97 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 04:00:40 -0700 Subject: [PATCH 131/163] Replace unreachable refused-click job variants with a reachable one The server attaches the task's latest job to every merge-job refusal, and that only moves forward, so an older running job cannot arrive. Test an ended job later than the one shown instead, and keep the no-later running record as a named defensive pin for the "later than" half of the follow condition. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 2b3be5aa..c74e1470 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1174,12 +1174,15 @@ test('follows the running merge job when a click from an older review is refused expect(mergeClicks).toBe(1); }); // A refused click's job is followed only when it is running and later than the job this page shows (web/public/app.js): -// the server attaches the task's latest job to every merge-job refusal (web/server.ts), so an ended or older one comes too. +// the server attaches the task's latest job to every merge-job refusal (web/server.ts), so an ended one comes too, even +// one later than the job this page shows (another tab's job that has ended since this page read its review). const shownJob=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:CHECK_FAILED_BLOCK,sequence:2,revision:4,updatedAt:'2026-10-11T00:00:05Z'}); for(const refusal of [ {name:'an ended job',shown:null,button:'Merge PR',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.'})}, - {name:'an older running job',shown:shownJob,button:'Retry merge',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'checks',checks:[{context:'ci',state:'pending'}],updatedAt:'2026-10-11T00:00:09Z'})}, - {name:'a running record no later than the job shown',shown:shownJob,button:'Retry merge',job:mergeJobRecord({...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}],revision:3})}, + {name:'an ended job later than the job shown',shown:shownJob,button:'Retry merge',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c3',state:'failed',step:null,reason:'The server restarted before the merge finished.',sequence:3,updatedAt:'2026-10-11T00:00:09Z'})}, + // Defensive: the server's latest job only moves forward, so it cannot send a running record no later than the job this + // page shows. Kept to pin the "later than" half of the follow condition, which the reachable cases above do not reach. + {name:'a running record no later than the job shown (defensive)',shown:shownJob,button:'Retry merge',job:mergeJobRecord({...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}],revision:3})}, ]){ test(`asks for a refresh, not following it, when a refused merge click carries ${refusal.name} (F6b)`,async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); From 57c7786eb20d387007cd18a2b65010d9101ab816 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 06:21:47 -0700 Subject: [PATCH 132/163] Reload the click's review with a body-less GET in browser merge fixtures route.fetch with method GET resent the click's POST body; the server never reads a GET body, closes the connection over it, and the reset surfaced as read ECONNRESET. One shared helper now issues a separate GET with the page's token. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index c74e1470..0fbfdfe9 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { test, expect } from '@playwright/test'; +import { test, expect, type Route } from '@playwright/test'; import { openReview, refreshReview, reloadReview } from './review-page.ts'; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -20,6 +20,14 @@ function requestAdmitted(server: typeof app.server) { const admitted=new Promise(resolve=>{const onRequest=(req:IncomingMessage)=>{if(req.headers['x-codeboost-test-request']!==id)return;server.off('request',onRequest);resolve();};server.on('request',onRequest);}); return {admitted,marker}; } +// Reads the review a runner server reloads for a merge click. `route.fetch` with method GET resends the click's POST body, +// which the server never reads for a GET: it closes the connection over the unread body, and the reset surfaced as +// `read ECONNRESET`. A separate body-less GET with the page's token reaches the same handler. +async function clickReview(route: Route) { + const request=route.request(),response=await fetch(request.url().replace('/api/action','/api/review'),{headers:{'x-codeboost-token':request.headers()['x-codeboost-token']??''}}); + if(!response.ok)throw new Error(`The click's review reload answered ${response.status}.`); + return await response.json(); +} function removeDemoOutOfScope(config: typeof app.service.config) { chmodSync(join(config.repository,'run.sh'),0o644);fixtureGit(config.repository,'commit','-am','Restore declared scope'); } 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}); }); @@ -677,7 +685,7 @@ test('shows only the outcome once a polled merge job ends, not the blockers it r // As a runner server answers: Merge PR is offered while only job-resolved blockers remain, and the click's review, // reloaded while the job prepares, still lists them. await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing'); + const review=await clickReview(route),running=job('running','preparing'); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); @@ -704,7 +712,7 @@ test('keeps every blocker when the click is answered with a job that ended witho const failed=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason:head.message}); let polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + const review=await clickReview(route); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:failed,blockers:[...review.merge.blockers,head,conflict,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:failed})});}); await page.route('**/api/merge',async route=>{polls++;await route.continue();}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); @@ -747,7 +755,7 @@ for(const variant of endedMergeJobs)test(`names a merge job that ended ${variant const running=mergeJobRecord({step:'preparing'}); let polls=0,released=false; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + const review=await clickReview(route); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); // The merging step is held until the test has read its banner. await page.route('**/api/merge',async route=>{const next=pollsvalue.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}); const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-000000000001',state:'running',step:'checks'}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + const review=await clickReview(route); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); // A job is running and no merge queue exists; GitHub's status cannot be read. await page.route('**/api/merge',route=>route.fulfill({status:502,contentType:'application/json',body:JSON.stringify({error:'GitHub returned HTTP 502.'})})); @@ -790,7 +798,7 @@ test('ends a running merge job the poll no longer reports, keeping every blocker 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}); const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f3',state:'running',step:'checks'}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + const review=await clickReview(route); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); // The job is no longer reported (no job record and no merge queue): the page cannot tell whether it reached the merge. let polls=0; @@ -820,7 +828,7 @@ test('keeps a draft typed while a merge-job click is in flight (F6b)',async({pag const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?CHECK_FAILED_BLOCK:null}); let started:MergeJobView|null=null,release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - await held;const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing');started=running; + await held;const review=await clickReview(route),running=job(body.actionId,'running','preparing');started=running; await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); @@ -844,7 +852,7 @@ test('starts a new action for Retry merge once the job its retained key started const clicks:string[]=[];let first:string|null=null,started:MergeJobView|null=null; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing',2);started=running; + const review=await clickReview(route),running=job(body.actionId,'running','preparing',2);started=running; await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); // The first click's job was saved and has since ended blocked: the review offers Retry merge. @@ -870,7 +878,7 @@ test('starts a new action once another job became the latest after a retained ke const clicks:string[]=[];let first:string|null=null,started:MergeJobView|null=null; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job(body.actionId,'running','preparing',3);started=running; + const review=await clickReview(route),running=job(body.actionId,'running','preparing',3);started=running; await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); // The first click's job ended, then another client's job ran and ended blocked: the latest job is not this key's. @@ -1105,7 +1113,7 @@ test('keeps the Merge button pending while its click is in flight, across a rend const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000d1',state:'running',step:'preparing'}); let release!:()=>void,arrived!:()=>void;const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{arrived=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + const review=await clickReview(route); arrived();await released; await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:running})})); @@ -1288,7 +1296,7 @@ test('follows a one-click merge job from preparation through the required-check const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('running','merging'),job('merged',null)]; let polls=0,reviews=0,mergeClicks=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing'); + const review=await clickReview(route),running=job('running','preparing'); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); // A runner server reports merging as a job (its merge status carries `job`). @@ -1345,7 +1353,7 @@ test('follows the merge queue a polled job ended in, from a review with no queue const steps=[{queue:null,job:job('running','merging',4)},{queue:queue('queued'),job:job('queued',null,5)},{queue:queue('queued'),job:job('queued',null,5)},{queue:queue('merged'),job:job('queued',null,5)}]; const answered:Array<{queue:unknown;job:{state:string}}>=[];let reviews=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(),running=job('running','preparing',1); + const review=await clickReview(route),running=job('running','preparing',1); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,queue:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(answered.length,steps.length-1)]!;answered.push(next);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json(); @@ -1383,7 +1391,7 @@ test('follows a later running merge job when a resent click replays its own ende if(clicks.length===1){await route.abort('failed');return;} const jobA=record(body.actionId,2,'blocked',null,CHECK_FAILED_BLOCK);jobB=record('0f6b0000-0000-4000-8000-0000000000d3',3,'running','checks'); // As web/server.ts answers a resend: the replayed job A, with the review reloaded meanwhile, whose job is the latest (B). - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + const review=await clickReview(route); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:jobB},job:jobA})});}); await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:jobB})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); @@ -1525,7 +1533,7 @@ test(`shows the job's own attempt, ${variant.state}, when the click is answered // Another client's poll saw the job end queued, and its attempt reach `variant.state`, before this click's review reload // read them: the click's answer carries both. await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - const response=await route.fetch({url:route.request().url().replace('/api/action','/api/review'),method:'GET'}),review=await response.json(); + const review=await clickReview(route); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,queue:queue(variant.state),job:queued,blockers:[...attemptBlocker,...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:queued})});}); // A later poll sees GitHub merge an attempt that was still queued. await page.route('**/api/merge',async route=>{const next={queue:queue(polls.length===0&&variant.state==='queued'?'queued':'merged'),job:queued};polls.push(`${next.job.state}/${next.queue.state}`); From dba9e7c51cd4ab37de6f34a800a7a06056415285 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 06:21:47 -0700 Subject: [PATCH 133/163] Pin the merge job's admission and status claims with tests Covers the failed-reload answer carrying the job's latest record, shutdown winning over an unrelated admission-load failure, the unsaved refusal for an empty or non-string token, the pre-placeholder startability check (labelled defensive), Retry merge for a task in review, and the server passing the task's current status into withMergeJob. Co-Authored-By: Claude Opus 5.5 --- runner/merge-job.ts | 3 +- test/merge-job.test.ts | 46 +++++++++++++++++++++++++++++++ test/runner-publishing.test.ts | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/runner/merge-job.ts b/runner/merge-job.ts index b8095a2b..7ef92583 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -170,7 +170,8 @@ export class MergeJobCoordinator { const prepareId = randomUUID(), task = store.getTask(identity), snapshot = store.getSnapshot(identity); const reviewVersion = store.reviewVersion(identity); // Refused here, before the placeholder below is saved, if a preparation cannot start now (the preparation's own check - // repeats it in this turn). + // repeats it in this turn). Defensive: admission checked it, the server refuses a standalone preparation while a job + // runs, and shutdown aborts the job (checked above) in the turn before it stops preparation. this.preMerge.assertStartable(); store.userAction(identity, { actionId: prepareId, kind: 'prepare-merge', request: { expectedStateVersion: task.stateVersion, expectedReviewVersion: reviewVersion, mergeJob: job.id } }, diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 9b33213d..6199654e 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -1145,3 +1145,49 @@ it('starts no preparation when shutdown began while the job\'s first review load expect(finish).not.toHaveBeenCalled(); expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'running', step: 'preparing', reason: null }); }); + +it('answers shutdown, not the load\'s own error, when the admission load fails for another reason after shutdown began', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const starting = f.coordinator.start('review-token', id); + await held.loading; + f.coordinator.stop(); + // No request signal: only the coordinator's own shutdown flag says the load failed during shutdown. + held.fail(new Error('History work exceeded its overall deadline.')); + await expect(starting).rejects.toBeInstanceOf(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); +}); + +it.each([['an empty', ''], ['a non-string', 42]] as const)('refuses a click with %s token as stale, saving nothing, and refuses its resend the same way', async (_kind, token) => { + const f = setup(), id = randomUUID(); + for (let send = 0; send < 2; send++) { + const refused = await f.coordinator.start(token, id).then(() => null, (error: unknown) => error); + expect(refused).toBeInstanceOf(GuardRefusal); + expect((refused as Error).message).toBe('Stale review state. Refresh before merging.'); + // A refusal is saved under the click's token, so a click with none has nothing to save it under. + expect(f.store.getMergeJob(identity, id)).toBeNull(); + } + expect(f.prep.calls).toEqual([]); +}); + +it('saves no prepare-merge action when preparation can no longer start once the job\'s first review load resolves', async () => { + // Defensive at the coordinator's boundary (see #run): the server refuses a standalone preparation while a job runs and + // stops the job before preparation at shutdown, so the fake's startability changes directly. + const f = setup(), id = randomUUID(); + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + const held = holdNextLoad(f.review as never); + const userAction = vi.spyOn(f.store, 'userAction'); + await f.coordinator.start('review-token', id); + await held.loading; + (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('Pre-merge preparation is already running.'); }; + held.release(); + expect(await settled(f.coordinator)).toMatchObject({ id, state: 'failed', reason: 'Pre-merge preparation is already running.' }); + expect(userAction.mock.calls.filter(([, action]) => (action as { kind: string }).kind === 'prepare-merge')).toEqual([]); + expect(f.prep.calls).toEqual([]); + expect(f.gh.merged).toEqual([]); +}); + +it('keeps the guarded merge\'s Retry merge for a task still in review', () => { + const status = { ready: true, action: 'retry', blockers: [{ code: 'preparation', message: 'preparation' }], remote: { mergeable: 'MERGEABLE' }, queue: null } as Parameters[0]; + expect(withMergeJob(status, null, 'in review')).toMatchObject({ ready: true, action: 'retry' }); + expect(withMergeJob({ ...status, action: 'merge' }, null, 'in review')).toMatchObject({ ready: true, action: 'merge' }); +}); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 340cbc65..8626d58f 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -2183,6 +2183,56 @@ describe('the one-click merge job (F6b of #22)', () => { expect(clicked).toMatchObject({ status: 200, body: { job: { id, state: 'merged' } } }); }, 60_000); + it('answers a merge click whose reload fails with the job\'s latest record, once the job ended during that reload', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + // The admission load and the click's reload share the request's signal; the job's loads carry its own. The reload + // waits until the job has merged, then fails (as a history worker timing out would). + const service = served.app.service, realLoad = service.load.bind(service), requestSignals = new Set(); + let failed = false; + service.load = async (options?: Parameters[0]) => { + const signal = options?.signal; + if (signal && !failed && requestSignals.has(signal)) { + failed = true; + await vi.waitFor(() => expect(served.store.getMergeJob(served.identity, id)?.state).toBe('merged'), { timeout: 40_000, interval: 50 }); + throw new Error('History work exceeded its overall deadline.'); + } + if (signal) requestSignals.add(signal); + return realLoad(options); + }; + const clicked = await merge(served.app, token, id); + expect(failed).toBe(true); + // The admission's record said "running, preparing"; the answer is the job as it is now. + expect(clicked).toMatchObject({ status: 200, body: { job: { id, state: 'merged' }, mergeRefreshRequired: true } }); + }, 60_000); + + it('offers Merge PR to a task in review and Retry merge once a job ended blocked, from the task\'s current status', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'failure' }]); + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const before = await review(served.app); + expect(served.store.getTask(served.identity).status).toBe('in review'); + expect(before.merge).toMatchObject({ ready: true, action: 'merge', job: null }); + const started = await merge(served.app, before.token); + expect(started.status).toBe(200); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ id: started.body.job.id, state: 'blocked' }), { timeout: 10_000 }); + expect(served.store.getTask(served.identity).status).toBe('approved but merge blocked'); + const after = await review(served.app); + // The guarded merge has no attempt to retry, so only the task's status makes this a retry. + expect(after.merge.queue).toBeNull(); + expect(after.merge).toMatchObject({ ready: true, action: 'retry', job: { id: started.body.job.id, state: 'blocked' } }); + }, 60_000); + it('closes the pull requests of a task whose cancel waited for the merge job\'s preparation to settle', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, release!: () => void, preparing = false; From 1a0aa0fb56072da34210523382dd3a6db1dd591b Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 07:11:30 -0700 Subject: [PATCH 134/163] Pin the merge job's production timing defaults and timing check A coordinator built without a timing, as the server builds it, waits 10 s first, doubles to 60 s and gives up at 30 minutes; a timing that is not a whole, non-negative number of milliseconds is refused. Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 6199654e..7693c4a1 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -953,6 +953,40 @@ it('doubles the wait between required-check inspections from the first delay up expect(waits).toEqual([3, 6, 12, 20, 20, 20]); }); +it('waits for required checks with the production timing when constructed without one, as the server does', async () => { + // Required checks never pass. The clock and timers are fake, so the 30-minute wait runs at once. + const f = setup({ checks: () => [pending()] }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const waits: number[] = []; + try { + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges); jobs.push(coordinator); + const timer = setTimeout, spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler: () => void, ms?: number, ...args: unknown[]) => { + if (coordinator.latest()?.step === 'checks') waits.push(ms ?? 0); + return timer(handler, ms, ...args); + }) as typeof setTimeout); + try { + await coordinator.start('review-token', randomUUID()); + for (let turn = 0; coordinator.active && turn < 1_000; turn++) { + await new Promise(resolve => setImmediate(resolve)); + vi.advanceTimersToNextTimer(); + } + expect(await settled(coordinator)).toMatchObject({ state: 'blocked', + reason: 'Required checks did not pass within 30 minutes: ci is pending. Wait for the checks to pass, then Retry merge.' }); + } finally { spy.mockRestore(); } + } finally { vi.useRealTimers(); } + // 10 s first, doubling to the 60 s maximum, and the last wait cut to what is left of the 30 minutes. + expect(waits.slice(0, 5)).toEqual([10_000, 20_000, 40_000, 60_000, 60_000]); + expect(waits.reduce((sum, ms) => sum + ms, 0)).toBe(30 * 60_000); + expect(waits).toEqual([10_000, 20_000, 40_000, ...Array(28).fill(60_000), 50_000]); +}); + +it('refuses a merge job timing that is not a whole, non-negative number of milliseconds', () => { + const f = setup(); + for (const timing of [{ checkWaitMs: -1 }, { firstDelayMs: 1.5 }, { maxDelayMs: Number.NaN }, { checkWaitMs: Number.POSITIVE_INFINITY }]) + expect(() => new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, timing)).toThrow('Invalid merge job timing.'); + expect(() => new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 0, firstDelayMs: 0, maxDelayMs: 0 })).not.toThrow(); +}); + it('words the guarded merge\'s refusal as what merging does when the base moves after the job\'s last ready inspection', async () => { // Inspection 1 is the job's wait (ready); a push to the base lands before inspection 2, the guarded merge's first. const f = setup({ change: n => (n >= 2 ? { base: oid(5) } : {}) }); From f717220d523fb8964258cf34e81feca454abe465 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 07:15:26 -0700 Subject: [PATCH 135/163] Pin the merge-job poll back-off, the draft kept on a failed click, and the preparing banner The poll doubles its wait while a running job does not change and starts again at 2 s once its step changes; a draft typed while a merge click is in flight survives a 409 or 503 answer; the preparing banner is asserted in full. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 48 ++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 0fbfdfe9..62ee49d0 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -843,6 +843,52 @@ test('keeps a draft typed while a merge-job click is in flight (F6b)',async({pag await expect(page.getByLabel('Question about this item')).toHaveValue('Typed while the merge request was in flight.'); }); +for(const refusal of [{name:'refused',status:409,error:'Issue #1 is not trusted for its current author.'},{name:'not applied',status:503,error:'The server is shutting down.'}]) +test(`keeps a draft typed while a merge-job click is in flight and then ${refusal.name} (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + let release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + await held;await route.fulfill({status:refusal.status,contentType:'application/json',body:JSON.stringify({error:refusal.error})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await page.getByLabel('Question about this item').fill('Before the click.'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The person keeps typing while the click's request is in flight; the click then fails. + await page.getByLabel('Question about this item').fill('Typed while the merge request was in flight.'); + release(); + await expect(page.locator('#banner')).toContainText(refusal.error); + await expect(page.getByLabel('Question about this item')).toHaveValue('Typed while the merge request was in flight.'); +}); + +test('backs off the merge-job poll while the job does not change, and polls soon again once its step changes (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=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}); + const checks=[{context:'ci',state:'pending'}],waiting=mergeJobRecord({state:'running',step:'checks',checks}); + // Poll 1 sees the same record as the click (no change); poll 2 a new step; poll 3 that step again; poll 4 the end. + const steps=[waiting,mergeJobRecord({state:'running',step:'merging',checks}),mergeJobRecord({state:'running',step:'merging',checks}),mergeJobRecord({state:'merged',step:null})]; + const polledAt:number[]=[]; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:waiting},job:waiting})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polledAt.length,steps.length-1)];polledAt.push(Date.now());await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:30_000}); + expect(polledAt).toHaveLength(4); + const intervals=polledAt.slice(1).map((at,index)=>at-polledAt[index]!); + // 2 s doubled to 4 s after the unchanged poll; back to 2 s after the step changed; doubled again after the next unchanged one. + expect(intervals[0]).toBeGreaterThanOrEqual(3500); + expect(intervals[1]).toBeLessThan(3000); + expect(intervals[2]).toBeGreaterThanOrEqual(3500); +}); + test('starts a new action for Retry merge once the job its retained key started has ended (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; @@ -1309,7 +1355,7 @@ test('follows a one-click merge job from preparation through the required-check // Not disabled in the DOM (Playwright's toBeEnabled counts aria-disabled as disabled), and still focused. await expect(button).toHaveJSProperty('disabled',false);await expect(button).toBeFocused(); await expect.poll(()=>looksInactive(button)).toBe(true); - await expect(page.locator('#banner')).toContainText('Preparing the merge'); + await expect(page.locator('#banner')).toHaveText('Preparing the merge: refresh, rebase, push and command checks.'); await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toHaveAttribute('aria-disabled','true'); await expect(button).toBeFocused(); await expect(page.locator('#banner')).toContainText('Waiting for required checks: ci is pending.'); From 5ec39eef1dc301890e2f578094943a9e0a1161d1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 09:21:06 -0700 Subject: [PATCH 136/163] Pin the merge-job poll delays directly, including a checks-only change The back-off test now records the delays the page schedules instead of timing round trips, and a new case pins that a changed check list within the same step neither backs off nor resets the delay. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 46 ++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 62ee49d0..5e9ec6da 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { test, expect, type Route } from '@playwright/test'; +import { test, expect, type Page, type Route } from '@playwright/test'; import { openReview, refreshReview, reloadReview } from './review-page.ts'; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -864,29 +864,47 @@ test(`keeps a draft typed while a merge-job click is in flight and then ${refusa await expect(page.getByLabel('Question about this item')).toHaveValue('Typed while the merge request was in flight.'); }); -test('backs off the merge-job poll while the job does not change, and polls soon again once its step changes (F6b)',async({page})=>{ +// Records the delay of every timer of 500 ms or more the page schedules: the merge poll's delays, read without timing the +// round trips (which vary with machine load). +const recordPollDelays=(page:Page)=>page.addInitScript(()=>{const delays:number[]=[];(window as typeof window&{__mergePollDelays:number[]}).__mergePollDelays=delays;const native=window.setTimeout.bind(window);window.setTimeout=((handler:TimerHandler,timeout?:number,...args:unknown[])=>{if(typeof timeout==='number'&&timeout>=500)delays.push(timeout);return native(handler,timeout,...args);}) as typeof window.setTimeout;}); +const pollDelays=(page:Page)=>page.evaluate(()=>(window as typeof window&{__mergePollDelays:number[]}).__mergePollDelays.slice()); +// Runs a merge job whose click answers `steps[0]` and whose polls answer the rest in order, then returns the poll delays. +async function mergeJobPollDelays(page:Page,steps:MergeJobView[]){ + await recordPollDelays(page); const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const checks=[{context:'ci',state:'pending'}],waiting=mergeJobRecord({state:'running',step:'checks',checks}); - // Poll 1 sees the same record as the click (no change); poll 2 a new step; poll 3 that step again; poll 4 the end. - const steps=[waiting,mergeJobRecord({state:'running',step:'merging',checks}),mergeJobRecord({state:'running',step:'merging',checks}),mergeJobRecord({state:'merged',step:null})]; - const polledAt:number[]=[]; + const [clicked,...polled]=steps;let polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:waiting},job:waiting})});}); - await page.route('**/api/merge',async route=>{const next=steps[Math.min(polledAt.length,steps.length-1)];polledAt.push(Date.now());await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:clicked},job:clicked})});}); + await page.route('**/api/merge',async route=>{const next=polled[Math.min(polls,polled.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:30_000}); - expect(polledAt).toHaveLength(4); - const intervals=polledAt.slice(1).map((at,index)=>at-polledAt[index]!); - // 2 s doubled to 4 s after the unchanged poll; back to 2 s after the step changed; doubled again after the next unchanged one. - expect(intervals[0]).toBeGreaterThanOrEqual(3500); - expect(intervals[1]).toBeLessThan(3000); - expect(intervals[2]).toBeGreaterThanOrEqual(3500); + expect(polls).toBe(polled.length); + return pollDelays(page); +} + +test('backs off the merge-job poll while the job does not change, and polls soon again once its step changes (F6b)',async({page})=>{ + const checks=[{context:'ci',state:'pending'}],waiting=mergeJobRecord({state:'running',step:'checks',checks}),merging=mergeJobRecord({state:'running',step:'merging',checks}); + // The click shows `waiting`; poll 1 sees it again (no change); poll 2 a new step; poll 3 that step again; poll 4 the end. + const delays=await mergeJobPollDelays(page,[waiting,waiting,merging,merging,mergeJobRecord({state:'merged',step:null})]); + // 2 s after the click, doubled to 4 s after the unchanged poll, back to 2 s after the step changed, doubled again after + // the next unchanged one. The job's end schedules nothing. + expect(delays).toEqual([2000,4000,2000,4000]); +}); + +test('does not back off the merge-job poll when only the waiting checks change within a step (F6b)',async({page})=>{ + const waiting=(checks:MergeJobView['checks'])=>mergeJobRecord({state:'running',step:'checks',checks}); + const pending=waiting([{context:'ci',state:'pending'}]),progressed=waiting([{context:'ci',state:'pending'},{context:'lint',state:'pending'}]); + // The click shows `pending`; poll 1 sees it again (no change); poll 2 a changed check list in the same step; poll 3 the end. + const delays=await mergeJobPollDelays(page,[pending,pending,progressed,mergeJobRecord({state:'merged',step:null})]); + // 2 s after the click, doubled to 4 s after the unchanged poll; the changed checks keep it at 4 s (the step is the same, + // so the delay is not reset to 2 s either). + expect(delays).toEqual([2000,4000,4000]); }); test('starts a new action for Retry merge once the job its retained key started has ended (F6b)',async({page})=>{ From 00298e41cbd2fc795f82836f0cb7e91dfa2a77bf Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 09:21:38 -0700 Subject: [PATCH 137/163] Pin that a foreign ledger commit is not passed as the task's own Co-Authored-By: Claude Opus 5.5 --- test/merge-task-pr.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index 9d22bd3d..b4bcd645 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -395,6 +395,17 @@ describe('the merge gate with a runner block (#121)', () => { expect([...gh.targets.at(-1)!.ownCommits!].sort()).toEqual([oid(12), oid(2)].sort()); }); + it('does not skip a foreign commit in the task\'s ledger when scanning for an earlier fix', async () => { + const { store } = published(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // A base commit the task's history shows (for example one mentioning the issue) is foreign, not the task's own. + store.recordHistory(identity, reviewed(), oid(1), oid(2), [{ sha: oid(5), owner: null, origin: 'foreign', sourceSha: null }]); + expect(store.getLedger(identity).map(entry => [entry.sha, entry.origin])).toEqual([[oid(2), 'owned'], [oid(5), 'foreign']]); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)!.ownCommits).toEqual([oid(2)]); + }); + it('keeps scanning from the PR\'s own pre-PR check after a re-publish records a newer check from a rebased base', async () => { const { store } = published(), gh = github(store); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); From abdcc65a16f654ee34ee36567cb6a33b39df0e01 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 09:23:26 -0700 Subject: [PATCH 138/163] Label the unreachable busy and active-attempt guards as defensive blockMergeJob's attempt and merge terms, and the merge route's executor busy term, cannot decide anything in production; comments say why. Co-Authored-By: Claude Opus 5.5 --- runner/store.ts | 6 ++++++ web/server.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/runner/store.ts b/runner/store.ts index b98c6444..078b9902 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2222,6 +2222,12 @@ export class Store { return this.finishMergeJob(identity, id, { state: 'failed', reason: `${reason} The review changed while GitHub was asked to merge; review it first.` }); } + // The attempt and merge terms are defensive. A job blocks after its preparation settled (every check attempt it + // admitted ended), and while it runs nothing else admits an attempt or a merge: the server refuses start, resume, + // a standalone preparation and a second merge click; admission refuses item work in review; and a merge attempt is + // begun only by the job's own guarded merge. With `readyFor`, any later admission moved the state version, so the + // job ended `failed` above. Otherwise the job's own merge attempt ended (failed or removed) before the block, and + // startup recovery blocks only for such an attempt. if (task.status === 'in review' && !this.#activeAttempt(key) && !this.#activeMerge(key) && task.rebase_in_progress === null && (task.push_in_progress === null || task.push_in_progress === undefined)) { this.#run('UPDATE tasks SET status=? WHERE plan_key=?', 'approved but merge blocked', key); diff --git a/web/server.ts b/web/server.ts index 36c2ce4a..7895303c 100644 --- a/web/server.ts +++ b/web/server.ts @@ -744,6 +744,11 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if(access) try { requireTrustedIssue(access); } catch (error) { if(!(error instanceof GuardRefusal))throw error; untrusted=error.message; } } let job: Awaited>; + // The executor term is defensive. Admission needs a task in review (or approved, merge blocked), and an executor + // run holds its task out of those statuses: an item run is queued or running (admission refuses item work in + // review), only a publish moves a task to in review, and it does so only after the run has returned (a publish + // refuses while the executor is busy). A run started from review that only settles owed work ends synchronously, + // so it is busy only for the microtasks before its promise settles. try { job=await mergeJobs.start(input.token,input.actionId,()=>untrusted??(runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) ?'The runner or publisher is busy; the merge cannot start yet.':null),requestAbort.signal); From f9bfa8d154f3cfb7e914a166ca6b79b880f8a032 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 10:07:44 -0700 Subject: [PATCH 139/163] Test that updating an ended merge job leaves it unchanged Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 7693c4a1..8aac0154 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -642,6 +642,16 @@ it('settles a job a crash left in its merge step from its merge attempt at start } }); +it('leaves a job that is no longer running unchanged when asked to update it', () => { + const store = publishedTask(), id = randomUUID(); + store.beginMergeJob(identity, { actionId: id, token: 'review-token' }); + expect(store.finishMergeJob(identity, id, { state: 'failed', reason: 'The pull request moved.' })).toBe(true); + const ended = store.getMergeJob(identity, id)!; + expect(store.updateMergeJob(identity, id, { step: 'merging', checks: [{ context: 'ci', state: 'pending' }] })).toBeNull(); + // Neither the step, the checks nor the revision moved: a late update cannot revive or rewrite an ended job. + expect(store.getMergeJob(identity, id)).toEqual(ended); +}); + it('blocks the job and the task in one write, leaving a task that moved meanwhile as it is', () => { const store = publishedTask(), first = randomUUID(), second = randomUUID(); store.beginMergeJob(identity, { actionId: first, token: 'review-token' }); From 82b857f95787ce5ab96a4d6779bacbcb7b62517f Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 10:39:28 -0700 Subject: [PATCH 140/163] Pin the store's merge-job refusals and say which refusals start() saves Co-Authored-By: Claude Opus 5.5 --- runner/merge-job.ts | 10 ++++++---- test/merge-job.test.ts | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 7ef92583..bf908737 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -59,8 +59,9 @@ export class MergeJobCoordinator { /** * Admit the click and start its job after the caller's transaction commits. A resend of the same click returns its - * job without starting another. A definite refusal is saved under the click's action ID and replayed on resend; only a - * refusal during shutdown saves nothing, so that click may be sent again. + * job without starting another. A definite refusal is saved under the click's action ID and replayed on resend. A + * refusal during shutdown saves nothing, so that click may be sent again, and neither does one for a click without a + * usable review token (nothing to save it under; it is refused as stale every time). */ async start(token: unknown, actionId: string, busy: () => string | null = () => null, signal?: AbortSignal): Promise { const { store, config } = this.service, identity = config.identity; @@ -95,8 +96,9 @@ export class MergeJobCoordinator { let job: MergeJob; try { ({ job } = store.beginMergeJob(identity, { actionId, token: token as string })); } catch (error) { - // The store's own refusal (a job still running on disk after its ending write failed, or a closed task) is - // definite too: saved and replayed like the refusals above. + // The store's own refusal (a job still running on disk after its ending write failed) is definite too: saved and + // replayed like the refusals above. Its closed-task refusal is defensive here: admission refused that task above, + // in this same turn. if (error instanceof GuardRefusal && !(error instanceof ActionIdReused)) store.recordMergeJobRefusal(identity, { actionId, token: token as string, reason: error.message }); throw error; diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 8aac0154..7729595a 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { afterEach, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; -import { GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; +import { ActionIdReused, GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; import { JOB_RESOLVES, MergeJobCoordinator, SETTLED_BEFORE_JOB_MERGE, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; import type { PreMergeCoordinator, PreMergeResult } from '../runner/pre-merge.ts'; @@ -642,6 +642,20 @@ it('settles a job a crash left in its merge step from its merge attempt at start } }); +it('refuses a merge job for a closed task in the store itself', () => { + const store = publishedTask(); + expect(store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); + expect(() => store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' })) + .toThrow(new GuardRefusal('The task is cancelled; it cannot be merged.')); +}); + +it('returns a saved refusal to a resend of the same click and refuses its ID for another request', () => { + const store = publishedTask(), id = randomUUID(); + const saved = store.recordMergeJobRefusal(identity, { actionId: id, token: 'review-token', reason: 'The runner is busy.' }); + expect(store.recordMergeJobRefusal(identity, { actionId: id, token: 'review-token', reason: 'Something else.' })).toEqual(saved); + expect(() => store.recordMergeJobRefusal(identity, { actionId: id, token: 'other-token', reason: 'The runner is busy.' })).toThrow(ActionIdReused); +}); + it('leaves a job that is no longer running unchanged when asked to update it', () => { const store = publishedTask(), id = randomUUID(); store.beginMergeJob(identity, { actionId: id, token: 'review-token' }); From 0c7171c63227a0107483c6d1ee14e68dad5545dd Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 10:53:46 -0700 Subject: [PATCH 141/163] Say how start() refuses a tokenless click, and use production's 503 and 409 shapes in merge-click fixtures Co-Authored-By: Claude Opus 5.5 --- runner/merge-job.ts | 2 +- test/browser/review.spec.ts | 7 ++++--- test/merge-job.test.ts | 12 ++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/runner/merge-job.ts b/runner/merge-job.ts index bf908737..977b6616 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -61,7 +61,7 @@ export class MergeJobCoordinator { * Admit the click and start its job after the caller's transaction commits. A resend of the same click returns its * job without starting another. A definite refusal is saved under the click's action ID and replayed on resend. A * refusal during shutdown saves nothing, so that click may be sent again, and neither does one for a click without a - * usable review token (nothing to save it under; it is refused as stale every time). + * usable review token (it is refused, as stale or because a job runs, and nothing is saved). */ async start(token: unknown, actionId: string, busy: () => string | null = () => null, signal?: AbortSignal): Promise { const { store, config } = this.service, identity = config.identity; diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 5e9ec6da..8bcad799 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -843,7 +843,7 @@ test('keeps a draft typed while a merge-job click is in flight (F6b)',async({pag await expect(page.getByLabel('Question about this item')).toHaveValue('Typed while the merge request was in flight.'); }); -for(const refusal of [{name:'refused',status:409,error:'Issue #1 is not trusted for its current author.'},{name:'not applied',status:503,error:'The server is shutting down.'}]) +for(const refusal of [{name:'refused',status:409,error:'Issue #1 is not trusted for its current author.'},{name:'not applied',status:503,error:'The review server is shutting down.'}]) test(`keeps a draft typed while a merge-job click is in flight and then ${refusal.name} (F6b)`,async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; @@ -851,7 +851,8 @@ test(`keeps a draft typed while a merge-job click is in flight and then ${refusa 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}); let release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - await held;await route.fulfill({status:refusal.status,contentType:'application/json',body:JSON.stringify({error:refusal.error})});}); + // A merge-job refusal (409) carries the task's latest job, none here; a shutdown (503) carries only its error. + await held;await route.fulfill({status:refusal.status,contentType:'application/json',body:JSON.stringify(refusal.status===409?{error:refusal.error,job:null}:{error:refusal.error})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); @@ -1203,7 +1204,7 @@ test('keeps focus on the Merge button when a merge-job click is refused (F6b)',a 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}); let mergeClicks=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; - await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Issue #1 is not trusted for its current author.'})});}); + await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Issue #1 is not trusted for its current author.',job:null})});}); // A runner server reports merging as a job (its merge status carries `job`). await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 7729595a..aea04a78 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -1227,6 +1227,18 @@ it.each([['an empty', ''], ['a non-string', 42]] as const)('refuses a click with expect(f.prep.calls).toEqual([]); }); +it('refuses a click without a usable token while a job runs as already running, saving nothing', async () => { + const f = setup({ checks: () => [pending()] }), id = randomUUID(); + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()).toMatchObject({ state: 'running', step: 'checks' })); + const refused = await f.coordinator.start('', id).then(() => null, (error: unknown) => error); + expect(refused).toBeInstanceOf(GuardRefusal); + expect((refused as Error).message).toBe('A merge is already running for this task.'); + expect(f.store.getMergeJob(identity, id)).toBeNull(); + f.coordinator.cancel(); + await f.coordinator.current; +}); + it('saves no prepare-merge action when preparation can no longer start once the job\'s first review load resolves', async () => { // Defensive at the coordinator's boundary (see #run): the server refuses a standalone preparation while a job runs and // stops the job before preparation at shutdown, so the fake's startability changes directly. From 250abadc55a52aa70c0f4b56657e86787d49a592 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 11:29:02 -0700 Subject: [PATCH 142/163] Test that a resent merge click replays its own job after a later job started Co-Authored-By: Claude Opus 5.5 --- test/runner-publishing.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 8626d58f..a003abdc 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -2161,6 +2161,38 @@ describe('the one-click merge job (F6b of #22)', () => { expect(resent).toMatchObject({ status: 200, body: { job: { id: served.store.getMergeJob(served.identity, id)!.id } } }); }, 60_000); + it('replays a click\'s own job on resend after that job ended and another click started a later job', async () => { + const w = world(); mergeable(w); + let storeRef: Store | null = null; + // A failing required check ends each job blocked, which leaves the task mergeable again (Retry merge). + const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'failure' }]); + const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const first = await review(served.app), clickA = randomUUID(); + expect((await merge(served.app, first.token, clickA)).body.job).toMatchObject({ id: clickA }); + await vi.waitFor(() => expect(served.store.getMergeJob(served.identity, clickA)).toMatchObject({ state: 'blocked' }), { timeout: 10_000 }); + // Another tab reads the review again and clicks Retry merge: job B is now the task's latest job. + const clickB = randomUUID(); + expect((await merge(served.app, (await review(served.app)).token, clickB)).body.job).toMatchObject({ id: clickB }); + await vi.waitFor(() => expect(served.store.getMergeJob(served.identity, clickB)).toMatchObject({ state: 'blocked' }), { timeout: 10_000 }); + expect(served.store.latestMergeJob(served.identity)).toMatchObject({ id: clickB }); + // Click A, resent (its answer was lost): the answer is job A, not the task's latest job. + expect(await merge(served.app, first.token, clickA)).toMatchObject({ status: 200, body: { job: { id: clickA, state: 'blocked' } } }); + // The same when the resend's reload fails: both jobs have ended, so the only review load now is that reload. + const service = served.app.service, realLoad = service.load.bind(service); + let failed = false; + service.load = async (options?: Parameters[0]) => { + if (!failed) { failed = true; throw new Error('History work exceeded its overall deadline.'); } + return realLoad(options); + }; + try { + expect(await merge(served.app, first.token, clickA)).toMatchObject({ status: 200, body: { job: { id: clickA, state: 'blocked' }, mergeRefreshRequired: true } }); + expect(failed).toBe(true); + } finally { service.load = realLoad; } + }, 60_000); + it('answers a merge click with the job\'s latest record when it ended during the click\'s reload', async () => { const w = world(); mergeable(w); let storeRef: Store | null = null, slow = false; From 986a517a13bebda30149a8f0704f4bf8362a736c Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 11:29:02 -0700 Subject: [PATCH 143/163] Say why the stopping check after a merge job is defensive Co-Authored-By: Claude Opus 5.5 --- web/server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/server.ts b/web/server.ts index 7895303c..5501bf4b 100644 --- a/web/server.ts +++ b/web/server.ts @@ -760,7 +760,10 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge } const running=mergeJobs.current; // The job's run never rejects, but this callback's own promise would: it must not throw (an unhandled rejection - // exits the server), so a failed read or close start is logged instead. + // exits the server), so a failed read or close start is logged instead. The `stopping` term is defensive: + // shutdown sets it in the same synchronous turn as it closes publishing, and a closing publisher starts no close + // (its `owed` reads the shutdown refusal as nothing owed) and has already aborted any publish in progress. The + // cancelled task's pull requests are closed by the next startup, whose publish of owed work finds the close owed. if(running)void running.finally(()=>{ try { if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); } catch (error) { console.error(`Could not close the cancelled task's pull requests after its merge job: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } From 365a7205be9c9ee871b793b07b544f3bfac27d68 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 11:29:02 -0700 Subject: [PATCH 144/163] Refuse a merge click with a too-long token as stale without saving it Co-Authored-By: Claude Opus 5.5 --- runner/merge-job.ts | 7 ++++--- runner/store.ts | 6 ++++-- test/merge-job.test.ts | 6 +++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 977b6616..8cf68398 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -3,7 +3,7 @@ import { ActionIdReused, GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, se import { MergeBlocked, MergeOutcomeUnknown, PUSH_UNSETTLED, type MergeCoordinator, type MergeStatus } from './merge.ts'; import type { PreMergeCoordinator } from './pre-merge.ts'; import type { ReviewService } from './review.ts'; -import { FIX_THEN_RETRY, type MergeAttempt, type MergeJob, type MergeJobState } from './store.ts'; +import { FIX_THEN_RETRY, MAX_MERGE_JOB_TOKEN_LENGTH, type MergeAttempt, type MergeJob, type MergeJobState } from './store.ts'; /** How long a job waits for GitHub's required checks on the pushed head (docs/implementation/merge-handoff.md). */ export const REQUIRED_CHECK_WAIT_MS = 30 * 60_000; @@ -61,7 +61,8 @@ export class MergeJobCoordinator { * Admit the click and start its job after the caller's transaction commits. A resend of the same click returns its * job without starting another. A definite refusal is saved under the click's action ID and replayed on resend. A * refusal during shutdown saves nothing, so that click may be sent again, and neither does one for a click without a - * usable review token (it is refused, as stale or because a job runs, and nothing is saved). + * usable review token (missing, not a string, or too long): it is refused, as stale or because a job runs, and nothing + * is saved, since the store saves a refusal only under a usable token. */ async start(token: unknown, actionId: string, busy: () => string | null = () => null, signal?: AbortSignal): Promise { const { store, config } = this.service, identity = config.identity; @@ -90,7 +91,7 @@ export class MergeJobCoordinator { const refusal = this.#admissionRefusal(token, reviewToken, busy); if (refusal !== null) { // A definite refusal is this click's outcome; a resend replays it (as the guarded merge does). - if (typeof token === 'string' && token) store.recordMergeJobRefusal(identity, { actionId, token, reason: refusal }); + if (typeof token === 'string' && token && token.length <= MAX_MERGE_JOB_TOKEN_LENGTH) store.recordMergeJobRefusal(identity, { actionId, token, reason: refusal }); throw new GuardRefusal(refusal); } let job: MergeJob; diff --git a/runner/store.ts b/runner/store.ts index 078b9902..1268831a 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -90,6 +90,8 @@ export interface PreMergeReadiness { export const MAX_REWRITE_LINEAGE_ROWS = 1_000; /** The next step after a failing required check or GitHub's refusal blocks a merge job (docs/implementation/merge-handoff.md). */ export const FIX_THEN_RETRY = 'Fix it, then Retry merge.'; +/** The longest review token a merge job or its saved refusal is saved under. */ +export const MAX_MERGE_JOB_TOKEN_LENGTH = 512; /** What a completed `prepare-merge` action replays after its background coordinator settles. */ export function preMergeActionResponse(result: PreMergeActionResult) { return { outcome: result.state, base: result.base, head: result.head, checked: result.checked, reason: result.reason }; @@ -2137,7 +2139,7 @@ export class Store { */ beginMergeJob(identity: PlanIdentity, input: { actionId: string; token: string; now?: string }): { job: MergeJob; resent: boolean } { assertUuidV4(input.actionId, 'Action ID'); - if (typeof input.token !== 'string' || !input.token || input.token.length > 512) throw new Error('A review token is required.'); + if (typeof input.token !== 'string' || !input.token || input.token.length > MAX_MERGE_JOB_TOKEN_LENGTH) throw new Error('A review token is required.'); const key = identityKey(identity); return this.#transaction(() => { const task = this.#task(key); @@ -2163,7 +2165,7 @@ export class Store { */ recordMergeJobRefusal(identity: PlanIdentity, input: { actionId: string; token: string; reason: string }): MergeJob { assertUuidV4(input.actionId, 'Action ID'); - if (typeof input.token !== 'string' || !input.token || input.token.length > 512) throw new Error('A review token is required.'); + if (typeof input.token !== 'string' || !input.token || input.token.length > MAX_MERGE_JOB_TOKEN_LENGTH) throw new Error('A review token is required.'); const key = identityKey(identity); return this.#transaction(() => { this.#task(key); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index aea04a78..42929343 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { afterEach, expect, it, vi } from 'vitest'; -import { Store } from '../runner/store.ts'; +import { MAX_MERGE_JOB_TOKEN_LENGTH, Store } from '../runner/store.ts'; import { ActionIdReused, GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; import { JOB_RESOLVES, MergeJobCoordinator, SETTLED_BEFORE_JOB_MERGE, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; @@ -1215,13 +1215,13 @@ it('answers shutdown, not the load\'s own error, when the admission load fails f expect(f.store.getMergeJob(identity, id)).toBeNull(); }); -it.each([['an empty', ''], ['a non-string', 42]] as const)('refuses a click with %s token as stale, saving nothing, and refuses its resend the same way', async (_kind, token) => { +it.each([['an empty', ''], ['a non-string', 42], ['a too long', 'a'.repeat(MAX_MERGE_JOB_TOKEN_LENGTH + 1)]] as const)('refuses a click with %s token as stale, saving nothing, and refuses its resend the same way', async (_kind, token) => { const f = setup(), id = randomUUID(); for (let send = 0; send < 2; send++) { const refused = await f.coordinator.start(token, id).then(() => null, (error: unknown) => error); expect(refused).toBeInstanceOf(GuardRefusal); expect((refused as Error).message).toBe('Stale review state. Refresh before merging.'); - // A refusal is saved under the click's token, so a click with none has nothing to save it under. + // A refusal is saved under the click's token, so a click without a usable one has nothing to save it under. expect(f.store.getMergeJob(identity, id)).toBeNull(); } expect(f.prep.calls).toEqual([]); From cda7852c1762027032af3c2d23bdb0c11b748c40 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 11:29:02 -0700 Subject: [PATCH 145/163] Give the ended-job browser fixture the revision production saves Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 8bcad799..0cee83ab 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1302,11 +1302,11 @@ test('asks for a refresh when the click is answered with a merge job that has al const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=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}); - const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[],reason:string|null=null)=>mergeJobRecord({state,step,checks,reason}); let polls=0; - // The job failed before the click was answered (its review load failed as the click's reload did). + // The job failed before the click was answered: its first review load failed, as the click's reload did, so it was saved + // twice (preparing at revision 1, failed at revision 2) and never reached the merge (no merge action ID). await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:job('failed',null,[],'History work exceeded its overall deadline.'),mergeRefreshRequired:true})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:mergeJobRecord({state:'failed',step:null,reason:'History work exceeded its overall deadline.',revision:2}),mergeRefreshRequired:true})});}); await page.route('**/api/merge',async route=>{polls++;await route.continue();}); // A runner server reports merging as a job (its merge status carries `job`), as production does. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); From 84d93f0011bc98a0c06597343e4520128214129c Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 12:02:43 -0700 Subject: [PATCH 146/163] Refuse a reused click ID before checking its token, and pin a job's guarded-merge refusal reason exactly Co-Authored-By: Claude Opus 5.5 --- runner/merge-job.ts | 3 ++- runner/store.ts | 4 +++- test/merge-job.test.ts | 9 ++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 8cf68398..918ae244 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -62,7 +62,8 @@ export class MergeJobCoordinator { * job without starting another. A definite refusal is saved under the click's action ID and replayed on resend. A * refusal during shutdown saves nothing, so that click may be sent again, and neither does one for a click without a * usable review token (missing, not a string, or too long): it is refused, as stale or because a job runs, and nothing - * is saved, since the store saves a refusal only under a usable token. + * is saved, since the store saves a refusal only under a usable token. A resend under an action ID already saved with + * another token is refused as a reused ID. */ async start(token: unknown, actionId: string, busy: () => string | null = () => null, signal?: AbortSignal): Promise { const { store, config } = this.service, identity = config.identity; diff --git a/runner/store.ts b/runner/store.ts index 1268831a..29fe9197 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2139,10 +2139,11 @@ export class Store { */ beginMergeJob(identity: PlanIdentity, input: { actionId: string; token: string; now?: string }): { job: MergeJob; resent: boolean } { assertUuidV4(input.actionId, 'Action ID'); - if (typeof input.token !== 'string' || !input.token || input.token.length > MAX_MERGE_JOB_TOKEN_LENGTH) throw new Error('A review token is required.'); const key = identityKey(identity); return this.#transaction(() => { const task = this.#task(key); + // A saved click is looked up first, so a resend under its ID with any other token (unusable ones too) is refused + // as a reused ID, as a different usable one is. const existing = this.#get('SELECT data FROM merge_jobs WHERE id=?', input.actionId); if (existing) { const job = decode(existing.data); @@ -2150,6 +2151,7 @@ export class Store { throw new ActionIdReused('Action ID already used for a different request.'); return { job, resent: true }; } + if (typeof input.token !== 'string' || !input.token || input.token.length > MAX_MERGE_JOB_TOKEN_LENGTH) throw new Error('A review token is required.'); if (this.#closed(task.status as TaskStatus)) throw new GuardRefusal(`The task is ${task.status}; it cannot be merged.`); if (this.runningMergeJob(identity)) throw new GuardRefusal('A merge is already running for this task.'); const now = input.now ?? new Date().toISOString(); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 42929343..60d0c3c6 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -649,6 +649,13 @@ it('refuses a merge job for a closed task in the store itself', () => { .toThrow(new GuardRefusal('The task is cancelled; it cannot be merged.')); }); +it.each([['an empty', ''], ['a non-string', 42]] as const)('refuses a resend with %s token under a saved click as a reused ID', async (_kind, token) => { + const f = setup(), id = randomUUID(); + await f.coordinator.start('review-token', id, () => 'The runner is busy.').catch(() => undefined); + expect(f.store.getMergeJob(identity, id)).toMatchObject({ refused: true }); + await expect(f.coordinator.start(token, id)).rejects.toThrow(ActionIdReused); +}); + it('returns a saved refusal to a resend of the same click and refuses its ID for another request', () => { const store = publishedTask(), id = randomUUID(); const saved = store.recordMergeJobRefusal(identity, { actionId: id, token: 'review-token', reason: 'The runner is busy.' }); @@ -708,7 +715,7 @@ it('decides a retried job only from its own merge attempt, never from an earlier // Retry: the guarded merge's second validation pass now sees a conflict, so it refuses before asking GitHub. refuse = false; conflictFrom = f.gh.inspections() + 3; await f.coordinator.start('review-token', randomUUID()); - expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/merge conflicts/) }); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: 'Merge requirements changed during validation. The pull request has merge conflicts.' }); // At startup too: a job that saved its merge action ID but never got an attempt fails, whatever older attempts say. const crashed = randomUUID(); f.store.beginMergeJob(identity, { actionId: crashed, token: 'review-token' }); From 38e54cc8e21ffd8532fa4f0bcc9fc186134f495c Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 12:33:59 -0700 Subject: [PATCH 147/163] Test that a merge click ID saved under another plan is refused as reused Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 60d0c3c6..3f8f00ac 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -663,6 +663,18 @@ it('returns a saved refusal to a resend of the same click and refuses its ID for expect(() => store.recordMergeJobRefusal(identity, { actionId: id, token: 'other-token', reason: 'The runner is busy.' })).toThrow(ActionIdReused); }); +it('refuses a click ID saved under another plan as a reused ID, even with the same token', () => { + const store = publishedTask(), id = randomUUID(); + const other = { ...identity, taskId: 'Task_43', planId: 'other' }; + store.createPlan(JSON.stringify(plan), 'json', { ...context, identity: other }, oid(1), oid(2)); + store.beginMergeJob(identity, { actionId: id, token: 'review-token' }); + expect(() => store.beginMergeJob(other, { actionId: id, token: 'review-token' })).toThrow(ActionIdReused); + expect(() => store.recordMergeJobRefusal(other, { actionId: id, token: 'review-token', reason: 'The runner is busy.' })).toThrow(ActionIdReused); + expect(store.getMergeJob(other, id)).toBeNull(); + expect(store.latestMergeJob(other)).toBeNull(); + expect(store.getMergeJob(identity, id)).toMatchObject({ state: 'running' }); +}); + it('leaves a job that is no longer running unchanged when asked to update it', () => { const store = publishedTask(), id = randomUUID(); store.beginMergeJob(identity, { actionId: id, token: 'review-token' }); From 03f11fd8d7885da09e6434bf0cbcd6307767d1f0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 12:35:55 -0700 Subject: [PATCH 148/163] Test the snapshot and plan-revision terms of a refused job merge, and say when each can land Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- runner/store.ts | 4 ++- test/merge-job.test.ts | 43 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 2f9d1205..82ce6403 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -33,7 +33,7 @@ Without a runner, **Merge PR** works as before. | Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. If a resend of the same click was admitted while this load or the click's trust read was pending, a failure of either answers that resend's job. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again. A trust read that timed out, or that gh answered, keeps that answer even when shutdown started while gh stopped | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | -| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed before the job read it, the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | +| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed before the job read it, the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit or a new snapshot from the review screen's HEAD observation may land while the attempt is in flight; a plan revision only after a merge-queue poll ended the attempt and before the job read it, as plan writes wait while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. diff --git a/runner/store.ts b/runner/store.ts index 29fe9197..932020aa 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2218,7 +2218,9 @@ export class Store { reason: 'The review or task changed while waiting for required checks; the preparation no longer applies.' }); } // GitHub refused the job's own merge attempt: the block applies only to the review that attempt was admitted for. - // A review edit, new snapshot or plan revision while GitHub was asked leaves the changed task as it is. + // A review edit, new snapshot or plan revision while GitHub was asked leaves the changed task as it is. A review edit + // or the review screen's HEAD observation (a new snapshot) may land while the attempt is in flight; a plan revision + // only once the attempt ended (#savePlan waits for an in-flight merge), such as a queue poll ending it before the job read it. const attempt = job.mergeActionId ? this.getMergeAttempt(identity) : null; if (attempt && attempt.actionId === job.mergeActionId) { const plan = this.#current(key); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 3f8f00ac..9740ca9a 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -221,6 +221,49 @@ it('ends failed, leaving the task in review, when the review changed while GitHu expect(f.store.getTask(identity).status).toBe('in review'); }); +it('ends failed, leaving the task in review, when a new snapshot was observed while GitHub was asked to merge and refused', async () => { + let storeRef: Store | null = null, before = ''; + const f = setup({ merge: async () => { + // The review screen's HEAD observation (no ledger entries) may run during a merge; it saves a new snapshot. + const store = storeRef!, snapshot = store.getSnapshot(identity); + store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, oid(1), oid(2), []); + throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); + } }); + storeRef = f.store; before = f.store.getSnapshot(identity).id; + const reviewVersion = f.store.reviewVersion(identity); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge; review it first\.$/) }); + // Only the snapshot changed: the plan revision and review version are those the attempt was admitted for. + expect(f.store.getSnapshot(identity).id).not.toBe(before); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed', revision: 1, reviewVersion, snapshotId: before }); + expect(f.store.reviewVersion(identity)).toBe(reviewVersion); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('ends failed, leaving the task in review, when a plan revision lands after a queue poll removed the attempt and before the job resumed', async () => { + const f = setup({ queue: true }); + const inspectQueue = f.gh.client as unknown as { inspectQueue: ReturnType }; + inspectQueue.inspectQueue.mockImplementationOnce(async (head: string) => + ({ state: 'removed', reviewedHead: head, removedAt: '2026-10-11T00:01:00Z', reason: 'The merge queue removed it: checks failed.' })); + const merge = f.merges.merge.bind(f.merges); + const reviewVersion = f.store.reviewVersion(identity), snapshotId = f.store.getSnapshot(identity).id; + f.merges.merge = (async (...args: Parameters) => { + const result = await merge(...args); + await f.merges.pollQueue(); + // The removed attempt no longer holds plan writes back, so a plan import lands before the job reads the attempt. + f.store.importRevision(JSON.stringify({ ...plan, revision: 2 }), 'json', context, 1); + return result; + }) as typeof merge; + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + expect(f.store.getPlan(identity).revision).toBe(2); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ actionId: done.mergeActionId, state: 'removed', revision: 1, snapshotId }); + expect(f.store.reviewVersion(identity)).toBe(reviewVersion); + expect(f.store.getSnapshot(identity).id).toBe(snapshotId); + expect(done).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge; review it first\.$/) }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + it('ends failed, leaving the task in review, when a failing check arrives after the review changed', async () => { let storeRef: Store | null = null; const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }], during: () => { From 98db23f2d6cf786b18b53dd36b6588ce5177cf1d Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 12:35:55 -0700 Subject: [PATCH 149/163] Label the already-fixed range's outcome check defensive Co-Authored-By: Claude Opus 5.5 --- runner/merge.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/runner/merge.ts b/runner/merge.ts index 38c6a215..1dd0a6f0 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -154,6 +154,7 @@ export class MergeCoordinator { const first = here.find(pr => pr.base === base); const check = first?.checkId ? store.getAlreadyFixed(identity, first.checkId) : null; const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); + // The outcome term is defensive: beginPullRequest saves a check ID only for a clear check (#assertCheckedHead). if (check?.result.outcome !== 'clear') return { alreadyFixedSince: original, ownCommits }; let scannedFrom: string | null = null; try { scannedFrom = store.getSnapshot(identity, check.snapshotId).base; } catch { /* unknown: scan from the original base */ } From 69b3e0502b65ec6cc058a18dd53cdb95f37bd462 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 12:56:31 -0700 Subject: [PATCH 150/163] Test queue-poll and plan-import interleavings in their reachable order In one process the guarded merge saves its outcome and the job reads it in one turn, so a queue poll or plan import can only land while GitHub's merge command is in flight. Hold the gateway's merge in the removed/failed and plan-revision tests, run the poll and import while it is held, and assert the guarded merge rejects. Label the fulfilled path's failed/removed branch defensive. Observe a moved HEAD in the new-snapshot test, as production's load does. Plan writes are refused, not delayed, during a merge. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 4 +- runner/merge-job.ts | 8 ++- runner/store.ts | 2 +- test/merge-job.test.ts | 82 +++++++++++++++++++--------- 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 82ce6403..bea668ce 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -33,7 +33,7 @@ Without a runner, **Merge PR** works as before. | Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. If a resend of the same click was admitted while this load or the click's trust read was pending, a failure of either answers that resend's job. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again. A trust read that timed out, or that gh answered, keeps that answer even when shutdown started while gh stopped | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | -| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed before the job read it, the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit or a new snapshot from the review screen's HEAD observation may land while the attempt is in flight; a plan revision only after a merge-queue poll ended the attempt and before the job read it, as plan writes wait while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | +| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit or a new snapshot from the review screen's HEAD observation may land while the attempt is in flight; a plan revision only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. @@ -86,7 +86,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Further regressions cover a cancel, then shutdown, before the job's own review load rejects (the job ends `cancelled`), a queue poll that records `removed` or `failed` after the guarded merge saved `queued` and before the job resumes (the job ends `blocked`), and an inspection that finds the checks passing, or one failing, but finishes after the wait limit before its timer runs (a timeout, or the failing check). Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a job the poll sees end `queued` is followed through GitHub's queue to merged without a refresh, from a review with no queue, that a resent click whose answer replays its ended job follows the later running job the reloaded review carries, that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Further regressions cover a cancel, then shutdown, before the job's own review load rejects (the job ends `cancelled`), a queue poll that records `removed` or `failed` while GitHub is asked to merge (the guarded merge rejects and the job ends `blocked`; with a plan import after the poll, the job ends `failed` and the task stays in review), and an inspection that finds the checks passing, or one failing, but finishes after the wait limit before its timer runs (a timeout, or the failing check). Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a job the poll sees end `queued` is followed through GitHub's queue to merged without a refresh, from a review with no queue, that a resent click whose answer replays its ended job follows the later running job the reloaded review carries, that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. ## Check this document with a reader diff --git a/runner/merge-job.ts b/runner/merge-job.ts index 918ae244..ba9ab9e9 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -202,8 +202,9 @@ export class MergeJobCoordinator { // is applied; after, the outcome is unknown. const merged = await this.merges.merge(view.token, mergeId, signal); // A fulfilled merge does not prove its outcome: when saving it fails, the attempt stays submitting and reconciles - // from GitHub; a queue poll may also have ended the attempt (removed, failed or merged) after it was saved queued - // and before this job resumed. This job's own attempt decides, as when the merge rejects. + // from GitHub. This job's own attempt decides, as when the merge rejects. A queue poll that ended the attempt + // (removed or failed) while GitHub was asked makes the guarded merge reject instead; one ending it after the save is + // defensive here: the save and this read run in one turn, and a poll's result needs I/O. const attempt = store.getMergeAttempt(identity); if (attempt?.actionId !== mergeId || attempt.state === 'submitting') return this.#finish(job.id, 'submitted', 'GitHub accepted the merge, but its outcome was not saved; it reconciles from GitHub.'); @@ -228,6 +229,9 @@ export class MergeJobCoordinator { */ #endAsAttempt(job: MergeJob, attempt: MergeAttempt, reason: string, url: string | null): void { if (attempt.state === 'merged' || attempt.state === 'queued') return this.#finish(job.id, attempt.state, null, url); + // Reached through the merge's rejection. After a fulfilled merge this is defensive (see #run): a removal or failure + // recorded while GitHub was asked makes the guarded merge reject, and nothing can end the attempt after its save + // before the job reads it. if (attempt.state === 'failed' || attempt.state === 'removed') { // A refusal because the head changed needs a fresh review, not a retry: the task is not "approved" any more. if (attempt.requiresFreshReview) diff --git a/runner/store.ts b/runner/store.ts index 932020aa..a604eeb4 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2220,7 +2220,7 @@ export class Store { // GitHub refused the job's own merge attempt: the block applies only to the review that attempt was admitted for. // A review edit, new snapshot or plan revision while GitHub was asked leaves the changed task as it is. A review edit // or the review screen's HEAD observation (a new snapshot) may land while the attempt is in flight; a plan revision - // only once the attempt ended (#savePlan waits for an in-flight merge), such as a queue poll ending it before the job read it. + // only once the attempt ended (#savePlan refuses while a merge is in flight), such as a queue poll ending it while GitHub was asked. const attempt = job.mergeActionId ? this.getMergeAttempt(identity) : null; if (attempt && attempt.actionId === job.mergeActionId) { const plan = this.#current(key); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 9740ca9a..5e5413d3 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -103,6 +103,24 @@ function setup(options: Parameters[1] & { prepare?: Parameters { await coordinator.current; return coordinator.latest()!; }; +/** GitHub's merge command, held once asked until released; `started` resolves when the guarded merge asks. */ +function holdGatewayMerge() { + let release!: () => void, started!: () => void, calls = 0; + const gate = new Promise(resolve => { release = resolve; }); + const asked = new Promise(resolve => { started = resolve; }); + const merge = async () => { calls++; started(); await gate; return { url: 'https://github.com/owner/repo/pull/7' }; }; + return { merge, release, started: asked, calls: () => calls }; +} +/** Records how the guarded merge the job calls settled: fulfilled, or the error it rejected with. */ +function guardedMergeOutcome(merges: MergeCoordinator) { + const merge = merges.merge.bind(merges); + let settledAs: { fulfilled: true } | { rejected: unknown } | null = null; + merges.merge = (async (...args: Parameters) => { + try { const result = await merge(...args); settledAs = { fulfilled: true }; return result; } + catch (error) { settledAs = { rejected: error }; throw error; } + }) as typeof merge; + return () => settledAs; +} /** Holds the next review load until released or failed; a load whose signal aborts rejects with the abort's reason. */ function holdNextLoad(review: ReviewService & { view: () => unknown }) { let release!: () => void, fail!: (error: Error) => void, started!: (signal?: AbortSignal) => void; @@ -226,37 +244,50 @@ it('ends failed, leaving the task in review, when a new snapshot was observed wh const f = setup({ merge: async () => { // The review screen's HEAD observation (no ledger entries) may run during a merge; it saves a new snapshot. const store = storeRef!, snapshot = store.getSnapshot(identity); - store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, oid(1), oid(2), []); + // As production's load does, only for a HEAD that moved from the snapshot's (the user's checkout moved on). + store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, oid(1), oid(3), []); throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); } }); storeRef = f.store; before = f.store.getSnapshot(identity).id; + expect(f.store.getSnapshot(identity).head).toBe(oid(2)); const reviewVersion = f.store.reviewVersion(identity); await f.coordinator.start('review-token', randomUUID()); expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge; review it first\.$/) }); // Only the snapshot changed: the plan revision and review version are those the attempt was admitted for. + expect(f.store.getSnapshot(identity)).toMatchObject({ head: oid(3) }); expect(f.store.getSnapshot(identity).id).not.toBe(before); expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed', revision: 1, reviewVersion, snapshotId: before }); expect(f.store.reviewVersion(identity)).toBe(reviewVersion); expect(f.store.getTask(identity).status).toBe('in review'); }); -it('ends failed, leaving the task in review, when a plan revision lands after a queue poll removed the attempt and before the job resumed', async () => { - const f = setup({ queue: true }); +it('ends failed, leaving the task in review, when a queue poll removes the attempt and a plan revision lands while GitHub is asked to merge', async () => { + // GitHub's merge command is held, so the queue poll and the plan import run while it is on its way: in one process this + // is the only order (the guarded merge saves its outcome, then the job reads it, with no I/O in between). + const asked = holdGatewayMerge(); + const f = setup({ queue: true, merge: asked.merge }); const inspectQueue = f.gh.client as unknown as { inspectQueue: ReturnType }; inspectQueue.inspectQueue.mockImplementationOnce(async (head: string) => ({ state: 'removed', reviewedHead: head, removedAt: '2026-10-11T00:01:00Z', reason: 'The merge queue removed it: checks failed.' })); - const merge = f.merges.merge.bind(f.merges); + const outcome = guardedMergeOutcome(f.merges); const reviewVersion = f.store.reviewVersion(identity), snapshotId = f.store.getSnapshot(identity).id; - f.merges.merge = (async (...args: Parameters) => { - const result = await merge(...args); - await f.merges.pollQueue(); - // The removed attempt no longer holds plan writes back, so a plan import lands before the job reads the attempt. - f.store.importRevision(JSON.stringify({ ...plan, revision: 2 }), 'json', context, 1); - return result; - }) as typeof merge; await f.coordinator.start('review-token', randomUUID()); - const done = await settled(f.coordinator); + await asked.started; + // While the attempt is in flight, plan writes are refused. + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'submitting' }); + expect(() => f.store.importRevision(JSON.stringify({ ...plan, revision: 2 }), 'json', context, 1)).toThrow(/A merge is in progress/); + await f.merges.pollQueue(); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'removed' }); + // The removed attempt no longer holds plan writes back, so a plan import lands before GitHub answers. + f.store.importRevision(JSON.stringify({ ...plan, revision: 2 }), 'json', context, 1); expect(f.store.getPlan(identity).revision).toBe(2); + expect(f.coordinator.latest()).toMatchObject({ state: 'running', step: 'merging' }); + asked.release(); + const done = await settled(f.coordinator); + // The interleaving happened: GitHub accepted the command, but the guarded merge could not save it queued and rejected + // with the removal, so the job ended through its error path. + expect(asked.calls()).toBe(1); + expect(outcome()).toMatchObject({ rejected: expect.objectContaining({ message: 'The merge queue removed it: checks failed.' }) }); expect(f.store.getMergeAttempt(identity)).toMatchObject({ actionId: done.mergeActionId, state: 'removed', revision: 1, snapshotId }); expect(f.store.reviewVersion(identity)).toBe(reviewVersion); expect(f.store.getSnapshot(identity).id).toBe(snapshotId); @@ -1129,25 +1160,26 @@ it('ends the job cancelled when its own review load rejects only after the task expect(f.gh.merged).toEqual([]); }); -it.each(['removed', 'failed'] as const)('ends the job from its attempt when a queue poll records %s after the merge queued and before the job resumed', async state => { - const f = setup({ queue: true }); +it.each(['removed', 'failed'] as const)('ends the job from its attempt when a queue poll records %s while GitHub is asked to merge', async state => { + // GitHub's merge command is held, so the queue poll runs while it is on its way: in one process this is the only order + // (the guarded merge saves its outcome, then the job reads it, with no I/O in between). + const asked = holdGatewayMerge(); + const f = setup({ queue: true, merge: asked.merge }); const inspectQueue = f.gh.client as unknown as { inspectQueue: ReturnType }; inspectQueue.inspectQueue.mockImplementationOnce(async (head: string) => state === 'removed' ? { state: 'removed', reviewedHead: head, removedAt: '2026-10-11T00:01:00Z', reason: 'The merge queue removed it: checks failed.' } : { state: 'failed', reviewedHead: head, entryId: 'E', reason: 'The merge queue failed it: checks failed.' }); - // The guarded merge settles (attempt saved queued); a queue poll then ends the attempt before the job resumes. - const merge = f.merges.merge.bind(f.merges); - let queuedBeforePoll: string | undefined; - f.merges.merge = (async (...args: Parameters) => { - const result = await merge(...args); - queuedBeforePoll = f.store.getMergeAttempt(identity)?.state; - await f.merges.pollQueue(); - return result; - }) as typeof merge; + const outcome = guardedMergeOutcome(f.merges); await f.coordinator.start('review-token', randomUUID()); + await asked.started; + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'submitting' }); + await f.merges.pollQueue(); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state }); + asked.release(); const done = await settled(f.coordinator); - // The interleaving happened: the merge saved queued, and the poll ended this job's attempt before the job read it. - expect(queuedBeforePoll).toBe('queued'); + // The interleaving happened: GitHub accepted the command, but the guarded merge could not save it queued and rejected + // with the poll's reason, so the job ended through its error path. + expect(outcome()).toMatchObject({ rejected: expect.objectContaining({ message: expect.stringMatching(/^The merge queue .* it: checks failed\.$/) }) }); expect(f.store.getMergeAttempt(identity)).toMatchObject({ actionId: done.mergeActionId, state, requiresFreshReview: false }); expect(done).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge: The merge queue .* it: checks failed\. Fix it, then Retry merge\.$/) }); expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); From 4465a5d79fac724c994a54b571d8ae894df55d24 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 13:43:30 -0700 Subject: [PATCH 151/163] Label the merge click's shutdown check before its trust read as defensive Co-Authored-By: Claude Opus 5.5 --- web/server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/server.ts b/web/server.ts index 5501bf4b..6e566193 100644 --- a/web/server.ts +++ b/web/server.ts @@ -729,6 +729,9 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge // refusal saved. A failed read decides nothing: it saves nothing, and the same click may be sent again. let untrusted: string|null=null; if(config.github&&!mergeJobs.saved(input.actionId)) { + // Defensive: unreachable today. The merge check after the body is parsed already answers 503 once `stopping` + // is set, and nothing awaits between it and here; `runner.closing` is set only by shutdown, in the same turn + // as `stopping`. It keeps a later await added above from sending a trust read to GitHub during shutdown. if(stopping||runner?.closing)throw new ShuttingDownError(); let access: IssueAccess|null=null; try { access=await readIssueAccess(requestAbort.signal); } From b1808ef9d8f6f13b2a3409c6fcf8de625909184c Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 13:43:30 -0700 Subject: [PATCH 152/163] Test the direct Merge button across a render in flight, and deliver the timed-out blocked job through the poll Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 0cee83ab..421f026d 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1197,6 +1197,26 @@ test('keeps the Merge button pending while its click is in flight, across a rend await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); }); +test('keeps the direct Merge button disabled while its click is in flight, across a render',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let release!:()=>void,arrived!:()=>void; + const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{arrived=resolve;}); + // No runner: the click merges directly, and the server answers once GitHub has. + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + merge:async()=>{arrived();await released;return {url:'https://github.com/example/repo/pull/21'};}}; + app=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}); + await openReview(page,app.url);page.on('dialog',dialog=>dialog.accept()); + const button=page.locator('#merge'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click();await held; + await expect(button).toBeDisabled();await expect(button).toHaveText('Requesting merge…'); + // Selecting an item renders the page, merge control included, while the click's request is still in flight. + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.getByRole('button',{name:/P2 Document retry behavior/})).toHaveAttribute('aria-current','true'); + await expect(button).toBeDisabled();await expect(button).toHaveText('Requesting merge…'); + release(); + await expect(page.locator('#banner')).toContainText('Merge submitted.');await expect(button).toBeDisabled(); +}); + test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; @@ -1289,9 +1309,12 @@ test('states the next step a blocked merge job names, once (F6b)',async({page})= // The highest revision such a record can reach: preparing saves 1 and the checks step 2. Each inspection that finds checks // waiting saves once; with the 30-minute limit and waits of 10 s doubling to 60 s, inspections start at most at 0, 10, 30 // and 70 s, then every 60 s from 130 to 1750 s: 32 inspections, revisions 3 to 34. The timeout's block saves 35. - const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000b2',state:'blocked',step:null,reason,revision:35}); + const id='0f6b0000-0000-4000-8000-0000000000b2',blocked=mergeJobRecord({id,state:'blocked',step:null,reason,revision:35}); + // The click is answered while the job prepares; the 30-minute wait ends long after, so the poll delivers the block. await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:blocked,mergeRefreshRequired:true})});}); + const review=await clickReview(route),running=mergeJobRecord({id,state:'running',step:'preparing'}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:blocked})})); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); From 355a79445112b72f185b0f8e88f398d74bddea49 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 14:05:46 -0700 Subject: [PATCH 153/163] Test the job wording of the guarded merge's later refusals A local HEAD observation during the guarded merge advances the task's state version, so the job's preparation no longer applies. The queue correlation, queue boundary and authorization checks reuse the review loaded before the second pass, see only that blocker, and the job now words their refusal as what merging does. Co-Authored-By: Claude Opus 5.5 --- test/merge-job.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 5e5413d3..45753c5b 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -1120,6 +1120,61 @@ it('words the guarded merge\'s refusal as what merging does when the base moves await expect(f.merges.merge('review-token', randomUUID())).rejects.toThrow('The base branch moved. Rebase and review the resulting snapshot.'); }); +/** + * Another page's review load observes a local HEAD the pull request does not have (runner/review.ts records it as a new + * snapshot with no ledger entries, which a merge job does not refuse). The task's state version advances, so the job's + * preparation no longer applies; GitHub's head is unchanged. The guarded merge's later passes that reuse the review it + * loaded see only that lost readiness: the reloads before the second pass and the final check see a new review token. + */ +function observeLocalHead(store: Store): void { + const snapshot = store.getSnapshot(identity); + store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, snapshot.base, oid(3), []); +} +const UNPREPARED_IN_JOB = 'The merge has not been prepared for the current review; merging prepares it.'; + +for (const { pass, at, prefix } of [ + { pass: 'the queue-correlation check', at: 3, prefix: 'Merge requirements changed after queue correlation. ' }, + { pass: 'the queue-boundary check', at: 4, prefix: 'Merge requirements changed after queue correlation. ' }, +] as const) { + it(`words the guarded merge's refusal at ${pass} as what merging does when preparation no longer applies`, async () => { + // Inspections: 1 the job's wait, 2 and 3 the guarded merge's validation passes, 4 the queue-correlation check, + // 5 the queue-boundary check. The observation lands while inspection `at` reads GitHub; the next one refuses. + const observed: number[] = []; + let storeRef: Store | null = null; + const f = setup({ queue: true, change: () => ({ head: oid(2) }), + during: n => { if (n === at) { observeLocalHead(storeRef!); observed.push(n); } } }); + storeRef = f.store; + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + // The interleaving happened: the inspection after the observation was the guarded merge's last. + expect(observed).toEqual([at]); + expect(f.gh.inspections()).toBe(at + 1); + expect(f.store.getMergeAttempt(identity)).toBeNull(); + expect(done).toMatchObject({ state: 'failed', reason: `${prefix}${UNPREPARED_IN_JOB}` }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); + }); +} + +it('words the guarded merge\'s refusal at the authorization check as what merging does when preparation no longer applies', async () => { + // Inspections: 1 the job's wait, 2 and 3 the guarded merge's validation passes, 4 the check after authorization. + const calls: string[] = []; + const f = setup({ change: () => ({ head: oid(2) }) }); + f.merges.setAuthorization(async () => ({ + // The observation lands while the issue's trust is read again from GitHub. + refresh: async () => { calls.push(`refresh after ${f.gh.inspections()}`); observeLocalHead(f.store); }, + validate: () => { calls.push('validate'); } })); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + // The interleaving happened: both validation passes were ready, and the inspection after the refresh refused. + expect(calls).toEqual(['refresh after 3']); + expect(f.gh.inspections()).toBe(4); + expect(f.store.getMergeAttempt(identity)).toBeNull(); + expect(done).toMatchObject({ state: 'failed', reason: `Merge requirements changed during authorization validation. ${UNPREPARED_IN_JOB}` }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + it('keeps the unsettled push in the job\'s wording of the preparation blocker', async () => { const store = publishedTask(), gh = github(store); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); From f63feccc83802b4580807ef7392fafd339b0c5d1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 14:24:30 -0700 Subject: [PATCH 154/163] Mark snapshot-observation merge job paths as defensive A merge job needs a runner, and a runner server's review load reads the recorded snapshot head, so no HEAD observation records a snapshot during a job. Rename the tests that reach their state that way as defensive, correct the observeLocalHead docstring, and correct the handoff doc, blockMergeJob and guarded merge comments to match. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 4 ++-- runner/merge.ts | 3 +++ runner/store.ts | 6 ++++-- test/merge-job.test.ts | 28 +++++++++++++++++++--------- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index bea668ce..516ab2a0 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -33,13 +33,13 @@ Without a runner, **Merge PR** works as before. | Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. If a resend of the same click was admitted while this load or the click's trust read was pending, a failure of either answers that resend's job. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again. A trust read that timed out, or that gh answered, keeps that answer even when shutdown started while gh stopped | | Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | -| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit or a new snapshot from the review screen's HEAD observation may land while the attempt is in flight; a plan revision only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | +| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit (a review note) may land while the attempt is in flight. The snapshot term is defensive for a merge job: jobs need a runner, and a runner server's review load reads the recorded head, so no HEAD observation records a new snapshot. A plan revision lands only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. -With merge jobs, the merge status words the blockers the job resolves as what merging does, because the screen offers no Rebase or Prepare control: a moved base reads as above, and preparation reads "The merge has not been prepared for the current review; merging prepares it." A push of the rewritten head whose outcome is not known yet is a preparation blocker too, and keeps its own detail: "A push of the rewritten head has not settled; merging reads its outcome and prepares again." The reason a job ends `failed` with uses the same wording, both when the job's own inspection finds the blocker and when the guarded merge's inspections do (for example, the base moved after the job's last inspection): the guarded merge's refusal carries the blocker, and the job words it. Without a runner, and for a direct merge, the guarded merge's own wording is unchanged. +With merge jobs, the merge status words the blockers the job resolves as what merging does, because the screen offers no Rebase or Prepare control: a moved base reads as above, and preparation reads "The merge has not been prepared for the current review; merging prepares it." A push of the rewritten head whose outcome is not known yet is a preparation blocker too, and keeps its own detail: "A push of the rewritten head has not settled; merging reads its outcome and prepares again." The reason a job ends `failed` with uses the same wording when the job's own inspection or the guarded merge's first pass finds the blocker (for example, the base moved after the job's last inspection): the guarded merge's refusal carries the blocker, and the job words it. At the guarded merge's later passes this wording is defensive: during a job nothing changes the task's state version, and a moved base fails the pull request identity check first. Without a runner, and for a direct merge, the guarded merge's own wording is unchanged. ## Outcomes diff --git a/runner/merge.ts b/runner/merge.ts index 1dd0a6f0..d6b29995 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -491,6 +491,9 @@ export class MergeCoordinator { if (finalStatus.remote.base !== status.remote.base || finalStatus.remote.head !== status.remote.head || !samePullRequest({ status: finalStatus, resolved: finalTarget })) throw new Error('The pull request changed during merge validation. Refresh before merging.'); if (finalStatus.remote.mergeQueue !== status.remote.mergeQueue) throw new Error('Merge-queue requirements changed during validation. Refresh before merging.'); + // From here on, a MergeBlocked's job wording (runner/merge-job.ts inJobTerms) is defensive in job mode: nothing changes + // the state version during a job, the reloads catch a changed token first, and a moved base trips the PR identity + // check first. Defensive tests in test/merge-job.test.ts pin it. if (!finalStatus.ready) throw new MergeBlocked('Merge requirements changed during validation. ', finalStatus.blockers[0]); let commandStatus = finalStatus; let queueWatermark: string | null = null; diff --git a/runner/store.ts b/runner/store.ts index a604eeb4..808e7d5f 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -2219,8 +2219,10 @@ export class Store { } // GitHub refused the job's own merge attempt: the block applies only to the review that attempt was admitted for. // A review edit, new snapshot or plan revision while GitHub was asked leaves the changed task as it is. A review edit - // or the review screen's HEAD observation (a new snapshot) may land while the attempt is in flight; a plan revision - // only once the attempt ended (#savePlan refuses while a merge is in flight), such as a queue poll ending it while GitHub was asked. + // (a review note, review_version only) may land while the attempt is in flight. The snapshot term is defensive: a + // merge job needs a runner, whose review load reads the recorded head, so no HEAD observation records a snapshot. A + // plan revision lands only once the attempt ended (#savePlan refuses while a merge is in flight), such as a queue + // poll ending it while GitHub was asked. const attempt = job.mergeActionId ? this.getMergeAttempt(identity) : null; if (attempt && attempt.actionId === job.mergeActionId) { const plan = this.#current(key); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 45753c5b..1f726a10 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -239,12 +239,16 @@ it('ends failed, leaving the task in review, when the review changed while GitHu expect(f.store.getTask(identity).status).toBe('in review'); }); -it('ends failed, leaving the task in review, when a new snapshot was observed while GitHub was asked to merge and refused', async () => { +// Defensive pin. Production cannot reach this for a merge job: jobs exist only with a runner, setUpRunner sets +// review.runnerRepository before the server listens, and ReviewService.load then reads the recorded snapshot head, not +// HEAD, so the HEAD observation that records this snapshot never runs. The test keeps blockMergeJob's snapshot term +// honest should another snapshot writer become reachable during a merge. +it('(defensive) ends failed, leaving the task in review, when a new snapshot was recorded while GitHub was asked to merge and refused', async () => { let storeRef: Store | null = null, before = ''; const f = setup({ merge: async () => { - // The review screen's HEAD observation (no ledger entries) may run during a merge; it saves a new snapshot. + // Records a new snapshot with no ledger entries, as the HEAD observation of a server without a runner does. const store = storeRef!, snapshot = store.getSnapshot(identity); - // As production's load does, only for a HEAD that moved from the snapshot's (the user's checkout moved on). + // As that load does, only for a HEAD that moved from the snapshot's (the user's checkout moved on). store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, oid(1), oid(3), []); throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); } }); @@ -1121,10 +1125,13 @@ it('words the guarded merge\'s refusal as what merging does when the base moves }); /** - * Another page's review load observes a local HEAD the pull request does not have (runner/review.ts records it as a new - * snapshot with no ledger entries, which a merge job does not refuse). The task's state version advances, so the job's - * preparation no longer applies; GitHub's head is unchanged. The guarded merge's later passes that reuse the review it - * loaded see only that lost readiness: the reloads before the second pass and the final check see a new review token. + * Records a new snapshot (head oid(3), no ledger entries) through Store.recordHistory, as runner/review.ts's HEAD + * observation does on a server without a runner. That advances the task's state version, so the job's preparation no + * longer applies; GitHub's head is unchanged. The fake review service's token does not change, so the guarded merge's + * reloads pass and only the later passes' readiness check sees the change. + * + * Not a production path for a merge job: a runner server sets review.runnerRepository before it listens, so its review + * load reads the recorded snapshot head and never records a HEAD observation. The tests that use this are defensive. */ function observeLocalHead(store: Store): void { const snapshot = store.getSnapshot(identity); @@ -1136,7 +1143,9 @@ for (const { pass, at, prefix } of [ { pass: 'the queue-correlation check', at: 3, prefix: 'Merge requirements changed after queue correlation. ' }, { pass: 'the queue-boundary check', at: 4, prefix: 'Merge requirements changed after queue correlation. ' }, ] as const) { - it(`words the guarded merge's refusal at ${pass} as what merging does when preparation no longer applies`, async () => { + // Defensive pin (see observeLocalHead): in job mode production cannot change the state version during the guarded + // merge, so this later pass's job wording is unreachable there; the test keeps the wording right if that changes. + it(`(defensive) words the guarded merge's refusal at ${pass} as what merging does when preparation no longer applies`, async () => { // Inspections: 1 the job's wait, 2 and 3 the guarded merge's validation passes, 4 the queue-correlation check, // 5 the queue-boundary check. The observation lands while inspection `at` reads GitHub; the next one refuses. const observed: number[] = []; @@ -1156,7 +1165,8 @@ for (const { pass, at, prefix } of [ }); } -it('words the guarded merge\'s refusal at the authorization check as what merging does when preparation no longer applies', async () => { +// Defensive pin (see observeLocalHead): unreachable in production for a merge job, kept for the wording. +it('(defensive) words the guarded merge\'s refusal at the authorization check as what merging does when preparation no longer applies', async () => { // Inspections: 1 the job's wait, 2 and 3 the guarded merge's validation passes, 4 the check after authorization. const calls: string[] = []; const f = setup({ change: () => ({ head: oid(2) }) }); From 98ce3f793caf511a5cba815209f62d0a2b4b47e8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 14:39:22 -0700 Subject: [PATCH 155/163] Never let a completed check run without a start time outrank a started one A completed run with no start time (or the zero time) counted as the latest, so a skipped or neutral run could hide a newer failure and read as passing. Only a run not yet completed counts as the latest without a start time; a completed one counts at its completion time, or as the oldest. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- github/merge.ts | 22 +++++++++++++++++----- test/merge.test.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 516ab2a0..86d2f054 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit (a review note) may land while the attempt is in flight. The snapshot term is defensive for a merge job: jobs need a runner, and a runner server's review load reads the recorded head, so no HEAD observation records a new snapshot. A plan revision lands only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts, and a run not yet started counts as the latest. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. A run not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run with no start time counts at its completion time, or as the oldest if it has none, so it never outranks a run that started. Among runs at the same time, the one that passes least counts. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. diff --git a/github/merge.ts b/github/merge.ts index ed5effb4..df007757 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -164,14 +164,26 @@ function observedCheckState(check: Record): 'success' | 'pendin const status = String(check.state ?? '').toUpperCase(); return status === 'SUCCESS' ? 'success' : ['PENDING','EXPECTED'].includes(status) ? 'pending' : 'failure'; } +/** A real time gh reports, or NaN for none or the zero time (year 1) it gives a run that has not started or finished. */ +function observedTime(value: unknown): number { + const at = typeof value === 'string' ? Date.parse(value) : NaN; + return Number.isFinite(at) && new Date(at).getUTCFullYear() > 1 ? at : NaN; +} /** - * When an observed check run or status started, to pick the latest of several with one name. gh reports a run that has - * not started yet with no time or the zero time (year 1); such a run was queued after every started one, so it counts - * as the latest. + * When an observed check run or status started, to pick the latest of several with one name. A run not yet completed + * (queued, in progress, pending) with no start time was queued after every started one, so it counts as the latest. A + * completed run with no start time is placed at its completion time, or else counts as the oldest, so it never outranks + * a run that started (fail closed). */ function observedCheckStart(check: Record): number { - const at = typeof check.startedAt === 'string' ? Date.parse(check.startedAt) : NaN; - return Number.isFinite(at) && new Date(at).getUTCFullYear() > 1 ? at : Number.POSITIVE_INFINITY; + const started = observedTime(check.startedAt); + if (Number.isFinite(started)) return started; + const completed = typeof check.name === 'string' + ? String(check.status ?? '').toUpperCase() === 'COMPLETED' + : ['SUCCESS','FAILURE','ERROR'].includes(String(check.state ?? '').toUpperCase()); + if (!completed) return Number.POSITIVE_INFINITY; + const finished = observedTime(check.completedAt); + return Number.isFinite(finished) ? finished : Number.NEGATIVE_INFINITY; } /** Among runs with the same start, the one that passes least counts, so a tie never reads as passing. */ const checkStateRank = { failure: 0, pending: 1, success: 2 } as const; diff --git a/test/merge.test.ts b/test/merge.test.ts index 7a887c97..b07b2018 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -984,6 +984,22 @@ it.each([ { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, ], 'failure'], + ['a newer started failure over a completed skip with the zero start time', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SKIPPED', startedAt: '0001-01-01T00:00:00Z', completedAt: '0001-01-01T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a newer started failure over a completed neutral run with no start time', [ + { name: 'test', status: 'COMPLETED', conclusion: 'NEUTRAL' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a completed run with no start time placed by its completion, after an older failure', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, + ], 'success'], + ['a queued run with no start time over a completed pass', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'QUEUED', conclusion: '' }, + ], 'pending'], ['a newer commit status over an older failed one', [ { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, From d945617740a6604b299d5efad273f574ba02e604 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 15:08:57 -0700 Subject: [PATCH 156/163] Reach the F6b tests' mergeable review as a runner server does The merge job tests' server never set runnerRepository, so review loads observed the user's HEAD, and a user-side commit restoring run.sh's mode made the task mergeable; a runner server never reads that commit. The tests now open the runner-owned repository and set runnerRepository before any load, as setUpRunner does, and reach a mergeable review by amending the plan to declare the run.sh change. The shared setup asserts the review reads the runner repository and that the snapshot never moves to the user's (moved) HEAD. Co-Authored-By: Claude Opus 5.5 --- test/runner-publishing.test.ts | 125 +++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 44 deletions(-) diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index a003abdc..296da0ad 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -13,7 +13,7 @@ import type { PreparedAttempt, RunnerCoordinator, RunnerDeps } from '../runner/c import { SafetyFindings, executionDeps, type ExecutionSources, type TaskWorkspace } from '../runner/execution.ts'; import { GitBranchPusher } from '../runner/branch-push.ts'; import { PullRequestPublisher } from '../runner/publish.ts'; -import { ensureCommit, openRunnerRepository } from '../runner/runner-repository.ts'; +import { ensureCommit, openRunnerRepository, retainSnapshotCommit } from '../runner/runner-repository.ts'; import { baseBranch } from '../runner/production.ts'; import { MergeCoordinator } from '../runner/merge.ts'; import { MergeSubmissionError, type MergeGateway } from '../github/merge.ts'; @@ -240,7 +240,12 @@ async function serve(w: World, options: { before?: (service: ReviewService) => v /** The runner also admits command-check attempts, as pre-merge preparation starts them (held by `hold` too). */ checks?: boolean; /** A runner block and no github.pullRequest: merging targets the PR the runner published, after preparation. */ - runnerPublished?: boolean } = {}) { + runnerPublished?: boolean; + /** + * As a production runner server (`setUpRunner`): before any review load, the runner-owned repository holds the + * snapshot's commits and is set as `runnerRepository`, so reviews read `snapshot.head` there, never the user's HEAD. + */ + runnerReview?: boolean } = {}) { let app: App | undefined, spawns = 0, closing: Promise | undefined; const close = () => closing ??= app!.close(); let branchOf: (identity: PlanIdentity) => string = () => ''; @@ -248,6 +253,12 @@ async function serve(w: World, options: { before?: (service: ReviewService) => v const config = { ...w.demo, demo: options.demo ?? false, ...(options.issueGateway ? { github: { repository: options.issueGateway.repository, issue: 3, ...(options.runnerPublished ? {} : { pullRequest: 1 }), baseBranch: 'main' } } : {}), ...(options.runnerPublished ? { runner: {} } : {}) }; app = await startServer(config, 0, undefined, options.mergeGateway, 2_000, options.issueGateway, undefined, undefined, async service => { + if (options.runnerReview) { + const repository = await openRunnerRepository({ runnerRoot: join(w.root, 'runner'), runnerOwner: OWNER, repositoryId: service.config.identity.repositoryId, source: service.config.repository }); + const snapshot = service.store.getSnapshot(service.config.identity); + for (const commit of new Set([snapshot.base, snapshot.head])) await retainSnapshotCommit(repository, commit); + service.config.runnerRepository = repository.path; + } const identity = service.config.identity, task = service.store.getTask(identity), plan = service.store.getPlan(identity); if (task.status !== 'merged' && task.status !== 'cancelled' && service.store.unapprovedExecutionItems(identity, plan.revision).length) { const review = await service.load(); @@ -1800,9 +1811,12 @@ describe('the one-click merge job (F6b of #22)', () => { merge: async head => { merged.push(head); return { url: 'https://github.com/acme/app/pull/100' }; } }; }; let createIdentity: () => PlanIdentity = () => { throw new Error('not set'); }; - /** A review a person could merge: the demo's out-of-scope mode change undone, its unplanned changes accepted, all approved. */ - const mergeable = (w: World) => { - chmodSync(join(w.demo.repository, 'run.sh'), 0o644); git(w.demo.repository, 'commit', '-qam', 'Restore declared scope'); + /** + * The user's repository moves on after the task's snapshot: a runner server never reads it, so a review that observed + * the user's HEAD would record this commit as a new snapshot (`mergeableReview` asserts that it does not). + */ + const moveUserHead = (w: World) => { + git(w.demo.repository, 'commit', '-q', '--allow-empty', '-m', 'A local commit the runner never reads'); }; const acceptAndApprove = async (service: ReviewService) => { let view = await service.load(); @@ -1811,6 +1825,29 @@ describe('the one-click merge job (F6b of #22)', () => { for (const item of view.items) if (item.state !== 'approved') view = await service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); }; + /** + * A review a person could merge, reached as on a runner server (`runnerReview`): the person amends the plan so P2 + * declares the demo's run.sh mode change (plan import, as /api/plan/import does) and approves it, the runner completes + * every item, and the person accepts the unplanned change and approves every item. Every load reads `snapshot.head` + * in the runner-owned repository, so the snapshot never moves to the user's HEAD. + */ + const mergeableReview = async (service: ReviewService) => { + const s = service.store, id = service.config.identity, initial = s.getSnapshot(id); + expect(service.config.runnerRepository).toBeDefined(); + expect(service.reviewRepository()).toEqual({ path: service.config.runnerRepository, runnerOwned: true }); + expect(git(service.config.repository, 'rev-parse', 'HEAD')).not.toBe(initial.head); + const plan = s.getPlan(id); + plan.items.find(item => item.id === 'P2')!.files.push({ path: 'run.sh', kind: 'edit', renamed_from: null, change: 'Make the script executable.' }); + s.importRevision(JSON.stringify(plan), 'json', service.planContext(), plan.revision); + const amended = await service.load(); + s.saveReview(id, amended.expected, amended.items.map(item => approveItem(amended.plan, amended.segments, item.id, id, item.count === 0)), []); + completeAll(service); + await acceptAndApprove(service); + const view = await service.load(); + expect(view.items.every(item => item.state === 'approved')).toBe(true); + expect(view.segments.filter(segment => segment.scope === 'out-of-scope')).toEqual([]); + expect(s.getSnapshot(id)).toEqual(initial); + }; const review = async (app: App) => (await fetch(`${new URL(app.url).origin}/api/review`, { headers: { 'x-codeboost-token': app.token } })).json() as Promise>; const merge = async (app: App, token: string, actionId = randomUUID()) => { const response = await fetch(`${new URL(app.url).origin}/api/action`, { method: 'POST', headers: { 'x-codeboost-token': app.token, 'content-type': 'application/json' }, @@ -1820,10 +1857,10 @@ describe('the one-click merge job (F6b of #22)', () => { const mergeState = async (app: App) => (await fetch(`${new URL(app.url).origin}/api/merge`, { headers: { 'x-codeboost-token': app.token } })).json() as Promise>; it('offers Merge PR while only blockers the job resolves remain, then waits for checks and merges, refusing prepare-merge and publish meanwhile', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let checks: RequiredCheck[] = [{ context: 'ci', appId: null, state: 'pending' }], storeRef: Store | null = null; const gh = gateway(() => storeRef!, () => checks); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -1858,7 +1895,7 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('refuses start and resume while a merge job waits, even after a new plan revision is imported and approved; the import only ends the job failed', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null; const base = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }]); // The job's fresh inspections after its first are held until the test releases them (or the job's signal stops them), @@ -1872,7 +1909,7 @@ describe('the one-click merge job (F6b of #22)', () => { }); return base.inspect(request); } }; - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -1916,11 +1953,11 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('refuses start while a merge job prepares, even after a new plan revision is imported and approved', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, release!: () => void, preparing = false; const held = new Promise(resolve => { release = resolve; }); const gh = gateway(() => storeRef!, () => []); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, mergeGateway: gh, runnerPublished: true, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, mergeGateway: gh, runnerPublished: true, // A preparation held until the test releases it; it then fails, as a real one does once the review changed under it. preMerge: service => ({ get active() { return preparing; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, @@ -1955,7 +1992,7 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('admits issue trust before a merge job starts: an untrusted click is refused and replayed, a failed read saves nothing', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, access: 'untrusted' | 'unreadable' | 'trusted' = 'trusted', reads = 0, prepared = 0; const gh = gateway(() => storeRef!, () => []); const gate: IssueTrustGateway = { ...issueGateway, async issueAccess(number) { @@ -1963,7 +2000,7 @@ describe('the one-click merge job (F6b of #22)', () => { if (access === 'unreadable') throw new Error('GitHub returned HTTP 502'); return access === 'trusted' ? { number, authorLogin: 'member', collaborator: true } : { number, authorLogin: 'outside', collaborator: false }; } }; - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway: gate, mergeGateway: gh, runnerPublished: true, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway: gate, mergeGateway: gh, runnerPublished: true, preMerge: service => { const real = preparation(service); return { ...real, get active() { return false; }, get last() { return null; }, start: (expected: { actionId: string }) => { prepared++; return (real.start as (value: unknown) => unknown)(expected); } } as unknown as PreMergeCoordinator; } }); storeRef = served.store; createIdentity = () => served.identity; @@ -1989,11 +2026,11 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('refuses a merge click while a preparation\'s command check holds the runner, and the resend replays the refusal', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, preparing = false; const hold = Promise.withResolvers(); const gh = gateway(() => storeRef!, () => []); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, mergeGateway: gh, runnerPublished: true, checks: true, hold: hold.promise, // A preparation started by prepare-merge while the task is in review: it runs a command-check attempt through the // real runner (held until the test releases it) and waits for it to settle, as production's does. @@ -2033,7 +2070,7 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('answers 503 and saves nothing when shutdown stops a merge click\'s trust read', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, hang = false, reading = false; const gh = gateway(() => storeRef!, () => []); const gate: IssueTrustGateway = { ...issueGateway, issueAccess(number, options = {}) { @@ -2042,7 +2079,7 @@ describe('the one-click merge job (F6b of #22)', () => { // GitHub does not answer; only the request's own abort (at the end of shutdown's drain) ends the read. return new Promise((_resolve, reject) => options.signal?.addEventListener('abort', () => reject(options.signal!.reason), { once: true })); } }; - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; @@ -2057,7 +2094,7 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('answers a merge click whose trust read fails with the job a resend of the same click saved meanwhile', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, hold = false, fail!: (error: Error) => void, reading!: () => void; const held = new Promise(resolve => { reading = resolve; }); const gh = gateway(() => storeRef!, () => []); @@ -2066,7 +2103,7 @@ describe('the one-click merge job (F6b of #22)', () => { if (hold) { hold = false; reading(); return new Promise((_resolve, reject) => { fail = reject; }); } return Promise.resolve({ number, authorLogin: 'member', collaborator: true }); } }; - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); const { token } = await review(served.app), id = randomUUID(); @@ -2085,7 +2122,7 @@ describe('the one-click merge job (F6b of #22)', () => { ['timed out', () => new RemoteTimeout('Issue retrieval timed out.'), 503, 'Issue trust could not be verified: Issue retrieval timed out.'], ['gh answered', () => new CommandFailed('gh failed (exit 1): HTTP 404: Not Found', '', ''), 502, 'Issue trust could not be verified: gh failed (exit 1): HTTP 404: Not Found'], ] as const)('keeps a merge click\'s trust read failure (%s) when shutdown starts while gh stops, saving nothing', async (_name, failure, status, error) => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, hang = false, fail!: (error: Error) => void, reading!: () => void; const held = new Promise(resolve => { reading = resolve; }); const gh = gateway(() => storeRef!, () => []); @@ -2094,7 +2131,7 @@ describe('the one-click merge job (F6b of #22)', () => { reading(); return new Promise((_resolve, reject) => { fail = reject; }); } }; - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; @@ -2111,11 +2148,11 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('answers 503 when shutdown stops the reload after a merge job was saved, keeping the saved job for a resend', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, hanging = false; // From the click on, GitHub does not answer; only the caller's abort ends each read. const gh = gateway(() => storeRef!, () => [], () => hanging); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; @@ -2132,10 +2169,10 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('answers the started job when its click cannot reload the review, and the resent click returns the job', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null; const gh = gateway(() => storeRef!, () => []); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -2162,11 +2199,11 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('replays a click\'s own job on resend after that job ended and another click started a later job', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null; // A failing required check ends each job blocked, which leaves the task mergeable again (Retry merge). const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'failure' }]); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -2194,7 +2231,7 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('answers a merge click with the job\'s latest record when it ended during the click\'s reload', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, slow = false; const base = gateway(() => storeRef!, () => []); // The click's reload reads GitHub's display status (not fresh), and that read is held until the job has merged; the @@ -2203,7 +2240,7 @@ describe('the one-click merge job (F6b of #22)', () => { if (slow && !request?.fresh) await vi.waitFor(() => expect(storeRef!.latestMergeJob(createIdentity())?.state).toBe('merged'), { timeout: 40_000, interval: 50 }); return base.inspect(request); } }; - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -2216,10 +2253,10 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('answers a merge click whose reload fails with the job\'s latest record, once the job ended during that reload', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null; const gh = gateway(() => storeRef!, () => []); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -2245,10 +2282,10 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('offers Merge PR to a task in review and Retry merge once a job ended blocked, from the task\'s current status', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null; const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'failure' }]); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -2266,7 +2303,7 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('closes the pull requests of a task whose cancel waited for the merge job\'s preparation to settle', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, release!: () => void, preparing = false; const held = new Promise(resolve => { release = resolve; }); // The stub records a rebase to commits that do not exist, so it starts only once the click has been answered: the @@ -2274,7 +2311,7 @@ describe('the one-click merge job (F6b of #22)', () => { let answered!: () => void; const clickAnswered = new Promise(resolve => { answered = resolve; }); const gh = gateway(() => storeRef!, () => []); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, mergeGateway: gh, runnerPublished: true, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, mergeGateway: gh, runnerPublished: true, // A preparation caught in its push: it holds a real push marker, so a cancel waits ("stopping") until the push // settles, and clearing the marker closes the task, as production does. preMerge: service => { @@ -2315,10 +2352,10 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('stops a waiting merge job promptly on shutdown and leaves it running on disk for the next startup', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, hanging = false; const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }], () => hanging); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 30, maxDelayMs: 30 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -2338,7 +2375,7 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('closes the merge job before the store, so a job settling after the shutdown abort records its outcome', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, merging: AbortSignal | null = null; const base = gateway(() => storeRef!, () => []); // GitHub was asked to merge; after the abort it answers, a little later, with a refusal. @@ -2346,7 +2383,7 @@ describe('the one-click merge job (F6b of #22)', () => { merging = options!.signal!; merging.addEventListener('abort', () => setTimeout(() => reject(new MergeSubmissionError('Base branch was modified.', 'refused')), 300), { once: true }); }) }; - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); @@ -2371,10 +2408,10 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('stops a waiting merge job when the task is cancelled, and closes the task\'s pull requests', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null; const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }]); - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, // Waits far longer than this test: only the cancel route's own stop can end the job in time. mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 60_000, maxDelayMs: 60_000 } }); storeRef = served.store; createIdentity = () => served.identity; @@ -2388,13 +2425,13 @@ describe('the one-click merge job (F6b of #22)', () => { }, 60_000); it('logs, and does not reject, when reading the task fails as the server follows a settled merge job', async () => { - const w = world(); mergeable(w); + const w = world(); moveUserHead(w); let storeRef: Store | null = null, release!: () => void; const answered = new Promise(resolve => { release = resolve; }); const base = gateway(() => storeRef!, () => []); // GitHub answers the merge only once the click was answered, so the job settles after the click's own review reload. const gh: MergeGateway = { inspect: base.inspect, merge: async () => { await answered; return { url: 'https://github.com/acme/app/pull/100' }; } }; - const served = await serve(w, { before: async service => { completeAll(service); await acceptAndApprove(service); }, issueGateway, preMerge: preparation, mergeGateway: gh, + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); storeRef = served.store; createIdentity = () => served.identity; await publishSettled(served.app, served.identity); From eab5d2ebf1145a0d40c5990d1c9ca4ec47a985f1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 15:58:06 -0700 Subject: [PATCH 157/163] End a merge job whose review changed before its first load The click's review token was checked only at admission. A review change from another tab that landed while the job's first review load was pending made the job prepare and merge a review the person never saw. The job now ends failed, saving no prepare-merge action, when that load's token differs from the click's, as the direct merge refuses a review that changed during validation. The guarded merge keeps the current token: a rebase during preparation makes a new one, and readiness binds the review version. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 8 ++++---- runner/merge-job.ts | 10 ++++++++++ test/merge-job.test.ts | 26 +++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 86d2f054..72aac99b 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -30,10 +30,10 @@ Without a runner, **Merge PR** works as before. | Step | What happens | It continues when | It stops when | |---|---|---|---| -| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. If a resend of the same click was admitted while this load or the click's trust read was pending, a failure of either answers that resend's job. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again. A trust read that timed out, or that gh answered, keeps that answer even when shutdown started while gh stopped | -| Prepare | The job records its own `prepare-merge` action and runs preparation. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. If a resend of the same click was admitted while this load or the click's trust read was pending, a failure of either answers that resend's job. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. If the review changed between admission and the end of that load (another tab approved an item, accepted a segment, or imported a plan), its token no longer matches the click's: the job ends `failed` with "The review changed after Merge PR was clicked. Refresh to see the change.", saves no `prepare-merge` action and prepares nothing, as the direct merge refuses a review that changed during its validation. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again. A trust read that timed out, or that gh answered, keeps that answer even when shutdown started while gh stopped | +| Prepare | Only for the review the click was admitted for (see Admission). The job records its own `prepare-merge` action and runs preparation, which binds its readiness to the review version it read. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | -| Merge | The job calls the guarded merge with the current review token. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit (a review note) may land while the attempt is in flight. The snapshot term is defensive for a merge job: jobs need a runner, and a runner server's review load reads the recorded head, so no HEAD observation records a new snapshot. A plan revision lands only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | +| Merge | The job calls the guarded merge with the current review token, not the click's: a rebase during preparation makes a new snapshot and so a new token. A review change after preparation is refused through readiness, which binds the review version. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit (a review note) may land while the attempt is in flight. The snapshot term is defensive for a merge job: jobs need a runner, and a runner server's review load reads the recorded head, so no HEAD observation records a new snapshot. A plan revision lands only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. A run not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run with no start time counts at its completion time, or as the oldest if it has none, so it never outranks a run that started. Among runs at the same time, the one that passes least counts. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. @@ -86,7 +86,7 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol ## Tests -`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Further regressions cover a cancel, then shutdown, before the job's own review load rejects (the job ends `cancelled`), a queue poll that records `removed` or `failed` while GitHub is asked to merge (the guarded merge rejects and the job ends `blocked`; with a plan import after the poll, the job ends `failed` and the task stays in review), and an inspection that finds the checks passing, or one failing, but finishes after the wait limit before its timer runs (a timeout, or the failing check). Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a job the poll sees end `queued` is followed through GitHub's queue to merged without a refresh, from a review with no queue, that a resent click whose answer replays its ended job follows the later running job the reloaded review carries, that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Further regressions cover a cancel, then shutdown, before the job's own review load rejects (the job ends `cancelled`), a plan import while the job's first review load is pending (the job ends `failed` and prepares nothing), a queue poll that records `removed` or `failed` while GitHub is asked to merge (the guarded merge rejects and the job ends `blocked`; with a plan import after the poll, the job ends `failed` and the task stays in review), and an inspection that finds the checks passing, or one failing, but finishes after the wait limit before its timer runs (a timeout, or the failing check). Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a job the poll sees end `queued` is followed through GitHub's queue to merged without a refresh, from a review with no queue, that a resent click whose answer replays its ended job follows the later running job the reloaded review carries, that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. ## Check this document with a reader diff --git a/runner/merge-job.ts b/runner/merge-job.ts index ba9ab9e9..da567678 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -11,6 +11,9 @@ export const REQUIRED_CHECK_WAIT_MS = 30 * 60_000; export const REQUIRED_CHECK_FIRST_DELAY_MS = 10_000; export const REQUIRED_CHECK_MAX_DELAY_MS = 60_000; +/** Why a job ends when the review changed between the click's admission and the job's first review load. */ +export const REVIEW_CHANGED_AFTER_CLICK = 'The review changed after Merge PR was clicked. Refresh to see the change.'; + /** A job's answer to the browser: everything but the click's review token. */ export type MergeJobView = Omit; export const mergeJobView = ({ token: _token, ...job }: MergeJob): MergeJobView => job; @@ -171,6 +174,11 @@ export class MergeJobCoordinator { // The load awaited a history worker (#170). A cancel or shutdown during it ends here; otherwise the preparation is // bound to the task and review as they are now, all read in this turn (the preparation refuses if they move). if (signal.aborted || this.#cancelled()) return this.#end(job, signal); + // The click was admitted for the review its token names. A review change that landed before this load finished (an + // approval, an accepted segment, or a plan import from another tab) is one the person never saw, so the job prepares + // nothing, as the direct merge refuses a review that changed during its validation. Readiness binds the review + // version from here on, so a later change is refused by the preparation, an inspection or the guarded merge. + if (view.token !== job.token) return this.#finish(job.id, 'failed', REVIEW_CHANGED_AFTER_CLICK); const prepareId = randomUUID(), task = store.getTask(identity), snapshot = store.getSnapshot(identity); const reviewVersion = store.reviewVersion(identity); // Refused here, before the placeholder below is saved, if a preparation cannot start now (the preparation's own check @@ -197,6 +205,8 @@ export class MergeJobCoordinator { const mergeId = randomUUID(); this.settle(() => store.updateMergeJob(identity, job.id, { step: 'merging', checks: [], mergeActionId: mergeId })); view = await this.service.load({ signal }); + // The current token, not the click's: a rebase during preparation makes a new snapshot, and so a new token. The + // readiness the guarded merge requires binds the review version preparation read, so a review change since is refused. try { // The job's signal stops the guarded merge as shutdown or a deadline stops a click: before GitHub is asked, nothing // is applied; after, the outcome is unknown. diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index 1f726a10..b6d9f28b 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { MAX_MERGE_JOB_TOKEN_LENGTH, Store } from '../runner/store.ts'; import { ActionIdReused, GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; -import { JOB_RESOLVES, MergeJobCoordinator, SETTLED_BEFORE_JOB_MERGE, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; +import { JOB_RESOLVES, MergeJobCoordinator, REVIEW_CHANGED_AFTER_CLICK, SETTLED_BEFORE_JOB_MERGE, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; import type { PreMergeCoordinator, PreMergeResult } from '../runner/pre-merge.ts'; import type { ReviewService } from '../runner/review.ts'; import { MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type RemoteMergeState, type RequiredCheck } from '../github/merge.ts'; @@ -1199,6 +1199,30 @@ it('keeps the unsettled push in the job\'s wording of the preparation blocker', message: 'A push of the rewritten head has not settled; merging reads its outcome and prepares again.' }]); }); +it('ends the job failed, preparing nothing, when the review changes between the click and the job\'s first review load', async () => { + const f = setup(), id = randomUUID(); + // The token covers the plan, as the real review's does: a plan import from another tab makes a new token. + const view = f.review.view; + f.review.view = () => ({ ...(view() as object), token: f.store.getPlan(identity).revision === 1 ? 'review-token' : 'review-token-2' }); + // The admission's load passes; the job's own first load is held. + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => f.review.view()); + const held = holdNextLoad(f.review), actions = vi.spyOn(f.store, 'userAction'); + expect(await f.coordinator.start('review-token', id)).toMatchObject({ state: 'running', step: 'preparing' }); + await held.loading; + f.store.importRevision(JSON.stringify({ ...plan, revision: 2 }), 'json', context, 1); + // The interleaving happened: the click was admitted for the old review, and the review changed while the job's first + // load was pending, before it prepared anything. + expect(f.store.getPlan(identity).revision).toBe(2); + expect(f.coordinator.latest()).toMatchObject({ state: 'running', step: 'preparing' }); + expect(f.prep.calls).toEqual([]); + held.release(); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: REVIEW_CHANGED_AFTER_CLICK }); + expect(f.prep.calls).toEqual([]); + expect(actions.mock.calls.filter(([, action]) => action.kind === 'prepare-merge')).toEqual([]); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + it('ends the job cancelled when its own review load rejects only after the task was cancelled and shutdown began', async () => { const f = setup(), id = randomUUID(); // The admission's load passes; the job's own first load is held, and rejects only when the test says so (not on abort). From 27427121609236dc7e36bce6934bf9341d78cbf3 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 16:20:04 -0700 Subject: [PATCH 158/163] Build the F6b browser fixtures' merge status as a runner server does Every runner-mode merge status in the browser fixtures now goes through runnerMerge, which adds settledBeforeJobMerge as withMergeJob always does; many click answers and reviews omitted it. The poll back-off test for a changed check list now sends that list at revision 4, as production saves it, and the end at 6. Co-Authored-By: Claude Opus 5.5 --- test/browser/review.spec.ts | 95 +++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 421f026d..0a4a96d5 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -649,6 +649,9 @@ const looksInactive=(locator:import('@playwright/test').Locator)=>locator.evalua /** The blockers a runner server's review lists while a merge job prepares (runner/merge.ts): preparation has not completed * for the review, and a required check is pending. The job resolves both. */ const jobResolvedBlockers=[{code:'preparation',message:'The merge has not been prepared for the current review; merging prepares it.'},{code:'check',message:'ci is pending.'}]; +/** A runner server's merge status (runner/merge-job.ts withMergeJob): `merge` with `fields` over it, and the blocker codes a + * job settles once it asks GitHub to merge, which withMergeJob always sends. Build every runner-mode merge status with it. */ +const runnerMerge=(merge:Record,fields:Record={})=>({...merge,...fields,settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE}); /** A merge job record with every field the server sends (runner/merge-job.ts mergeJobView: the store's record without its * token), `fields` overriding the defaults. A job saves its merge action ID before the guarded merge, so one in that step or * past it (merged, queued, submitted) carries one by default. Every saved change adds one to a job's revision @@ -686,10 +689,10 @@ test('shows only the outcome once a polled merge job ends, not the blockers it r // reloaded while the job prepares, still lists them. await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route),running=job('running','preparing'); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]}),job:running})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); @@ -713,10 +716,10 @@ test('keeps every blocker when the click is answered with a job that ended witho let polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:failed,blockers:[...review.merge.blockers,head,conflict,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:failed})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:failed,blockers:[...review.merge.blockers,head,conflict,...jobResolvedBlockers]}),job:failed})});}); await page.route('**/api/merge',async route=>{polls++;await route.continue();}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); @@ -756,12 +759,12 @@ for(const variant of endedMergeJobs)test(`names a merge job that ended ${variant let polls=0,released=false; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]}),job:running})});}); // The merging step is held until the test has read its banner. await page.route('**/api/merge',async route=>{const next=polls{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); if(variant.course.some(job=>job.step==='merging')){ @@ -782,10 +785,10 @@ test('names a failed merge-job poll as a merge-status failure (F6b)',async({page const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-000000000001',state:'running',step:'checks'}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); // A job is running and no merge queue exists; GitHub's status cannot be read. await page.route('**/api/merge',route=>route.fulfill({status:502,contentType:'application/json',body:JSON.stringify({error:'GitHub returned HTTP 502.'})})); - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toHaveText(/^Could not refresh merge status\. GitHub returned HTTP 502\./,{timeout:15_000}); @@ -799,12 +802,12 @@ test('ends a running merge job the poll no longer reports, keeping every blocker const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f3',state:'running',step:'checks'}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]}),job:running})});}); // The job is no longer reported (no job record and no merge queue): the page cannot tell whether it reached the merge. let polls=0; await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:null})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); 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:'Waiting for checks…',exact:true})).toBeVisible({timeout:10_000}); @@ -829,9 +832,9 @@ test('keeps a draft typed while a merge-job click is in flight (F6b)',async({pag let started:MergeJobView|null=null,release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await held;const review=await clickReview(route),running=job(body.actionId,'running','preparing');started=running; - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); await page.getByLabel('Question about this item').fill('Before the click.'); @@ -853,7 +856,7 @@ test(`keeps a draft typed while a merge-job click is in flight and then ${refusa await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} // A merge-job refusal (409) carries the task's latest job, none here; a shutdown (503) carries only its error. await held;await route.fulfill({status:refusal.status,contentType:'application/json',body:JSON.stringify(refusal.status===409?{error:refusal.error,job:null}:{error:refusal.error})});}); - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); await page.getByLabel('Question about this item').fill('Before the click.'); @@ -879,9 +882,9 @@ async function mergeJobPollDelays(page:Page,steps:MergeJobView[]){ const [clicked,...polled]=steps;let polls=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:clicked},job:clicked})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:clicked}),job:clicked})});}); await page.route('**/api/merge',async route=>{const next=polled[Math.min(polls,polled.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:30_000}); @@ -899,10 +902,12 @@ test('backs off the merge-job poll while the job does not change, and polls soon }); test('does not back off the merge-job poll when only the waiting checks change within a step (F6b)',async({page})=>{ - const waiting=(checks:MergeJobView['checks'])=>mergeJobRecord({state:'running',step:'checks',checks}); - const pending=waiting([{context:'ci',state:'pending'}]),progressed=waiting([{context:'ci',state:'pending'},{context:'lint',state:'pending'}]); + const waiting=(checks:MergeJobView['checks'],revision:number)=>mergeJobRecord({state:'running',step:'checks',checks,revision}); + // As production saves them: the first wait at revision 3, the changed check list at 4, then merging at 5 (not polled + // here) and the end at 6. + const pending=waiting([{context:'ci',state:'pending'}],3),progressed=waiting([{context:'ci',state:'pending'},{context:'lint',state:'pending'}],4); // The click shows `pending`; poll 1 sees it again (no change); poll 2 a changed check list in the same step; poll 3 the end. - const delays=await mergeJobPollDelays(page,[pending,pending,progressed,mergeJobRecord({state:'merged',step:null})]); + const delays=await mergeJobPollDelays(page,[pending,pending,progressed,mergeJobRecord({state:'merged',step:null,revision:6})]); // 2 s after the click, doubled to 4 s after the unchanged poll; the changed checks keep it at 4 s (the step is the same, // so the delay is not reset to 2 s either). expect(delays).toEqual([2000,4000,4000]); @@ -918,11 +923,11 @@ test('starts a new action for Retry merge once the job its retained key started await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} const review=await clickReview(route),running=job(body.actionId,'running','preparing',2);started=running; - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); // The first click's job was saved and has since ended blocked: the review offers Retry merge. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,job:first?job(first,'blocked',null):null,...(first?{ready:true,action:'retry'}:{})};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{job:first?job(first,'blocked',null):null,...(first?{ready:true,action:'retry'}:{})});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toContainText('Merge blocked.'); @@ -944,11 +949,11 @@ test('starts a new action once another job became the latest after a retained ke await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} const review=await clickReview(route),running=job(body.actionId,'running','preparing',3);started=running; - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); // The first click's job ended, then another client's job ran and ended blocked: the latest job is not this key's. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,job:first?job('0f6b0000-0000-4000-8000-0000000000b2','blocked',null,2):null,...(first?{ready:true,action:'retry'}:{})};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{job:first?job('0f6b0000-0000-4000-8000-0000000000b2','blocked',null,2):null,...(first?{ready:true,action:'retry'}:{})});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toContainText('Merge blocked.'); @@ -972,7 +977,7 @@ test('resends the same action for Retry merge after a retry failed while an earl await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});}); // An earlier job ended blocked: the review offers Retry merge, and keeps showing that job. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,job:blocked,ready:true,action:'retry'};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{job:blocked,ready:true,action:'retry'});await route.fulfill({response,json:body});}); const confirms:string[]=[]; await page.goto(app.url);page.on('dialog',dialog=>{confirms.push(dialog.message());void dialog.accept();}); await page.getByRole('button',{name:'Retry merge',exact:true}).click(); @@ -1004,7 +1009,7 @@ test(`keeps a merge-job outcome a poll saw while an older Refresh response was h // Every review response was read while the job ran; the Refresh response is held until the poll has seen it end. As a // runner server's review read during preparation does, each lists the blockers the job resolves. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:false,action:null,job:running,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE}; + body.merge=runnerMerge(body.merge,{ready:false,action:null,job:running,blockers:[...body.merge.blockers,...jobResolvedBlockers]}); if(hold){hold=false;holding();await released;} await route.fulfill({response,json:body});}); await page.goto(app.url); @@ -1044,8 +1049,8 @@ test('keeps a newer merge job a poll saw while a held Refresh response shows an // The page loads with A running. The held Refresh response was read after A ended (blocked, Retry offered) and before B // started; it is answered only after the poll has seen B running. await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - if(hold){hold=false;body.merge={...body.merge,ready:true,action:'retry',job:blockedA};holding();await released;} - else body.merge={...body.merge,ready:false,action:null,job:jobA}; + if(hold){hold=false;body.merge=runnerMerge(body.merge,{ready:true,action:'retry',job:blockedA});holding();await released;} + else body.merge=runnerMerge(body.merge,{ready:false,action:null,job:jobA}); await route.fulfill({response,json:body});}); await page.goto(app.url); await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toBeVisible(); @@ -1092,7 +1097,7 @@ async function mergeJobOutcomeRace(page:import('@playwright/test').Page,planning await page.route('**/api/merge',route=>{race.polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:race.ended?merged:running})});}); // A full response (review or action) read while the job ran. The one marked held waits, and the job ends meanwhile. const answer=async(route:import('@playwright/test').Route)=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:false,action:null,job:running}; + body.merge=runnerMerge(body.merge,{ready:false,action:null,job:running}); if(race.hold){race.hold=false;race.ended=true;holding();await released;} await route.fulfill({response,json:body});}; await page.route('**/api/review',answer); @@ -1180,10 +1185,10 @@ test('keeps the Merge button pending while its click is in flight, across a rend await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route); arrived();await released; - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:running})})); // A runner server reports merging as a job (its merge status carries `job`). - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const button=page.locator('#merge'); await page.getByRole('button',{name:'Merge PR',exact:true}).click();await held; @@ -1226,7 +1231,7 @@ test('keeps focus on the Merge button when a merge-job click is refused (F6b)',a await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Issue #1 is not trusted for its current author.',job:null})});}); // A runner server reports merging as a job (its merge status carries `job`). - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const button=page.locator('#merge'); // The review loads asynchronously (#170); press only once the page offers the merge. @@ -1256,7 +1261,7 @@ test('follows the running merge job when a click from an older review is refused await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'A merge is already running for this task.',job:job('running','checks',[{context:'ci',state:'pending'}])})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); // The page follows the other tab's job, as its poll would, rather than asking for a refresh. @@ -1287,7 +1292,7 @@ for(const refusal of [ await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Stale review state. Refresh before merging.',job:refusal.job})});}); await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:refusal.job})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:refusal.shown?'retry':'merge',job:refusal.shown,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{ready:true,action:refusal.shown?'retry':'merge',job:refusal.shown,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:refusal.button,exact:true}).click(); await expect(page.locator('#banner')).toHaveText('Merge blocked. Stale review state. Refresh before merging.'); @@ -1313,9 +1318,9 @@ test('states the next step a blocked merge job names, once (F6b)',async({page})= // The click is answered while the job prepares; the 30-minute wait ends long after, so the poll delivers the block. await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route),running=mergeJobRecord({id,state:'running',step:'preparing'}); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:blocked})})); - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toHaveText(`Approved, merge blocked. ${reason} Refresh to see the result.`); @@ -1332,7 +1337,7 @@ test('asks for a refresh when the click is answered with a merge job that has al await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:mergeJobRecord({state:'failed',step:null,reason:'History work exceeded its overall deadline.',revision:2}),mergeRefreshRequired:true})});}); await page.route('**/api/merge',async route=>{polls++;await route.continue();}); // A runner server reports merging as a job (its merge status carries `job`), as production does. - await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.locator('#banner')).toContainText('Refresh to see the result.'); @@ -1357,7 +1362,7 @@ test('follows a started merge job when the server could not reload the review af await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:job('running','preparing'),mergeRefreshRequired:true})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); - await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); await expect(page.locator('#items button').first()).toBeVisible(); const items=await page.locator('#items button').count(); @@ -1385,10 +1390,10 @@ test('follows a one-click merge job from preparation through the required-check let polls=0,reviews=0,mergeClicks=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; const review=await clickReview(route),running=job('running','preparing'); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:running},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); // A runner server reports merging as a job (its merge status carries `job`). - await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json();body.merge={...body.merge,job:null};await route.fulfill({response,json:body});}); + await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const button=page.locator('#merge'); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); @@ -1442,10 +1447,10 @@ test('follows the merge queue a polled job ended in, from a review with no queue const answered:Array<{queue:unknown;job:{state:string}}>=[];let reviews=0; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route),running=job('running','preparing',1); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,queue:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:running})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,queue:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]}),job:running})});}); await page.route('**/api/merge',async route=>{const next=steps[Math.min(answered.length,steps.length-1)]!;answered.push(next);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:'merge',queue:null,job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',queue:null,job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const clicked=page.waitForResponse(response=>response.url().endsWith('/api/action')); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); @@ -1480,10 +1485,10 @@ test('follows a later running merge job when a resent click replays its own ende const jobA=record(body.actionId,2,'blocked',null,CHECK_FAILED_BLOCK);jobB=record('0f6b0000-0000-4000-8000-0000000000d3',3,'running','checks'); // As web/server.ts answers a resend: the replayed job A, with the review reloaded meanwhile, whose job is the latest (B). const review=await clickReview(route); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,job:jobB},job:jobA})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:jobB}),job:jobA})});}); await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:jobB})});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,job:shown,ready:true,action:'retry'};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{job:shown,ready:true,action:'retry'});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>void dialog.accept()); await page.getByRole('button',{name:'Retry merge',exact:true}).click(); await expect(page.locator('#banner')).toContainText('Merge blocked.'); @@ -1523,7 +1528,7 @@ async function endedJobQueueServer(page:import('@playwright/test').Page,answer:( await page.route('**/api/merge',async route=>{const next=answer(race.answered.length,{job:jobRecord,queue:queueRecord}); race.answered.push(`${next.job.state}/${next.queue?.state??'none'}`);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:false,action:null,queue:null,job:jobRecord('running',4),blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE}; + body.merge=runnerMerge(body.merge,{ready:false,action:null,queue:null,job:jobRecord('running',4),blockers:[...body.merge.blockers,...jobResolvedBlockers]}); if(race.hold){race.hold=false;holding();await released;} await route.fulfill({response,json:body});}); await page.goto(app.url); @@ -1622,12 +1627,12 @@ test(`shows the job's own attempt, ${variant.state}, when the click is answered // read them: the click's answer carries both. await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} const review=await clickReview(route); - await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:{...review.merge,ready:false,action:null,queue:queue(variant.state),job:queued,blockers:[...attemptBlocker,...review.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE},job:queued})});}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,queue:queue(variant.state),job:queued,blockers:[...attemptBlocker,...review.merge.blockers,...jobResolvedBlockers]}),job:queued})});}); // A later poll sees GitHub merge an attempt that was still queued. await page.route('**/api/merge',async route=>{const next={queue:queue(polls.length===0&&variant.state==='queued'?'queued':'merged'),job:queued};polls.push(`${next.job.state}/${next.queue.state}`); await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); - body.merge={...body.merge,ready:true,action:'merge',queue:null,job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers],settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE};await route.fulfill({response,json:body});}); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',queue:null,job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); const clicked=page.waitForResponse(response=>response.url().endsWith('/api/action')); await page.getByRole('button',{name:'Merge PR',exact:true}).click(); From 8028cba1418bdd769d9bc240c57a4159e3507561 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 16:33:41 -0700 Subject: [PATCH 159/163] Test the no-start-time rule for commit statuses; label a defensive removal Add latest-check regressions for commit statuses without a start time, and say in the docstring and merge-handoff doc that the rule covers statuses. Label the job-refresh removal in withMergeJobObservation as defensive. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- github/merge.ts | 8 ++++---- test/merge.test.ts | 12 ++++++++++++ web/public/app.js | 2 ++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 72aac99b..2d670dfa 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token, not the click's: a rebase during preparation makes a new snapshot and so a new token. A review change after preparation is refused through readiness, which binds the review version. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit (a review note) may land while the attempt is in flight. The snapshot term is defensive for a merge job: jobs need a runner, and a runner server's review load reads the recorded head, so no HEAD observation records a new snapshot. A plan revision lands only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. A run not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run with no start time counts at its completion time, or as the oldest if it has none, so it never outranks a run that started. Among runs at the same time, the one that passes least counts. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. The same rule applies to commit statuses. A run or status not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run, or a status with success, failure or error, with no start time counts at its completion time, or as the oldest if it has none, so it never outranks one that started. Among runs at the same time, the one that passes least counts. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. diff --git a/github/merge.ts b/github/merge.ts index df007757..e12ebf75 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -170,10 +170,10 @@ function observedTime(value: unknown): number { return Number.isFinite(at) && new Date(at).getUTCFullYear() > 1 ? at : NaN; } /** - * When an observed check run or status started, to pick the latest of several with one name. A run not yet completed - * (queued, in progress, pending) with no start time was queued after every started one, so it counts as the latest. A - * completed run with no start time is placed at its completion time, or else counts as the oldest, so it never outranks - * a run that started (fail closed). + * When an observed check run or status started, to pick the latest of several with one name. A run or commit status not + * yet completed (queued, in progress, pending) with no start time was queued after every started one, so it counts as + * the latest. A completed run, or a commit status in state SUCCESS, FAILURE or ERROR, with no start time is placed at its + * completion time, or else counts as the oldest, so it never outranks one that started (fail closed). */ function observedCheckStart(check: Record): number { const started = observedTime(check.startedAt); diff --git a/test/merge.test.ts b/test/merge.test.ts index b07b2018..c7f70d29 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -1004,6 +1004,18 @@ it.each([ { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, ], 'success'], + ['a pending commit status with no start time over a started passing one', [ + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + { context: 'test', state: 'PENDING' }, + ], 'pending'], + ['a newer started passing commit status over a failed one with no start time', [ + { context: 'test', state: 'FAILURE' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'success'], + ['an older started failed commit status over a passing one with no start time (the oldest)', [ + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS' }, + ], 'failure'], ] as const)('counts the latest observation of a required check: %s', async (_case, rollup, expected) => { expect(await requiredTestState([...rollup])).toEqual([expected]); }); diff --git a/web/public/app.js b/web/public/app.js index 0bd783aa..5ed6783e 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -1051,6 +1051,8 @@ function laterMergeJob(newer, older) { * full response may offer Retry for an earlier job), and an ended job asks for a refresh, with its own attempt `queue`. */ function withMergeJobObservation(view, job, queue = null) { + // Removing `job-refresh` here is defensive: the server never sends it, and the page adds it only once it saw a job + // end, after which nothing makes the merge ready again (so no refused click carries a running job) until Refresh. if (job.state === "running") return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: view.merge.blockers.filter(blocker => blocker.code !== "job-refresh") } }; return withEndedJobAndAttempt(view, job, queue); From 9f8f01fc7c979210eb2437007af175b0ce2172fd Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 16:48:44 -0700 Subject: [PATCH 160/163] Tie a required check observation with no time to the latest A completed check run or commit status with neither a start nor a completion time was ordered as the oldest, so an older started success outranked a failure with no time and the check read as passing. Such an observation now ties with the latest, and the existing tie rule (the one that passes least counts) decides, so it can never make a check pass. Completion is read from the observed state, which replaces the explicit SUCCESS/FAILURE/ERROR list. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- github/merge.ts | 28 +++++++++++++++------------- test/merge.test.ts | 26 +++++++++++++++++++++----- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 2d670dfa..cdca0f3a 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token, not the click's: a rebase during preparation makes a new snapshot and so a new token. A review change after preparation is refused through readiness, which binds the review version. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit (a review note) may land while the attempt is in flight. The snapshot term is defensive for a merge job: jobs need a runner, and a runner server's review load reads the recorded head, so no HEAD observation records a new snapshot. A plan revision lands only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. The same rule applies to commit statuses. A run or status not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run, or a status with success, failure or error, with no start time counts at its completion time, or as the oldest if it has none, so it never outranks one that started. Among runs at the same time, the one that passes least counts. A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. The same rule applies to commit statuses. A run or status not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run or status with no start time counts at its completion time. One with neither a start nor a completion time could be the newest, so it is never ordered below another: it ties with the latest. Among runs that tie, the one that passes least counts, so a run or status with no time can make the check fail or wait, but never makes it pass (fail closed). A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. diff --git a/github/merge.ts b/github/merge.ts index e12ebf75..3dc55152 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -170,20 +170,17 @@ function observedTime(value: unknown): number { return Number.isFinite(at) && new Date(at).getUTCFullYear() > 1 ? at : NaN; } /** - * When an observed check run or status started, to pick the latest of several with one name. A run or commit status not - * yet completed (queued, in progress, pending) with no start time was queued after every started one, so it counts as - * the latest. A completed run, or a commit status in state SUCCESS, FAILURE or ERROR, with no start time is placed at its - * completion time, or else counts as the oldest, so it never outranks one that started (fail closed). + * When an observed check run or status started, to pick the latest of several with one name, or NaN when that is not + * known. A run or commit status not yet completed (queued, in progress, pending) with no start time was queued after + * every started one, so it counts as the latest. A completed run or commit status with no start time is placed at its + * completion time. One with neither time could be the newest, so it is never ordered below another: it ties with the + * latest, and the tie rule (the one that passes least counts) decides, so an unknown time never reads as passing. */ function observedCheckStart(check: Record): number { const started = observedTime(check.startedAt); if (Number.isFinite(started)) return started; - const completed = typeof check.name === 'string' - ? String(check.status ?? '').toUpperCase() === 'COMPLETED' - : ['SUCCESS','FAILURE','ERROR'].includes(String(check.state ?? '').toUpperCase()); - if (!completed) return Number.POSITIVE_INFINITY; - const finished = observedTime(check.completedAt); - return Number.isFinite(finished) ? finished : Number.NEGATIVE_INFINITY; + if (observedCheckState(check) === 'pending') return Number.POSITIVE_INFINITY; + return observedTime(check.completedAt); } /** Among runs with the same start, the one that passes least counts, so a tie never reads as passing. */ const checkStateRank = { failure: 0, pending: 1, success: 2 } as const; @@ -415,9 +412,14 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { // does not outrank a newer pending or passing run. A check run and a commit status with the same name are not // re-runs of each other: GitHub requires both to pass. So the latest of each kind counts, and the one that passes // least decides. - const latestOfKind = (kind: Record[]) => kind.length === 0 ? null - : kind.map(check => ({ state: observedCheckState(check), at: observedCheckStart(check) })) - .reduce((best, next) => next.at > best.at || (next.at === best.at && checkStateRank[next.state] < checkStateRank[best.state]) ? next : best).state; + // A completed observation with no time (NaN) ties with the latest, so it can only make the result pass less. + const latestOfKind = (kind: Record[]) => { + if (kind.length === 0) return null; + const seen = kind.map(check => ({ state: observedCheckState(check), at: observedCheckStart(check) })); + const latest = Math.max(...seen.map(check => Number.isNaN(check.at) ? Number.NEGATIVE_INFINITY : check.at)); + return seen.filter(check => Number.isNaN(check.at) || check.at === latest) + .reduce((worst, next) => checkStateRank[next.state] < checkStateRank[worst.state] ? next : worst).state; + }; const states = [latestOfKind(matches.filter(check => typeof check.name === 'string')), latestOfKind(matches.filter(check => typeof check.name !== 'string'))] .filter(state => state !== null); return { ...required, state: states.reduce((worst, next) => checkStateRank[next] < checkStateRank[worst] ? next : worst) }; diff --git a/test/merge.test.ts b/test/merge.test.ts index c7f70d29..00de404b 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -984,11 +984,11 @@ it.each([ { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, ], 'failure'], - ['a newer started failure over a completed skip with the zero start time', [ + ['a started failure beside a completed skip with only zero times (a tie: the failure counts)', [ { name: 'test', status: 'COMPLETED', conclusion: 'SKIPPED', startedAt: '0001-01-01T00:00:00Z', completedAt: '0001-01-01T00:00:00Z' }, { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, ], 'failure'], - ['a newer started failure over a completed neutral run with no start time', [ + ['a started failure beside a completed neutral run with no time (a tie: the failure counts)', [ { name: 'test', status: 'COMPLETED', conclusion: 'NEUTRAL' }, { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, ], 'failure'], @@ -1008,11 +1008,27 @@ it.each([ { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, { context: 'test', state: 'PENDING' }, ], 'pending'], - ['a newer started passing commit status over a failed one with no start time', [ + ['a failed commit status with no time beside a started passing one (a tie: the failure counts)', [ { context: 'test', state: 'FAILURE' }, { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, - ], 'success'], - ['an older started failed commit status over a passing one with no start time (the oldest)', [ + ], 'failure'], + ['an errored commit status with no time beside a started passing one (a tie: the error counts)', [ + { context: 'test', state: 'ERROR' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a passing commit status with no time beside a started failed one (a tie: the failure counts)', [ + { context: 'test', state: 'SUCCESS' }, + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a passing run with no time beside a started failed one (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a failed run with no time beside a started passing one (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a started failed commit status beside a passing one with no time (a tie: the failure counts)', [ { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, { context: 'test', state: 'SUCCESS' }, ], 'failure'], From c0d0a4b6651b7974c6c75f483d403089dc506c0f Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 17:05:08 -0700 Subject: [PATCH 161/163] Fail closed for a completed check with no start time A completed run or status with no start time is dropped only when another of its kind provably started after its completion; otherwise it ties with the latest, so the run that passes least counts. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-handoff.md | 2 +- github/merge.ts | 25 +++++++++++++++++-------- test/merge.test.ts | 22 +++++++++++++++++++++- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index cdca0f3a..66ddf5e4 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -35,7 +35,7 @@ Without a runner, **Merge PR** works as before. | Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | | Merge | The job calls the guarded merge with the current review token, not the click's: a rebase during preparation makes a new snapshot and so a new token. A review change after preparation is refused through readiness, which binds the review version. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit (a review note) may land while the attempt is in flight. The snapshot term is defensive for a merge job: jobs need a runner, and a runner server's review load reads the recorded head, so no HEAD observation records a new snapshot. A plan revision lands only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | -A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. The same rule applies to commit statuses. A run or status not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run or status with no start time counts at its completion time. One with neither a start nor a completion time could be the newest, so it is never ordered below another: it ties with the latest. Among runs that tie, the one that passes least counts, so a run or status with no time can make the check fail or wait, but never makes it pass (fail closed). A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. The same rule applies to commit statuses. A run or status not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run or status with no start time started before its completion time, so it is known to be older than, and is dropped for, any run or status of its kind that started after that time (or that is not yet completed and has no start time). Otherwise, including when it has no completion time either, its order relative to the latest is unknown, so it is never ordered below another: it ties with the latest. Among runs that tie, the one that passes least counts, so a run or status with no time can make the check fail or wait, but never makes it pass (fail closed). A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. diff --git a/github/merge.ts b/github/merge.ts index 3dc55152..d7346cbe 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -172,15 +172,21 @@ function observedTime(value: unknown): number { /** * When an observed check run or status started, to pick the latest of several with one name, or NaN when that is not * known. A run or commit status not yet completed (queued, in progress, pending) with no start time was queued after - * every started one, so it counts as the latest. A completed run or commit status with no start time is placed at its - * completion time. One with neither time could be the newest, so it is never ordered below another: it ties with the - * latest, and the tie rule (the one that passes least counts) decides, so an unknown time never reads as passing. + * every started one, so it counts as the latest (+Infinity). A completed one with no start time gets NaN: its order + * relative to the latest is unknown (see `observedCheckOlderThan`). */ function observedCheckStart(check: Record): number { const started = observedTime(check.startedAt); if (Number.isFinite(started)) return started; - if (observedCheckState(check) === 'pending') return Number.POSITIVE_INFINITY; - return observedTime(check.completedAt); + return observedCheckState(check) === 'pending' ? Number.POSITIVE_INFINITY : NaN; +} +/** + * For a completed run or commit status with no start time: its completion time, before which it started, so it is + * known to be older than any observation of its kind that started after that time. NaN when it has a start time, is not + * completed, or has no completion time. + */ +function observedCheckOlderThan(check: Record): number { + return Number.isNaN(observedCheckStart(check)) ? observedTime(check.completedAt) : NaN; } /** Among runs with the same start, the one that passes least counts, so a tie never reads as passing. */ const checkStateRank = { failure: 0, pending: 1, success: 2 } as const; @@ -412,12 +418,15 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { // does not outrank a newer pending or passing run. A check run and a commit status with the same name are not // re-runs of each other: GitHub requires both to pass. So the latest of each kind counts, and the one that passes // least decides. - // A completed observation with no time (NaN) ties with the latest, so it can only make the result pass less. + // The latest is the observation with the greatest known start (a not-completed one with no start counts as + // +Infinity). A completed observation with no start time is dropped when another of its kind started (or is + // not-completed with no start) strictly after its completion: it is known to be older. Otherwise its order is + // unknown, so it ties with the latest, and it can only make the result pass less. const latestOfKind = (kind: Record[]) => { if (kind.length === 0) return null; - const seen = kind.map(check => ({ state: observedCheckState(check), at: observedCheckStart(check) })); + const seen = kind.map(check => ({ state: observedCheckState(check), at: observedCheckStart(check), olderThan: observedCheckOlderThan(check) })); const latest = Math.max(...seen.map(check => Number.isNaN(check.at) ? Number.NEGATIVE_INFINITY : check.at)); - return seen.filter(check => Number.isNaN(check.at) || check.at === latest) + return seen.filter(check => Number.isNaN(check.at) ? !(latest > check.olderThan) : check.at === latest) .reduce((worst, next) => checkStateRank[next.state] < checkStateRank[worst.state] ? next : worst).state; }; const states = [latestOfKind(matches.filter(check => typeof check.name === 'string')), latestOfKind(matches.filter(check => typeof check.name !== 'string'))] diff --git a/test/merge.test.ts b/test/merge.test.ts index 00de404b..aa8155ca 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -992,10 +992,30 @@ it.each([ { name: 'test', status: 'COMPLETED', conclusion: 'NEUTRAL' }, { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, ], 'failure'], - ['a completed run with no start time placed by its completion, after an older failure', [ + ['a passing run with no start time, completed after a failure started before its completion (a tie: the failure counts)', [ { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a passing run with no start time, completed after a failure started (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:03:00Z' }, + ], 'failure'], + ['a passing run with no start time, completed before a failure started (older: dropped)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:06:00Z' }, + ], 'failure'], + ['a failed run with no start time, completed before a pass started (older: dropped)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:06:00Z' }, ], 'success'], + ['a failed run with no start time, completed after a pass started (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:03:00Z' }, + ], 'failure'], + ['a failed run with no start time, completed before a queued run with no start (older: dropped)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'QUEUED', conclusion: '' }, + ], 'pending'], ['a queued run with no start time over a completed pass', [ { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, { name: 'test', status: 'QUEUED', conclusion: '' }, From 5f9350dbc6ed9cddd0c405c775b2db0e58b76fd8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 17:17:43 -0700 Subject: [PATCH 162/163] Pin the strict boundary of the dropped-record rule, and retitle a case its assertion cannot distinguish Co-Authored-By: Claude Opus 5.5 --- test/merge.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/merge.test.ts b/test/merge.test.ts index aa8155ca..ff24cd1b 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -1000,10 +1000,14 @@ it.each([ { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:03:00Z' }, ], 'failure'], - ['a passing run with no start time, completed before a failure started (older: dropped)', [ + ['a passing run with no start time, completed before a failure started (the later failure counts)', [ { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:06:00Z' }, ], 'failure'], + ['a failed run with no start time, completed in the second a pass started (order unknown: a tie, the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], ['a failed run with no start time, completed before a pass started (older: dropped)', [ { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:06:00Z' }, From 23cadeeadbf5b46197b3db7365ca6af69bd4cdf0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 17:30:57 -0700 Subject: [PATCH 163/163] Say where the already-fixed scan starts in the v19 comment and its test title Co-Authored-By: Claude Opus 5.5 --- runner/store.ts | 3 ++- test/merge-task-pr.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/runner/store.ts b/runner/store.ts index 808e7d5f..d39f8e94 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -371,7 +371,8 @@ export class Store { this.#db.exec(`CREATE TABLE IF NOT EXISTS merge_jobs ( id TEXT PRIMARY KEY, plan_key TEXT NOT NULL REFERENCES plans(key), data TEXT NOT NULL); CREATE INDEX IF NOT EXISTS merge_jobs_plan ON merge_jobs(plan_key);`); - // The pre-PR already-fixed check each opening followed: the merge's scan starts at that check's base. + // The pre-PR already-fixed check each opening followed: the merge's scan may start at the tip that check scanned + // through (docs/implementation/merge-handoff.md). if (!this.#db.prepare('PRAGMA table_info(task_pull_requests)').all().some(column => column.name === 'check_id')) this.#db.exec('ALTER TABLE task_pull_requests ADD COLUMN check_id TEXT'); this.#db.exec('PRAGMA user_version=19'); diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index b4bcd645..1c94d169 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -384,7 +384,7 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.merged).toEqual([]); }); - it('scans for an earlier fix from the pre-PR check\'s base after a rebase, skipping the task\'s own commits', async () => { + it('scans for an earlier fix from the tip the first opening\'s pre-PR check scanned through after a rebase, skipping the task\'s own commits', async () => { const { store } = published(), gh = github(store); const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); // A pre-merge rebase moved the reviewed base from oid(1) to oid(10) and rewrote the owned head oid(2) as oid(12).