Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/services/ai-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions test/unit/ai-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading