fix(ai): background generation cannot spend the interactive day

The automatic generators — status and reference cards, the briefing, the
arrival reaction, the nudge, document summaries and the index backfill —
reserved against the same ceiling as the chat. On the operator's account
that meant 150-330 ledger rows a day, 172 metric generations before
06:42Z, and a ceiling already spent when he opened the chat.

Every background surface now reserves through
`resolveDailyCapFor("job", chain)`: half the cost owner's ceiling, all
job surfaces together. The interactive routes keep the whole ceiling, so
a day of background work can no longer take the user's own away.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 10:09:34 +02:00
1 parent 2c46712e13
commit d3196ee69e
38 files changed
+151 -17

No files matched your search

@@ -35,6 +35,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
})),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: () => 200_000,
resolveDailyCapFor: () => 200_000,
resolveCostOwner: () => "operator" as const,
}));
@@ -51,6 +51,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 100000),
resolveDailyCapFor: vi.fn(() => 100000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
@@ -69,6 +69,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/documents/extract", () => ({
@@ -52,6 +52,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/modules/gate", () => ({
@@ -40,6 +40,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/modules/gate", () => ({
@@ -53,6 +53,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/modules/gate", () => ({
@@ -105,6 +105,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
@@ -86,6 +86,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
@@ -134,6 +134,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
@@ -127,6 +127,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
@@ -109,6 +109,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
@@ -101,6 +101,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
@@ -100,6 +100,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
})),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
@@ -103,6 +103,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
@@ -133,6 +133,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
@@ -177,6 +177,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
@@ -45,6 +45,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/labs/ocr-extract", async () => {
@@ -44,6 +44,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
// returns a distinctive figure so an accidental default-cap regression is
// visible in the assertion rather than silently passing.
resolveDailyCap: vi.fn(() => 1_234_567),
resolveDailyCapFor: vi.fn(() => 1_234_567),
resolveCostOwner: vi.fn(() => "user" as const),
}));
+36
View File
@@ -4,8 +4,10 @@ import {
buildDateKey,
OPERATOR_COST_CAP,
USER_PLAN_CAP,
JOB_SURFACE_SHARE,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
} from "../budget";
vi.mock("@/lib/db", () => ({
@@ -198,6 +200,40 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
* `totalAfter: 513539` against the 200 k operator ceiling. Comparing the total
* is the defect; the operator ceiling may only see operator-funded tokens.
*/
/**
* v1.38.19 (Wave E) — background generation gets a bounded slice of the day.
*
* Production evidence (2026-09-11): 150–330 ledger rows a day on the
* operator's account, 172 `insights.metric` generations by 06:42Z — the
* automatic jobs had spent the day's ceiling before he opened the chat. A
* background surface may reserve at most half of the interactive ceiling.
*/
describe("resolveDailyCapFor (Wave E — the job share)", () => {
it("gives a background surface half the operator ceiling", () => {
expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe(
OPERATOR_COST_CAP * JOB_SURFACE_SHARE,
);
expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe(
100_000,
);
});
it("leaves the interactive surfaces the whole ceiling", () => {
expect(
resolveDailyCapFor("coach", [{ providerType: "admin-openai" }]),
).toBe(OPERATOR_COST_CAP);
expect(resolveDailyCapFor("coach", [{ providerType: "codex" }])).toBe(
USER_PLAN_CAP,
);
});
it("shares the user-plan ceiling the same way", () => {
expect(resolveDailyCapFor("job", [{ providerType: "codex" }])).toBe(
USER_PLAN_CAP * JOB_SURFACE_SHARE,
);
});
});
describe("reserveBudget cap by cost owner (Wave E)", () => {
let prismaMock: {
$queryRaw: ReturnType<typeof vi.fn>;
@@ -46,6 +46,7 @@ const budgetMocks = vi.hoisted(() => ({
})),
reconcileSpend: vi.fn(async () => {}),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
+28
View File
@@ -68,6 +68,34 @@ export function resolveDailyCap(
: USER_PLAN_CAP;
}
/**
* v1.38.19 (Wave E) — the share of a day's ceiling that BACKGROUND generation
* may reserve, all job surfaces together.
*
* Production evidence (2026-09-11): the operator's account wrote 150–330
* ledger rows a day, 172 of them `insights.metric` generations before 06:42Z.
* The automatic work had eaten the day before he opened the chat. Half the
* ceiling keeps the interactive surfaces a day of their own no matter how much
* background generation the instance schedules.
*/
export const JOB_SURFACE_SHARE = 0.5;
/**
* v1.38.19 (Wave E) — the daily ceiling for one SURFACE on a chain. `"coach"`
* (every interactive surface: the chat, the extraction routes, the document
* routes, the connection probe) gets the whole ceiling; `"job"` (the automatic
* generators) gets `JOB_SURFACE_SHARE` of it. The cost owner is unchanged —
* this only rations how much of the owner's ceiling a background surface may
* claim.
*/
export function resolveDailyCapFor(
surface: "coach" | "job",
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
): number {
const cap = resolveDailyCap(chain);
return surface === "job" ? Math.floor(cap * JOB_SURFACE_SHARE) : cap;
}
/**
* v1.37.19 (A7-2) — does this provider spend the OPERATOR's money?
* `admin-openai` (the server's own API key) and `admin-codex` (the
@@ -32,6 +32,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-16"),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCapFor: vi.fn(() => 100_000),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { ocrExtractText: { temperature: 0, maxTokens: 2000 } },
@@ -99,6 +100,8 @@ beforeEach(() => {
allowed: true,
reserved: 10,
totalAfter: 10,
owner: "operator",
operatorAfter: 10,
} as Awaited<ReturnType<typeof reserveBudget>>);
mockExtract.mockResolvedValue({
reportDate: "2026-07-10",
@@ -29,6 +29,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 100000),
resolveDailyCapFor: vi.fn(() => 100000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
@@ -45,6 +45,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
@@ -56,6 +56,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
+7 -2
View File
@@ -32,6 +32,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { resolveIndexProvider } from "@/lib/documents/index-document";
@@ -276,13 +277,17 @@ export async function maybeAutoStageLabFacts(
}
// A text-structuring pass, not a vision call — reserve the proportionate
// ceiling under the same daily cap the auto-index resolved.
// ceiling under the cost owner the auto-index resolved.
// v1.38.19 (Wave E) — auto-staging runs off the upload, with no one waiting
// on it: a background surface, so half the day's ceiling.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
provider.dailyCap,
resolveDailyCapFor("job", [
{ providerType: provider.pick.entry.providerType },
]),
provider.costOwner,
);
if (!reservation.allowed) return { staged: false, reason: "budget" };
@@ -128,6 +128,33 @@ describe("runStatusCompletion — ledger accounting", () => {
expect(ledgerTotal).toBe(1234);
});
it("refuses a background generation at the job share while the chat still runs", async () => {
// Wave E — the automatic status/reference generators may spend at most
// half the operator ceiling, so the interactive chat still has a day left
// when the jobs have had theirs. One token past the job share:
ledgerTotal = 100_001;
ledgerOperator = 100_001;
mockProviderReply(500);
const result = await runStatusCompletion(completionArgs());
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(result.kind).toBe("error");
// The same spend, reserved by the interactive coach ceiling, is admitted.
const { reserveBudget, resolveDailyCap, resolveCostOwner } =
await import("@/lib/ai/coach/budget");
const chatChain = [{ providerType: "admin-openai" as const }];
const chat = await reserveBudget(
"u1",
3_000,
"2026-09-11",
resolveDailyCap(chatChain),
resolveCostOwner(chatChain),
);
expect(chat.allowed).toBe(true);
});
it("charges the reservation when the provider reports no token count", async () => {
mockProviderReply(null);
+5 -4
View File
@@ -14,12 +14,12 @@
*
* This is the same accounting the status tier uses, not a second mechanism:
* `reserveBudget` (one atomic upsert-increment, no read-then-write window) +
* `resolveDailyCap(chain)` for the cost owner + `reconcileSpend` against the
* `resolveDailyCapFor("job", chain)` for the cost owner + `reconcileSpend` against the
* provider's reported count. Every provider call on the briefing path routes
* through here, so a retry is reserved and charged like any other call — a
* user at the ceiling does not get a free correction pass.
*
* The cap follows the COST OWNER, not the surface. `resolveDailyCap` charges
* The cap follows the COST OWNER. `resolveDailyCapFor` charges
* the operator ceiling only when the chain's primary is the operator's own
* credential (`admin-openai` / `admin-codex`); a self-hoster on their own key,
* their own ChatGPT plan, or a local model is measured against the generous
@@ -40,7 +40,7 @@ import {
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { singleUserTurn } from "@/lib/ai/types";
import { annotate } from "@/lib/logging/context";
@@ -122,7 +122,8 @@ export async function runBriefingCompletion(
args.userId,
estimatedTokens,
dateKey,
resolveDailyCap(args.chain),
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
resolveDailyCapFor("job", args.chain),
resolveCostOwner(args.chain),
);
if (!reservation.allowed) {
+4 -3
View File
@@ -14,7 +14,7 @@ import {
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { singleUserTurn } from "@/lib/ai/types";
@@ -211,7 +211,7 @@ export async function runStatusCompletion(
// prompt we are about to send — and reconcile against the provider's reported
// count afterwards, refunding in full when nothing was generated.
//
// The cap follows the COST OWNER, not the surface: `resolveDailyCap` charges
// The cap follows the COST OWNER: `resolveDailyCapFor` charges
// the operator ceiling only when the chain's primary is the operator's own
// credential (`admin-openai` / `admin-codex`). A self-hoster on their own key
// or a local model is measured against the generous user-plan ceiling, so
@@ -223,7 +223,8 @@ export async function runStatusCompletion(
userId,
estimatedTokens,
dateKey,
resolveDailyCap(chain),
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
resolveDailyCapFor("job", chain),
resolveCostOwner(chain),
);
if (!reservation.allowed) {
@@ -76,6 +76,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
@@ -35,6 +35,7 @@ const budgetMocks = vi.hoisted(() => ({
})),
reconcileSpend: vi.fn(async () => {}),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
@@ -49,6 +49,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
@@ -50,6 +50,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
@@ -88,6 +88,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: (...a: unknown[]) => reserveBudget(...a),
reconcileSpend: (...a: unknown[]) => reconcileSpend(...a),
resolveDailyCap: () => 200_000,
resolveDailyCapFor: () => 200_000,
resolveCostOwner: () => "operator" as const,
}));
+3 -2
View File
@@ -44,7 +44,7 @@ import {
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { singleUserTurn } from "@/lib/ai/types";
@@ -191,7 +191,8 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
const budget = AI_BUDGETS.coachNudge;
const maxTokens = budget.maxTokens ?? 160;
const dailyCap = resolveDailyCap(chain);
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
const dailyCap = resolveDailyCapFor("job", chain);
const dateKey = buildDateKey();
// Per-user budget gate (atomic reserve; refunds itself on refusal).
@@ -39,7 +39,7 @@ import {
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import {
@@ -200,7 +200,10 @@ export async function runContentIndexBackfillForUser(
// leaves every PDF out of "index all documents".
const candidateMimes = [...IMAGE_MIMES, "application/pdf"];
const dailyCap = resolveDailyCap([{ providerType: pick.entry.providerType }]);
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
const dailyCap = resolveDailyCapFor("job", [
{ providerType: pick.entry.providerType },
]);
let indexed = 0;
let skipped = 0;
let failed = stale.failed;
+3 -2
View File
@@ -31,7 +31,7 @@ import {
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import {
loadOwnedDocument,
@@ -191,7 +191,8 @@ export async function runDocumentSummaryJob(
userId,
AI_BUDGETS.documentSummary.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
resolveDailyCapFor("job", [{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
);
// Budget exhausted → skip (no local fallback for a summary).
+3 -2
View File
@@ -42,7 +42,7 @@ import {
buildDateKey,
reconcileSpend,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
type BudgetCostOwner,
} from "@/lib/ai/coach/budget";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
@@ -553,7 +553,8 @@ export async function runReactionLine(
job.userId,
revision,
buildDateKey(),
resolveDailyCap(chain),
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
resolveDailyCapFor("job", chain),
resolveCostOwner(chain),
);
} catch (err) {