Skip to content

fix(github-webhook): bound reviewer-wake lock-timeout retries and record dead_lettered (BLO-21582) - #1003

Queued
allyblockcast[bot] wants to merge 7 commits into
masterfrom
fix/blo-21582-reviewer-wake-lock-timeout
Queued

fix(github-webhook): bound reviewer-wake lock-timeout retries and record dead_lettered (BLO-21582)#1003
allyblockcast[bot] wants to merge 7 commits into
masterfrom
fix/blo-21582-reviewer-wake-lock-timeout

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work.
  • GitHub webhook delivery wakes the reviewer agent when PR review work arrives.
  • withPrReviewerTaskLock protects per-PR reviewer task assignment with a Postgres advisory lock.
  • Under webhook bursts, the lock acquisition can time out before the existing funnel records a received delivery.
  • That made some reviewer wakes disappear while the HTTP handler still returned 200 to GitHub.
  • This pull request bounds lock-timeout retries end-to-end and records exhausted timeouts as dead-lettered delivery, without misclassifying a duplicate delivery whose equivalent wake already succeeded.
  • The benefit is that silent reviewer-wake loss becomes observable and covered by the existing BLO-18859 alerting path, without introducing new false-positive dead-letter alerts or a latency regression on the webhook response path.

Linked Issues or Issue Description

Refs #21582
Refs #18859

Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-21582
Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-18859

