Skip to content

Commit c31de60

Browse files
authored
fix(queue): dedupe concurrent AI reviews for the same PR head (#2429)
* fix(queue): dedupe concurrent AI reviews for the same PR head A webhook pass and an agent-regate-pr sweep pass can independently miss the AI review cache for the same (repo, PR, head SHA, mode) and each fire a real LLM call, potentially landing different verdicts at the same head. Add a per-key advisory lock (mirroring the existing per-PR maintenance lock) around the review call; a pass that loses the race returns the existing ai_review_inconclusive shape so the gate holds for human review instead of racing an independent verdict. * fix(queue): close the concurrent-claim race in the transient-lock get/set fallback claimAiReviewLock's fallback path (used when the cache adapter has no atomic claim()) was a plain get-then-set pair: two concurrent callers can both observe an absent key before either writes, and both believe they claimed the lock -- defeating the dedupe guarantee for exactly the double-LLM-call race this lock exists to prevent. The same latent gap already existed in the sibling claimAgentMaintenanceLock fallback. Extract a shared claimTransientLock helper used by both locks. Its fallback now writes a token unique to the attempt, then reads the key back -- since a correctly-behaved key-value store serializes writes to a single key, only the caller whose token survives the final read actually won; every other concurrent caller reads a different (later) token and correctly backs off. Still fails open on a missing cache or any read/write error, matching the existing defense-in-depth design. * fix(queue): stop pretending the transient-lock fallback can serialize without atomic claim() The prior fallback (write a unique token, then re-read to verify) does not close the race: caller A can write its token, read it back, and return true entirely before caller B's later write/read also returns true -- both callers can still "win" under real concurrent interleavings, which defeats the whole point of the lock. There is no way to build real mutual exclusion out of separate get/set calls without an atomic primitive. Rather than keep pretending to serialize, claimTransientLock now requires the cache adapter's native claim() for any exclusivity at all; without it, every caller proceeds (fail open), matching this lock family's existing defense-in-depth philosophy. Self-host's Redis-backed cache always implements claim(), so this is a documented limitation for a hypothetical future adapter, not a live production gap.
1 parent 7e3e20e commit c31de60

4 files changed

Lines changed: 424 additions & 26 deletions

File tree

src/queue/processors.ts

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2446,6 +2446,34 @@ async function ciHeadShaResolutionCoalesced(
24462446
);
24472447
}
24482448

2449+
/**
2450+
* Best-effort exclusive claim against the self-host transient cache, shared by every per-PR/per-review advisory
2451+
* lock below. Requires the store's native atomic claim() (Redis SET NX) to provide any real exclusivity — it is
2452+
* the only way to close the race between two concurrent callers each observing an absent key. A plain
2453+
* get-then-set pair CANNOT close that race in general, even with an extra write-then-verify re-read: caller A
2454+
* can write its own token, read it straight back, and return true entirely BEFORE caller B's later write/read
2455+
* also completes and also returns true — both callers "win" (#confirmed-bug). Rather than pretend to serialize
2456+
* via a check that silently fails under exactly the concurrent load this lock exists to guard against, an
2457+
* adapter without claim() gets NO exclusivity from this helper: every caller proceeds. This is honest about the
2458+
* limitation rather than a false guarantee, and costs nothing in practice — self-host's Redis-backed cache (the
2459+
* only cache adapter this codebase ships) always implements claim(), so this is a documented limitation for a
2460+
* hypothetical future adapter, not a live gap. A missing cache or a thrown claim() also fails OPEN (returns
2461+
* true) — every lock built on this helper is defense-in-depth, never the primary safety gate, and must never
2462+
* itself block real work from running.
2463+
*/
2464+
async function claimTransientLock(
2465+
env: Env,
2466+
key: string,
2467+
ttlSeconds: number,
2468+
): Promise<boolean> {
2469+
if (!env.SELFHOST_TRANSIENT_CACHE?.claim) return true; // no atomic primitive — nothing to serialize against.
2470+
try {
2471+
return await env.SELFHOST_TRANSIENT_CACHE.claim(key, "1", ttlSeconds);
2472+
} catch {
2473+
return true; // fail open — see the doc comment above.
2474+
}
2475+
}
2476+
24492477
// Per-PR advisory lock around maybeRunAgentMaintenance's plan-and-execute critical section (#2129). The TTL is a
24502478
// crash-safety backstop only — the normal path releases explicitly in a finally block within a few seconds — so
24512479
// it is sized well above any realistic pass duration (matches CI_COALESCE_WINDOW_SECONDS, an already-vetted
@@ -2466,25 +2494,11 @@ export async function claimAgentMaintenanceLock(
24662494
repoFullName: string,
24672495
prNumber: number,
24682496
): Promise<boolean> {
2469-
const key = agentMaintenanceLockKey(repoFullName, prNumber);
2470-
// Atomic claim (#2129): a get-then-set pair has a window between the read and the write where two concurrent
2471-
// passes for the SAME PR can both observe an absent key and both claim it, defeating the serializer entirely.
2472-
// env.SELFHOST_TRANSIENT_CACHE.claim performs the check-and-set as one operation (Redis SET NX server-side),
2473-
// closing that window. Falls back to the non-atomic get/set pair only for a cache adapter that hasn't
2474-
// implemented claim yet — strictly no worse than this function's prior behavior.
2475-
if (env.SELFHOST_TRANSIENT_CACHE?.claim) {
2476-
try {
2477-
return await env.SELFHOST_TRANSIENT_CACHE.claim(key, "1", AGENT_MAINTENANCE_LOCK_TTL_SECONDS);
2478-
} catch {
2479-
return true; // fail open — see the doc comment above.
2480-
}
2481-
}
2482-
// getTransientKey/putTransientKey already fail open internally (a missing cache or a thrown read/write error
2483-
// both resolve rather than throw), so this never needs its own try/catch — a cache fault surfaces here as
2484-
// "no lock held", which correctly falls through to claiming it.
2485-
if (await getTransientKey(env, key)) return false; // another pass is already in-flight for this PR
2486-
await putTransientKey(env, key, "1", AGENT_MAINTENANCE_LOCK_TTL_SECONDS);
2487-
return true;
2497+
return claimTransientLock(
2498+
env,
2499+
agentMaintenanceLockKey(repoFullName, prNumber),
2500+
AGENT_MAINTENANCE_LOCK_TTL_SECONDS,
2501+
);
24882502
}
24892503

