Four of the five webhook coalesce families never coalesce
src/github/webhook-coalesce.ts computes a job coalesce key for five event families. githubWebhookCoalesceDelaySeconds returns a non-zero window for exactly one of them — the push family (#9479) — and 0 for the rest.
A zero-delay job is claimed off the queue before a sibling can arrive, so there is no pending row to merge into. The file says so itself, in the push section:
On a queue that coalesces (self-host pg/sqlite, which is where the ORB runs) this is what CREATES the window: without a delay the first push is claimed immediately and later pushes find no pending row to merge into.
That reasoning applies to every family, but the window was only ever wired for pushes. The other four keys are derived, attached to the job, and structurally unable to do anything.
Measured cost on the Orb
event_name | action | deliveries (since 2026-07-30)
check_suite | completed | 1727
issue_comment | edited | 998
issue_comment | created | 996
pull_request | labeled | 177
total_records | distinct_heads | records_per_head
1519 | 510 | 2.98
Nearly three full gate passes per distinct head SHA. Each repeat buys a prologue (PR + file list, CI aggregate, config resolve), GitHub REST/GraphQL quota, a decision_records write, and label/comment reconciliation, over state that has not changed.
The LLM review itself is not duplicated — ai_review_pr shows 415 calls against 510 distinct heads, so the per-head review cache is holding. This is API, queue, and DB waste rather than model spend, which is why it went unnoticed: the cost line looked fine.
A worked example, #10098 — eleven hold records at one head SHA in 33 minutes, each upstream_state_change, all reason_code: success.
Contributing factor: the bot's own writes come back as deliveries
shouldProcessPullRequestPublicSurface treats a disposition label change as a re-sync trigger (#9059/#9171 — correct in itself; those labels are read as inputs elsewhere). But the gate writes those labels. So a pass that adds manual-review and clears a stale sibling generates two pull_request.labeled deliveries, each of which re-enters the gate. With pr-label keyed but windowless, both become full re-evaluations.
Fix
Return the key and its window from one classifier, so a family cannot have one without the other:
type WebhookCoalescePlan = { key: string; quietWindowSeconds: number };
function planGithubWebhookCoalesce(eventName, payload): WebhookCoalescePlan | null;
Both exported functions become projections of it. Windows:
| family |
window |
why |
pr-push |
45s |
unchanged (#9479) |
ci-completed |
45s |
interchangeable — whichever wins re-fetches the same settled CI state. Kept under CI_COALESCE_WINDOW_SECONDS (60s) so the downstream per-PR throttle stays the outer bound |
pr-label |
15s |
shorter: a maintainer removing a hold label expects prompt notice |
| review-surface |
15s |
payload-interchangeable within a family |
pr-refresh |
0 |
lifecycle — opened/reopened/ready_for_review each happen once and start the clock a contributor waits on. Keyed, never delayed |
The coupling is the actual fix; the numbers are tuning. The invariant is asserted as key implies window over the real families rather than as expected constants, so a family added later cannot reintroduce a keyed-but-inert delivery.
Four of the five webhook coalesce families never coalesce
src/github/webhook-coalesce.tscomputes a job coalesce key for five event families.githubWebhookCoalesceDelaySecondsreturns a non-zero window for exactly one of them — the push family (#9479) — and0for the rest.A zero-delay job is claimed off the queue before a sibling can arrive, so there is no pending row to merge into. The file says so itself, in the push section:
That reasoning applies to every family, but the window was only ever wired for pushes. The other four keys are derived, attached to the job, and structurally unable to do anything.
Measured cost on the Orb
Nearly three full gate passes per distinct head SHA. Each repeat buys a prologue (PR + file list, CI aggregate, config resolve), GitHub REST/GraphQL quota, a
decision_recordswrite, and label/comment reconciliation, over state that has not changed.The LLM review itself is not duplicated —
ai_review_prshows 415 calls against 510 distinct heads, so the per-head review cache is holding. This is API, queue, and DB waste rather than model spend, which is why it went unnoticed: the cost line looked fine.A worked example, #10098 — eleven
holdrecords at one head SHA in 33 minutes, eachupstream_state_change, allreason_code: success.Contributing factor: the bot's own writes come back as deliveries
shouldProcessPullRequestPublicSurfacetreats a disposition label change as a re-sync trigger (#9059/#9171 — correct in itself; those labels are read as inputs elsewhere). But the gate writes those labels. So a pass that addsmanual-reviewand clears a stale sibling generates twopull_request.labeleddeliveries, each of which re-enters the gate. Withpr-labelkeyed but windowless, both become full re-evaluations.Fix
Return the key and its window from one classifier, so a family cannot have one without the other:
Both exported functions become projections of it. Windows:
pr-pushci-completedCI_COALESCE_WINDOW_SECONDS(60s) so the downstream per-PR throttle stays the outer boundpr-labelpr-refreshopened/reopened/ready_for_revieweach happen once and start the clock a contributor waits on. Keyed, never delayedThe coupling is the actual fix; the numbers are tuning. The invariant is asserted as key implies window over the real families rather than as expected constants, so a family added later cannot reintroduce a keyed-but-inert delivery.