Skip to content

F6b: one-click merge job with a required-check wait - #161

Draft
mchwang wants to merge 32 commits into
codex/f6a-rewritten-head-pushfrom
codex/f6b-merge-handoff
Draft

mchwang wants to merge 32 commits into
codex/f6a-rewritten-head-pushfrom
codex/f6b-merge-handoff

Conversation

@mchwang

@mchwang mchwang commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

F6b of #22, stacked on F6a (#153): one-click merge. With a runner, Merge PR starts one merge job and returns; the job prepares, waits for GitHub's required checks, and hands the exact pair to the guarded merge. Design: docs/implementation/merge-handoff.md.

  • Merge job (runner/merge-job.ts): admits the click (fresh token, mergeable task, issue trust read from GitHub, nothing busy; a definite refusal is saved and replayed to a resend), records its own prepare-merge action and runs preparation (refresh, rebase, push, cmd: checks); then inspects the PR (fresh) at once and with backoff (10 s → 60 s) for up to 30 minutes. Pending or missing checks and mergeability GitHub is still computing wait; a failing check or the 30-minute limit ends the job blocked; any other blocker (PR moved, readiness lost, already-fixed) or a failed GitHub read ends it failed and outranks a failing check. No inspection starts after the limit, and one running into it is stopped. Then it saves a merge action ID and calls the guarded merge (MergeCoordinator.merge, which now takes the job's abort signal).
  • Outcomes: merged, queued, submitted, blocked (task → approved, merge blocked, in one transaction with the job), review-required, failed, cancelled. Once the job's own merge attempt exists, its recorded state decides the outcome.
  • Durability (schema v19): merge_jobs keeps job progress off the task, so the readiness preparation records stays bound while the job waits. Startup fails a job that had not asked GitHub to merge, or settles it from its own merge attempt.
  • Server: Merge PR is offered while only blockers the job resolves or reports remain (withMergeJob); GET /api/merge returns the task's latest job (saved refusals excluded); prepare-merge is refused while a job runs; cancel stops the job, and its PRs close when the task ends cancelled; shutdown stops the job in its first synchronous step.
  • Already-fixed range: the merge's scan covers every base commit after the task's original base, which a rebase can bring under the PR. Openings record the pre-PR check they followed (schema v19); the scan starts at the tip scanned by the check of the task's first opening into the current base branch when that check scanned from the original base (pre-v19: the oldest clear check), and otherwise at the original base.
  • Browser: the button and banner follow the job's step; the details dialog lists waiting checks. In runner mode the Merge button is aria-disabled, never disabled, so it keeps focus; when the job ends, only the merge control and banner change ("Refresh to see the result"), so selections and input survive.

F6c (common CI / T9) comes next.

Validation

Exact pair: base f3bc3e1176c9c7436e82faa216b67582854e9fb5 (current F6a head, which contains main 22e76a8), head 2c5ccc3704116303a6d0a53e782c13b613ee7349 (current head; F6a merged in).

  • current head, independent full-diff review: clean on f3bc3e1..2c5ccc3 (round 29), including the integration with F6a's new commits and main's Size the plan import request limit from the parser bound (#146) #158, Close out Lane G status records (#145) #154 and Read an oversized body before refusing it, and close on early refusals (#157) #160; 10 focused files, 740 of 740 tests passed; merge browser tests 15 of 15
  • current head, full non-Docker suite (CI's command), local: 75 files, 2,103 of 2,105 passed; the 2 failures (15 s timeouts in pre-merge.test.ts at load average ~195) passed alone; npm run typecheck passed
  • current head, CI and real-Docker: pending
  • intermediate checkpoint cbacd19 (base old F6a 9e1f695): independent review clean (round 28); 8 focused files, 679 of 679 passed
  • earlier validated head 777c9b5: full non-Docker suite 75 files / 2,079 passed; browser suite 139 of 139; CI and real-Docker passed
  • every regression added during review was shown to fail with its fix removed (mutation probes)
  • local load note: under host load averages of 100–290, F5 deadline tests and some browser timeouts fail in broad runs on this branch and on F6a alike; they pass alone

Known limitations

  • The already-fixed check reads at most 250 base commits. If more landed since the pre-PR check, the answer is unknown and merging stays blocked, as for a PR never rebased; there is no person override yet (the Continue action, pull-request-opening.md).
  • After a base-branch setting change following a rebase, or with no usable check, the scan starts at the task's original base, so the 250-commit cap is reached sooner. A force-pushed base branch that drops the start fails closed.
  • The job's full step list and a Cancel button are not built; cancel the task instead.
  • One failed GitHub read during the 30-minute wait ends the job failed rather than retrying (fails closed; documented and tested).

Review ledger

Round Head Findings Remediation
independent 1 4a3d23e shutdown did not stop a job's guarded merge; cancel during the merge ended failed; a cancel without the job's abort could strand it; untested claims (queued, busy, retry, startup and shutdown wiring); a non-existent merging response in the doc 7cc6ab2
independent 2 7cc6ab2 acceptance tests never reached the readiness hand-off (no runner-published PR); a failing check outranked a moved PR; a job could merge during shutdown's drain 79b7fd5
independent 3 79b7fd5 cancel wiring untested; scan could miss base commits after a re-publish; a refusal during shutdown left the job running e9575aa
independent 4 e9575aa pre-v19 fallback used any latest check; a base-move conflict disabled Merge PR; a restart during the merge step reported failed; a misplaced comment ffb995e
independent 5 ffb995e claimed a blocked task could not start a new revision; two cancel tests and one fake used non-production order or state 922007f (start change later reverted)
independent 6 922007f starting an amended plan from a blocked task is never reachable (a task with runner commits needs a scope checkpoint, as from review), so round 5's change was reverted; only the job's own attempt must decide its outcome (untested); blocking was two writes def4b0a
independent 7 def4b0a the scan's fixed start could exceed the 250-commit cap; doc said only pending checks keep Merge PR available; two untested claims 19d5abc (scan starts at the pre-PR check's scanned tip; limit documented)
independent 8 19d5abc no actionable defects —
Copilot 1 19d5abc pre-v19 scan start used the latest check; admission refusals were not saved for replay; the wait could start an inspection after its limit; the Merge button lost focus while disabled 18fc568
independent 9 18fc568 a saved refusal row hid the running job from latestMergeJob; stale start() docstring 727f279
independent 10 727f279 merge-job admission skipped the issue-trust read prepare-merge performs; two doc claims (limit vs merge time, merge_jobs rows) db189ae
independent 11 db189ae doc overstated when shutdown stops a job's preparation 841e395
independent 12 841e395 doc gave the wrong task status for queued and failed jobs fc57d20, 76c81f2
independent 13 76c81f2 Merge button lost focus after a refused job click; scan-range doc ignored pinned attempts 368495c
independent 14 368495c an aria-disabled Merge button kept the primary look 9e0ee39
independent 15 9e0ee39 a job's end reloaded the whole review, erasing selection and input 1234091
independent 16 1234091 a new opening after a rebase (PR closed, re-published) scanned from its own check and skipped rebased-in commits ab74aec
independent 17 ab74aec scanning from the task's first clear check blocked a task forever after a base-branch setting change be4f391
independent 18 be4f391 the first opening into a new base branch may follow a rebase, so its check started at the rebased base f6eb190
independent 19 f6eb190 wrong explanation of why the scan covers a rebase's commits 887f0cd, 7302d68
independent 20 7302d68 stale scan comments in github/merge.ts; the one-transaction block claim untested (also: a job that already ended no longer moves the task) b3ba376
independent 21 b3ba376 restart settlement of a queued attempt untested; a failed read during the wait undocumented and untested 4ea8724
independent 22 4ea8724 shutdown awaiting the job before the store closes untested 3293278
independent 23 3293278 that test passed with the await removed 2e3e860 (unit test on close(); server test narrowed)
independent 24 2e3e860 one doc sentence omitted the pre-v19 fallback 777c9b5
independent 25 777c9b5 no actionable defects —
Copilot 2 777c9b5 a failing-check result read before a review edit could block the changed task; shutdown refusals answered 409 (browser dropped its key); comment wording 0180256
independent 26 0180256 a GitHub refusal because the head changed still moved the task to approved, merge blocked; two shutdown paths in the merge route still answered 409 7180aad
independent 27 7180aad a test name claimed a replay it did not check cbacd19
independent 28 cbacd19 no actionable defects —
merge F6a 2c5ccc3 merged F6a f3bc3e1 (with main 22e76a8) with no conflicts; architecture doc now names schema 19 —
independent 29 2c5ccc3 no actionable defects —

Declined: none. Every regression added from round 9 on was shown to fail with its fix removed (mutation probes). Round 5's finding was accepted, then shown by round 6 to rest on a false premise; its change was reverted and the doc corrected.

Review-lesson audit

  • lifecycle, cancellation, shutdown, crash-point and race findings (rounds 1–4, 6, 11, 20, 26, Copilot 2): covered by Async jobs and polling and Required race regressions in AGENTS.md
  • untested claims and fail-before evidence (rounds 1, 3, 4, 6, 7, 20–23, 27): covered by Review readiness ("give each claim a test that fails if the claim is false")
  • non-production test setups (rounds 2, 5): covered by Review readiness ("A test's setup must leave the state the production path would")
  • documentation claims (rounds 1, 6, 7, 10–13, 19, 24): covered by Review readiness (treat every behavioural claim as something to verify)
  • round 5's false premise and the already-fixed range findings (Copilot 1, rounds 16–18): covered by Review readiness ("Trace each claim through every path that can break it")
  • focus, styling and background-reload findings (Copilot 1, rounds 13–15): covered by Async review UI in AGENTS.md
  • refusal replay and the trust admission (Copilot 1, rounds 9–10): covered by Async jobs and polling (idempotent actions) and the existing trust-gate rules
  • no new rule is needed

Gate status

  • independent exact-head review: clean (round 29) on base f3bc3e1176c9c7436e82faa216b67582854e9fb5 / head 2c5ccc3704116303a6d0a53e782c13b613ee7349
  • CI and real-Docker: pending on 2c5ccc3; passed on 777c9b5
  • Copilot review: rounds 1 (19d5abc) and 2 (777c9b5) addressed; round 3 requested only after exact-head CI passes
  • base: stacked on F6a (codex/f6a-rewritten-head-push, F6a: push the rewritten head behind a durable marker #153) at f3bc3e1, which contains main 22e76a8. After F6a: push the rewritten head behind a durable marker #153 merges, this PR is retargeted to main and re-reviewed and revalidated.
  • merge: not authorized

🤖 Generated with Claude Code

mchwang and others added 9 commits October 11, 2026 17:15
A pre-merge rebase moves the PR's base past base commits the pre-PR
already-fixed check never saw. Merge inspections of a runner-published
PR now scan from the base of the snapshot the last clear check ran on,
excluding the ledger's owned commits. Adds the merge-handoff design.

Part of #22 (F6b).

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

Part of #22 (F6b).

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

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

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

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

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

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

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

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Merge idempotency, legacy scan safety, deadline enforcement, and keyboard-focus handling have unresolved defects.

4 open findings
What changed in this PR

Adds F6b’s durable one-click merge workflow for runner-published pull requests.

Changes:

  • Adds merge-job preparation, required-check polling, guarded merge handoff, cancellation, and recovery.
  • Extends schema v19 with merge-job state and already-fixed scan bindings.
  • Adds browser status reporting, documentation, and regression coverage.
File Description
web/​server.ts Wires merge jobs into API and shutdown.
web/​public/​app.js Displays and polls merge-job progress.
runner/​merge-job.ts Implements the merge-job lifecycle.
runner/​merge.ts Adds abort handling and scan ranges.
runner/​store.ts Adds schema v19 persistence.
runner/​production.ts Settles interrupted jobs at startup.
github/​merge.ts Supports bounded already-fixed ranges.
test/​merge-job.test.ts Tests merge-job lifecycle behavior.
test/​runner-publishing.test.ts Tests server integration and cancellation.
test/​pre-merge.test.ts Tests acceptance scenarios end-to-end.
test/​merge-task-pr.test.ts Tests published-PR scan ranges.
test/​merge.test.ts Tests merge adapter inputs.
test/​runner-production.test.ts Tests startup recovery.
test/​browser/​review.spec.ts Tests browser job progression.
test/​store.test.ts Updates migration expectations.
test/​runner-lifecycle-store.test.ts Updates schema expectations.
test/​publish.test.ts Updates schema expectations.
test/​planning-drafts.test.ts Updates schema expectations.
docs/​implementation/​merge-handoff.md Documents the new workflow.
docs/​implementation/​guarded-merge.md Updates guarded-merge behavior.
docs/​architecture.md Records F6b architecture status.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread runner/merge.ts Outdated
Comment thread runner/merge-job.ts Outdated
Comment thread runner/merge-job.ts Outdated
Comment thread web/public/app.js
mchwang and others added 7 commits October 11, 2026 23:04
- A pre-v19 opening scans from the task's oldest clear check: a compare
  range excludes its start, so only that start is provably continuous.
- A definite admission refusal is saved under the click's action ID and
  replayed on resend; only a shutdown refusal stays resendable.
- No required-check inspection starts after the 30-minute limit, and one
  running into it is stopped, so a job never merges after its limit.
- While a job runs, the focused Merge button stays enabled and marked
  aria-disabled; a repeat activation sends nothing.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ify 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 <noreply@anthropic.com>
mchwang and others added 10 commits October 12, 2026 00:44
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nsaction

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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mchwang and others added 2 commits October 12, 2026 03:24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

A stale required-check response can incorrectly block a task after its review generation has changed.

3 open findings
4 resolved since last review

🧠 Review effort: Balanced

Comment thread runner/merge-job.ts Outdated
Comment thread runner/merge-job.ts
Comment thread runner/merge-job.ts Outdated
mchwang and others added 4 commits October 12, 2026 12:05
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 <noreply@anthropic.com>
…down 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also names schema version 19 in the architecture doc.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Merge-job readiness and refusal settlement have unguarded races with concurrent task and review changes.

3 open findings
3 resolved since last review

🧠 Review effort: Balanced

Comment thread runner/merge-job.ts
// 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;
Comment thread runner/store.ts
Comment on lines +2198 to +2202
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);
}
Comment thread web/public/app.js
data = { ...data, merge: { ...data.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message: ended }, ...blockers] } };
$("banner").textContent = ended;
renderMerge();
return;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants