Skip to content

Commit 1b5e98d

Browse files
authored
fix(gate): defer the screenshot-table close on a capture-pipeline blip instead of treating it as missing evidence (#9207)
Visual capture failing outright (browserless down, a timeout, a GitHub hiccup fetching a token) looked identical to "capture concluded normally and found no visual evidence" -- both left visualCaptureSatisfiedSha unset, so the very next maintenance pass could close a legitimate visual PR purely because an internal service blipped. The same gap applied to a preview still building (previewPending), which already self-heals via a scheduled recapture but never suppressed the close on the pass that discovered it. visualCaptureRetryPendingSha now marks the exact head a bounded recapture retry is scheduled for -- set only while a retry chance remains (MAX_PREVIEW_POLL_ATTEMPTS), so the screenshot-table gate's close defers for exactly as long as that chance exists and never indefinitely. The deferral itself is audited (github_app.screenshot_table_close_deferred_capture_retry), not silent.
1 parent 376ad8a commit 1b5e98d

6 files changed

Lines changed: 294 additions & 26 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Screenshot-table gate false-positive close (#9030). Visual capture (`review.visual.enabled`) can fail to
2+
-- reach a conclusive result for reasons that have NOTHING to do with whether the PR actually needs
3+
-- before/after evidence: the capture pipeline can throw (browserless down, timeout, a GitHub API hiccup
4+
-- fetching a token) or the preview deploy can still be building (previewPending). Before this column existed,
5+
-- both looked identical to "capture concluded normally and found no visual evidence" -- the very next
6+
-- maintenance pass could then close the PR under the screenshotTableGate purely because an internal service
7+
-- blipped, with no distinction and no grace period.
8+
--
9+
-- visual_capture_retry_pending_sha is the head SHA a bounded recapture retry is currently scheduled/in-flight
10+
-- for (see MAX_PREVIEW_POLL_ATTEMPTS) -- set ONLY when that retry was actually scheduled (budget not yet
11+
-- exhausted), so the screenshotTableGate's CLOSE action defers for exactly as long as a retry chance remains,
12+
-- never indefinitely. Scoped to head SHA (mirrors visual_capture_satisfied_sha, 0125) so a new commit re-arms
13+
-- it; cleared by a subsequent successful capture for the same head.
14+
ALTER TABLE pull_requests ADD COLUMN visual_capture_retry_pending_sha TEXT;

src/db/repositories.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4417,7 +4417,25 @@ export async function markPullRequestVisualCaptureSatisfied(env: Env, fullName:
44174417
const db = getDb(env.DB);
44184418
await db
44194419
.update(pullRequests)
4420-
.set({ visualCaptureSatisfiedSha: headSha, updatedAt: nowIso() })
4420+
// #9030: a proven-successful capture for this head supersedes any earlier "retry pending" marker recorded
4421+
// for the SAME head (an error or a still-building preview on an earlier attempt) -- clearing it here keeps
4422+
// the row's state minimal instead of leaving a now-moot marker sitting alongside a satisfied one.
4423+
.set({ visualCaptureSatisfiedSha: headSha, visualCaptureRetryPendingSha: null, updatedAt: nowIso() })
4424+
.where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.number, number), eq(pullRequests.headSha, headSha)));
4425+
}
4426+
4427+
/** False-positive close guard (#9030): record that a bounded visual-capture recapture retry is currently
4428+
* scheduled/in-flight for `headSha` -- called ONLY when the capture pipeline errored, or the preview is still
4429+
* building, AND a retry budget attempt remains (see MAX_PREVIEW_POLL_ATTEMPTS at the call site). While this
4430+
* equals the PR's current headSha, the screenshotTableGate's CLOSE action defers instead of treating the
4431+
* transient blip as missing evidence. Scoped to headSha (mirrors markPullRequestVisualCaptureSatisfied) so a
4432+
* later commit re-arms the requirement; superseded by a later successful capture for the same head (see that
4433+
* function's own clearing write above). */
4434+
export async function markPullRequestVisualCaptureRetryPending(env: Env, fullName: string, number: number, headSha: string): Promise<void> {
4435+
const db = getDb(env.DB);
4436+
await db
4437+
.update(pullRequests)
4438+
.set({ visualCaptureRetryPendingSha: headSha, updatedAt: nowIso() })
44214439
.where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.number, number), eq(pullRequests.headSha, headSha)));
44224440
}
44234441

