Three contributor PRs in JSONbored/awesome-claude (5701, 5702, 5704) have been frozen for up to an hour: the screenshot-table gate can neither close nor pass them, so nothing happens to them at all. Verified on the production ORB — visual_capture_retry_pending_sha = head_sha for all three open PRs.
Root cause
visualCaptureRetryPendingSha is a latch. It defers the gate’s one-shot CLOSE while a bounded recapture retry is still in flight for that head (#9030), so a browserless blip is not mistaken for “this PR has no visual evidence”. Only two writes release it: a successful capture, or the budget-exhausted clear added in #9462.
That clear lives inside scheduleVisualCaptureRetry, which the call site only invokes when capture.previewPending || capture.renderFailed. But the durable per-head poll budget (#6323, preview-poll-budget.ts) terminates the retry chain by suppressing previewPending once the head’s R2 budget is spent. So on the attempt that ends the chain, the condition is false, scheduleVisualCaptureRetry is never called, and the exhaustion-clear it contains never runs.
The #9462 clear is therefore unreachable in exactly the case it was written for. The comment at the gate site — “once the budget is exhausted, the marker is never set again and the gate falls through to its normal, accurate evaluation … this can never hold a PR forever” — is false as written.
Live evidence: 6 screenshot_table_close_deferred_capture_retry events per PR ~90s apart (PREVIEW_POLL_SECONDS), matching MAX_PREVIEW_POLL_ATTEMPTS = 5, then silence and a permanently-set latch.
Two further holes in the same latch
- Mark-before-enqueue.
scheduleVisualCaptureRetry writes the latch first, then enqueues the retry job with a best-effort .catch that only logs. A failed enqueue leaves a latch with no retry that could ever release it.
- Conclusive capture does not clear. When a capture concludes with no successful bot capture, no
previewPending and no renderFailed, neither branch at the call site fires, so a latch from an earlier attempt survives a run that definitively answered the question.
Fix
- Clear the latch at the call site whenever the capture concludes without a live retry being scheduled — the release belongs to the capture outcome, not to the scheduler that may never be called.
- Enqueue first, mark only after the enqueue succeeds.
- Give the latch a recorded timestamp and treat one older than the longest possible retry chain as expired, so the bound is arithmetic rather than a function of which code paths happened to run. This latch has now had two separate “this can never hold a PR forever” claims in comments, both false in production; a time bound is what makes the claim structural. Mirrors
BUDGET_MARKER_MAX_AGE_MS on the sibling R2 marker.
Three contributor PRs in
JSONbored/awesome-claude(5701, 5702, 5704) have been frozen for up to an hour: the screenshot-table gate can neither close nor pass them, so nothing happens to them at all. Verified on the production ORB —visual_capture_retry_pending_sha = head_shafor all three open PRs.Root cause
visualCaptureRetryPendingShais a latch. It defers the gate’s one-shot CLOSE while a bounded recapture retry is still in flight for that head (#9030), so a browserless blip is not mistaken for “this PR has no visual evidence”. Only two writes release it: a successful capture, or the budget-exhausted clear added in #9462.That clear lives inside
scheduleVisualCaptureRetry, which the call site only invokes whencapture.previewPending || capture.renderFailed. But the durable per-head poll budget (#6323,preview-poll-budget.ts) terminates the retry chain by suppressingpreviewPendingonce the head’s R2 budget is spent. So on the attempt that ends the chain, the condition is false,scheduleVisualCaptureRetryis never called, and the exhaustion-clear it contains never runs.The
#9462clear is therefore unreachable in exactly the case it was written for. The comment at the gate site — “once the budget is exhausted, the marker is never set again and the gate falls through to its normal, accurate evaluation … this can never hold a PR forever” — is false as written.Live evidence: 6
screenshot_table_close_deferred_capture_retryevents per PR ~90s apart (PREVIEW_POLL_SECONDS), matchingMAX_PREVIEW_POLL_ATTEMPTS = 5, then silence and a permanently-set latch.Two further holes in the same latch
scheduleVisualCaptureRetrywrites the latch first, then enqueues the retry job with a best-effort.catchthat only logs. A failed enqueue leaves a latch with no retry that could ever release it.previewPendingand norenderFailed, neither branch at the call site fires, so a latch from an earlier attempt survives a run that definitively answered the question.Fix
BUDGET_MARKER_MAX_AGE_MSon the sibling R2 marker.