diff --git a/src/queue/processors.ts b/src/queue/processors.ts index b03350e75..16f11d0c7 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -206,6 +206,7 @@ const OFFICIAL_MINER_DETECTION_TTL_MS = 5 * 60 * 1000; const OFFICIAL_MINER_DETECTION_UNAVAILABLE_TTL_MS = 60 * 1000; const PR_PUBLIC_SURFACE_ACTIONS = new Set(["opened", "reopened", "synchronize", "ready_for_review", "edited"]); const PR_GATE_CLOSED_ACTIONS = new Set(["closed"]); +const ISSUE_PLAN_COOLDOWN_MS = 10 * 60 * 1000; /** * Run (or dry-run) the data-retention prune across the configured log/snapshot tables and audit the @@ -3189,7 +3190,8 @@ async function recordGateOverrideSkip( * issue comment so a contributor has a concrete starting point. Flag-OFF (default) returns false immediately * (BEFORE any parse), so `@gittensory plan` falls through to the existing mention path → byte-identical. Returns * true once it owns the event (so the caller records it processed and stops). Fail-safe: a model/post error is - * recorded as a skip and never throws into the webhook loop. + * recorded as a skip and never throws into the webhook loop. A per-actor/per-repo cooldown prevents repeated + * maintainer comments from spending shared AI quota in a burst. */ async function maybeProcessPlanCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise { if (!isPlannerEnabled(env)) return false; // flag-OFF → not handled here; the worker is byte-identical to today @@ -3208,7 +3210,11 @@ async function maybeProcessPlanCommand(env: Env, deliveryId: string, payload: Gi await recordPlanSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "actor_not_maintainer"); return true; } - const plan = await generateIssuePlan(env, { title: req.issue.title, body: req.issue.body }); + if (await isPlanCommandCoolingDown(env, req.repoFullName, req.actor, ISSUE_PLAN_COOLDOWN_MS)) { + await recordPlanSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "cooldown_active"); + return true; + } + const plan = await generateIssuePlan(env, { title: req.issue.title, body: req.issue.body }, { actor: req.actor, repoFullName: req.repoFullName, issueNumber: req.issue.number }); if (!plan) { await recordPlanSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "no_plan_generated"); return true; @@ -3226,6 +3232,23 @@ async function maybeProcessPlanCommand(env: Env, deliveryId: string, payload: Gi return true; } +async function isPlanCommandCoolingDown(env: Env, repoFullName: string, actor: string, cooldownMs: number): Promise { + const since = new Date(Date.now() - cooldownMs).toISOString(); + const row = await env.DB.prepare( + `select 1 as active + from audit_events + where event_type in ('github_app.issue_plan_generated', 'github_app.issue_plan_skipped') + and actor = ? + and json_extract(metadata_json, '$.repoFullName') = ? + and created_at >= ? + and (event_type = 'github_app.issue_plan_generated' or coalesce(detail, '') in ('no_plan_generated', 'cooldown_active')) + limit 1`, + ) + .bind(actor, repoFullName, since) + .first<{ active: number }>(); + return Boolean(row); +} + async function recordPlanSkip(env: Env, deliveryId: string, repoFullName: string | null, targetKey: string | null, actor: string | null, reason: string): Promise { await recordAuditEvent(env, { eventType: "github_app.issue_plan_skipped", diff --git a/src/review/planner.ts b/src/review/planner.ts index bdfa0426f..a8f0671a8 100644 --- a/src/review/planner.ts +++ b/src/review/planner.ts @@ -6,9 +6,11 @@ // • flag-OFF (default) → isPlannerEnabled is false, the handler short-circuits BEFORE parsing, and the worker // is byte-identical to today (`@gittensory plan` falls through to the existing mention path → help card). // • flag-ON → only a MAINTAINER can trigger it; the model sees only the (already-public) issue title + body; -// the output is public-safe-sanitized before posting; any model/error degrades to a no-plan no-op. +// shared AI budget accounting runs before Workers AI; the output is public-safe-sanitized before posting; +// any model/error degrades to a no-plan no-op. -import { BEST_REVIEW_MODELS, coerceAiText, RELIABLE_FALLBACK_MODELS } from "../services/ai-review"; +import { BEST_REVIEW_MODELS, clampNumber, coerceAiText, estimateNeurons, RELIABLE_FALLBACK_MODELS, utcDayStartIso } from "../services/ai-review"; +import { recordAiUsageEvent, sumAiEstimatedNeuronsSince } from "../db/repositories"; import { sanitizePublicComment } from "../github/commands"; import { AGENT_COMMAND_COMMENT_MARKER } from "../github/comments"; import { gittensoryFooter } from "../github/footer"; @@ -63,6 +65,25 @@ const PLANNER_SYSTEM_PROMPT = [ const MAX_ISSUE_CHARS = 6_000; const MAX_PLAN_CHARS = 8_000; const PLANNER_MAX_TOKENS = 1_200; +const PLANNER_MODEL_COUNT = 4; + +function plannerDailyBudget(env: Env): number { + const raw = Number(env.AI_DAILY_NEURON_BUDGET); + return clampNumber(env.AI_DAILY_NEURON_BUDGET && Number.isFinite(raw) ? raw : 10_000_000, 0, 10_000_000); +} + +async function recordPlannerUsage(env: Env, args: { actor?: string | null | undefined; repoFullName?: string | null | undefined; issueNumber?: number | null | undefined; status: string; estimatedNeurons: number; detail: string }): Promise { + await recordAiUsageEvent(env, { + feature: "issue_plan", + actor: args.actor ?? null, + route: "github_app.issue_plan", + model: [BEST_REVIEW_MODELS[0], RELIABLE_FALLBACK_MODELS[0]].join("+"), + status: args.status, + estimatedNeurons: args.estimatedNeurons, + detail: args.detail, + metadata: { repoFullName: args.repoFullName ?? null, issueNumber: args.issueNumber ?? null }, + }); +} /** One Workers-AI text completion for the planner: primary model, one reliable fallback, a single retry each. * Fail-safe — any error or empty output returns null. Mirrors runWorkersOpinion's routing (AI Gateway when set). */ @@ -88,12 +109,23 @@ async function runPlannerModel(env: Env, system: string, user: string): Promise< /** Generate an implementation plan (markdown) from an issue's title + body via Workers AI. Returns null when AI * is unavailable or returns nothing (the caller then posts no plan). The returned text is bounded; the caller * still sanitizes it before posting. */ -export async function generateIssuePlan(env: Env, issue: { title?: string | null | undefined; body?: string | null | undefined }): Promise { +export async function generateIssuePlan( + env: Env, + issue: { title?: string | null | undefined; body?: string | null | undefined }, + accounting: { actor?: string | null | undefined; repoFullName?: string | null | undefined; issueNumber?: number | null | undefined } = {}, +): Promise { const title = (issue.title ?? "").trim(); const body = (issue.body ?? "").trim().slice(0, MAX_ISSUE_CHARS); if (!title && !body) return null; // nothing to plan from const user = `Issue title: ${title || "(none)"}\n\nIssue description:\n${body || "(no description provided)"}`; + const estimatedNeurons = estimateNeurons(PLANNER_SYSTEM_PROMPT.length + user.length, PLANNER_MAX_TOKENS, PLANNER_MODEL_COUNT); + const remainingBudget = Math.max(0, plannerDailyBudget(env) - (await sumAiEstimatedNeuronsSince(env, utcDayStartIso()))); + if (estimatedNeurons > remainingBudget) { + await recordPlannerUsage(env, { ...accounting, status: "quota_exceeded", estimatedNeurons: 0, detail: `estimated ${estimatedNeurons} neurons exceeds remaining ${remainingBudget}` }); + return null; + } const plan = await runPlannerModel(env, PLANNER_SYSTEM_PROMPT, user); + await recordPlannerUsage(env, { ...accounting, status: plan ? "ok" : "no_output", estimatedNeurons: plan ? estimatedNeurons : 0, detail: plan ? "issue plan generated" : "no usable output" }); if (!plan) return null; return plan.slice(0, MAX_PLAN_CHARS); } diff --git a/test/unit/planner.test.ts b/test/unit/planner.test.ts index 60973b69c..978259bb6 100644 --- a/test/unit/planner.test.ts +++ b/test/unit/planner.test.ts @@ -49,6 +49,16 @@ describe("generateIssuePlan (#issue-coding-plan)", () => { expect((run.mock.calls[0] as unknown as unknown[])[2]).toEqual({ gateway: { id: "gw-1" } }); }); + it("does not call Workers AI when the shared daily neuron budget is exhausted", async () => { + const run = vi.fn(async () => ({ response: "should not run" })); + const env = createTestEnv({ AI: { run } as unknown as Ai, AI_DAILY_NEURON_BUDGET: "0" }); + await expect(generateIssuePlan(env, { title: "Add a flag", body: "Use AI budget." }, { actor: "maint", repoFullName: "acme/widgets", issueNumber: 7 })).resolves.toBeNull(); + expect(run).not.toHaveBeenCalled(); + const usage = await env.DB.prepare("select feature, actor, status, estimated_neurons, metadata_json from ai_usage_events where feature = ?").bind("issue_plan").first<{ feature: string; actor: string; status: string; estimated_neurons: number; metadata_json: string }>(); + expect(usage).toMatchObject({ feature: "issue_plan", actor: "maint", status: "quota_exceeded", estimated_neurons: 0 }); + expect(JSON.parse(usage?.metadata_json ?? "{}")).toMatchObject({ repoFullName: "acme/widgets", issueNumber: 7 }); + }); + it("returns null when there is no issue text to plan from (no AI call)", async () => { const run = vi.fn(async () => ({ response: "x" })); const env = createTestEnv({ AI: { run } as unknown as Ai }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index d74cb08f3..c7f0e8537 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -1493,6 +1493,47 @@ describe("queue processors", () => { expect(postedBody).toContain("Add retry-on-5xx"); const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.issue_plan_generated").first<{ n: number }>(); expect(audit?.n).toBe(1); + const usage = await env.DB.prepare("select feature, actor, status, estimated_neurons, metadata_json from ai_usage_events where feature = ?").bind("issue_plan").first<{ feature: string; actor: string; status: string; estimated_neurons: number; metadata_json: string }>(); + expect(usage?.status).toBe("ok"); + expect(usage?.actor).toBe("maintainer1"); + expect(usage?.estimated_neurons).toBeGreaterThan(0); + expect(JSON.parse(usage?.metadata_json ?? "{}")).toMatchObject({ repoFullName: "JSONbored/gittensory", issueNumber: 77 }); + }); + + it("planner: enforces the shared AI budget before calling Workers AI", async () => { + const run = vi.fn(async () => ({ response: "should not run" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "true", AI_DAILY_NEURON_BUDGET: "0", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory plan", "maintainer1")); + expect(run).not.toHaveBeenCalled(); + const usage = await env.DB.prepare("select status from ai_usage_events where feature = ?").bind("issue_plan").first<{ status: string }>(); + expect(usage?.status).toBe("quota_exceeded"); + const skip = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.issue_plan_skipped").first<{ detail: string }>(); + expect(skip?.detail).toBe("no_plan_generated"); + }); + + it("planner: enforces a per-actor per-repo cooldown before spending AI", async () => { + const run = vi.fn(async () => ({ response: "## Summary\nPlan." })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_PLANNER: "true", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/issues/77/comments")) return Response.json({ id: init?.body ? 5 : 6 }, { status: 201 }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory plan", "maintainer1")); + await processJob(env, plannerWebhook("@gittensory plan again", "maintainer1")); + expect(run).toHaveBeenCalledTimes(1); + const cooldown = await env.DB.prepare("select detail from audit_events where event_type = ? and detail = ?").bind("github_app.issue_plan_skipped", "cooldown_active").first<{ detail: string }>(); + expect(cooldown?.detail).toBe("cooldown_active"); }); it("planner: flag OFF is byte-identical — @gittensory plan posts no plan and the AI is never called", async () => {