Parent: #1936
Problem
jobCoalesceKey() only dedups a pending job against another pending job with an identical key. A check_run/check_suite completed webhook keys on github-webhook:ci-completed:{repo}@{headSha}#{prNumbers}, a pull_request synchronize webhook keys on github-webhook:pr-refresh:{repo}#{pr}@{headSha}, and a sweep-driven re-gate keys on agent-regate-pr:{repo}#{pr} (src/selfhost/queue-common.ts:727, src/github/webhook-coalesce.ts:19-34). These are three different key shapes for the same PR, so they never coalesce with each other. Combined with the default QUEUE_CONCURRENCY = 4 (explicitly sized to overlap I/O-bound jobs), two of these jobs for the same PR can be claimed and processed by two concurrent workers, each with its own freshly-fetched (and independently-timed) live CI/mergeable/reviewDecision snapshot.
The only per-PR-keyed coalescing window that exists (ciReReviewCoalesced, a 60s TTL) is wired into the check_run/check_suite webhook handler only — it is never consulted by the sweep's agent-regate-pr path, so sweep-vs-webhook races are entirely unguarded. This is a codebase-acknowledged gap: src/env.d.ts carries an explicit TODO for a per-PR SubmissionLock Durable Object, deliberately deferred as a separate, more-involved sub-task.
Failure scenario: a scheduled re-gate sweep enqueues agent-regate-pr:{repo}#71 for a PR at the same moment GitHub redelivers a check_run completed webhook for that PR's current head (a different coalesce key). Both jobs are claimed concurrently. Both resync to the same live headSha and both pass their own freshness check. If the two live reads (fetched a few hundred ms apart) disagree on reviewDecision/mergeable_state — e.g. the bot's own prior approve review lands asynchronously between the two reads — one job could plan close while the other plans merge/approve, and both would pass their independent freshness checks and both execute.
Requirements
- Two jobs targeting the same PR, regardless of coalesce-key shape, must not both reach live-read-and-actuate concurrently.
- The fix should not require every job type to share an identical coalesce key (their current key shapes serve other purposes).
Deliverables
- Add a lightweight per-PR mutual-exclusion guard around the plan-and-execute critical section — e.g. a short-TTL advisory lock keyed
agent-maintenance-lock:{repo}#{pr} (reusable via the existing SELFHOST_TRANSIENT_CACHE, the same mechanism ciReReviewCoalesced already uses), acquired at the start of maybeRunAgentMaintenance and released after executeAgentMaintenanceActions returns. A job that fails to acquire the lock skips this pass rather than proceeding (the next webhook/sweep tick is the backstop).
- Route both the sweep path (
regatePullRequest) and the webhook paths through the same lock, closing the asymmetry where only the webhook path currently has any per-PR coalescing.
- Longer-term: implement the per-PR
SubmissionLock Durable Object already noted as a TODO, for a durable primitive that works across the sqlite/pg self-host split.
- Add a regression test asserting two concurrently-claimed jobs for the same PR — one shaped as
agent-regate-pr, one as a github-webhook (ci-completed or pr-refresh) — cannot both reach executeAgentMaintenanceActions for the same PR at the same time.
Acceptance criteria
- Two jobs for the same PR arriving via different trigger shapes never run their plan-and-execute critical sections concurrently.
- A job that can't acquire the lock defers cleanly rather than erroring or silently dropping the PR from evaluation.
Expected outcome
Actuation for a given PR is always serialized to one in-flight decision at a time, regardless of which trigger (webhook or sweep) initiated it — closing a real (if narrow) path to two independently-timed, possibly-disagreeing actuations racing each other.
Parent: #1936
Problem
jobCoalesceKey()only dedups a pending job against another pending job with an identical key. Acheck_run/check_suite completedwebhook keys ongithub-webhook:ci-completed:{repo}@{headSha}#{prNumbers}, apull_request synchronizewebhook keys ongithub-webhook:pr-refresh:{repo}#{pr}@{headSha}, and a sweep-driven re-gate keys onagent-regate-pr:{repo}#{pr}(src/selfhost/queue-common.ts:727,src/github/webhook-coalesce.ts:19-34). These are three different key shapes for the same PR, so they never coalesce with each other. Combined with the defaultQUEUE_CONCURRENCY = 4(explicitly sized to overlap I/O-bound jobs), two of these jobs for the same PR can be claimed and processed by two concurrent workers, each with its own freshly-fetched (and independently-timed) live CI/mergeable/reviewDecision snapshot.The only per-PR-keyed coalescing window that exists (
ciReReviewCoalesced, a 60s TTL) is wired into thecheck_run/check_suitewebhook handler only — it is never consulted by the sweep'sagent-regate-prpath, so sweep-vs-webhook races are entirely unguarded. This is a codebase-acknowledged gap:src/env.d.tscarries an explicit TODO for a per-PRSubmissionLockDurable Object, deliberately deferred as a separate, more-involved sub-task.Failure scenario: a scheduled re-gate sweep enqueues
agent-regate-pr:{repo}#71for a PR at the same moment GitHub redelivers acheck_run completedwebhook for that PR's current head (a different coalesce key). Both jobs are claimed concurrently. Both resync to the same live headSha and both pass their own freshness check. If the two live reads (fetched a few hundred ms apart) disagree onreviewDecision/mergeable_state— e.g. the bot's own priorapprovereview lands asynchronously between the two reads — one job could planclosewhile the other plansmerge/approve, and both would pass their independent freshness checks and both execute.Requirements
Deliverables
agent-maintenance-lock:{repo}#{pr}(reusable via the existingSELFHOST_TRANSIENT_CACHE, the same mechanismciReReviewCoalescedalready uses), acquired at the start ofmaybeRunAgentMaintenanceand released afterexecuteAgentMaintenanceActionsreturns. A job that fails to acquire the lock skips this pass rather than proceeding (the next webhook/sweep tick is the backstop).regatePullRequest) and the webhook paths through the same lock, closing the asymmetry where only the webhook path currently has any per-PR coalescing.SubmissionLockDurable Object already noted as a TODO, for a durable primitive that works across the sqlite/pg self-host split.agent-regate-pr, one as agithub-webhook(ci-completed or pr-refresh) — cannot both reachexecuteAgentMaintenanceActionsfor the same PR at the same time.Acceptance criteria
Expected outcome
Actuation for a given PR is always serialized to one in-flight decision at a time, regardless of which trigger (webhook or sweep) initiated it — closing a real (if narrow) path to two independently-timed, possibly-disagreeing actuations racing each other.