diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index aa1510c013..c38b1f0a6e 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -1729,12 +1729,21 @@ export async function runGittensoryAiReview( const dual = combine !== "single" && (!configured || configured.length > 1); const freeAiCalls = (input.mode === "block" ? (dual ? 2 : 1) : 0) + (input.providerKey ? 0 : 1); + // Consensus disagreements may spend extra free calls on the order-swapped tie-break judge. Reserve the + // worst-case retry budget up front so the daily limiter remains a hard cap even when judge output is unstable. + const tieBreakAiCalls = + input.mode === "block" && dual && combine === "consensus" + ? 2 * 3 * (primaryFallback && primaryFallback !== primary.model ? 2 : 1) + : 0; // Estimate against the EFFECTIVE system prompt (`system`) so grounding's extra context is billed against the // budget. Flag-OFF, `system === REVIEW_SYSTEM_PROMPT`, so the estimate is byte-identical to today. const estimatedNeurons = - freeAiCalls === 0 + (freeAiCalls === 0 ? 0 - : estimateNeurons(system.length + user.length, maxTokens, freeAiCalls); + : estimateNeurons(system.length + user.length, maxTokens, freeAiCalls)) + + (tieBreakAiCalls === 0 + ? 0 + : estimateNeurons(system.length + user.length, 512, tieBreakAiCalls)); // FAIL-SAFE default (#budget-no-starve): the daily neuron budget is a runaway-LOOP backstop, not a normal- // operation gate. An absent/empty/non-numeric env var must default HIGH (the clamp max), never to a tiny value // that silently starves every dual-AI review into quota_exceeded — that exact misconfig (the deployed worker diff --git a/test/unit/ai-review.test.ts b/test/unit/ai-review.test.ts index 927a0da54c..ff774818c5 100644 --- a/test/unit/ai-review.test.ts +++ b/test/unit/ai-review.test.ts @@ -142,6 +142,41 @@ describe("runGittensoryAiReview gating", () => { expect(run).not.toHaveBeenCalled(); }); + it("reserves consensus tie-break judge retries in the shared daily neuron budget", async () => { + const run = vi.fn(); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "600", + }); + await expect( + runGittensoryAiReview(env, { ...baseInput, mode: "block" }), + ).resolves.toMatchObject({ + status: "quota_exceeded", + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("still reserves the tie-break budget (at the x1 fallback multiplier) when a configured reviewer has no distinct fallback model", async () => { + // A self-host pair with no explicit `fallback` reuses its own model (primaryFallback === primary.model), + // so the worst-case tie-break reservation must fall back to the x1 multiplier instead of x2 -- still + // non-zero, still enforced against the shared daily budget. + const run = vi.fn(); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "600", + }); + await expect( + runGittensoryAiReview(env, { ...baseInput, mode: "block", reviewers: [{ model: "claude-code" }, { model: "codex" }] }), + ).resolves.toMatchObject({ + status: "quota_exceeded", + }); + expect(run).not.toHaveBeenCalled(); + }); + it("clamps a non-numeric AI_MAX_OUTPUT_TOKENS back to the default", async () => { const run = vi.fn(async () => ({ response: reviewJson() })); const env = createTestEnv({