24902504
/** Best-effort release, called from a finally block so the lock frees promptly instead of waiting out the TTL. */
@@ -2502,6 +2516,54 @@ export async function releaseAgentMaintenanceLock(
25022516
}
25032517
}
25042518

2519+
// Per-(repo, PR, head SHA) advisory lock around runAiReviewForAdvisory's expensive grounding/RAG/enrichment/LLM
2520+
// section (#confirmed-bug: a webhook pass and an agent-regate-pr sweep pass can independently reach this same
2521+
// code for the SAME PR at the SAME head SHA, both miss the cache, and both fire a real LLM call — which can
2522+
// return DIFFERENT verdicts). The TTL is a crash-safety backstop only (see AI_REVIEW_LOCK_TTL_SECONDS below), not
2523+
// a throughput bound — same philosophy as AGENT_MAINTENANCE_LOCK_TTL_SECONDS (#2129/#2368).
2524+
const AI_REVIEW_LOCK_TTL_SECONDS = 1_800; // 30 minutes — see justification below.
2525+
2526+
function aiReviewLockKey(repoFullName: string, prNumber: number, headSha: string, mode: string): string {
2527+
return `ai-review-lock:${repoFullName.toLowerCase()}#${prNumber}@${headSha.toLowerCase()}:${mode}`;
2528+
}
2529+
2530+
/**
2531+
* Claim the per-(repo, PR, head SHA, mode) advisory lock before the expensive grounding/RAG/enrichment/LLM
2532+
* section of runAiReviewForAdvisory. Returns false when another pass already holds it for this exact head (the
2533+
* caller must treat this as "another pass is already reviewing this head" and return the inconclusive-hold shape
2534+
* below — the next webhook/sweep tick, or the pass that IS running, is the backstop that populates the cache).
2535+
* A missing cache or cache hiccup fails OPEN (returns true — the lock is defense-in-depth, never the primary
2536+
* safety gate, and must never itself block a real review from running).
2537+
*/
2538+
export async function claimAiReviewLock(
2539+
env: Env,
2540+
repoFullName: string,
2541+
prNumber: number,
2542+
headSha: string,
2543+
mode: string,
2544+
): Promise<boolean> {
2545+
return claimTransientLock(
2546+
env,
2547+
aiReviewLockKey(repoFullName, prNumber, headSha, mode),
2548+
AI_REVIEW_LOCK_TTL_SECONDS,
2549+
);
2550+
}
2551+
2552+
/** Best-effort release, called from a finally block so the lock frees promptly instead of waiting out the TTL. */
2553+
export async function releaseAiReviewLock(
2554+
env: Env,
2555+
repoFullName: string,
2556+
prNumber: number,
2557+
headSha: string,
2558+
mode: string,
2559+
): Promise<void> {
2560+
try {
2561+
await env.SELFHOST_TRANSIENT_CACHE?.del?.(aiReviewLockKey(repoFullName, prNumber, headSha, mode));
2562+
} catch {
2563+
// best-effort; the TTL is the backstop if release fails
2564+
}
2565+
}
2566+
25052567
/** Read the CI head SHA off a `check_suite`/`check_run` `completed` payload (the event node carries `head_sha`;
25062568
* `check_run` also nests it under `check_suite.head_sha`). Returns "" when absent. The payload type doesn't model
25072569
* these events, so we narrow off `Record<string, unknown>` the same way the `pull_requests[]` read does. */
@@ -4677,6 +4739,39 @@ export async function runAiReviewForAdvisory(
46774739
}))
46784740
)
46794741
return undefined;
4742+
// Per-(repo, PR, head SHA, mode) advisory lock (#confirmed-bug, mirrors #2129/#2368's claimAgentMaintenanceLock):
4743+
// a webhook pass and an agent-regate-pr sweep pass can independently reach this point for the SAME PR at the
4744+
// SAME head, both miss the cache (neither has written yet), and both fire a real, wasteful LLM call that can
4745+
// return different verdicts. Claim before the expensive section below; a pass that loses the race returns the
4746+
// same inconclusive-hold shape the "AI produced no usable verdict" path already returns, so the gate is held
4747+
// (neutral) for a human rather than either pass's independently-decided verdict racing the other's cache write.
4748+
if (
4749+
!(await claimAiReviewLock(
4750+
env,
4751+
args.repoFullName,
4752+
args.pr.number,
4753+
args.advisory.headSha,
4754+
args.settings.aiReviewMode,
4755+
))
4756+
) {
4757+
const findings: AdvisoryFinding[] = [
4758+
{
4759+
code: "ai_review_inconclusive",
4760+
severity: "warning",
4761+
title: "AI review already in progress for this PR head",
4762+
detail: "Another Gittensory pass is already running the AI review for this exact PR head. This pass is skipping to avoid a duplicate LLM call.",
4763+
action: "The gate is held for a human reviewer rather than passed automatically; it re-evaluates once the in-flight review completes or on the next update.",
4764+
},
4765+
];
4766+
args.advisory.findings.push(...findings);
4767+
return {
4768+
notes: "AI review is already running for this PR head in another Gittensory pass. Gittensory is holding this PR for manual review until that pass completes.",
4769+
reviewerCount: 0,
4770+
inlineFindings: [],
4771+
findings,
4772+
cacheable: false,
4773+
};
4774+
}
46804775
try {
46814776
// BYOK: decrypt the maintainer's provider key only for confirmed contributors when opted in. Falls back to free Workers AI when
46824777
// no key is configured or the encryption secret is unavailable (getDecryptedRepositoryAiKey → null).
@@ -4968,6 +5063,14 @@ export async function runAiReviewForAdvisory(
49685063
head_sha: args.advisory.headSha,
49695064
});
49705065
return undefined;
5066+
} finally {
5067+
await releaseAiReviewLock(
5068+
env,
5069+
args.repoFullName,
5070+
args.pr.number,
5071+
args.advisory.headSha,
5072+
args.settings.aiReviewMode,
5073+
);
49715074
}
49725075
}
49735076

