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
24 changes: 24 additions & 0 deletions migrations/0119_ai_slop_cache.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- AI slop advisory cache (mirrors ai_review_cache, #74/#98/#112): runGittensoryAiSlopAdvisory makes a real
-- LLM call (up to 6 free-tier attempts, or one BYOK call) with NO caching, so every scheduled re-gate sweep
-- tick re-spends it for every open PR with slopAiAdvisory on, even at an unchanged head SHA -- confirmed in
-- production: 1,469 ai_slop_pr calls in 24h across 3 repos, 110 of them on a single PR. Unlike ai_review_cache,
-- the slop advisory has no dynamic-context dimension (no RAG/grounding/enrichment/reputation feed into it --
-- see ai-slop.ts's AiSlopInput) and nothing analogous to a "published" GitHub artifact to protect against
-- replaying: its output is folded into the SAME advisory pass that (re)computes it, never stamped separately.
-- So this cache is unconditionally durable for a given (repo, pull, head SHA) -- no cacheable/published_at
-- cooldown columns needed, deliberately simpler than ai_review_cache.
CREATE TABLE IF NOT EXISTS ai_slop_cache (
repo_full_name TEXT NOT NULL,
pull_number INTEGER NOT NULL,
head_sha TEXT NOT NULL,
-- Fingerprints the one input that can change independently of the head SHA: which provider produced the
-- opinion (free/default reviewer vs. a maintainer's BYOK key/model). Title/body/diff/deterministicBand are
-- all already pinned to the head SHA (see getReviewFiles/buildAiReviewDiff), so they need no fingerprinting.
input_fingerprint TEXT NOT NULL,
status TEXT NOT NULL,
band TEXT,
finding_json TEXT,
estimated_neurons INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (repo_full_name, pull_number, head_sha)
);
52 changes: 52 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4371,6 +4371,58 @@ export async function markAiReviewPublished(
.run();
}

/** #ai-slop-cache: the stored AI slop advisory result for (repo, pull, head SHA), or null on a miss. Mirrors
* getCachedAiReview but deliberately simpler -- see ai_slop_cache's migration doc comment for why no
* cacheable/allowNonCacheable/maxAgeMs dimension is needed here: every stored row is unconditionally durable.
* A nullish head SHA is always a miss (nothing to key on). `expectedInputFingerprint` mismatching (e.g. the
* repo turned BYOK on/off, or changed its BYOK provider/model, since this row was written) is also a miss so a
* config change can't silently replay an opinion produced under a different reviewer. */
export async function getCachedAiSlopAdvisory(
env: Env,
repoFullName: string,
pullNumber: number,
headSha: string | null | undefined,
expectedInputFingerprint: string,
): Promise<{ status: string; band: string | null; finding: AdvisoryFinding | null; estimatedNeurons: number } | null> {
if (!headSha) return null;
const row = await env.DB
.prepare("SELECT status, band, finding_json AS findingJson, estimated_neurons AS estimatedNeurons, input_fingerprint AS inputFingerprint FROM ai_slop_cache WHERE repo_full_name = ? AND pull_number = ? AND head_sha = ?")
.bind(repoFullName, pullNumber, headSha)
.first<{ status: string; band: string | null; findingJson: string | null; estimatedNeurons: number; inputFingerprint: string }>();
if (!row || row.inputFingerprint !== expectedInputFingerprint) return null;
return {
status: row.status,
band: row.band,
finding: parseJson<AdvisoryFinding | null>(row.findingJson, null),
estimatedNeurons: row.estimatedNeurons,
};
}

/** #ai-slop-cache: upsert the AI slop advisory result for (repo, pull, head SHA). A nullish head SHA is a
* no-op (mirrors putCachedAiReview). Only call this for a result that actually spent the LLM call/attempts
* (status "ok") -- the caller is responsible for not caching a pre-call short-circuit (disabled/unavailable/
* quota_exceeded), since those return before any provider call and caching them would suppress a legitimate
* retry once quota resets without having saved anything. */
export async function putCachedAiSlopAdvisory(
env: Env,
repoFullName: string,
pullNumber: number,
headSha: string | null | undefined,
inputFingerprint: string,
result: { status: string; band: string | null; finding: AdvisoryFinding | null; estimatedNeurons: number },
): Promise<void> {
if (!headSha) return;
await env.DB
.prepare(
`INSERT INTO ai_slop_cache (repo_full_name, pull_number, head_sha, input_fingerprint, status, band, finding_json, estimated_neurons, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo_full_name, pull_number, head_sha) DO UPDATE SET
input_fingerprint = excluded.input_fingerprint, status = excluded.status, band = excluded.band, finding_json = excluded.finding_json, estimated_neurons = excluded.estimated_neurons, created_at = excluded.created_at`,
)
.bind(repoFullName, pullNumber, headSha, inputFingerprint, result.status, result.band, jsonString(result.finding), result.estimatedNeurons, nowIso())
.run();
}

export async function replaceCollisionEdges(env: Env, repoFullName: string, edges: CollisionEdgeRecord[]): Promise<void> {
const db = getDb(env.DB);
await env.DB.prepare("DELETE FROM collision_edges WHERE repo_full_name = ?").bind(repoFullName).run();
Expand Down
24 changes: 24 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1358,3 +1358,27 @@ export const aiReviewCache = sqliteTable(
primary: primaryKey({ columns: [table.repoFullName, table.pullNumber, table.headSha] }),
}),
);

// AI slop advisory cache (#ai-slop-cache): mirrors aiReviewCache above but deliberately simpler -- the slop
// advisory has no dynamic-context dimension (no RAG/grounding/enrichment feed into it, see ai-slop.ts) and
// nothing analogous to a published GitHub artifact to protect against replaying, so a hit here is always
// unconditionally durable for a given (repo, pull, head SHA) -- no cacheable/published_at columns needed.
export const aiSlopCache = sqliteTable(
"ai_slop_cache",
{
repoFullName: text("repo_full_name").notNull(),
pullNumber: integer("pull_number").notNull(),
headSha: text("head_sha").notNull(),
// Fingerprints the one input that can change independently of the head SHA: which provider produced the
// opinion (free/default reviewer vs. a maintainer's BYOK key/model) -- see ai-slop-cache-input.ts.
inputFingerprint: text("input_fingerprint").notNull(),
status: text("status").notNull(),
band: text("band"),
findingJson: text("finding_json"),
estimatedNeurons: integer("estimated_neurons").notNull().default(0),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
},
(table) => ({
primary: primaryKey({ columns: [table.repoFullName, table.pullNumber, table.headSha] }),
}),
);
80 changes: 71 additions & 9 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import {
countPublishedAiReviewHeads,
putCachedAiReview,
markAiReviewPublished,
getCachedAiSlopAdvisory,
putCachedAiSlopAdvisory,
markPullRequestsRegated,
markPullRequestReviewsInvalidated,
markPullRequestSurfacePublished,
Expand Down Expand Up @@ -270,6 +272,7 @@ import {
queueSnapshotFromBinding,
} from "../selfhost/queue-common";
import { aiReviewCacheInputFingerprint } from "../review/ai-review-cache-input";
import { aiSlopCacheInputFingerprint } from "../review/ai-slop-cache-input";
import {
AGENT_LABEL_NEEDS_REVIEW,
DEFAULT_REVIEW_EVASION_LABEL,
Expand Down Expand Up @@ -7502,16 +7505,75 @@ export async function runAiSlopForAdvisory(
model: args.settings.aiReviewModel ?? storedKey.model,
}
: null;
const result = await runGittensoryAiSlopAdvisory(env, {
repoFullName: args.repoFullName,
prNumber: args.pr.number,
title: args.pr.title,
body: args.pr.body ?? undefined,
diff: buildAiReviewDiff(args.files),
actor: args.author,
deterministicBand: args.deterministicBand,
providerKey,
// #ai-slop-cache: the slop advisory's LLM call is fully deterministic given the same head SHA (no RAG/
// grounding/enrichment feeds into it, unlike ai review — see ai_slop_cache's migration doc comment), so a
// repeated scheduled sweep pass at an unchanged head reuses the stored result instead of re-spending up to
// 6 free-tier attempts (or a BYOK call) on every tick — confirmed in production: 110 ai_slop_pr calls on a
// single PR in 24h at an unchanged head. The fingerprint only needs to cover which provider would answer
// (free vs. this repo's BYOK key/model); everything else the model sees is already pinned to the head SHA.
const inputFingerprint = await aiSlopCacheInputFingerprint({
byok: Boolean(providerKey),
provider: providerKey?.provider,
model: providerKey?.model,
});
const cachedSlop = await getCachedAiSlopAdvisory(env, args.repoFullName, args.pr.number, args.advisory.headSha, inputFingerprint).catch(() => null);
let result: Awaited<ReturnType<typeof runGittensoryAiSlopAdvisory>>;
if (cachedSlop) {
result = { status: "ok", finding: cachedSlop.finding, band: cachedSlop.band as SlopBand | null, estimatedNeurons: cachedSlop.estimatedNeurons };
incr("gittensory_ai_slop_cache_hit_total");
await recordAuditEvent(env, {
eventType: "github_app.ai_slop_cache_hit",
actor: args.author,
targetKey: `${args.repoFullName}#${args.pr.number}`,
outcome: "completed",
detail: "reused a stored AI slop advisory instead of re-spending an LLM call",
/* v8 ignore next -- reached only past this function's own `!args.advisory.headSha` early return, so headSha is always truthy here; the `?? null` is a type-level fallback for an unreachable branch. */
metadata: { repoFullName: args.repoFullName, headSha: args.advisory.headSha ?? null },
}).catch(() => undefined);
} else {
incr("gittensory_ai_slop_cache_miss_total");
await recordAuditEvent(env, {
eventType: "github_app.ai_slop_cache_miss",
actor: args.author,
targetKey: `${args.repoFullName}#${args.pr.number}`,
outcome: "completed",
detail: "no reusable stored AI slop advisory for this head+fingerprint; running a fresh advisory",
/* v8 ignore next -- reached only past this function's own `!args.advisory.headSha` early return, so headSha is always truthy here; the `?? null` is a type-level fallback for an unreachable branch. */
metadata: { repoFullName: args.repoFullName, headSha: args.advisory.headSha ?? null },
}).catch(() => undefined);
result = await runGittensoryAiSlopAdvisory(env, {
repoFullName: args.repoFullName,
prNumber: args.pr.number,
title: args.pr.title,
body: args.pr.body ?? undefined,
diff: buildAiReviewDiff(args.files),
actor: args.author,
deterministicBand: args.deterministicBand,
providerKey,
});
// Only "ok" actually spent the LLM call (free-tier attempts or a BYOK call) — disabled/unavailable/
// quota_exceeded all short-circuit BEFORE any provider call, so caching them would suppress a legitimate
// retry once the condition clears without having saved anything.
if (result.status === "ok") {
await putCachedAiSlopAdvisory(env, args.repoFullName, args.pr.number, args.advisory.headSha, inputFingerprint, {
status: result.status,
band: result.band,
finding: result.finding,
estimatedNeurons: result.estimatedNeurons,
}).catch((error) => {
incr("gittensory_ai_slop_cache_write_error_total");
return recordAuditEvent(env, {
eventType: "github_app.ai_slop_cache_write_error",
actor: args.author,
targetKey: `${args.repoFullName}#${args.pr.number}`,
outcome: "error",
detail: errorMessage(error),
/* v8 ignore next -- reached only past this function's own `!args.advisory.headSha` early return, so headSha is always truthy here; the `?? null` is a type-level fallback for an unreachable branch. */
metadata: { repoFullName: args.repoFullName, headSha: args.advisory.headSha ?? null },
}).catch(() => undefined);
});
}
}
if (result.status === "ok" && result.finding)
args.advisory.findings.push(result.finding);
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/review/ai-slop-cache-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { sha256Hex } from "../utils/crypto";

// #ai-slop-cache: unlike ai-review-cache-input.ts (whose fingerprint spans a large, independently-mutable
// prompt-shaping surface -- reviewer plan, model overrides, path instructions, feature toggles, ...), the slop
// advisory's ONLY input that can change independently of the PR's head SHA is which provider writes the
// opinion: the free/default reviewer vs. a maintainer's BYOK key/model (see AiSlopInput in ../services/ai-slop).
// Title/body/diff/deterministicBand are all already pinned to the head SHA -- the same commit always produces
// the same diff and the same deterministic band, so none of them need fingerprinting. A repo flipping BYOK on
// or changing its BYOK provider/model must miss the cache rather than replay an opinion written under a
// different reviewer.
export const AI_SLOP_CACHE_INPUT_VERSION = "ai-slop-input:v1";

export type AiSlopCacheInput = {
byok: boolean;
provider: string | null | undefined;
model: string | null | undefined;
};

export async function aiSlopCacheInputFingerprint(input: AiSlopCacheInput): Promise<string> {
const payload = [
AI_SLOP_CACHE_INPUT_VERSION,
input.byok ? "1" : "0",
input.provider ?? "",
input.model ?? "",
].join("|");
return `${AI_SLOP_CACHE_INPUT_VERSION}:${await sha256Hex(payload)}`;
}
3 changes: 3 additions & 0 deletions src/selfhost/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [
["gittensory_ai_review_cache_hit_total", { help: "AI review cache hits.", type: "counter" }],
["gittensory_ai_review_cache_miss_total", { help: "AI review cache misses.", type: "counter" }],
["gittensory_ai_review_cache_write_error_total", { help: "AI review cache write errors.", type: "counter" }],
["gittensory_ai_slop_cache_hit_total", { help: "AI slop advisory cache hits.", type: "counter" }],
["gittensory_ai_slop_cache_miss_total", { help: "AI slop advisory cache misses.", type: "counter" }],
["gittensory_ai_slop_cache_write_error_total", { help: "AI slop advisory cache write errors.", type: "counter" }],
["gittensory_ai_review_non_cacheable_total", { help: "AI reviews skipped by cacheability rules.", type: "counter" }],
["gittensory_ai_review_force_bypass_total", { help: "AI review cache force-bypass events.", type: "counter" }],
["gittensory_ai_review_inconclusive_total", { help: "AI review inconclusive outcomes.", type: "counter" }],
Expand Down
Loading
Loading