@@ -7007,6 +7025,7 @@ function toPullRequestRecordFromRow(row: typeof pullRequests.$inferSelect): Pull
70077025
linkedIssueHardRuleViolationIssues: parseJson<number[]>(row.linkedIssueHardRuleViolationIssuesJson, []),
70087026
linkedIssueHardRuleViolationReason: row.linkedIssueHardRuleViolationReason,
70097027
visualCaptureSatisfiedSha: row.visualCaptureSatisfiedSha,
7028+
visualCaptureRetryPendingSha: row.visualCaptureRetryPendingSha,
70107029
screenshotTablePresenceSatisfied: parseJson<{ headSha: string; evidenceFingerprint: string } | null>(row.screenshotTablePresenceSatisfiedJson, null),
70117030
};
70127031
}

src/db/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,13 @@ export const pullRequests = sqliteTable(
450450
// new head. loopover-computed (publish-written), omitted from the GitHub-sync SET clause so a later sync
451451
// cannot clobber it.
452452
visualCaptureSatisfiedSha: text("visual_capture_satisfied_sha"),
453+
// False-positive close guard (#9030): the head SHA a bounded visual-capture recapture retry is currently
454+
// scheduled/in-flight for -- set ONLY when the capture pipeline errored or the preview is still building
455+
// AND a retry budget attempt remains (MAX_PREVIEW_POLL_ATTEMPTS). While set for the PR's current head, the
456+
// screenshotTableGate's CLOSE action defers instead of treating the transient blip as "no visual evidence
457+
// provided". Cleared by a subsequent successful capture for the same head. loopover-computed
458+
// (publish-written), omitted from the GitHub-sync SET clause so a later sync cannot clobber it.
459+
visualCaptureRetryPendingSha: text("visual_capture_retry_pending_sha"),
453460
// Screenshot-table PRESENCE-mode staleness correlation (#stale-screenshot-table-fix, follow-up to #2006).
454461
// JSON `{headSha, evidenceFingerprint}` -- the head SHA and before/after-image-URL fingerprint that last
455462
// satisfied screenshotTableGate's presence-mode check (see evaluateScreenshotTableGate's staleness comment).

src/queue/processors.ts

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
markPullRequestReviewsInvalidated,
5757
markPullRequestSurfacePublished,
5858
markPullRequestVisualCaptureSatisfied,
59+
markPullRequestVisualCaptureRetryPending,
5960
markPullRequestScreenshotTablePresenceSatisfied,
6061
getLatestRegatedAt,
6162
getLatestBacklogConvergenceRegatedAt,
@@ -3369,10 +3370,29 @@ async function runAgentMaintenancePlanAndExecute(
33693370
headSha: pr.headSha,
33703371
presenceModeSatisfied: pr.screenshotTablePresenceSatisfied,
33713372
});
3373+
// #9030: a visual-capture pipeline ERROR (browserless down, timeout, GitHub hiccup) or a still-building
3374+
// preview looked IDENTICAL to "capture concluded normally, no visual evidence found" -- both left
3375+
// visualCaptureSatisfiedSha unset, and the very next maintenance pass could close a legitimate visual PR
3376+
// purely because an internal service blipped. visualCaptureRetryPendingSha (set only while a bounded
3377+
// recapture retry is genuinely still scheduled for this exact head -- see the capture block in
3378+
// maybePublishPrPublicSurface) defers the CLOSE for exactly as long as that retry chance remains; once the
3379+
// budget is exhausted, the marker is never set again and the gate falls through to its normal, accurate
3380+
// evaluation on the final attempt -- this can never hold a PR forever.
3381+
const botCaptureRetryPending = Boolean(pr.headSha) && pr.visualCaptureRetryPendingSha === pr.headSha;
33723382
const screenshotTableMatch =
3373-
screenshotTableGateResult.violated && screenshotTableGateConfig.action === "close"
3383+
screenshotTableGateResult.violated && screenshotTableGateConfig.action === "close" && !botCaptureRetryPending
33743384
? { matched: true, reason: screenshotTableGateResult.reason }
33753385
: undefined;
3386+
if (screenshotTableGateResult.violated && screenshotTableGateConfig.action === "close" && botCaptureRetryPending) {
3387+
await recordAuditEvent(env, {
3388+
eventType: "github_app.screenshot_table_close_deferred_capture_retry",
3389+
actor: null,
3390+
targetKey: `${repoFullName}#${pr.number}`,
3391+
outcome: "queued",
3392+
detail: "Screenshot-table gate would have closed this PR, but the bot's own visual-capture pipeline has a bounded retry still pending for this head -- deferring the close instead of treating the blip as missing evidence",
3393+
metadata: { deliveryId, repoFullName, headSha: pr.headSha ?? null },
3394+
}).catch(() => undefined);
3395+
}
33763396
// #stale-screenshot-table-fix / #8866: presence or matrix mode just independently re-confirmed the gate for
33773397
// THIS head SHA -- persist the (headSha, evidenceFingerprint) checkpoint so a LATER push that carries the
33783398
// SAME UNCHANGED evidence correctly re-violates instead of silently staying green forever (see
@@ -9311,6 +9331,59 @@ async function logTypeLabelSkip(env: Env, repoFullName: string, pullNumber: numb
93119331
}).catch(() => undefined);
93129332
}
93139333

9334+
/** False-positive close guard (#9030): schedule the SAME bounded self-heal `recapture-preview` retry for both
9335+
* "the preview deploy is still building" (capture.previewPending) and "the capture pipeline itself errored"
9336+
* (browserless down, timeout, a GitHub hiccup) -- neither means "this PR genuinely has no visual evidence",
9337+
* so neither should let the screenshotTableGate treat it that way. Persists visualCaptureRetryPendingSha for
9338+
* the current head ONLY when a retry was actually scheduled (the budget is not yet exhausted) -- once
9339+
* MAX_PREVIEW_POLL_ATTEMPTS is reached, the marker is deliberately left unset so the gate falls through to its
9340+
* normal (accurate) evaluation on this final attempt rather than holding the PR open forever. Best-effort:
9341+
* either write failing only means this ONE recovery chance is silently missed, never a crash. */
9342+
async function scheduleVisualCaptureRetry(
9343+
env: Env,
9344+
args: {
9345+
webhook: { deliveryId: string };
9346+
repoFullName: string;
9347+
pr: { number: number; headSha?: string | null | undefined };
9348+
installationId: number;
9349+
previewPollAttempt: number;
9350+
},
9351+
): Promise<void> {
9352+
if (args.previewPollAttempt >= MAX_PREVIEW_POLL_ATTEMPTS) return;
9353+
if (args.pr.headSha) {
9354+
await markPullRequestVisualCaptureRetryPending(env, args.repoFullName, args.pr.number, args.pr.headSha).catch((error) => {
9355+
console.log(
9356+
JSON.stringify({
9357+
event: "visual_capture_retry_pending_mark_failed",
9358+
repoFullName: args.repoFullName,
9359+
pull: args.pr.number,
9360+
message: errorMessage(error).slice(0, 200),
9361+
}),
9362+
);
9363+
});
9364+
}
9365+
await env.JOBS.send(
9366+
{
9367+
type: "recapture-preview",
9368+
deliveryId: args.webhook.deliveryId,
9369+
repoFullName: args.repoFullName,
9370+
prNumber: args.pr.number,
9371+
installationId: args.installationId,
9372+
attempt: args.previewPollAttempt + 1,
9373+
},
9374+
{ delaySeconds: PREVIEW_POLL_SECONDS },
9375+
).catch((error) =>
9376+
console.log(
9377+
JSON.stringify({
9378+
event: "recapture_enqueue_failed",
9379+
repoFullName: args.repoFullName,
9380+
pull: args.pr.number,
9381+
message: errorMessage(error).slice(0, 120),
9382+
}),
9383+
),
9384+
);
9385+
}
9386+
93149387
async function maybePublishPrPublicSurface(
93159388
env: Env,
93169389
installationId: number,
@@ -12243,30 +12316,14 @@ async function maybePublishPrPublicSurface(
1224312316
// the now-ready shot — bounded by `attempt` so a never-resolving preview can't loop (the deployment_status
1224412317
// webhook also refills it; this is the backstop when that event is missed/late).
1224512318
const previewPollAttempt = webhook.previewPollAttempt ?? 0;
12246-
if (
12247-
capture.previewPending &&
12248-
previewPollAttempt < MAX_PREVIEW_POLL_ATTEMPTS
12249-
) {
12250-
await env.JOBS.send(
12251-
{
12252-
type: "recapture-preview",
12253-
deliveryId: webhook.deliveryId,
12254-
repoFullName,
12255-
prNumber: pr.number,
12256-
installationId,
12257-
attempt: previewPollAttempt + 1,
12258-
},
12259-
{ delaySeconds: PREVIEW_POLL_SECONDS },
12260-
).catch((error) =>
12261-
console.log(
12262-
JSON.stringify({
12263-
event: "recapture_enqueue_failed",
12264-
repoFullName,
12265-
pull: pr.number,
12266-
message: errorMessage(error).slice(0, 120),
12267-
}),
12268-
),
12269-
);
12319+
if (capture.previewPending) {
12320+
await scheduleVisualCaptureRetry(env, {
12321+
webhook,
12322+
repoFullName,
12323+
pr,
12324+
installationId,
12325+
previewPollAttempt,
12326+
});
1227012327
}
1227112328
} catch (error) {
1227212329
console.log(
@@ -12277,6 +12334,17 @@ async function maybePublishPrPublicSurface(
1227712334
message: errorMessage(error).slice(0, 200),
1227812335
}),
1227912336
);
12337+
// #9030: a capture-pipeline ERROR (browserless down, timeout, a GitHub hiccup fetching a token) must
12338+
// not be silently indistinguishable from a legitimate "no visual routes found" result -- the
12339+
// screenshotTableGate's CLOSE action would otherwise fire on a false positive purely because an
12340+
// internal service blipped. Schedule the SAME bounded self-heal retry previewPending already uses.
12341+
await scheduleVisualCaptureRetry(env, {
12342+
webhook,
12343+
repoFullName,
12344+
pr,
12345+
installationId,
12346+
previewPollAttempt: webhook.previewPollAttempt ?? 0,
12347+
});
1228012348
}
1228112349
}
1228212350
// AI-vision analysis of a confirmed visual regression (#4111 wiring) — see runVisualVisionForAdvisory's

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,12 @@ export type PullRequestRecord = {
741741
* screenshotTableGate treats visualCaptureSatisfiedSha === headSha as evidence equivalent to a hand-authored
742742
* before/after table. Publish-written; read straight from the row. */
743743
visualCaptureSatisfiedSha?: string | null | undefined;
744+
/** False-positive close guard (#9030): the head SHA a bounded visual-capture recapture retry is currently
745+
* scheduled/in-flight for -- set only when the capture pipeline errored, or the preview is still building,
746+
* AND a retry budget attempt remains. While this equals the PR's current headSha, the screenshotTableGate's
747+
* CLOSE action defers instead of treating the transient blip as missing evidence. Publish-written; read
748+
* straight from the row. */
749+
visualCaptureRetryPendingSha?: string | null | undefined;
744750
/** Screenshot-table PRESENCE-mode staleness correlation (#stale-screenshot-table-fix): the (headSha,
745751
* evidenceFingerprint) checkpoint the screenshotTableGate's presence-mode check last satisfied for this PR
746752
* (see evaluateScreenshotTableGate's staleness comment). `null`/absent = presence mode has never satisfied

0 commit comments

Comments
 (0)