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
28 changes: 25 additions & 3 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ import {
isGlobalAgentPause,
resolveAgentActionMode,
resolveAgentPermissionReadiness,
type AgentActionMode,
} from "../settings/agent-execution";
import {
ISSUE_WAKE_MAX_PRS,
Expand Down Expand Up @@ -6990,6 +6991,13 @@ async function resolveReviewEnrichmentGithubToken(
export async function runAiReviewForAdvisory(
env: Env,
args: {
// The caller's already-resolved resolveRepoActionMode() result (#token-bleed-spend-gate): a "paused" repo
// must NEVER reach the LLM call below, full stop -- not just have its GitHub publish suppressed. Every
// feature-specific gate below (aiReviewMode, confirmedContributor, ...) is independent of this and was, on
// its own, insufficient: a fleet-wide freeze or per-repo pause with aiReviewMode still "block"/"advisory"
// spent real tokens for hours on frozen repos before this field existed. "dry_run" still computes (so a
// maintainer can validate decision logic locally); only "paused" stops spend.
mode: AgentActionMode;
settings: RepositorySettings;
advisory: Awaited<ReturnType<typeof buildPullRequestAdvisory>>;
installationId?: number | null | undefined;
Expand Down Expand Up @@ -7101,6 +7109,7 @@ export async function runAiReviewForAdvisory(
packAllowsAnyAuthorBlockingReview ||
args.settings.aiReviewAllAuthors;
if (
args.mode === "paused" ||
args.settings.aiReviewMode === "off" ||
!reviewableAuthor ||
!args.advisory.headSha
Expand Down Expand Up @@ -7675,6 +7684,9 @@ export async function maybeAddLockfileTamperFinding(
export async function runAiSlopForAdvisory(
env: Env,
args: {
// See runAiReviewForAdvisory's doc comment on this same field (#token-bleed-spend-gate) -- a paused repo
// must never reach the LLM call below, independent of settings.slopAiAdvisory.
mode: AgentActionMode;
settings: RepositorySettings;
advisory: Awaited<ReturnType<typeof buildPullRequestAdvisory>>;
repoFullName: string;
Expand All @@ -7687,7 +7699,7 @@ export async function runAiSlopForAdvisory(
): Promise<void> {
// Confirmed-contributor gate (matches runAiReviewForAdvisory): no AI spend — free OR BYOK — on a PR from
// an unconfirmed author. The deterministic slop core still ran for everyone; only the AI layer is gated.
if (!args.confirmedContributor || !args.advisory.headSha) return;
if (args.mode === "paused" || !args.confirmedContributor || !args.advisory.headSha) return;
try {
// BYOK (opt-in): reuse the repo's encrypted key + aiReviewByok flag — one BYOK key serves both AI
// features. A declared provider must match the stored key's provider, else skip BYOK (Workers-AI
Expand Down Expand Up @@ -7817,6 +7829,9 @@ export async function runAiSlopForAdvisory(
export async function runLinkedIssueSatisfactionForAdvisory(
env: Env,
args: {
// See runAiReviewForAdvisory's doc comment on this same field (#token-bleed-spend-gate) -- a paused repo
// must never reach the LLM call below, independent of settings.linkedIssueSatisfactionGateMode.
mode: AgentActionMode;
settings: RepositorySettings;
advisory: Awaited<ReturnType<typeof buildPullRequestAdvisory>>;
repoFullName: string;
Expand All @@ -7827,7 +7842,7 @@ export async function runLinkedIssueSatisfactionForAdvisory(
installationId: number;
},
): Promise<{ status: "addressed" | "partial" | "unaddressed"; rationale: string } | null> {
if (!args.confirmedContributor || !args.advisory.headSha) return null;
if (args.mode === "paused" || !args.confirmedContributor || !args.advisory.headSha) return null;
const primaryIssueNumber = args.pr.linkedIssues[0];
if (primaryIssueNumber === undefined) return null;
try {
Expand Down Expand Up @@ -8235,6 +8250,9 @@ async function runSelfHostVisualVision(env: Env, system: string, user: string, i
export async function runVisualVisionForAdvisory(
env: Env,
args: {
// See runAiReviewForAdvisory's doc comment on this same field (#token-bleed-spend-gate) -- a paused repo
// must never reach the vision-model call below.
mode: AgentActionMode;
repoFullName: string;
pr: { number: number };
author: string | null;
Expand All @@ -8244,7 +8262,7 @@ export async function runVisualVisionForAdvisory(
routes: readonly CaptureRoute[];
},
): Promise<void> {
if (args.routes.length === 0) return;
if (args.mode === "paused" || args.routes.length === 0) return;
try {
const visionReputation = await getSubmitterReputation(env, args.repoFullName, args.author ?? undefined);
// BYOK resolution mirrors runAiReviewForAdvisory's own (re-resolved per-caller is this codebase's
Expand Down Expand Up @@ -9152,6 +9170,7 @@ async function maybePublishPrPublicSurface(
// advisory-only finding. Deliberately does NOT update slopRisk — only the deterministic core blocks.
if (shouldRunSlopAiAdvisory(settings)) {
await runAiSlopForAdvisory(env, {
mode,
settings,
advisory,
repoFullName,
Expand All @@ -9170,6 +9189,7 @@ async function maybePublishPrPublicSurface(
// to function scope above, alongside gateEvaluation, since it is consumed later outside this try block.)
if (settings.linkedIssueSatisfactionGateMode !== "off" && pr.linkedIssues.length > 0) {
linkedIssueSatisfaction = await runLinkedIssueSatisfactionForAdvisory(env, {
mode,
settings,
advisory,
repoFullName,
Expand Down Expand Up @@ -9826,6 +9846,7 @@ async function maybePublishPrPublicSurface(
}).catch(() => undefined);
}
aiReview = await runAiReviewForAdvisory(env, {
mode,
settings,
advisory,
installationId,
Expand Down Expand Up @@ -10727,6 +10748,7 @@ async function maybePublishPrPublicSurface(
// own doc comment. Deliberately independent of the capture block above (its own try/catch there) so a
// vision failure can never affect the "Visual preview" section that block already rendered.
await runVisualVisionForAdvisory(env, {
mode,
repoFullName,
pr,
author,
Expand Down
11 changes: 10 additions & 1 deletion src/services/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { buildContributorOpenPrMonitor, type ContributorOpenPrMonitor } from "..
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest, type LocalBranchAnalysis, type LocalBranchAnalysisInput } from "../signals/local-branch";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { resolveRepoActionMode } from "../github/client";
import { isGlobalAgentPause } from "../settings/agent-execution";
import { withAdvisoryAiEnv } from "../selfhost/ai";
import { withAgentActionExplanationCard } from "./agent-action-explanation-card";
import { attachRecommendationSnapshots } from "./recommendation-snapshots";
Expand Down Expand Up @@ -229,7 +231,14 @@ async function attachPrivateAiSummary(env: Env, bundle: AgentRunBundle): Promise
// slop/e2e-test-gen/planner. repoFullName can be absent for a cross-repo run (e.g. plan_next_work) --
// falls back to the plain env (byte-identical) rather than resolving settings for an empty key.
const repoFullName = String(bundle.run.payload.repoFullName ?? "");
const routeThroughAdvisory = repoFullName ? (await resolveRepositorySettings(env, repoFullName)).advisoryAiRouting?.summaries === true : false;
const repoSettings = repoFullName ? await resolveRepositorySettings(env, repoFullName) : null;
const routeThroughAdvisory = repoSettings?.advisoryAiRouting?.summaries === true;
// #token-bleed-spend-gate: a paused repo (or the fleet-wide env brake, which applies with no repoFullName at
// all) must never reach the LLM call below -- same reasoning as runAiReviewForAdvisory/runAiSlopForAdvisory in
// src/queue/processors.ts. A cross-repo run (no repoFullName) has no per-repo freeze to check, so only the
// fleet-wide brake applies to it.
const mode = repoSettings ? await resolveRepoActionMode(env, repoSettings) : (isGlobalAgentPause(env) ? "paused" : "live");
if (mode === "paused") return bundle;
const summary = await summarizeAgentBundleWithAi(withAdvisoryAiEnv(env, routeThroughAdvisory), bundle, "private");
if (summary.status === "disabled" || summary.status === "unavailable") return bundle;
await updateAgentRun(env, bundle.run.id, {
Expand Down
3 changes: 3 additions & 0 deletions test/unit/advisory-ai-routing-call-sites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe("runAiSlopForAdvisory routes through AI_ADVISORY (#4364)", () => {
AI_ADVISORY: { run: advisoryRun } as unknown as Ai,
});
await runAiSlopForAdvisory(env, {
mode: "live",
settings: settingsFixture({ slop: true, e2eTestGen: false, planner: false, summaries: false }),
advisory,
repoFullName: "owner/repo",
Expand All @@ -57,6 +58,7 @@ describe("runAiSlopForAdvisory routes through AI_ADVISORY (#4364)", () => {
AI_ADVISORY: { run: advisoryRun } as unknown as Ai,
});
await runAiSlopForAdvisory(env, {
mode: "live",
settings: settingsFixture(undefined),
advisory,
repoFullName: "owner/repo",
Expand All @@ -75,6 +77,7 @@ describe("runAiSlopForAdvisory routes through AI_ADVISORY (#4364)", () => {
advisoryRun.mockClear();
const env = createTestEnv({ AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI: { run: frontierRun } as unknown as Ai });
await runAiSlopForAdvisory(env, {
mode: "live",
settings: settingsFixture({ slop: true, e2eTestGen: false, planner: false, summaries: false }),
advisory,
repoFullName: "owner/repo",
Expand Down
12 changes: 12 additions & 0 deletions test/unit/agent-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type AgentRunBundle,
} from "../../src/services/agent-orchestrator";
import { buildAgentActionExplanationCard } from "../../src/services/agent-action-explanation-card";
import * as aiSummariesModule from "../../src/services/ai-summaries";
import { CONTRIBUTOR_DECISION_PACK_SIGNAL, type ContributorDecisionPack, type RepoOutcomeSummary } from "../../src/services/decision-pack";
import { buildPublicAgentCommandComment, parseGittensoryMentionCommand } from "../../src/github/commands";
import { normalizeRegistryPayload } from "../../src/registry/normalize";
Expand Down Expand Up @@ -150,6 +151,17 @@ describe("agent orchestrator", () => {
});
});

it("REGRESSION (#token-bleed-spend-gate): the fleet-wide env pause skips the private AI summary spend entirely", async () => {
const summarizeSpy = vi.spyOn(aiSummariesModule, "summarizeAgentBundleWithAi");
const env = createTestEnv({ AGENT_ACTIONS_PAUSED: "true" });
await persistDecisionPack(env, decisionPackFixture());

await planNextWork(env, { login: "oktofeesh1", repoFullName: "we-promise/sure", objective: "Pick one action" });

expect(summarizeSpy).not.toHaveBeenCalled();
summarizeSpy.mockRestore();
});

it("threads scoped counterfactual reasons into decision context snapshots", () => {
const generatedAt = nowIso();
const rejectedAlternative = {
Expand Down
Loading
Loading