Skip to content

Commit a0d8e2e

Browse files
authored
fix(gate): stop two subsystem failures from becoming wrong auto-verdicts (#9460, #9462) (#9501)
* fix(gate): stop two subsystem failures from becoming wrong auto-verdicts (#9460, #9462) An unpublishable AI blocker and a stuck visual-capture marker each let a subsystem failure resolve as a verdict rather than as "cannot determine". pre-merge-checks already models the correct shape (an unresolvable enforced check -> NEUTRAL -> hold, never a silent auto-merge bypass); neither of these followed it. combineReviews (#9460): synthesizeDefect returns null both when nobody flagged anything and when a real blocker's title is unpublishable, and single/synthesis collapsed both to a clean pass -- so a blocker phrased with ordinary review vocabulary (reward/ranking/cohort, or a bare score term) auto-MERGED the change it blocked. single is this deployment's live strategy. Both strategies now hold; consensus already failed closed and is untouched. Note synthesizeDefect calls toPublicSafe with no options, so the gate-bearing title is filtered even on a repo in LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS -- the allowlist never rescued this path. Visual capture (#9462): scheduleVisualCaptureRetry's budget-exhausted early return only skipped re-writing visualCaptureRetryPendingSha, leaving the previous attempt's marker standing forever (the sole other clear needs a successful capture, which by definition never comes). A permanently-marked head silently disabled the screenshot gate's close -- a permanent bypass out of what was designed as a temporary deferral. The marker is now cleared on exhaustion, and the unresolved state is threaded into the planner so the deferral holds the PR rather than falling through to a merge. * fix(metrics): register the unpublishable-blocker counter in DEFAULT_METRIC_META (#9460) The drift guard in test/unit/selfhost-metrics.test.ts requires every literal metric name emitted from src/ to carry a registered meta entry; the counter added alongside the combineReviews hold had none, failing the backend coverage suite. * test(gate): cover the capture-retry marker clear on budget exhaustion (#9462) The existing #9030 exhaustion test starts from a PR that never had a marker written, so it exercises the early return but not the defect. This drives the real failing sequence -- attempt 0 errors and writes the marker, then the final attempt exhausts the budget with the pipeline still failing -- and asserts the marker does not outlive the retries that justified it. Verified to fail against the unfixed code (expected 'vis62' to be null), so it pins the fix rather than merely passing alongside it. Closes the codecov/patch gap flagged on the PR.
1 parent 50ce0b8 commit a0d8e2e

8 files changed

Lines changed: 278 additions & 21 deletions

File tree

src/db/repositories.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4505,6 +4505,22 @@ export async function markPullRequestVisualCaptureRetryPending(env: Env, fullNam
45054505
.where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.number, number), eq(pullRequests.headSha, headSha)));
45064506
}
45074507

