Skip to content

feat(merge-queue): hand a whole stack to trunk in one submission - #58

Merged
Gilbert09 merged 2 commits into
Gilbert09:mainfrom
webjunkie:feat/merge-queue-stack-batch
Sep 8, 2026
Merged

feat(merge-queue): hand a whole stack to trunk in one submission#58
Gilbert09 merged 2 commits into
Gilbert09:mainfrom
webjunkie:feat/merge-queue-stack-batch

Conversation

@webjunkie

@webjunkie webjunkie commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Session 85 built the merge stack on the premise that the external queue refuses stacks outright — R4b's own comment says so, and the comment parser still maps trunk's "unable to merge this PR" to rejected. That premise expired: trunk supports GitHub's native stacked PRs and lands the rung it is given plus every rung beneath it, atomically, in one round of CI.

So the serial drain was paying N test cycles (~40 min each on posthog/posthog) plus a retarget and often a paid rebase run per rung, for work the provider does once. A four-deep stack now costs one cycle.

What decides eligibility is GitHub's signal, not ours. linkStack's branch-shaped chain still drives the UI and the serial fallback, but trunk batches only what GitHub calls a stack — so the batch path reads stack/stackEntry off the existing PR query. Nullable fields, no preview header, no extra round-trip. GITHUB_STACK_FIELDS=0 drops the selection if GitHub withdraws it mid-preview; it rides in the one query every poll of every repo runs.

The gate probe was asking the wrong branch, which turned out to be the crux. A stacked PR's own base is the rung below it — an ordinary topic branch with no ruleset and no queue — so the gate probe answered "unguarded" for every rung above the bottom one. Fixed in the queue, the auto-keep watcher, and POST /pull-requests/:id/merge, which without it merged stack members into their parent's branch.

external_covered_by (migration 0051) is the safety, not the mechanism. The provider ejects the whole batch when anything pushes to any member, so every rung below the submitted one has to be as untouchable as it is — and a covered rung has no provider comment and no gate of its own to say so.

How it decides. R4b gained a branch above the park: the submit rung falls through and submits on the stack's gate, covered rungs park hands-off, and the rungs in between still remediate — deliberately, since the provider tests them as one unit and a conflict four deep is real work that has to happen first — but never merge, arm, or submit. The submission goes to the top rung, and only when every rung is queued, ready, and visible contiguously from position 1; a hole underneath would merge a PR nobody enqueued.

A refusal is a fallback, not a wall. repoStackBatching.ts is the fourth repoMergeGate-shaped decaying tally (24h, cleared the moment a submission is accepted). A rejected on a batched stack records it and requeues for the serial drain; a rejected on an unstacked PR still blocks exactly as before.

Testing. 11 new decide cases, 4 pglite integration cases for the batch path, 22 planner/capability unit cases, 3 watcher cases. Green on all three OSes in 32m, which is the normal band for this repo.

One thing worth reading before review (second commit). The first version also woke the other rungs' groups when an entry's status changed, so a covered rung would notice its batch ending without waiting for the reconciler. That hung CI on all three OSes for 100 minutes: scheduleGroupEvaluation starts a detached walk holding a 45s withTimeout timer, so rungs scheduling each other build an endless chain of scheduled walks and the process never goes idle — vitest never exits, and the chain outlives the test that started it, so guarding on "only when the status changed" does not save it. Reverted; the 60s reconciler is the backstop, as originally designed. The cost is a covered rung reading "queued with #N" for up to a minute after its batch ends, which is latency and not correctness. There is a DO NOT comment at the call site because wanting it back is the obvious instinct.

Follow-up, noted in ROADMAP: an ejected batch dispatches its fix run at the submitted rung even when trunk's bisection blames a lower one. The run gets the stack in its prompt and has to place the fix itself.

FYI this is off a fork branch rather than a push to main — this session's credentials have no write access to Gilbert09/talyn.

https://claude.ai/code/session_01XUh4zGKpX5L9mJc32RzqBP

Session 85 built the merge stack on the premise that the external queue
refuses stacks outright — R4b's own comment says so, and the comment parser
still maps trunk's "unable to merge this PR" to `rejected`. That premise
expired: trunk supports GitHub's native stacked PRs and lands the rung it is
given plus every rung beneath it, atomically, in one round of CI.

