feat(merge-queue): hand a whole stack to trunk in one submission - #58
Merged
Merged
Conversation
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
marked this pull request as ready for review
September 7, 2026 15:39
Contributor
Author
|
@Gilbert09 I found the stacks are not merged as one apparently. Let me know if this makes sense. |
Gilbert09
approved these changes
Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 readsstack/stackEntryoff the existing PR query. Nullable fields, no preview header, no extra round-trip.GITHUB_STACK_FIELDS=0drops 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.tsis the fourthrepoMergeGate-shaped decaying tally (24h, cleared the moment a submission is accepted). Arejectedon a batched stack records it and requeues for the serial drain; arejectedon 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:
scheduleGroupEvaluationstarts a detached walk holding a 45swithTimeouttimer, 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 aDO NOTcomment 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