diff --git a/src/review/unlinked-issue-guardrail.ts b/src/review/unlinked-issue-guardrail.ts index aabd127cd2..005da896a9 100644 --- a/src/review/unlinked-issue-guardrail.ts +++ b/src/review/unlinked-issue-guardrail.ts @@ -70,6 +70,21 @@ const VERIFY_MAX_MODEL_ATTEMPTS_PER_CANDIDATE = 2; // closes that gap by recording into the SAME shared ai_usage_events table this check reads from. const UNLINKED_ISSUE_VERIFY_USAGE_FEATURE = "unlinked_issue_verify"; +function unlinkedIssueVerifyCapacityHold(reason: "rate" | "budget"): UnlinkedIssueMatchDisposition { + const detail = reason === "rate" ? "the per-contributor verifier rate ceiling has been reached" : "the shared verifier budget is exhausted"; + return { + kind: "hold", + reason: `unlinked-issue-match verification was deferred because ${detail}; holding for manual review instead of treating capacity exhaustion as a clean pass`, + comment: + "This PR does not link an issue and matched the unlinked-issue prefilter, but the final verifier is temporarily capacity-limited. A maintainer should manually confirm whether it directly solves an open issue before this PR proceeds.", + }; +} + +function hasUnlinkedIssueVerifyAiBinding(env: Env): boolean { + const ai = env.AI as unknown as { run?: unknown } | undefined; + return typeof ai?.run === "function"; +} + /** Has this actor already run the AI verifier at or beyond the rate ceiling in the last window, across every * repo/PR? Fail-safe: a read error resolves to "not rate-limited," so the pre-#4515 unconditional- * verification behavior takes over rather than a DB hiccup silently disabling this guardrail. */ @@ -224,19 +239,20 @@ export async function resolveUnlinkedIssueMatchDisposition(env: Env, input: Reso const authorLogin = input.prAuthorLogin?.trim() || null; // #4515: cost-control gates ahead of the AI loop below. An unidentifiable author can't be rate-limited // individually (nothing to key the ceiling on), so only the shared budget check applies to them. - if (authorLogin && (await isOverUnlinkedIssueVerifyRateCeiling(env, authorLogin))) return undefined; - if (await isUnlinkedIssueVerifyBudgetExceeded(env, candidates.length)) return undefined; + if (authorLogin && (await isOverUnlinkedIssueVerifyRateCeiling(env, authorLogin))) return unlinkedIssueVerifyCapacityHold("rate"); + if (await isUnlinkedIssueVerifyBudgetExceeded(env, candidates.length)) return unlinkedIssueVerifyCapacityHold("budget"); for (const candidate of candidates) { if (authorLogin) await recordUnlinkedIssueVerifyAttempt(env, input.repoFullName, input.pullNumber, authorLogin); - // Record spend regardless of authorLogin -- an AI call happens either way; only the PER-ACTOR rate - // ceiling above needs a known actor, this shared-budget accounting does not. - await recordUnlinkedIssueVerifyUsage(env, input.repoFullName, input.pullNumber); + const hadAiBinding = hasUnlinkedIssueVerifyAiBinding(env); const verdict = await verifyUnlinkedIssueMatch(env, { prTitle: input.prTitle, prBody: input.prBody, diff: input.diff, candidate: candidate.issue, }); + // Record spend only after an actual verifier invocation was possible. Missing/no-op AI bindings should + // fail closed to NO_MATCH without burning the shared budget ledger as if an ok call occurred. + if (hadAiBinding) await recordUnlinkedIssueVerifyUsage(env, input.repoFullName, input.pullNumber); if (!verdict.matched || verdict.confidence < input.config.minConfidence) continue; const evidenceSuffix = verdict.evidence ? ` (${verdict.evidence})` : ""; if (!authorLogin) { diff --git a/test/unit/unlinked-issue-guardrail.test.ts b/test/unit/unlinked-issue-guardrail.test.ts index c2b967f650..6fec4f95b6 100644 --- a/test/unit/unlinked-issue-guardrail.test.ts +++ b/test/unit/unlinked-issue-guardrail.test.ts @@ -382,14 +382,15 @@ describe("resolveUnlinkedIssueMatchDisposition", () => { } } - it("skips AI verification entirely once the per-actor rate ceiling is already met, even on a brand new PR", async () => { + it("holds for manual review once the per-actor rate ceiling is already met, even on a brand new PR", async () => { const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); const env = createTestEnv({ AI: { run } as unknown as Ai }); await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); await seedVerifyAttempts(env, "contributor-a", 15); const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); - expect(result).toBeUndefined(); + expect(result?.kind).toBe("hold"); + expect(result?.reason).toContain("rate ceiling"); expect(run).not.toHaveBeenCalled(); }); @@ -449,6 +450,19 @@ describe("resolveUnlinkedIssueMatchDisposition", () => { expect(await sumAiEstimatedNeuronsSince(env, "2000-01-01T00:00:00.000Z")).toBeGreaterThan(0); }); + + it("does not record shared-budget spend when the verifier cannot make an AI call", async () => { + const env = createTestEnv({}); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + + const usedBefore = await sumAiEstimatedNeuronsSince(env, "2000-01-01T00:00:00.000Z"); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + const usedAfter = await sumAiEstimatedNeuronsSince(env, "2000-01-01T00:00:00.000Z"); + + expect(result).toBeUndefined(); + expect(usedAfter).toBe(usedBefore); + }); + it("swallows a usage-recording write failure without affecting the verification result", async () => { const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); const env = createTestEnv({ AI: { run } as unknown as Ai }); @@ -478,13 +492,14 @@ describe("resolveUnlinkedIssueMatchDisposition", () => { expect(run).toHaveBeenCalled(); }); - it("skips AI verification entirely when the shared daily neuron budget is exhausted", async () => { + it("holds for manual review when the shared daily neuron budget is exhausted", async () => { const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); const env = createTestEnv({ AI: { run } as unknown as Ai, AI_DAILY_NEURON_BUDGET: "1" }); await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); - expect(result).toBeUndefined(); + expect(result?.kind).toBe("hold"); + expect(result?.reason).toContain("budget is exhausted"); expect(run).not.toHaveBeenCalled(); });