@@ -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+
93149387async 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
0 commit comments