So the serial drain was paying N test cycles (~40 min each on posthog/posthog)
plus a retarget and often a paid rebase run per rung, for work the provider
does once. A four-deep stack now costs one cycle.

What decides eligibility is GitHub's signal, not ours. `linkStack` derives a
stack from branch shapes and still drives the UI and the serial fallback, but
trunk batches only what GitHub calls a stack, so the batch path reads
`stack { id number size baseRefName }` + `stackEntry { position }` off the PR
query — nullable fields, no preview header, no extra round-trip. A
branch-shaped chain takes the old path unchanged. `GITHUB_STACK_FIELDS=0`
drops the selection if GitHub withdraws the fields mid-preview; it rides in
the one query every poll of every repo runs.

The gate probe was asking the wrong branch, and that is the crux. A stacked
PR's own base is the rung below it — an ordinary topic branch with no ruleset
and no queue — so `getExternalMergeGate(…, entry.baseBranch)` answered
"unguarded" for every rung above the bottom one. The gate that applies is the
one on the stack's landing branch (`prLandingBranch`). Same fix in the
auto-keep watcher and in `POST /pull-requests/:id/merge`, which without it
merged stack members into their parent's branch.

`external_covered_by` (migration 0051) is the whole state addition, and it is
the safety rather than the mechanism: the provider ejects the entire batch
when anything pushes to any member, so every rung below the submitted one must
be as untouchable as it is — and a covered rung has no provider comment and no
gate of its own to say so.

R4b gained a branch above the park. The submit rung falls through and submits
itself on the stack's gate; covered rungs park hands-off; the rungs in between
still remediate, deliberately, because the provider tests the rungs as one
unit and a conflict four deep is real work that must happen before the
submission — they just never merge, arm, or submit (`stackBatchHoldsMerge`).

The submission goes to the top rung, and only when every rung is queued, ready
and visible from the bottom up. Top, because lower lands a prefix. "Every rung
ready" because a failed batch is bisected with everything alongside it waiting.
"Every rung queued and contiguous from position 1" because a submission lands
rungs whether or not Talyn tracks them, so a hole underneath would merge a PR
nobody enqueued.

A refusal is a fallback, not a wall: services/repoStackBatching.ts is the
fourth repoMergeGate-shaped decaying tally (24h, cleared on an accepted
submission). A `rejected` on a batched stack records it and requeues for the
serial drain instead of blocking a human; a `rejected` on an unstacked PR
still blocks exactly as before.

Claude-Session: https://claude.ai/code/session_01XUh4zGKpX5L9mJc32RzqBP
… process idle

The batch submission woke the other rungs' groups whenever an entry's status
changed, so a covered rung would notice its batch had ended without waiting for
the reconciler. It hung CI on all three OSes for 100 minutes — a run that
normally takes ~30 — and vitest never exited.

scheduleGroupEvaluation starts a DETACHED walk that holds a 45s withTimeout
timer, so rungs scheduling each other build an endless chain of scheduled
walks and the process never goes idle. Guarding on "only when the status
actually changed" does not save it: the chain outlives the test that started
it, which is why the teardown logs show a group evaluation still querying a
torn-down DB.

The reconciler was always the backstop and stays it. Cost is a covered rung
reading "queued with #N" for up to a minute after the batch it names has
ended — latency, not correctness: nothing acts on the stale marker except to
keep hands off a PR the queue has already released.

Leaves a DO NOT comment at the call site, since wanting this back is the
obvious instinct. `StackChainMember.baseBranch` goes with it; it existed only
to feed the scheduling.

Claude-Session: https://claude.ai/code/session_01XUh4zGKpX5L9mJc32RzqBP
@webjunkie
webjunkie marked this pull request as ready for review September 7, 2026 15:39
@webjunkie

Copy link
Copy Markdown
Contributor Author

@Gilbert09 I found the stacks are not merged as one apparently. Let me know if this makes sense.

@Gilbert09
Gilbert09 merged commit 94a5f3e into Gilbert09:main Sep 8, 2026
3 checks passed
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