test/unit/ai-review-advisory.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { buildAiReviewDiff, runAiReviewForAdvisory, shouldStartAiReviewForAdvisory } from "../../src/queue/processors";
2+
import { buildAiReviewDiff, claimAiReviewLock, runAiReviewForAdvisory, shouldStartAiReviewForAdvisory } from "../../src/queue/processors";
33
import { BEST_REVIEW_MODELS, INCOHERENT_DIFF_ASSESSMENT } from "../../src/services/ai-review";
44
import * as sentryModule from "../../src/selfhost/sentry";
55
import { upsertRepositoryAiKey } from "../../src/db/repositories";
@@ -524,6 +524,38 @@ describe("runAiReviewForAdvisory", () => {
524524
expect(adv.findings.map((f) => f.code)).toEqual(["ai_review_inconclusive"]);
525525
});
526526

527+
it("#confirmed-bug: defers to an already-held AI review lock and never invokes the AI when another pass is in-flight for this exact head", async () => {
528+
const adv = advisory();
529+
let aiCalls = 0;
530+
const env = aiEnv(async () => {
531+
aiCalls += 1;
532+
return { response: notesOnlyJson() };
533+
});
534+
// Simulate a webhook pass already in-flight for this exact (repo, PR, head, mode) tuple — the caller under
535+
// test (a sweep-shaped pass, say) must defer instead of racing it with a second, independently-decided
536+
// LLM call.
537+
expect(await claimAiReviewLock(env, "acme/widgets", 3, "sha3", "block")).toBe(true);
538+
539+
const result = await runAiReviewForAdvisory(env, {
540+
settings: { aiReviewMode: "block" } as RepositorySettings,
541+
advisory: adv,
542+
repoFullName: "acme/widgets",
543+
pr,
544+
author: "alice",
545+
confirmedContributor: true,
546+
});
547+
548+
expect(aiCalls).toBe(0); // the AI mock was never invoked — the lock short-circuited before the LLM call
549+
expect(result).toMatchObject({
550+
reviewerCount: 0,
551+
inlineFindings: [],
552+
cacheable: false,
553+
findings: [expect.objectContaining({ code: "ai_review_inconclusive" })],
554+
});
555+
expect(result?.notes).toContain("AI review is already running for this PR head in another Gittensory pass");
556+
expect(adv.findings.map((f) => f.code)).toEqual(["ai_review_inconclusive"]);
557+
});
558+
527559
it("withholds unstructured AI text while holding the PR for manual review", async () => {
528560
const adv = advisory();
529561
const result = await runAiReviewForAdvisory(aiEnv(async () => ({ response: "Looks coherent, but please verify the new cache branch before merging." })), {

test/unit/gate-check-policy.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,46 @@ describe("AI fail-closed hold (#ai-fail-closed)", () => {
131131
expect(result.blockers.map((blocker) => blocker.code)).toContain("secret_leak");
132132
});
133133

134+
it("holds the gate NEUTRAL (never a failure-close) when the AI review lock is held by another in-flight pass (#confirmed-bug)", () => {
135+
// Same code, different finding text — the lock-contention finding constructed by runAiReviewForAdvisory's
136+
// new claim-failure branch. advisory.ts only keys on `code`, so this proves the mechanism end-to-end for
137+
// the new finding shape without needing to touch advisory.ts.
138+
const adv: Advisory = {
139+
...missingIssueAdvisory(),
140+
findings: [
141+
{
142+
code: "ai_review_inconclusive",
143+
title: "AI review already in progress for this PR head",
144+
severity: "warning",
145+
detail: "Another Gittensory pass is already running the AI review for this exact PR head. This pass is skipping to avoid a duplicate LLM call.",
146+
action: "The gate is held for a human reviewer rather than passed automatically; it re-evaluates once the in-flight review completes or on the next update.",
147+
},
148+
],
149+
};
150+
const result = evaluateGateCheck(adv, gateCheckPolicy(settings(), null, true));
151+
expect(result.conclusion).toBe("neutral");
152+
expect(result.blockers).toEqual([]);
153+
});
154+
155+
it("a deterministic hard blocker (secret_leak) still FAILS even when the AI review is held by lock contention (#confirmed-bug)", () => {
156+
const adv: Advisory = {
157+
...missingIssueAdvisory(),
158+
findings: [
159+
{ code: "secret_leak", title: "Possible leaked secret", severity: "critical", detail: "a committed token", action: "remove and rotate it" },
160+
{
161+
code: "ai_review_inconclusive",
162+
title: "AI review already in progress for this PR head",
163+
severity: "warning",
164+
detail: "Another Gittensory pass is already running the AI review for this exact PR head. This pass is skipping to avoid a duplicate LLM call.",
165+
action: "The gate is held for a human reviewer rather than passed automatically; it re-evaluates once the in-flight review completes or on the next update.",
166+
},
167+
],
168+
};
169+
const result = evaluateGateCheck(adv, gateCheckPolicy(settings(), null, true));
170+
expect(result.conclusion).toBe("failure");
171+
expect(result.blockers.map((blocker) => blocker.code)).toContain("secret_leak");
172+
});
173+
134174
it("an enforced pre-merge check (pre_merge_check_required) hard-blocks; the advisory variant never does (#review-pre-merge-checks)", () => {
135175
const enforced: Advisory = {
136176
...missingIssueAdvisory(),

0 commit comments

Comments
 (0)