From 12e96bacf377cfc039610cbd1715a49a1485771a Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 9 Oct 2026 09:18:47 -0700 Subject: [PATCH 001/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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/103] 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 ec2e2e40eb28951b7159935b9dac325a7f6b7df8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 13:45:44 -0700 Subject: [PATCH 067/103] Run one merge poll chain at a time in the browser (#48) A render while a merge poll was in flight called scheduleMergePoll and started a second poll chain in the same generation, so an older poll's answer ("queued") or error could land after a newer one ("merged"). The page now tracks the poll in flight by generation and sequence. A render while it runs starts no new poll; only the latest poll's answer or error is applied, and only while its generation is current. An error thrown while applying a current answer is still reported and polling continues, and the merge click clears the old poll timer. Four browser regressions cover a re-render during a held poll, a held error, an older generation's late poll, and an unusable current answer; each fails when its guard is removed. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-queue.md | 4 +- test/browser/issue-48-poll-ordering.spec.ts | 120 ++++++++++++++++++++ test/browser/issue-48-stale-errors.spec.ts | 70 ++++++++++++ web/public/app.js | 29 ++++- 4 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 test/browser/issue-48-poll-ordering.spec.ts create mode 100644 test/browser/issue-48-stale-errors.spec.ts diff --git a/docs/implementation/merge-queue.md b/docs/implementation/merge-queue.md index 5990c90d..948d9c3e 100644 --- a/docs/implementation/merge-queue.md +++ b/docs/implementation/merge-queue.md @@ -20,7 +20,7 @@ Every update compares the current attempt ID and legal source state. A delayed p The coordinator owns at most one enqueue and one shared queue inspection. Cancellation does not release either operation until its GitHub call settles. One fourteen-second deadline covers every sequential validation, queue-correlation, and submission stage rather than restarting for each remote call. Shutdown rejects every new API request at admission and rechecks irreversible work after partially received request bodies, then gives admitted requests a bounded fourteen-and-a-half-second drain. At the deadline it aborts request-scoped status inspections, destroys requests still blocked on partial bodies, and cancels coordinator work before waiting for server closure and closing SQLite, so an admitted request or queue poll cannot deadlock teardown. -`GET /api/merge` is the narrow polling path for queue-backed attempts. It reads only the persisted attempt and the K1 queue observation; it does not reload Git history or reconstruct the review. Ambiguous direct submissions remain disabled and reconcile through a full review refresh instead of repeatedly calling the queue endpoint. The browser patches only the merge control and status banner, so current selection, scroll, code attachment, and composer drafts remain unchanged. Queue polling starts at two seconds, doubles while the same active state persists, and caps at thirty seconds; a lifecycle transition or explicit user action resets the interval. A review action advances the merge-poll generation, preventing an older response from re-enabling retry against pre-action review state. The action stays disabled while submitting or queued and after confirmed merge. Removal or failure exposes the reason but keeps retry disabled until a full review refresh revalidates current GitHub blockers. Retry is then offered only for an unchanged reviewed context, and the full gate is revalidated twice before another enqueue. +`GET /api/merge` is the narrow polling path for queue-backed attempts. It reads only the persisted attempt and the K1 queue observation; it does not reload Git history or reconstruct the review. Ambiguous direct submissions remain disabled and reconcile through a full review refresh instead of repeatedly calling the queue endpoint. The browser patches only the merge control and status banner, so current selection, scroll, code attachment, and composer drafts remain unchanged. Queue polling starts at two seconds, doubles while the same active state persists, and caps at thirty seconds; a lifecycle transition or explicit user action resets the interval. A review action advances the merge-poll generation, preventing an older response from re-enabling retry against pre-action review state. One merge poll runs at a time (#48): a render while a poll is in flight starts no second chain, and only the latest poll's answer or error is applied, and only while its generation is current. An older generation's poll that returns late neither applies nor releases the newer generation's poll. The page sets no timeout of its own on a poll; the server bounds `/api/merge` with its own read deadlines (traced, not tested). The action stays disabled while submitting or queued and after confirmed merge. Removal or failure exposes the reason but keeps retry disabled until a full review refresh revalidates current GitHub blockers. Retry is then offered only for an unchanged reviewed context, and the full gate is revalidated twice before another enqueue. Transient or incomplete GitHub observations leave the attempt active, surface an observation error, and continue polling rather than clearing the browser's active attempt. Submitting attempts use unknown-outcome wording until GitHub proves that the attempt is queued. Only a validated queued, merged, removed, or failed observation changes durable state. Queue rules count as the server-side current-base guard; adapters without queue inspection continue to fail closed. @@ -28,4 +28,4 @@ Both pre-action validation reads must agree on whether merge queues apply. Queue ## Acceptance evidence -Controlled unit and browser regressions cover enqueue success, delayed merge, queue removal, unmergeable failure, head replacement, retry, stale observation publication, active-attempt retry refusal, shutdown settlement, durable restart recovery, and preservation of UI input while polling. Final evidence is recorded against the exact pushed PR head. +Controlled unit and browser regressions cover enqueue success, delayed merge, queue removal, unmergeable failure, head replacement, retry, stale observation publication, active-attempt retry refusal, shutdown settlement, durable restart recovery, and preservation of UI input while polling. The #48 browser regressions (`test/browser/issue-48-poll-ordering.spec.ts`, `test/browser/issue-48-stale-errors.spec.ts`) cover one poll chain under a re-render, a held error, an older generation's late poll, and an unusable current answer; a re-render during a merge-job poll takes the same guard (traced, not tested). Final evidence is recorded against the exact pushed PR head. diff --git a/test/browser/issue-48-poll-ordering.spec.ts b/test/browser/issue-48-poll-ordering.spec.ts new file mode 100644 index 00000000..f5622b4b --- /dev/null +++ b/test/browser/issue-48-poll-ordering.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; +import { chmodSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createDemo } from '../../scripts/demo.ts'; +import { startServer } from '../../web/server.ts'; +import type { MergeGateway, MergeQueueGateway } from '../../github/merge.ts'; +import { fixtureGit } from '../fixtures/git.ts'; + +// Issue #48, poll ordering (browser): a merge-queue poll answer computed before a newer poll answer must not be +// applied after it. Before the fix, any render() (for example selecting another item) while a poll was in flight +// scheduled a second poll chain with the same mergeGeneration, so two polls could be outstanding and nothing ordered +// their answers. Now one poll chain runs at a time. +let root: string, app: Awaited>; +test.beforeEach(async () => { root=mkdtempSync(join(tmpdir(),'codeboost-browser-'));app=await startServer(createDemo(join(root,'demo')),0); }); +test.afterEach(async () => { await app.close();rmSync(root,{recursive:true,force:true}); }); + +test('a re-render while a merge-queue poll is held starts no second poll chain, so no older answer follows a newer one',async({page})=>{ + const config={...app.service.config,demo:false};await app.close(); + chmodSync(join(config.repository,'run.sh'),0o644);fixtureGit(config.repository,'commit','-am','Restore declared scope'); + let appRef:typeof app,phase:'queued'|'merged'='queued'; + const gateway:MergeGateway&MergeQueueGateway={ + inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + queueWatermark:async()=>null, + merge:async()=>({url:'https://github.com/example/repo/pull/24'}), + inspectQueue:async head=>phase==='merged'?{state:'merged',reviewedHead:head,mergedAt:'2026-09-24T08:10:00Z'}:{state:'queued',reviewedHead:head,entryId:'MQE_1',phase:'QUEUED',position:1,enqueuedAt:'2026-09-24T08:00:00Z',queueHead:head}, + }; + app=appRef=await startServer(config,0,undefined,gateway);let view=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}); + + // Poll 1: the server answers (queued) at once, but delivery of that answer to the page is held. Later polls pass. + let release!:()=>void,arrived!:()=>void; + const held=new Promise(resolve=>release=resolve),started=new Promise(resolve=>arrived=resolve); + let polls=0,heldState:string|undefined; + await page.route('**/api/merge',async route=>{ + const n=++polls; + if(n===1){const response=await route.fetch();const body=await response.json();heldState=body.queue?.state;arrived();await held;await route.fulfill({response,json:body});return;} + await route.continue(); + }); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); + try{ + await started; + expect(heldState).toBe('queued'); + // GitHub now confirms the merge, so any later answer is newer than the held one. + phase='merged'; + // Selecting another item re-renders while poll 1 is in flight. It must not start a second poll chain, whose newer + // answer the held older one could then overwrite (the first poll delay is 2 s; wait past it). + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await page.waitForTimeout(3000); + expect(polls).toBe(1); + // The held answer is still the latest one issued, so it applies; the next poll then reports the merge. + release(); + await expect(page.getByRole('button',{name:'Merged',exact:true})).toBeDisabled({timeout:10000}); + await expect(page.locator('#banner')).toContainText('GitHub confirmed the reviewed head was merged.'); + expect(polls).toBe(2); + // Nothing older can arrive later and move the control back. + await page.waitForTimeout(2500); + expect(await page.locator('#merge').textContent()).toBe('Merged'); + }finally{release();} +}); + +// The poll in flight belongs to its generation: after a refresh starts a new generation and its own poll, the old poll's +// late answer is discarded and must not release the new generation's single-chain guard (AGENTS.md: "Poll starts, then +// a user action completes, then the old poll returns"). +test('an older generation poll returning late neither applies nor lets a re-render start a second chain',async({page})=>{ + const config={...app.service.config,demo:false};await app.close(); + chmodSync(join(config.repository,'run.sh'),0o644);fixtureGit(config.repository,'commit','-am','Restore declared scope'); + let appRef:typeof app; + const gateway:MergeGateway&MergeQueueGateway={ + inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + queueWatermark:async()=>null, + merge:async()=>({url:'https://github.com/example/repo/pull/24'}), + inspectQueue:async head=>({state:'queued',reviewedHead:head,entryId:'MQE_1',phase:'QUEUED',position:1,enqueuedAt:'2026-09-24T08:00:00Z',queueHead:head}), + }; + app=appRef=await startServer(config,0,undefined,gateway);let view=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}); + // Polls 1 (old generation) and 2 (new generation) are held; any later poll is counted and passes. + const releases:Array<()=>void>=[],started:Array<()=>void>=[]; + const starts=[0,1].map(index=>new Promise(resolve=>started[index]=resolve)); + const holds=[0,1].map(index=>new Promise(resolve=>releases[index]=resolve)); + let polls=0; + await page.route('**/api/merge',async route=>{ + const n=++polls; + if(n<=2){ + const response=await route.fetch();const body=await response.json(); + // The old generation's answer says "merged", so applying it would be visible; the current generation's stays queued. + if(n===1&&body.queue)body.queue={...body.queue,state:'merged',mergedAt:'2026-09-24T08:10:00Z'}; + started[n-1]!();await holds[n-1];await route.fulfill({response,json:body});return; + } + await route.continue(); + }); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); + try{ + await starts[0]; + // A refresh starts a new merge generation; its own poll (2) starts after the poll delay and is held too. + await page.getByRole('button',{name:'Refresh',exact:true}).click(); + await starts[1]; + expect(polls).toBe(2); + // The old generation's poll now returns; it must not apply, nor clear the new generation's in-flight poll. + releases[0]!(); + await page.waitForTimeout(300); + // Not applied: the control still shows the current generation's queued state. + await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); + await expect(page.locator('#banner')).not.toContainText('confirmed the reviewed head was merged'); + // A re-render now must not start a third poll while poll 2 is in flight (wait past the 2 s poll delay). + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await page.waitForTimeout(3000); + expect(polls).toBe(2); + // Still not applied, however late the old answer arrived. + await expect(page.locator('#banner')).not.toContainText('confirmed the reviewed head was merged'); + releases[1]!(); + await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); + }finally{releases.forEach(release=>release());} +}); diff --git a/test/browser/issue-48-stale-errors.spec.ts b/test/browser/issue-48-stale-errors.spec.ts new file mode 100644 index 00000000..c0433894 --- /dev/null +++ b/test/browser/issue-48-stale-errors.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { chmodSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createDemo } from '../../scripts/demo.ts'; +import { startServer } from '../../web/server.ts'; +import type { MergeGateway, MergeQueueGateway } from '../../github/merge.ts'; +import { fixtureGit } from '../fixtures/git.ts'; +let root: string, app: Awaited>; +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-issue48-')); app=await startServer(createDemo(join(root,'demo')),0); }); +test.afterEach(async () => { await app.close(); rmSync(root,{recursive:true,force:true}); }); + +// Issue #48: an error from an older merge-queue poll must not overwrite the state a newer poll showed. Before the fix, +// selecting an item while poll #1 was held started poll #2 in the same merge generation; poll #2 confirmed "merged", +// and poll #1's later 500 replaced it. Now one poll chain runs at a time, so no newer poll exists while #1 is held: +// the test asserts that, shows #1's error as the latest result, and checks that polling then recovers. +test('a held merge-poll error starts no second poll chain, and polling recovers after it',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app,phase:'queued'|'merged'='queued'; + const gateway:MergeGateway&MergeQueueGateway={ + inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + queueWatermark:async()=>null, + merge:async()=>({url:'https://github.com/example/repo/pull/24'}), + inspectQueue:async head=>phase==='queued'?{state:'queued',reviewedHead:head,entryId:'MQE_1',phase:'AWAITING_CHECKS',position:2,enqueuedAt:'2026-09-24T08:00:00Z',queueHead:head}:{state:'merged',reviewedHead:head,mergedAt:'2026-09-24T08:10:00Z'}, + }; + app=appRef=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 release!:()=>void,arrived!:()=>void;const held=new Promise(resolve=>release=resolve),firstPollStarted=new Promise(resolve=>arrived=resolve);let polls=0; + await page.route('**/api/merge',async route=>{polls++; + if(polls===1){arrived();await held;await route.fulfill({status:500,contentType:'application/json',body:JSON.stringify({error:'Stale poll failure.'})});return;} + await route.continue();}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); + try{ + await firstPollStarted; + // GitHub now reports the merge. A plain item selection (no network write) re-renders while poll 1 is in flight; it + // must not start a second chain whose newer success the held error could then overwrite (wait past the 2 s delay). + phase='merged'; + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await page.waitForTimeout(3000); + expect(polls).toBe(1); + // The held error is the latest result and is shown; polling then continues and the newer state replaces it. + release(); + await expect(page.locator('#banner')).toContainText('Could not refresh merge status. Stale poll failure.'); + await expect(page.getByRole('button',{name:'Merged',exact:true})).toBeDisabled({timeout:15000}); + await expect(page.locator('#banner')).toContainText('GitHub confirmed the reviewed head was merged.'); + expect(polls).toBe(2); + }finally{release();} +}); + +// A current poll whose answer cannot be applied still reports the failure and keeps polling (an error thrown after the +// poll's result was accepted must not be dropped as if it came from an older poll). +test('reports an unusable current merge-poll answer and keeps polling',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app; + const gateway:MergeGateway&MergeQueueGateway={ + inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + queueWatermark:async()=>null, + merge:async()=>({url:'https://github.com/example/repo/pull/24'}), + inspectQueue:async head=>({state:'merged',reviewedHead:head,mergedAt:'2026-09-24T08:10:00Z'}), + }; + app=appRef=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 polls=0; + // Poll 1 answers 200 with a null body, which the page cannot apply; later polls pass. + await page.route('**/api/merge',async route=>{if(++polls===1){await route.fulfill({status:200,contentType:'application/json',body:'null'});return;}await route.continue();}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Could not refresh merge status.',{timeout:10000}); + await expect(page.getByRole('button',{name:'Merged',exact:true})).toBeDisabled({timeout:15000}); + expect(polls).toBe(2); +}); diff --git a/web/public/app.js b/web/public/app.js index 86e55694..8e3a5351 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -28,7 +28,11 @@ let mergeGeneration = 0, mergeObservationGeneration = 0, mergePollTimer = null, mergePollState = null, - mergePollDelay = 2000; + mergePollDelay = 2000, + // One merge poll chain at a time (#48): the poll in flight, by generation and sequence. A render while it runs does not + // start another, and only the latest poll's answer or error is applied. + mergePollSequence = 0, + mergePollInFlight = null; const mergePollMaximumDelay = 30000; const drafts = new Map(); const attachments = new Map(); @@ -196,6 +200,8 @@ function renderMerge() { scheduleMergePoll(); } function scheduleMergePoll() { + // The poll in flight reschedules itself when it settles; a second chain could apply an older answer after a newer one. + if (mergePollInFlight?.generation === mergeGeneration) return; if (mergePollTimer) clearTimeout(mergePollTimer); mergePollTimer = null; const queue = data?.merge?.queue; @@ -217,9 +223,23 @@ function backOffMergePoll() { mergePollDelay = Math.min(mergePollDelay * 2, mergePollMaximumDelay); } async function pollMergeQueue(generation) { + mergePollTimer = null; + const sequence = ++mergePollSequence; + mergePollInFlight = { generation, sequence }; + // Settles this poll's ownership and says whether its result may still be applied: only the latest poll's, and only + // while its generation is current. + const settle = () => { + const latest = mergePollInFlight?.sequence === sequence; + if (latest) mergePollInFlight = null; + return latest && generation === mergeGeneration && !!data?.merge?.available; + }; + // Decided once, when the request settles: an error thrown while applying a current answer is still reported, and + // polling still continues. + let current; try { const update = await api("/api/merge"); - if (generation !== mergeGeneration || !data?.merge?.available) return; + current = settle(); + if (!current) return; if (data.merge.job?.state === "running") { // 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. @@ -246,7 +266,8 @@ async function pollMergeQueue(generation) { if (queue?.state === mergePollState) backOffMergePoll(); renderMerge(); } catch (error) { - if (generation !== mergeGeneration || !data?.merge?.available) return; + current ??= settle(); + if (!current) return; $("banner").textContent = `Could not refresh merge status. ${error.message}`; backOffMergePoll(); scheduleMergePoll(); @@ -592,6 +613,8 @@ $("merge").onclick = async () => { const busyOwner = beginBusy("write"); reviewGeneration++; mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; mergePollState = null; mergePollDelay = 2000; // With merge jobs, the click returns at once and the focused control must keep focus: mark it, do not disable it. From a23ac7837cc62ee4efdc9dacc355fe70eadccd04 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 15:15:01 -0700 Subject: [PATCH 068/103] 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 069/103] 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 070/103] 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 071/103] 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 072/103] 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 073/103] 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 074/103] 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 075/103] 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 076/103] 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 077/103] 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 078/103] 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 079/103] 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 080/103] 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 d21cb0e15e6c5b10c9736eb5aec0fbf283fece04 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 14 Oct 2026 18:27:29 -0700 Subject: [PATCH 081/103] Check the merge control, not the cleared banner, after the late old answer (#48) The item selection re-renders and clears the banner, so the banner check could not see a late applied answer; the control shows "Merged" if it was applied. Co-Authored-By: Claude Opus 5.5 --- test/browser/issue-48-poll-ordering.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/browser/issue-48-poll-ordering.spec.ts b/test/browser/issue-48-poll-ordering.spec.ts index f3005dfc..18ac1caf 100644 --- a/test/browser/issue-48-poll-ordering.spec.ts +++ b/test/browser/issue-48-poll-ordering.spec.ts @@ -112,7 +112,9 @@ test('an older generation poll returning late neither applies nor lets a re-rend await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); await page.waitForTimeout(3000); expect(polls).toBe(2); - // Still not applied, however late the old answer arrived. + // Still not applied, however late the old answer arrived. The re-render cleared the banner, so the control is the + // evidence: an applied "merged" answer would show "Merged". + await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); await expect(page.locator('#banner')).not.toContainText('confirmed the reviewed head was merged'); releases[1]!(); await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); From 19a1049aa86bcc05a9e4a53d510ff06a9ec9ed3e Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 15 Oct 2026 07:44:26 -0700 Subject: [PATCH 082/103] 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 083/103] 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 084/103] 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 085/103] 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 086/103] 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 087/103] 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 088/103] 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 089/103] 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 090/103] 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 091/103] 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 092/103] 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 093/103] 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 094/103] 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 095/103] 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 096/103] 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 097/103] 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 098/103] 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 099/103] 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 100/103] 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 101/103] 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 121769c5d14ea2006b2ad27a94499d20cb40fd30 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 19:08:49 -0700 Subject: [PATCH 102/103] Wait for the next poll before the cross-generation spec's last check The control already read "Merge queued" before poll 2 answered, so the check proved nothing; a third poll shows poll 2's answer was applied and the one chain continued. Co-Authored-By: Claude Opus 5.5 --- test/browser/issue-48-poll-ordering.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/browser/issue-48-poll-ordering.spec.ts b/test/browser/issue-48-poll-ordering.spec.ts index 18ac1caf..c1b4265c 100644 --- a/test/browser/issue-48-poll-ordering.spec.ts +++ b/test/browser/issue-48-poll-ordering.spec.ts @@ -117,6 +117,8 @@ test('an older generation poll returning late neither applies nor lets a re-rend await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); await expect(page.locator('#banner')).not.toContainText('confirmed the reviewed head was merged'); releases[1]!(); + // The current generation's poll applies its answer and continues the one chain: a third poll follows. + await expect.poll(()=>polls,{timeout:15_000}).toBe(3); await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled(); }finally{releases.forEach(release=>release());} }); From 47d496b3b7f90746aa69146d8aa9a4143eabb03c Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 16 Oct 2026 19:29:43 -0700 Subject: [PATCH 103/103] Label the merge click's poll-timer clear defensive Co-Authored-By: Claude Opus 5.5 --- docs/implementation/merge-queue.md | 2 +- web/public/app.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/implementation/merge-queue.md b/docs/implementation/merge-queue.md index 06a80e29..4dfddd99 100644 --- a/docs/implementation/merge-queue.md +++ b/docs/implementation/merge-queue.md @@ -20,7 +20,7 @@ Every update compares the current attempt ID and legal source state. A delayed p The coordinator owns at most one enqueue and one shared queue inspection. Cancellation does not release either operation until its GitHub call settles. One fourteen-second deadline covers every sequential validation, queue-correlation, and submission stage rather than restarting for each remote call. Shutdown rejects every new API request at admission and rechecks irreversible work after partially received request bodies, then gives admitted requests a bounded fourteen-and-a-half-second drain. At the deadline it aborts request-scoped status inspections, destroys requests still blocked on partial bodies, and cancels coordinator work before waiting for server closure and closing SQLite, so an admitted request or queue poll cannot deadlock teardown. -`GET /api/merge` is the narrow polling path for queue-backed attempts. It reads only the persisted attempt and the K1 queue observation; it does not reload Git history or reconstruct the review. Ambiguous direct submissions remain disabled and reconcile through a full review refresh instead of repeatedly calling the queue endpoint. The browser patches only the merge control and status banner, so current selection, scroll, code attachment, and composer drafts remain unchanged. Queue polling starts at two seconds, doubles while the same active state persists, and caps at thirty seconds; a lifecycle transition or explicit user action resets the interval. A review action advances the merge-poll generation, preventing an older response from re-enabling retry against pre-action review state. One merge poll runs at a time (#48): a render while a poll is in flight starts no second chain, and only the latest poll's answer or error is applied, and only while its generation is current. An older generation's poll that returns late neither applies nor releases the newer generation's poll. The page sets no timeout of its own on a poll; the server bounds `/api/merge` with its own read deadlines (traced, not tested). The action stays disabled while submitting or queued and after confirmed merge. Removal or failure exposes the reason but keeps retry disabled until a full review refresh revalidates current GitHub blockers. Retry is then offered only for an unchanged reviewed context, and the full gate is revalidated twice before another enqueue. +`GET /api/merge` is the narrow polling path for queue-backed attempts. It reads only the persisted attempt and the K1 queue observation; it does not reload Git history or reconstruct the review. Ambiguous direct submissions remain disabled and reconcile through a full review refresh instead of repeatedly calling the queue endpoint. The browser patches only the merge control and status banner, so current selection, scroll, code attachment, and composer drafts remain unchanged. Queue polling starts at two seconds, doubles while the same active state persists, and caps at thirty seconds; a lifecycle transition or explicit user action resets the interval. A review action advances the merge-poll generation, preventing an older response from re-enabling retry against pre-action review state. One merge poll runs at a time (#48): a render while a poll is in flight starts no second chain, and only the latest poll's answer or error is applied, and only while its generation is current. An older generation's poll that returns late neither applies nor releases the newer generation's poll. The page sets no timeout of its own on a poll; the server bounds `/api/merge` with its own read deadlines (traced, not tested). The merge click also clears the poll timer, as every generation change does; that is defensive, because a click needs `ready`, which excludes a running job or an active attempt (traced, not tested). The action stays disabled while submitting or queued and after confirmed merge. Removal or failure exposes the reason but keeps retry disabled until a full review refresh revalidates current GitHub blockers. Retry is then offered only for an unchanged reviewed context, and the full gate is revalidated twice before another enqueue. Transient or incomplete GitHub observations leave the attempt active, surface an observation error, and continue polling rather than clearing the browser's active attempt. Submitting attempts use unknown-outcome wording until GitHub proves that the attempt is queued. Only a validated queued, merged, removed, or failed observation changes durable state. Queue rules count as the server-side current-base guard; adapters without queue inspection continue to fail closed. diff --git a/web/public/app.js b/web/public/app.js index 85007efb..202fd4ef 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -717,6 +717,8 @@ $("merge").onclick = async () => { const busyOwner = beginBusy("write"); reviewGeneration++; mergeGeneration++; + // Defensive, like every generation change: a click needs `ready`, which excludes a running job or an active attempt, + // so no poll timer is pending here today (traced, not tested). if (mergePollTimer) clearTimeout(mergePollTimer); mergePollTimer = null; mergePollState = null;