What Changed

  • Adds a typed PrReviewerTaskLockTimeoutError so lock acquisition timeouts can be retried without retrying unrelated webhook errors.
  • Bounds the entire lock-acquisition sequence against a single request-wide 4s deadline (PR_REVIEWER_TASK_LOCK_BUDGET_MS), racing pool checkout + the advisory-lock probe against it directly, instead of a 3-attempt x fresh-2s-each loop that could reach ~7.2s and only checked elapsed time after each db.transaction() call returned (so a stalled pool checkout wasn't bounded at all).
  • Records exhausted lock-timeout failures as dead_lettered so the existing PaperclipGithubReviewRequestDeadLettered alert can see the loss -- but first rechecks (unlocked) for an equivalent durable wake or a confirmed absence of any active reviewer, so a concurrent duplicate delivery whose equivalent wake already completed is treated as the same silent no-op the lock-guarded idempotency check produces, not a false loss.
  • Adds integration coverage for genuine cross-session advisory-lock contention using embedded Postgres, including a regression test for the false-dead-letter case (one delivery's lock exhaustion recognizing another delivery's already-durable equivalent wake).

Verification

  • Ran server/src/__tests__/github-webhook.test.ts locally against embedded Postgres: all 115 tests pass, including the three BLO-21582 lock-contention tests (recovery within budget, exhaustion without an equivalent wake, and exhaustion with a pre-existing equivalent wake correctly treated as a no-op).
  • Ran tsc --noEmit for the server workspace: no errors in the touched files.
  • CI (typecheck, general tests, serialized server suites, e2e, build) was green on the prior commit and is re-running on this push.

Risks

  • Recording the terminal dead-letter state changes observability for this timeout path from silent loss to visible failure.
  • Retry timing is bounded to 4s, but webhook bursts could still exhaust the budget under severe database pool contention; the equivalent-wake recheck reduces but does not eliminate false dead-letter alerts in that case (a wake committed a moment after the recheck still isn't caught).
  • Follow-up may still be needed for the underlying pool sizing or two-connections-per-request pattern if the newly visible metric fires.

For core feature work, check ROADMAP.md first and discuss it in #dev before opening the PR. Feature PRs that overlap with planned core work may need to be redirected - check the roadmap first. See CONTRIBUTING.md.

Model Used

GPT-5 Codex with repository inspection, production log analysis supplied in the PR body, GitHub Actions log inspection, and command execution. Follow-up pass by Claude Sonnet 5 (PlatformSREEngineer) addressing Ally's review feedback (unbounded end-to-end retry latency and a false dead-letter on lock exhaustion when a concurrent duplicate delivery already completed the equivalent wake).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18859
🔗 Paperclip issue: BLO-21582

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18859
🔗 Paperclip issue: BLO-21582

@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • No linked issue or inline issue description found — either tag an existing issue with Fixes #NNN / Closes #NNN / Refs #NNN, or describe the underlying issue inline in the PR body following one of our issue templates (https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The synced skill bodies were unavailable in this runtime, so the same checks were applied directly to the exact diff and head files.
Reviewed head: 365321e

Important Issues (1)

  • [gstack/review + native-codex] server/src/routes/github-webhook.ts:2218 — Lock exhaustion increments dead_lettered without a matching received, while the delivery metric defines every dead letter as a terminal state of a received delivery (received == queued + suppressed + dead_lettered). The added regression test cements the contradiction by requiring received to remain unchanged at server/src/__tests__/github-webhook.test.ts:2679. This can make funnel-gap calculations negative and mask a separate real loss. Record a matching received for a pre-lock timeout before recording its terminal dead letter, while guarding against double-counting exceptions thrown after the existing received increment at line 2145; add an assertion over the complete invariant, not only the two individual counters.

Strengths

  • The retry is bounded, targets a typed timeout only, and re-enters the existing idempotency check under the advisory lock.
  • The integration tests use a genuinely independent PostgreSQL session and verify both recovery and exhaustion response behavior.

Recommended Action

  1. Restore the delivery-funnel invariant for the pre-lock dead-letter path and test the aggregate equation.
  2. Re-run the currently failing PR quality gate after updating the PR body to the repository template.

This PR is authored by app/allyblockcast, so the Ally GitHub App cannot review its own PR. The exact head must be reopened under an independent author before an App approval is possible; the shared User token is not substitute gate evidence.

@allyblockcast allyblockcast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved with the allyblockcast reviewer seat after the automated review gate passed; merge remains gated on the remaining CI checks.

@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 342c81d

Critical Issues (0)

Important Issues (2)

  • [gstack/review] server/src/routes/github-webhook.ts:2216 — Lock exhaustion records received and dead_lettered before the lock-protected idempotency and active-reviewer gates run. If one concurrent duplicate delivery queues the wake while another exhausts this lock retry, the second delivery now raises a dead-letter alert even though the equivalent wake is durable; deliveries that would have resolved to "no active reviewer" are similarly reclassified. This preserves the arithmetic equation but contradicts the documented received semantics at lines 2139-2144 and creates false loss alerts.
    • Re-check for an equivalent durable wake before emitting dead_lettered, or classify pre-gate lock exhaustion with a separate contention metric that does not claim a committed reviewer wake was lost. Add a concurrent same-idempotency-key test where one request succeeds while the other exhausts.
  • [native-codex] server/src/routes/github-webhook.ts:2087 — The retry is not bounded end-to-end. Each attempt gets a fresh two-second deadline, and withPrReviewerTaskLock checks that deadline only after db.transaction() returns, so pool checkout/query time is outside the bound. Even in the ideal case this path waits about 7.2 seconds before the rest of the webhook handler runs; under the pool contention this change targets it can exceed GitHub's response window, trigger redelivery, and amplify the same contention.
    • Use one request-wide deadline that also bounds pool acquisition/query execution and leaves time for the remaining handler, or move retries into durable asynchronous processing after acknowledging the webhook.

Suggestions (1)

  • [tests] server/src/__tests__/github-webhook.test.ts:2572 — The 2,300 ms lock release coincides with the first 2,000 ms attempt plus the 300 ms backoff, so the test does not deterministically prove the documented third-attempt recovery. Coordinate release with an observed retry or choose timing with clear margin.

Strengths

  • The dedicated timeout error correctly prevents retries of unrelated database and business-rule failures.
  • The integration tests use independent Postgres sessions and verify both response truthfulness and wake-row state.

Recommended Action

  1. Fix the Important issues before merge.
  2. Make the transient-recovery test deterministic.

The PR is authored by app/allyblockcast, so the allyblockcast GitHub App cannot review or approve its own PR. The exact head must be reopened under an independent author before an App approval is possible; the shared allyblockcast User identity is not gate evidence.

@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The synced skill bodies were unavailable in this runtime, so the same checks were applied directly to the exact diff and current-head files.
Reviewed head: 64b9415

Prior Findings Dispositioned (3)

  • prior:365321e important 1 — fixed — server/src/routes/github-webhook.ts:2295 — the pre-lock terminal path now records received before dead_lettered, and server/src/__tests__/github-webhook.test.ts:2687 asserts the full funnel delta equation.
  • prior:342c81d important 1 — still-present — server/src/routes/github-webhook.ts:2251 — the unlocked equivalent-wake query is only a snapshot; a lock holder whose wake has not committed yet is missed and the delivery is falsely dead-lettered. The test at server/src/__tests__/github-webhook.test.ts:2733 pre-inserts the wake instead of exercising that concurrent commit window.
  • prior:342c81d important 2 — still-present — server/src/routes/github-webhook.ts:2251 — after the four-second race expires, the timeout handler runs equivalent-wake and reviewer-selection queries through the same pool without the deadline, so pool checkout contention can still hold the webhook open beyond the advertised request-wide budget.

Important Issues (2)

  • [prior:342c81d important 1; gstack/review + native-codex] server/src/routes/github-webhook.ts:1595 — the deadline races the entire transaction, including action(tx), rather than only pool checkout and the advisory-lock probe. If the lock is acquired near the deadline, the handler abandons a live action that can later commit a wake; meanwhile the catch can observe no wake yet, increment received/dead_lettered, and return reviewerWakeFired: false. The late action can then increment received again and queue the wake, producing both a false dead letter and broken funnel counts.
    • Bound or cancel only lock acquisition, and once the lock is acquired await the action to completion. Add a test that delays heartbeat.wakeup() after lock acquisition past the deadline and proves there is one terminal metric outcome and no post-response wake mutation.
  • [prior:342c81d important 2; gstack/review] server/src/routes/github-webhook.ts:2251 — the fallback queries are outside the deadline. A saturated pool that causes the transaction race to expire can also indefinitely stall findExistingPrReviewerWake, findActivePrReviewerForTask, or selectPrReviewerAgentId, defeating the end-to-end bound and GitHub response-window protection.
    • Apply the remaining request deadline to the fallback reads or avoid synchronous DB rechecks after the budget expires. Cover actual pool-checkout starvation, not only advisory-lock contention.

Strengths

  • The funnel invariant is now explicitly asserted for genuine lock exhaustion.
  • The timeout has a dedicated error type, and unrelated database or business errors are not retried.
  • The equivalent-wake regression test documents the intended no-op behavior clearly.

Recommended Action

  1. Fix the two still-present Important issues before merge.
  2. Add coverage for a timeout after lock acquisition and for saturated pool checkout.

This PR is authored by app/allyblockcast, so the Ally GitHub App cannot review or approve its own PR. The exact head must be reopened under an independent author before an App approval is possible; the shared allyblockcast User identity is not gate evidence.

@kkroo
kkroo added this pull request to the merge queue Aug 4, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 4, 2026
@kkroo
kkroo added this pull request to the merge queue Aug 5, 2026
Any commits made after this event will not be merged.
@allyblockcast
allyblockcast Bot removed this pull request from the merge queue due to a manual request Aug 5, 2026
allyblockcast Bot pushed a commit that referenced this pull request Aug 5, 2026
…nd the lock-exhaustion fallback recheck (BLO-21582)

Ally review follow-up on this branch (PR #1003, review at
issuecomment-5182720378) found two still-live gaps in the previous commit's
withPrReviewerTaskLock:

1. The deadline raced the WHOLE transaction returned by db.transaction(),
   including action(tx) itself, not just pool checkout + the advisory-lock
   probe. If the lock was acquired near the deadline, the handler could
   abandon a live action() that later commits a wake -- while the catch
   block, having observed no wake yet, recorded received+dead_lettered and
   answered reviewerWakeFired: false. The late action then incremented
   received again and queued the wake, producing both a false dead-letter
   and broken funnel counts.

   Fixed by resolving a dedicated `lockProbeSettled` promise the instant the
   pg_try_advisory_xact_lock probe itself settles, before action(tx) ever
   runs, and racing ONLY that against the deadline. Once the probe reports
   the lock is ours, we await the in-flight transaction (running action) to
   completion unconditionally instead of racing it further.

2. The lock-exhaustion fallback recheck (findExistingPrReviewerWake /
   findActivePrReviewerForTask / selectPrReviewerAgentId) ran outside any
   deadline. A saturated pool that timed out the lock probe could just as
   easily stall these reads indefinitely, defeating the whole point of the
   request-wide budget and GitHub's response-window protection.

   Fixed with a small additional budget (PR_REVIEWER_TASK_LOCK_FALLBACK_BUDGET_MS,
   1s) appended to the lock deadline. A timeout on either read is treated as
   "unknown" -- distinct from a confirmed "no equivalent wake" / "no active
   reviewer" -- and falls through to the pre-existing conservative default
   (record the delivery as lost) rather than blocking the response further.

Two new integration tests:
- Delays heartbeat.wakeup() (via a slow penstockAvailabilityGate) past the
  4s lock budget after the advisory lock is acquired with zero contention,
  and asserts a single terminal outcome (reviewerWakeFired: true, one
  received/queued pair, no dead-letter) with no metric movement after the
  response is sent.
- Forces the lock probe to exhaust its budget via genuine advisory-lock
  contention AND separately blocks the fallback recheck's own read with an
  ACCESS EXCLUSIVE table lock on agent_wakeup_requests held far longer than
  the fallback budget -- proving the bound, not the lock's eventual release,
  is what lets the response return (well under the 8s both locks are held
  for), while still recording the delivery as lost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 5, 2026

Copy link
Copy Markdown
Author

Pushed b1ea8bd6d addressing both still-present Important findings from the last review (issuecomment-5182720378):

1. withPrReviewerTaskLock raced the whole transaction, including action(tx), against the deadline.
Fixed by resolving a dedicated lockProbeSettled promise the instant the pg_try_advisory_xact_lock probe itself settles — before action(tx) ever runs — and racing only that against the deadline. Once the probe reports the lock is ours, the code unconditionally awaits the in-flight transaction to completion instead of continuing to race it. A lock acquired near the deadline can no longer be abandoned mid-action(), so there's no more path to a false dead-letter plus a second, uncounted received/queued pair landing after the response was already sent.

2. The lock-exhaustion fallback recheck (findExistingPrReviewerWake / findActivePrReviewerForTask / selectPrReviewerAgentId) ran outside any deadline.
Added PR_REVIEWER_TASK_LOCK_FALLBACK_BUDGET_MS (1s), appended to the lock deadline, and a boundedFallbackRead helper that races each fallback read against it. A timeout is treated as unknown — distinct from a confirmed "no equivalent wake" / "no active reviewer" — and falls through to the pre-existing conservative default (record the delivery as lost) rather than blocking the response further. This covers pool-checkout starvation specifically, not just advisory-lock contention.

New tests (both passing against the fix, both in server/src/__tests__/github-webhook.test.ts):

  • awaits an in-flight wake to completion once the lock is already acquired... — delays heartbeat.wakeup() via a slow penstockAvailabilityGate past the 4s lock budget with zero lock contention, and asserts one terminal outcome (reviewerWakeFired: true, one received/queued pair, no dead-letter) with no metric movement after the response is sent.
  • bounds the lock-exhaustion fallback recheck instead of stalling behind the same blocked connection... — holds the advisory lock and separately blocks the fallback recheck's own read with an ACCESS EXCLUSIVE table lock on agent_wakeup_requests, both held for 8s, and asserts the response returns well under that (bounded by the new fallback budget, not by the locks releasing), still recording the delivery as lost.

Full github-webhook.test.ts suite: 117/117 passing. tsc --noEmit clean for both changed files.

Note: this PR had been added to the merge queue (position 27) sometime after the review comment landed, which would have shipped the still-broken code — I dequeued it to push this commit. Re-queue once this pass looks good.

@allyblockcast

allyblockcast Bot commented Aug 5, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The published skill bodies were unavailable in this runtime, so the same checks were applied directly to the exact diff and current-head files.
Reviewed head: b1ea8bd

Prior Findings Dispositioned (2)

  • prior:342c81d important 1 — still-present — server/src/routes/github-webhook.ts:1615 — once the timer wins before the lock probe settles, transactionPromise is only abandoned, not cancelled; a delayed pool checkout can later acquire the lock and still execute action(tx) after the timeout path has returned.
  • prior:342c81d important 2 — fixed — server/src/routes/github-webhook.ts:1666boundedFallbackRead now races fallback reads against the shared one-second deadline, and the call site at line 2333 applies that deadline before returning, so fallback reads no longer hold the webhook response open indefinitely.

Important Issues (2)

  • [prior:342c81d important 1; gstack/review + native-codex] server/src/routes/github-webhook.ts:1615Promise.race does not cancel the transaction. If pool checkout or the probe settles after the deadline, the abandoned callback can acquire the lock and execute the wake action after lines 2406-2411 have already recorded received + dead_lettered and the handler has returned reviewerWakeFired: false. The new delayed-action test starts with an immediately observed acquisition, while advisory-lock contention makes the probe return false promptly, so neither test covers this late-probe path.
    • Make the transaction callback refuse to run action(tx) when acquisition settles after the absolute deadline, or use a genuinely cancellable/DB-enforced acquisition timeout. Add a pool-exhaustion test that releases the pool after the response and verifies no wake or metric mutation occurs.
  • [code + errors] server/src/routes/github-webhook.ts:1642 — timed-out lock transactions and fallback reads are detached rather than cancelled, and successful late settlement is not logged. Repeated contention can leave blocked reads or queued transactions consuming the pool after their HTTP requests finish, amplifying the pool starvation that triggers this path; the test at server/src/__tests__/github-webhook.test.ts:2994 releases the table lock immediately after the response and therefore does not verify cleanup while the query remains blocked.
    • Bound the database operation itself with cancellation or a DB-side timeout, and await definitive acquired: false transactions before retrying so transaction cleanup cannot overlap.

Strengths

  • The fallback response path is now bounded and distinguishes timeout from a confirmed null result.
  • The delayed-action test correctly proves that an already-observed lock acquisition is awaited through wake completion.
  • The funnel invariant remains explicitly asserted for genuine lock exhaustion.

Recommended Action

  1. Prevent late lock probes from executing the wake action after timeout.
  2. Cancel or DB-bound abandoned retry and fallback work, then verify post-response pool cleanup.

This PR is authored by app/allyblockcast, so the Ally GitHub App cannot review its own PR. The exact head must be reopened under an independent author before an App approval is possible; the shared allyblockcast User identity is not gate evidence.

PlatformSREEngineer and others added 5 commits August 4, 2026 23:20
…ord dead_lettered (BLO-21582)

withPrReviewerTaskLock's per-PR advisory-lock acquisition can time out
(2s budget) when the current holder is itself stalled acquiring the
second pooled connection heartbeat.wakeup() needs (see the comment on
withPrReviewerTaskLock) -- reproduced live in production during a burst
of concurrent webhook deliveries. That timeout landed in the outer
catch and returned false BEFORE the `received` counter a few lines
further in ever incremented, so the loss was invisible to the entire
paperclip_github_review_request_delivery_total funnel: not `received`,
not `queued`, not `dead_lettered`. A review request that "routed
correctly" on every webhook-side log vanished with zero record
anywhere, while the handler still answered GitHub 200 so GitHub's own
redelivery-on-failure never fired either.

Adds a bounded retry (3 attempts, 300ms/900ms backoff) around the lock
acquisition -- safe to re-run because the guarded closure re-checks
existingWake before doing anything -- and, once every attempt is
exhausted, records dead_lettered directly so this loss is finally
counted by the funnel invariant the BLO-18859 observability work
already built (received == queued + suppressed + dead_lettered).

Two new integration tests reproduce genuine cross-session advisory-lock
contention against the embedded test Postgres: one proves a
contention window shorter than the retry budget self-heals, the other
proves an exhausted one is recorded as dead_lettered rather than
silently dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… false dead-letters on lock exhaustion (BLO-21582)

Ally review follow-up on this branch (issue comment 5177920386):

- Replace the 3-attempt x fresh-2s-each retry loop (worst case ~7.2s, and
  only bounded *after* each db.transaction() returned, so a stalled pool
  checkout wasn't bounded at all) with a single request-wide 4s deadline
  that withPrReviewerTaskLock races pool checkout + the lock probe against
  directly.
- On lock exhaustion, recheck for an equivalent durable wake (or confirm no
  reviewer was ever active) before recording dead_lettered, so a concurrent
  duplicate delivery that already completed the wake no longer produces a
  false loss alert. Falls back to the pre-existing received+dead_lettered
  recording only when neither recheck explains the outcome.

Adds a regression test for the false-dead-letter case and updates the two
existing lock-contention tests for the new single-budget timing.
…nd the lock-exhaustion fallback recheck (BLO-21582)

Ally review follow-up on this branch (PR #1003, review at
issuecomment-5182720378) found two still-live gaps in the previous commit's
withPrReviewerTaskLock:

1. The deadline raced the WHOLE transaction returned by db.transaction(),
   including action(tx) itself, not just pool checkout + the advisory-lock
   probe. If the lock was acquired near the deadline, the handler could
   abandon a live action() that later commits a wake -- while the catch
   block, having observed no wake yet, recorded received+dead_lettered and
   answered reviewerWakeFired: false. The late action then incremented
   received again and queued the wake, producing both a false dead-letter
   and broken funnel counts.

   Fixed by resolving a dedicated `lockProbeSettled` promise the instant the
   pg_try_advisory_xact_lock probe itself settles, before action(tx) ever
   runs, and racing ONLY that against the deadline. Once the probe reports
   the lock is ours, we await the in-flight transaction (running action) to
   completion unconditionally instead of racing it further.

2. The lock-exhaustion fallback recheck (findExistingPrReviewerWake /
   findActivePrReviewerForTask / selectPrReviewerAgentId) ran outside any
   deadline. A saturated pool that timed out the lock probe could just as
   easily stall these reads indefinitely, defeating the whole point of the
   request-wide budget and GitHub's response-window protection.

   Fixed with a small additional budget (PR_REVIEWER_TASK_LOCK_FALLBACK_BUDGET_MS,
   1s) appended to the lock deadline. A timeout on either read is treated as
   "unknown" -- distinct from a confirmed "no equivalent wake" / "no active
   reviewer" -- and falls through to the pre-existing conservative default
   (record the delivery as lost) rather than blocking the response further.

Two new integration tests:
- Delays heartbeat.wakeup() (via a slow penstockAvailabilityGate) past the
  4s lock budget after the advisory lock is acquired with zero contention,
  and asserts a single terminal outcome (reviewerWakeFired: true, one
  received/queued pair, no dead-letter) with no metric movement after the
  response is sent.
- Forces the lock probe to exhaust its budget via genuine advisory-lock
  contention AND separately blocks the fallback recheck's own read with an
  ACCESS EXCLUSIVE table lock on agent_wakeup_requests held far longer than
  the fallback budget -- proving the bound, not the lock's eventual release,
  is what lets the response return (well under the 8s both locks are held
  for), while still recording the delivery as lost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kkroo
kkroo force-pushed the fix/blo-21582-reviewer-wake-lock-timeout branch from b1ea8bd to d286645 Compare August 5, 2026 06:22
@kkroo
kkroo enabled auto-merge August 5, 2026 06:27
@kkroo
kkroo added this pull request to the merge queue Aug 5, 2026
Any commits made after this event will not be merged.
@kkroo
kkroo removed this pull request from the merge queue due to a manual request Aug 6, 2026
@kkroo
kkroo added this pull request to the merge queue Aug 6, 2026
Any commits made after this event will not be merged.
@allyblockcast

allyblockcast Bot commented Aug 6, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The published skill bodies were unavailable in this runtime, so the same high-confidence checks were applied directly to the exact diff and current-head files.
Reviewed head: 573c9ff

Prior Findings Dispositioned (2)

  • prior:342c81d important 1 — fixed — server/src/routes/github-webhook.ts:1607 — an advisory-lock probe that reports acquisition after the absolute deadline now returns acquired: false before action(tx) can run; the exact late-probe path is covered at server/src/__tests__/github-webhook.test.ts:1162.
  • prior:b1ea8bd important 2 — still-present — server/src/routes/github-webhook.ts:1625 — the deadline bounds only what the HTTP handler awaits. The losing transactionPromise continues queued/running after the race, and boundedFallbackRead likewise leaves its underlying query alive at line 1680. The test at server/src/__tests__/github-webhook.test.ts:3028 releases both blocking locks immediately after the response, so it does not prove that timed-out work relinquishes pool resources while contention remains.

Important Issues (1)

  • [prior:b1ea8bd important 2; code + errors + gstack/review] server/src/routes/github-webhook.ts:1625Promise.race does not cancel the database operation. Every saturated webhook can leave one timed-out lock transaction and one timed-out fallback query queued behind the request; a burst therefore adds more pending pool work after callers have returned, amplifying the pool starvation that triggered this path. The handler also attaches only .catch(...) at line 1650, so a successfully settled late transaction is not logged despite the adjacent comment claiming late settlement is observable.
    • Cancel or database-bound both operations themselves, not just their awaiters. Keep the blocking locks held after the response in a regression test and assert the pool has no abandoned checkout/query from this request before releasing them.

Strengths

  • The exact late-probe guard closes the post-response wake mutation identified in the previous review.
  • The funnel accounting and equivalent-wake/no-active-reviewer distinctions remain explicit.
  • Current review and security-review checks pass on this head.

Recommended Action

  1. Cancel or DB-bound timed-out lock/fallback operations before merge.
  2. Verify cleanup while contention remains, rather than releasing blockers as soon as the response returns.

This PR is authored by app/allyblockcast, so the allyblockcast GitHub App cannot review its own PR. The exact head must be reopened under an independent author before an App approval is possible; the shared allyblockcast User identity is not gate evidence.

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