4508+
/** #9462: clear the retry marker once the bounded recapture budget is EXHAUSTED. Without this the marker set by
4509+
* the previous attempt outlives the retries that justified it: `scheduleVisualCaptureRetry` early-returns at the
4510+
* budget, which only skips writing it AGAIN, so `visualCaptureRetryPendingSha === headSha` stayed true forever
4511+
* for that head. The only other clear is markPullRequestVisualCaptureSatisfied's, which requires a SUCCESSFUL
4512+
* capture -- exactly the thing that is not happening. A permanently-marked head silently disabled the
4513+
* screenshotTableGate's close (see the botCaptureRetryPending branch in processors.ts), so the deferral, meant
4514+
* to be temporary, became a permanent bypass. Not scoped to headSha on the write: the caller has already
4515+
* established which head it is finishing, and a row whose head moved on has a stale marker worth clearing too. */
4516+
export async function clearPullRequestVisualCaptureRetryPending(env: Env, fullName: string, number: number, headSha: string): Promise<void> {
4517+
const db = getDb(env.DB);
4518+
await db
4519+
.update(pullRequests)
4520+
.set({ visualCaptureRetryPendingSha: null, updatedAt: nowIso() })
4521+
.where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.number, number), eq(pullRequests.visualCaptureRetryPendingSha, headSha)));
4522+
}
4523+
45084524
/** Screenshot-table PRESENCE-mode staleness correlation (#stale-screenshot-table-fix): record the (headSha,
45094525
* evidenceFingerprint) checkpoint at which evaluateScreenshotTableGate's presence-mode check just satisfied
45104526
* the gate for this PR (see that function's `presenceModeSatisfiedState` result field and staleness comment).

src/queue/processors.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
markPullRequestReviewsInvalidated,
5757
markPullRequestSurfacePublished,
5858
markPullRequestVisualCaptureSatisfied,
59+
clearPullRequestVisualCaptureRetryPending,
5960
markPullRequestVisualCaptureRetryPending,
6061
markPullRequestScreenshotTablePresenceSatisfied,
6162
getLatestRegatedAt,
@@ -2759,6 +2760,7 @@ function buildAgentMaintenancePlanInput(args: {
27592760
requiredContexts: Set<string> | null;
27602761
blacklistEntry: ReturnType<typeof findBlacklistEntry>;
27612762
screenshotTableMatch: AgentActionPlanInput["screenshotTableMatch"];
2763+
screenshotTableEvidenceUnresolved: AgentActionPlanInput["screenshotTableEvidenceUnresolved"];
27622764
contributorCapMatch: AgentActionPlanInput["contributorCapMatch"];
27632765
linkedIssueHardRule: AgentActionPlanInput["linkedIssueHardRule"];
27642766
linkedIssueRulesConfig: Awaited<ReturnType<typeof loadLinkedIssueHardRules>>;
@@ -2790,6 +2792,7 @@ function buildAgentMaintenancePlanInput(args: {
27902792
requiredContexts,
27912793
blacklistEntry,
27922794
screenshotTableMatch,
2795+
screenshotTableEvidenceUnresolved,
27932796
contributorCapMatch,
27942797
linkedIssueHardRule,
27952798
linkedIssueRulesConfig,
@@ -2856,6 +2859,7 @@ function buildAgentMaintenancePlanInput(args: {
28562859
: {}),
28572860
copycatGateMode: settings.copycatGateMode,
28582861
...(screenshotTableMatch !== undefined ? { screenshotTableMatch } : {}),
2862+
...(screenshotTableEvidenceUnresolved !== undefined ? { screenshotTableEvidenceUnresolved } : {}),
28592863
...(contributorCapMatch !== undefined ? { contributorCapMatch } : {}),
28602864
// Always threaded (the DB layer populates it, default "over-contributor-limit"); the planner applies its
28612865
// own fallback.
@@ -3451,7 +3455,11 @@ async function runAgentMaintenancePlanAndExecute(
34513455
screenshotTableGateResult.violated && screenshotTableGateConfig.action === "close" && !botCaptureRetryPending
34523456
? { matched: true, reason: screenshotTableGateResult.reason }
34533457
: undefined;
3454-
if (screenshotTableGateResult.violated && screenshotTableGateConfig.action === "close" && botCaptureRetryPending) {
3458+
// #9462: deferring the CLOSE is only half a deferral -- on its own it let the plan fall through to a MERGE.
3459+
// Thread the unresolved state into the planner so it holds the PR instead of silently skipping the gate.
3460+
const screenshotTableEvidenceUnresolved =
3461+
screenshotTableGateResult.violated && screenshotTableGateConfig.action === "close" && botCaptureRetryPending;
3462+
if (screenshotTableEvidenceUnresolved) {
34553463
await recordAuditEvent(env, {
34563464
eventType: "github_app.screenshot_table_close_deferred_capture_retry",
34573465
actor: null,
@@ -3592,6 +3600,7 @@ async function runAgentMaintenancePlanAndExecute(
35923600
requiredContexts,
35933601
blacklistEntry,
35943602
screenshotTableMatch,
3603+
screenshotTableEvidenceUnresolved,
35953604
contributorCapMatch,
35963605
linkedIssueHardRule,
35973606
linkedIssueRulesConfig,
@@ -9476,7 +9485,26 @@ async function scheduleVisualCaptureRetry(
94769485
previewPollAttempt: number;
94779486
},
94789487
): Promise<void> {
9479-
if (args.previewPollAttempt >= MAX_PREVIEW_POLL_ATTEMPTS) return;
9488+
if (args.previewPollAttempt >= MAX_PREVIEW_POLL_ATTEMPTS) {
9489+
// #9462: the budget is spent, so the marker the PREVIOUS attempt wrote must be cleared here. Returning
9490+
// without clearing only skips re-writing it -- it left `visualCaptureRetryPendingSha === headSha` standing
9491+
// forever (the sole other clear needs a successful capture, which by definition never came), which silently
9492+
// and permanently disabled the screenshotTableGate's close for that head. Best-effort, matching the mark
9493+
// write below: a failed clear only means the gate stays deferred until the head moves, never a crash.
9494+
if (args.pr.headSha) {
9495+
await clearPullRequestVisualCaptureRetryPending(env, args.repoFullName, args.pr.number, args.pr.headSha).catch((error) => {
9496+
console.log(
9497+
JSON.stringify({
9498+
event: "visual_capture_retry_pending_clear_failed",
9499+
repoFullName: args.repoFullName,
9500+
pull: args.pr.number,
9501+
message: errorMessage(error).slice(0, 200),
9502+
}),
9503+
);
9504+
});
9505+
}
9506+
return;
9507+
}
94809508
if (args.pr.headSha) {
94819509
await markPullRequestVisualCaptureRetryPending(env, args.repoFullName, args.pr.number, args.pr.headSha).catch((error) => {
94829510
console.log(

src/selfhost/metrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [
129129
["loopover_ai_review_non_cacheable_total", { help: "AI reviews skipped by cacheability rules.", type: "counter" }],
130130
["loopover_ai_review_force_bypass_total", { help: "AI review cache force-bypass events.", type: "counter" }],
131131
["loopover_ai_review_inconclusive_total", { help: "AI review inconclusive outcomes.", type: "counter" }],
132+
["loopover_ai_review_unpublishable_blocker_total", { help: "AI reviews where a reviewer named a real blocker whose title could not be published, so the verdict held instead of passing (#9460).", type: "counter" }],
132133
["loopover_ai_review_onmerge_clamped_total", { help: "AI review on-merge mode clamp events.", type: "counter" }],
133134
["loopover_ai_review_model_fallback_total", { help: "AI review model fallback attempts by primary and fallback model.", type: "counter" }],
134135
["loopover_regate_ai_skipped_current_total", { help: "Regate requests skipped because AI state is current.", type: "counter" }],

src/services/ai-review.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2576,6 +2576,37 @@ function synthesizeDefect(
25762576
return { title, detail: title, confidence: source.confidence };
25772577
}
25782578

2579+
/**
2580+
* #9460: resolve "these reviewers named a blocker" into a defect — or, when that blocker cannot be published,
2581+
* into a HOLD rather than a silent pass. `synthesizeDefect` returns null for two situations that must NOT share
2582+
* an outcome: nobody named a real blocker (a genuine clean pass), and a real blocker whose title `toPublicSafe`
2583+
* refuses to publish because it carries ordinary review vocabulary (`score`, `ranking`, `reward`, `cohort`, …
2584+
* see src/queue-intelligence.ts). Collapsing both to `{defect: null, inconclusive: false}` let the second case
2585+
* auto-MERGE a change a reviewer had explicitly blocked — on this deployment's own live strategy, since
2586+
* AI_PROVIDER=claude-code,ollama resolves to `single` (resolveAiReviewerPlan, src/selfhost/ai.ts).
2587+
*
2588+
* Resolves to `inconclusive` rather than `split` deliberately: the situation genuinely IS "no usable public
2589+
* verdict for this head", which is `ai_review_inconclusive`'s own semantics and user-facing copy, and it routes
2590+
* to a neutral gate → human hold (src/queue/ai-review-orchestration.ts). `ai_review_split`'s copy instead
2591+
* asserts that one reviewer flagged a defect and another did not — a disagreement that never happened here, and
2592+
* which would be actively misleading in single-reviewer mode. `consensus` already fails closed for this case via
2593+
* its own `split` arm below; these two strategies did not.
2594+
*/
2595+
function defectOrHold(flagged: readonly ModelReview[]): {
2596+
defect: AiConsensusDefect | null;
2597+
split: boolean;
2598+
inconclusive: boolean;
2599+
} {
2600+
// A blockers array holding only blank strings is NOT a flag (realBlockersOf filters them) — that stays a
2601+
// clean pass, exactly as before. Only a genuinely-named-but-unpublishable blocker becomes a hold.
2602+
if (flagged.every((review) => realBlockersOf(review).length === 0))
2603+
return { defect: null, split: false, inconclusive: false };
2604+
const defect = synthesizeDefect(flagged);
2605+
if (defect) return { defect, split: false, inconclusive: false };
2606+
incr("loopover_ai_review_unpublishable_blocker_total");
2607+
return { defect: null, split: false, inconclusive: true };
2608+
}
2609+
25792610
/** Combine the independent reviewer opinions into ONE gate decision per the configured strategy (#dual-ai-combiner).
25802611
* `reviews` carries one slot per reviewer; a slot is `null` when that reviewer errored or returned unparseable
25812612
* output. Returns the gate-relevant trio: a `defect` (→ blocker), `split` (reviewers disagree → HOLD), and
@@ -2600,11 +2631,7 @@ export function combineReviews(
26002631
// missing review can't certify the change → hold.
26012632
const r = present[0];
26022633
if (!r) return { defect: null, split: false, inconclusive: true };
2603-
return {
2604-
defect: r.blockers.length > 0 ? synthesizeDefect([r]) : null,
2605-
split: false,
2606-
inconclusive: false,
2607-
};
2634+
return defectOrHold([r]);
26082635
}
26092636

26102637
if (opts.strategy === "synthesis") {
@@ -2615,20 +2642,13 @@ export function combineReviews(
26152642
if (missing > 0)
26162643
return { defect: null, split: false, inconclusive: true };
26172644
const all = present.length > 0 && flagged.length === present.length;
2618-
return {
2619-
defect: all ? synthesizeDefect(present) : null,
2620-
split: false,
2621-
inconclusive: false,
2622-
};
2645+
return all
2646+
? defectOrHold(present)
2647+
: { defect: null, split: false, inconclusive: false };
26232648
}
26242649
// `either`: any present reviewer's blocker blocks. With no present blocker but a missing opinion we cannot
26252650
// certify the change is clean → hold (fail-closed).
2626-
if (flagged.length > 0)
2627-
return {
2628-
defect: synthesizeDefect(flagged),
2629-
split: false,
2630-
inconclusive: false,
2631-
};
2651+
if (flagged.length > 0) return defectOrHold(flagged);
26322652
return { defect: null, split: false, inconclusive: missing > 0 };
26332653
}
26342654

src/settings/agent-actions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,10 @@ export type AgentActionPlanInput = {
466466
// populates this field when the gate's configured `action` is `"close"` — an `"advisory"` violation (#4535)
467467
// never reaches the planner at all, by construction (see ScreenshotTableGateAction).
468468
screenshotTableMatch?: { matched: boolean; reason: string | null } | undefined;
469+
/** #9462: the screenshot-table gate is violated in `close` mode but a bounded capture retry is still pending
470+
* for this head, so the evidence is UNRESOLVED rather than absent. Holds the PR (no close, and critically no
471+
* merge) until the retry settles. */
472+
screenshotTableEvidenceUnresolved?: boolean | undefined;
469473
pr: {
470474
mergeableState?: string | null | undefined;
471475
reviewDecision?: string | null | undefined;
@@ -975,6 +979,17 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
975979
// already IS the full contract, so a separate enforcement label would be redundant noise on a PR that's
976980
// about to be closed anyway.
977981
const screenshotTableContributor = !input.authorIsOwner && !input.authorIsAdmin && !input.authorIsAutomationBot;
982+
// #9462: the gate is violated in close mode but its evidence cannot be evaluated right now (the bot's own
983+
// capture pipeline has a bounded retry pending for this head). Previously this left screenshotTableMatch
984+
// undefined and the plan simply fell through to ordinary merit/CI evaluation -- so a green PR planned a
985+
// MERGE, with no finding, hold or blocker representing "screenshot evidence unresolved". That is a
986+
// disposition inversion: a PR the gate would have CLOSED could instead merge. Hold instead, mirroring
987+
// pre-merge-checks' PRE_MERGE_CHECK_UNRESOLVED_CODE, whose doc is explicit that an unresolvable enforced
988+
// check must never silently skip a hard requirement (an auto-merge bypass). Returning no actions is the hold:
989+
// neither the close (the evidence may yet arrive) nor the merge (it may yet not).
990+
if (input.screenshotTableEvidenceUnresolved === true && screenshotTableContributor) {
991+
return actions;
992+
}
978993
if (input.screenshotTableMatch?.matched === true && screenshotTableContributor) {
979994
if (acting("close")) {
980995
const reason = input.screenshotTableMatch.reason ?? "missing a before/after screenshot table";

test/unit/agent-actions.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2578,6 +2578,39 @@ describe("screenshot-table gate short-circuit (#2006)", () => {
25782578
expect(classes(planAgentMaintenanceActions(missingTable({ screenshotTableMatch: { matched: false, reason: null } })))).not.toContain("close");
25792579
});
25802580

2581+
// #9462 regression: while the bot's own capture pipeline has a bounded retry pending for this head, the gate's
2582+
// evidence is UNRESOLVED, not absent. Deferring only the close left the plan falling through to ordinary
2583+
// merit/CI evaluation, so a green PR planned a MERGE and the gate was silently bypassed — a disposition
2584+
// inversion (the PR the gate would have CLOSED merged instead). It must hold: no close AND no merge.
2585+
const evidenceUnresolved = (extra: Partial<AgentActionPlanInput> = {}) =>
2586+
missingTable({ screenshotTableMatch: undefined, screenshotTableEvidenceUnresolved: true, ...extra });
2587+
2588+
it("#9462 holds — plans NEITHER a merge NOR a close while capture evidence is unresolved", () => {
2589+
const plan = planAgentMaintenanceActions(evidenceUnresolved());
2590+
expect(plan).toEqual([]);
2591+
expect(classes(plan)).not.toContain("merge");
2592+
expect(classes(plan)).not.toContain("close");
2593+
});
2594+
2595+
it("#9462 holds even with a fully green gate and merge autonomy (the exact auto-merge bypass)", () => {
2596+
const plan = planAgentMaintenanceActions(
2597+
evidenceUnresolved({ conclusion: "success", ciState: "passed", autonomy: { close: "auto", approve: "auto", merge: "auto" } }),
2598+
);
2599+
expect(classes(plan)).not.toContain("merge");
2600+
});
2601+
2602+
it("#9462 does NOT hold for the owner, an admin, or an automation bot (same standing rule as the close)", () => {
2603+
// The unresolved-evidence hold is a CONTRIBUTOR protection; these authors fall through to normal disposition.
2604+
expect(planAgentMaintenanceActions(evidenceUnresolved({ authorIsOwner: true }))).not.toEqual([]);
2605+
expect(planAgentMaintenanceActions(evidenceUnresolved({ authorIsAdmin: true }))).not.toEqual([]);
2606+
expect(planAgentMaintenanceActions(evidenceUnresolved({ authorIsAutomationBot: true }))).not.toEqual([]);
2607+
});
2608+
2609+
it("#9462 an explicit false (or absent) unresolved flag leaves the close path untouched", () => {
2610+
expect(classes(planAgentMaintenanceActions(missingTable({ screenshotTableEvidenceUnresolved: false })))).toEqual(["close"]);
2611+
expect(classes(planAgentMaintenanceActions(missingTable()))).toEqual(["close"]);
2612+
});
2613+
25812614
it("plans nothing when `close` autonomy is not acting", () => {
25822615
expect(planAgentMaintenanceActions(missingTable({ autonomy: {} }))).toEqual([]);
25832616
expect(classes(planAgentMaintenanceActions(missingTable({ autonomy: { close: "auto" } })))).toEqual(["close"]);

0 commit comments

Comments
 (0)