From 3ade4213c19459697eb02c60fed6653c8cce8c88 Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 11 Oct 2026 17:15:09 -0700 Subject: [PATCH 01/38] 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 02/38] 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 03/38] 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 04/38] 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 05/38] 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 06/38] 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 07/38] 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 08/38] 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 09/38] 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 10/38] 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 11/38] 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 12/38] 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 13/38] 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 14/38] 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 15/38] 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 16/38] 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 17/38] 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 18/38] 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 19/38] 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 20/38] 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 21/38] 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 22/38] 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 23/38] 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 24/38] 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 25/38] 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 26/38] 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 27/38] 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 28/38] 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 0180256cdd05f2504e59da628513be1fa5b02671 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 12:05:58 -0700 Subject: [PATCH 29/38] 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 30/38] 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 31/38] 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 1b81ee9835605318f4d890f448fcc8038473bfbd Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 14:20:26 -0700 Subject: [PATCH 32/38] 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 7604a7951347eba3c60096651617f128469dc38c Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 14:37:55 -0700 Subject: [PATCH 33/38] 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 8b365868e04a8a15a976a44c5f186b88bab8f0e3 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 16:10:54 -0700 Subject: [PATCH 34/38] 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 35/38] 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 36/38] 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 da0204a9ed498c89e264914b87a4fe6e0896d767 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 18:27:03 -0700 Subject: [PATCH 37/38] 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 2a9534dfe9e8f9643d6c0489805038c9d436ade6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 19:35:41 -0700 Subject: [PATCH 38/38] 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) {