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
26 changes: 21 additions & 5 deletions src/review/unlinked-issue-guardrail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 19 additions & 4 deletions test/unit/unlinked-issue-guardrail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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();
});

Expand Down