Skip to content

fix(visual): release the capture retry latch that froze three contributor PRs - #9879

Merged
loopover-orb[bot] merged 2 commits into
mainfrom
fix/visual-capture-latch
Jul 29, 2026
Merged

fix(visual): release the capture retry latch that froze three contributor PRs#9879
loopover-orb[bot] merged 2 commits into
mainfrom
fix/visual-capture-latch

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Closes #9876

What was happening

Three contributor PRs in JSONbored/awesome-claude (5701, 5702, 5704) were frozen: the screenshot-table gate could neither close nor pass them, so nothing happened to them at all. Verified on the production ORB — visual_capture_retry_pending_sha = head_sha on all three, six deferral audit events each ~90s apart, then silence.

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 missing evidence. Once set, only a later code path releases it.

The release added in #9462 lives inside scheduleVisualCaptureRetry, which the call site invokes only when capture.previewPending || capture.renderFailed. But the durable per-head poll budget (#6323) ends the retry chain by suppressing previewPending — so on the attempt that ends the chain, neither branch fires, the scheduler is never called, and the clear it contains never runs.

The clear was therefore unreachable in precisely the case it was written for.

The fix — three holes, one per way the latch outlives its retry

  1. Release on the capture outcome. The call site now clears on any conclusive capture (no pair, nothing pending, nothing broken). Every ending passes through there; a scheduler that stops being called cannot be the release point.
  2. Enqueue first, mark only if it succeeded. The send is best-effort by design, so marking first inverted the latch’s meaning — a failed enqueue left a latch with no retry in existence to release it. Failure now degrades to "no deferral this pass", which is the safe direction.
  3. Bound the latch by age. visual_capture_retry_pending_at records when it was set; past the longest possible retry chain it is stale by arithmetic, whatever ran. This latch has now carried two separate “can never hold a PR forever” comments, both false in production — an age bound makes that claim structural instead of a statement about control flow. Mirrors BUDGET_MARKER_MAX_AGE_MS on the sibling R2 marker.

A latched sha with no timestamp reads as EXPIRED — the honest reading of a row predating the column, and what unfreezes the PRs already stuck behind one.

PREVIEW_POLL_SECONDS moves next to MAX_PREVIEW_POLL_ATTEMPTS: together they are the budget the new deadline is derived from, and splitting them would let one move without the bound depending on it.

Tests

  • test/unit/visual-capture-retry-latch.test.ts — 12 cases on the pure helper: both deadline boundaries (at / one ms before), superseded head, absent latch, missing and unparseable timestamps, future-dated clamp, and an assertion that the deadline stays derived from the retry budget so the two cannot drift.
  • test/unit/queue-3.test.ts — 4 wiring regressions: the durable-budget freeze itself (marker set, then a conclusive capture while attempts remain — the case an exhaustion-triggered clear can never catch), a failed enqueue leaving no latch, an undatable latch expiring with an audit event instead of deferring again, and a failed conclusive clear not crashing the pass.

Changed-line coverage measured at zero uncovered statements and zero uncovered branches across all six changed src/ files.

Migration 0205 (0204 was taken by #9865 on main). Column is nullable with no backfill and is absent from the GitHub-sync SET clause, like its sibling.

…utor PRs (#9876)

`visualCaptureRetryPendingSha` defers the screenshot-table gate's one-shot
CLOSE while a bounded recapture retry is still in flight for a head (#9030),
so a browserless blip is never read as "this PR has no visual evidence".
It is a latch: once set, only a later code path releases it.

That release was unreachable. The #9462 clear lives inside
`scheduleVisualCaptureRetry`, which the call site invokes only when
`previewPending || renderFailed`. The durable per-head poll budget (#6323)
ends the retry chain by SUPPRESSING `previewPending`, so on the attempt that
ends it neither branch fires, the scheduler is never called, and the clear it
contains never runs. The latch then stands forever: the gate can neither close
the PR nor pass it, and nothing ever happens to it again.

Verified on the production ORB -- JSONbored/awesome-claude 5701, 5702 and 5704
all had `visual_capture_retry_pending_sha = head_sha`, with six deferral audit
events each ~90s apart (PREVIEW_POLL_SECONDS x MAX_PREVIEW_POLL_ATTEMPTS) and
then silence.

Three holes, one per way the latch outlives its retry:

- Release on the CAPTURE OUTCOME, at the call site's conclusive branch, which
  every ending passes through -- not in a scheduler that by definition stops
  being called.
- Enqueue first, mark only if the send succeeded. The send is best-effort by
  design, so marking first meant a failed enqueue left a latch behind with no
  retry in existence to release it.
- Record WHEN the latch was set and expire it past the longest possible retry
  chain, so the bound holds whatever code did or did not run. This latch has
  now carried two separate "can never hold a PR forever" comments, both false
  in production; an age bound makes the claim structural rather than a
  statement about control flow. Mirrors BUDGET_MARKER_MAX_AGE_MS on the
  sibling R2 marker.

A latched sha with no timestamp reads as EXPIRED, which is both the honest
reading of a row that predates the column and what unfreezes the PRs already
stuck behind one.

PREVIEW_POLL_SECONDS moves next to MAX_PREVIEW_POLL_ATTEMPTS: together they
are the retry budget the new deadline is derived from, and splitting them
would let one move without the bound that depends on it.

Closes #9876
@loopover-orb

loopover-orb Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Important

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏳ LoopOver is waiting…

LoopOver has seen this pull request and is waiting on CI checks to finish before reviewing it. This comment will update once the review runs.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed · 🟨 Waiting

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.75%. Comparing base (8e2d51d) to head (cede1a7).
⚠️ Report is 4 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #9879   +/-   ##
=======================================
  Coverage   91.75%   91.75%           
=======================================
  Files         919      920    +1     
  Lines      112987   113002   +15     
  Branches    27193    27198    +5     
=======================================
+ Hits       103667   103684   +17     
+ Misses       8034     8033    -1     
+ Partials     1286     1285    -1     
Flag Coverage Δ
backend 95.69% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/db/repositories.ts 96.86% <ø> (ø)
src/db/schema.ts 74.87% <ø> (ø)
src/queue/processors.ts 94.80% <100.00%> (+0.06%) ⬆️
src/review/visual/preview-poll-budget.ts 100.00% <100.00%> (ø)
src/review/visual/visual-capture-retry-latch.ts 100.00% <100.00%> (ø)
src/types.ts 100.00% <ø> (ø)

@loopover-orb loopover-orb Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LoopOver approves — the gate is satisfied and CI is green.

@loopover-orb
loopover-orb Bot merged commit 5e9cd8d into main Jul 29, 2026
8 checks passed
@loopover-orb
loopover-orb Bot deleted the fix/visual-capture-latch branch July 29, 2026 19:00
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.

The visual-capture retry latch can never be released once the durable poll budget ends the chain

1 participant