FN-8056: enforce task token budgets

Enforce configured task token budgets whenever session usage is persisted.

- Apply soft alerts and hard pauses atomically from all executor persistence paths.
- Exclude cache-read tokens from budget usage and dispatch budget notifications once.
- Document budget semantics and add regression coverage.

Files changed: .changeset/fn-8056-token-budget-enforcement.md     |   7 ++
 docs/settings-reference.md                         |   2 +
 packages/core/src/types.ts                         |   4 +-
 .../src/__tests__/session-token-usage.test.ts      | 101 ++++++++++++++++++++-
 .../src/__tests__/token-budget-enforcer.test.ts    |  81 ++++++++++-------
 packages/engine/src/executor.ts                    |  22 ++++-
 packages/engine/src/session-token-usage.ts         |   8 +-
 packages/engine/src/token-budget-enforcer.ts       |  98 +++++++++++++++++---
 8 files changed, 262 insertions(+), 61 deletions(-)

Fusion-Task-Id: FN-8056

Fusion-Task-Lineage: 5f5ed522-f950-42ce-b4fd-e0b1d45b5815

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 01:52:10 -07:00
parent d501c3b207
commit 274318aebf
8 changed files with 263 additions and 62 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Per-task token budgets now enforce — soft caps alert once and hard caps pause the task.
category: fix
dev: Wires persist-time enforcement and token-budget notifications; budgets exclude cache-read tokens.

View File

@@ -762,6 +762,8 @@ Backlog health is the alert family for scheduler/backlog imbalance, dependency-b
4. Global per-size (`global.taskTokenBudget.perSize[task.size]`)
5. Global base (`global.taskTokenBudget.soft/hard`)
Budgets measure **input + output + cache-write tokens**. Cache-read tokens are deliberately excluded: reading a cached prompt can be very large without representing newly processed model work. A soft cap records one alert timestamp and dispatches one `token-budget` notification; a hard cap records its timestamp, pauses the task with `pausedReason: "token_budget_exceeded"`, then dispatches one notification. These transitions are atomic and are not repeated on later token persists.
Example:
```json

View File

@@ -1111,9 +1111,9 @@ export interface TaskTokenUsage {
}
export interface TaskTokenBudget {
/** Total-token soft cap. When reached, emits one notification and continues. */
/** Input, output, and cache-write token soft cap (cache reads excluded). When reached, emits one notification and continues. */
soft?: number;
/** Total-token hard cap. When reached, pauses the task with pausedReason="token_budget_exceeded". */
/** Input, output, and cache-write token hard cap (cache reads excluded). When reached, pauses the task with pausedReason="token_budget_exceeded". */
hard?: number;
/** Optional per-size overrides keyed by Task.size (S/M/L). Falls back to soft/hard when absent. */
perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } };

View File

@@ -1,8 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { accumulateSessionTokenUsage, computeCacheHitRatio } from "../session-token-usage.js";
import { enforceTaskTokenBudgetForPersist } from "../token-budget-enforcer.js";
import { TaskExecutor } from "../executor.js";
const { notificationService } = vi.hoisted(() => ({ notificationService: { dispatch: vi.fn() } }));
vi.mock("../notifier.js", () => ({ getActiveNotificationService: () => notificationService }));
interface MockSessionStats {
tokens?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number };
}
@@ -14,17 +18,32 @@ function createSession(
return { getSessionStats: vi.fn(() => stats), ...(model ? { model } : {}) } as unknown as Parameters<typeof accumulateSessionTokenUsage>[2];
}
function createStore(initial: Task["tokenUsage"]): TaskStore & { _task: Task; updateTask: ReturnType<typeof vi.fn> } {
const task = { id: "FN-1", tokenUsage: initial } as Task;
function createStore(initial: Task["tokenUsage"], budget?: { soft?: number; hard?: number }): TaskStore & { _task: Task; updateTask: ReturnType<typeof vi.fn>; pauseTask: ReturnType<typeof vi.fn> } {
const task = { id: "FN-1", title: "Budget task", tokenUsage: initial } as Task;
const updateTask = vi.fn(async (_id: string, updates: Partial<Task>) => {
if (updates.tokenUsage !== undefined) task.tokenUsage = updates.tokenUsage as Task["tokenUsage"];
Object.assign(task, updates);
return task;
});
const pauseTask = vi.fn(async () => task);
// Model TaskStore's atomic transaction boundary: each updater observes the latest row.
let atomicQueue: Promise<void> = Promise.resolve();
const updateTaskAtomic = vi.fn((_id: string, updater: (current: Task) => Partial<Task> | null) => {
const result = atomicQueue.then(async () => {
const patch = await updater(task);
if (patch) Object.assign(task, patch);
return task;
});
atomicQueue = result.then(() => undefined, () => undefined);
return result;
});
const store = {
_task: task,
getTask: vi.fn(async () => task),
getSettingsByScope: vi.fn(async () => ({ project: budget ? { taskTokenBudget: budget } : {}, global: {} })),
updateTask,
} as unknown as TaskStore & { _task: Task; updateTask: ReturnType<typeof vi.fn> };
updateTaskAtomic,
pauseTask,
} as unknown as TaskStore & { _task: Task; updateTask: ReturnType<typeof vi.fn>; pauseTask: ReturnType<typeof vi.fn> };
return store;
}
@@ -32,6 +51,7 @@ describe("accumulateSessionTokenUsage", () => {
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
notificationService.dispatch.mockReset();
});
it("writes initial token usage and emits cache metrics log", async () => {
@@ -155,6 +175,7 @@ describe("accumulateSessionTokenUsage", () => {
executor.store = store;
executor.tokenUsageBaselines = new Map();
executor.activeSessions = new Map();
executor.currentRunContexts = new Map();
await executor.persistTokenUsage("FN-1", {
getSessionStats: () => ({ tokens: { input: 3, output: 2, cacheRead: 1, cacheWrite: 0, total: 6 } }),
@@ -167,6 +188,78 @@ describe("accumulateSessionTokenUsage", () => {
expect(call.tokenUsage).toMatchObject({ modelProvider: "mock", modelId: "scripted" });
});
it("enforces soft and hard budgets through the real persist helper exactly once", async () => {
const store = createStore(undefined, { soft: 10, hard: 20 });
const session = createSession({ tokens: { input: 12, output: 0, cacheRead: 0, cacheWrite: 0 } });
await accumulateSessionTokenUsage(store, "FN-1", session);
session.getSessionStats.mockReturnValue({ tokens: { input: 25, output: 0, cacheRead: 0, cacheWrite: 0 } });
await accumulateSessionTokenUsage(store, "FN-1", session);
session.getSessionStats.mockReturnValue({ tokens: { input: 30, output: 0, cacheRead: 0, cacheWrite: 0 } });
await accumulateSessionTokenUsage(store, "FN-1", session);
expect(store._task.tokenBudgetSoftAlertedAt).toBeTruthy();
expect(store._task.tokenBudgetHardAlertedAt).toBeTruthy();
expect(store.pauseTask).toHaveBeenCalledOnce();
expect(store.pauseTask).toHaveBeenCalledWith("FN-1", true, undefined, { pausedReason: "token_budget_exceeded" });
expect(notificationService.dispatch).toHaveBeenCalledTimes(2);
expect(notificationService.dispatch).toHaveBeenNthCalledWith(1, "token-budget", expect.objectContaining({ metadata: expect.objectContaining({ kind: "soft" }) }));
expect(notificationService.dispatch).toHaveBeenNthCalledWith(2, "token-budget", expect.objectContaining({ metadata: expect.objectContaining({ kind: "hard" }) }));
});
it("atomically claims concurrent soft and hard enforcement once", async () => {
const store = createStore({ inputTokens: 25, outputTokens: 0, cacheWriteTokens: 0, totalTokens: 25 }, { soft: 10, hard: 20 });
await Promise.all(Array.from({ length: 8 }, () => enforceTaskTokenBudgetForPersist(store, "FN-1")));
expect(store.pauseTask).toHaveBeenCalledOnce();
expect(notificationService.dispatch).toHaveBeenCalledTimes(2);
expect(store._task.tokenBudgetSoftAlertedAt).toBeTruthy();
expect(store._task.tokenBudgetHardAlertedAt).toBeTruthy();
});
it("enforces direct executor token persistence through its shared seam", async () => {
const store = createStore(undefined, { hard: 10 });
const executor = Object.create(TaskExecutor.prototype) as any;
executor.store = store;
executor.currentRunContexts = new Map();
await executor.persistTaskTokenUsage("FN-1", { inputTokens: 20, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 20 });
expect(store.pauseTask).toHaveBeenCalledOnce();
expect(notificationService.dispatch).toHaveBeenCalledWith("token-budget", expect.objectContaining({ metadata: expect.objectContaining({ kind: "hard" }) }));
});
it("retries a hard pause after a failed persisted enforcement attempt", async () => {
const store = createStore({ inputTokens: 20, outputTokens: 0, cacheWriteTokens: 0, totalTokens: 20 }, { hard: 10 });
store.pauseTask.mockRejectedValueOnce(new Error("temporary pause failure"));
await expect(enforceTaskTokenBudgetForPersist(store, "FN-1")).resolves.toBeUndefined();
expect(store._task.tokenBudgetHardAlertedAt).toBeNull();
await enforceTaskTokenBudgetForPersist(store, "FN-1");
expect(store.pauseTask).toHaveBeenCalledTimes(2);
expect(store._task.tokenBudgetHardAlertedAt).toBeTruthy();
expect(notificationService.dispatch).toHaveBeenCalledOnce();
});
it("keeps a successful hard pause durable when notification dispatch fails", async () => {
notificationService.dispatch.mockRejectedValueOnce(new Error("notification unavailable"));
const store = createStore(undefined, { hard: 10 });
await expect(accumulateSessionTokenUsage(store, "FN-1", createSession({ tokens: { input: 20, output: 0, cacheRead: 0, cacheWrite: 0 } }))).resolves.toBeUndefined();
expect(store.pauseTask).toHaveBeenCalledOnce();
expect(store._task.tokenBudgetHardAlertedAt).toBeTruthy();
});
it("does not enforce when no budget is configured", async () => {
const store = createStore(undefined);
await accumulateSessionTokenUsage(store, "FN-1", createSession({ tokens: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0 } }));
expect(store.pauseTask).not.toHaveBeenCalled();
expect(notificationService.dispatch).not.toHaveBeenCalled();
});
it("swallows store errors instead of throwing", async () => {
const store = createStore(undefined);
(store.updateTask as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("db down"));

View File

@@ -1,44 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import { enforceTaskTokenBudget, resolveTaskTokenBudget } from "../token-budget-enforcer.js";
import { enforceTaskTokenBudget, getTokenBudgetUsage, resolveTaskTokenBudget } from "../token-budget-enforcer.js";
const task = (patch: Record<string, unknown> = {}) => ({
id: "FN-1", description: "x", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "", updatedAt: "", ...patch,
}) as any;
describe("resolveTaskTokenBudget", () => {
it("prefers task override", () => {
const result = resolveTaskTokenBudget(
{ id: "FN-1", description: "x", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "", updatedAt: "", tokenBudgetOverride: { soft: 10, hard: 20 } } as any,
{ taskTokenBudget: { soft: 100, hard: 200 } } as any,
{ taskTokenBudget: { soft: 1000, hard: 2000 } } as any,
);
expect(result).toEqual({ soft: 10, hard: 20, source: "task-override" });
it.each([
["task override", task({ size: "M", tokenBudgetOverride: { soft: 1, hard: 2 } }), { taskTokenBudget: { soft: 10, hard: 20, perSize: { M: { soft: 11 } } } }, { taskTokenBudget: { soft: 100 } }, { soft: 1, hard: 2, source: "task-override" }],
["project per-size", task({ size: "M" }), { taskTokenBudget: { soft: 10, hard: 20, perSize: { M: { soft: 11 } } } }, { taskTokenBudget: { soft: 100 } }, { soft: 11, hard: 20, source: "project-per-size" }],
["project base", task(), { taskTokenBudget: { soft: 10, hard: 20 } }, { taskTokenBudget: { soft: 100 } }, { soft: 10, hard: 20, source: "project" }],
["global per-size", task({ size: "M" }), {}, { taskTokenBudget: { soft: 100, hard: 200, perSize: { M: { hard: 201 } } } }, { soft: 100, hard: 201, source: "global-per-size" }],
["global base", task(), {}, { taskTokenBudget: { soft: 100, hard: 200 } }, { soft: 100, hard: 200, source: "global" }],
["none", task(), {}, {}, { source: "none" }],
])("uses %s precedence", (_name, subject, project, global, expected) => {
expect(resolveTaskTokenBudget(subject, project as any, global as any)).toEqual(expected);
});
});
describe("enforceTaskTokenBudget", () => {
it("fires soft once and hard pause once", async () => {
const updateTask = vi.fn(async () => undefined);
const pauseTask = vi.fn(async () => undefined);
const notify = vi.fn(async () => undefined);
const task = {
id: "FN-1",
description: "x",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
createdAt: "",
updatedAt: "",
tokenUsage: { totalTokens: 150 },
} as any;
await enforceTaskTokenBudget({
store: { updateTask, pauseTask },
task,
projectSettings: { taskTokenBudget: { soft: 100, hard: 140 } } as any,
globalSettings: {} as any,
notify,
it("claims soft and hard caps once and pauses through pauseTask", async () => {
const current = task({ tokenUsage: { inputTokens: 150, outputTokens: 0, cacheWriteTokens: 0 } });
const updateTaskAtomic = vi.fn(async (_id, updater) => {
const patch = await updater(current);
if (patch) Object.assign(current, patch);
return current;
});
const pauseTask = vi.fn(async () => current);
const notify = vi.fn(async () => undefined);
expect(updateTask).toHaveBeenCalled();
expect(pauseTask).toHaveBeenCalledWith("FN-1", true, undefined);
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "hard" }));
await enforceTaskTokenBudget({ store: { updateTaskAtomic, pauseTask } as any, task: current, projectSettings: { taskTokenBudget: { soft: 100, hard: 140 } } as any, globalSettings: {} as any, notify });
await enforceTaskTokenBudget({ store: { updateTaskAtomic, pauseTask } as any, task: current, projectSettings: { taskTokenBudget: { soft: 100, hard: 140 } } as any, globalSettings: {} as any, notify });
expect(pauseTask).toHaveBeenCalledOnce();
expect(pauseTask).toHaveBeenCalledWith("FN-1", true, undefined, { pausedReason: "token_budget_exceeded" });
expect(notify).toHaveBeenCalledTimes(2);
});
it("releases a hard claim when pause fails so a later persist retries", async () => {
const current = task({ tokenUsage: { inputTokens: 25 } });
const updateTaskAtomic = vi.fn(async (_id, updater) => {
const patch = await updater(current);
if (patch) Object.assign(current, patch);
return current;
});
const pauseTask = vi.fn().mockRejectedValueOnce(new Error("transient")).mockResolvedValueOnce(current);
const notify = vi.fn(async () => undefined);
const params = { store: { updateTaskAtomic, pauseTask } as any, task: current, projectSettings: { taskTokenBudget: { hard: 20 } } as any, globalSettings: {} as any, notify };
await expect(enforceTaskTokenBudget(params)).rejects.toThrow("transient");
expect(current.tokenBudgetHardAlertedAt).toBeNull();
await enforceTaskTokenBudget(params);
expect(pauseTask).toHaveBeenCalledTimes(2);
expect(notify).toHaveBeenCalledOnce();
});
it("excludes cache reads from the budget basis", () => {
expect(getTokenBudgetUsage({ inputTokens: 10, outputTokens: 20, cacheWriteTokens: 30, cachedTokens: 9_999, totalTokens: 10_059 } as any)).toBe(60);
});
});

View File

@@ -90,6 +90,7 @@ import { Type, type Static } from "@earendil-works/pi-ai";
import { describeModel, formatModelMarkerDetails, promptWithFallback, compactSessionContext } from "./pi.js";
import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js";
import { accumulateSessionTokenUsage, mergeTokenUsagePerModel } from "./session-token-usage.js";
import { enforceTaskTokenBudgetForPersist } from "./token-budget-enforcer.js";
import {
createResolvedAgentSession,
extractRuntimeHint,
@@ -4207,6 +4208,17 @@ export class TaskExecutor {
}
}
/**
* FNXC:TokenBudget 2026-07-16-00:00:
* Step-session token usage bypasses the shared session helper, so all executor
* writes use this seam to retain the required persist-time budget enforcement.
*/
private async persistTaskTokenUsage(taskId: string, tokenUsage: TaskTokenUsage): Promise<void> {
const runContext = this.getRunContextFor(taskId);
await this.store.updateTask(taskId, { tokenUsage }, runContext);
await enforceTaskTokenBudgetForPersist(this.store, taskId, runContext);
}
private async persistTokenUsage(taskId: string, session?: AgentSession): Promise<void> {
const activeSession = session ?? this.activeSessions.get(taskId)?.session;
const currentUsage = await this.extractSessionTokenUsage(activeSession);
@@ -4250,7 +4262,7 @@ export class TaskExecutor {
hitRatio: tokenUsage.inputTokens + tokenUsage.cachedTokens > 0 ? tokenUsage.cachedTokens / (tokenUsage.inputTokens + tokenUsage.cachedTokens) : 0,
}));
await this.store.updateTask(taskId, { tokenUsage });
await this.persistTaskTokenUsage(taskId, tokenUsage);
}
/**
@@ -10482,7 +10494,7 @@ export class TaskExecutor {
return;
}
this.store.updateTask(task.id, { tokenUsage: accumulatedStepTokenUsage }).catch((err) => {
this.persistTaskTokenUsage(task.id, accumulatedStepTokenUsage).catch((err) => {
executorLog.warn(`${task.id}: failed to persist token usage on step ${stepIndex} complete: ${err}`);
});
},
@@ -10531,7 +10543,7 @@ export class TaskExecutor {
}
if (accumulatedStepTokenUsage) {
await this.store.updateTask(task.id, { tokenUsage: accumulatedStepTokenUsage });
await this.persistTaskTokenUsage(task.id, accumulatedStepTokenUsage);
}
const allSuccess = results.every(r => r.success);
@@ -10850,13 +10862,13 @@ export class TaskExecutor {
nextRecoveryAt: null,
});
if (accumulatedStepTokenUsage) {
await this.store.updateTask(task.id, { tokenUsage: accumulatedStepTokenUsage });
await this.persistTaskTokenUsage(task.id, accumulatedStepTokenUsage);
}
executorLog.log(`✗ ${task.id} transient retries exhausted — failed in execution`);
this.options.onError?.(task, err instanceof Error ? err : new Error(errorMessage));
} else {
if (accumulatedStepTokenUsage) {
await this.store.updateTask(task.id, { tokenUsage: accumulatedStepTokenUsage });
await this.persistTaskTokenUsage(task.id, accumulatedStepTokenUsage);
}
if (await this.handleNonContinuableSessionError(task, false, errorMessage)) {
return;

View File

@@ -1,6 +1,7 @@
import type { AgentRole, TaskStore, TaskTokenUsage, TaskTokenUsagePerModel } from "@fusion/core";
import type { AgentRole, RunMutationContext, TaskStore, TaskTokenUsage, TaskTokenUsagePerModel } from "@fusion/core";
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import { createLogger } from "./logger.js";
import { enforceTaskTokenBudgetForPersist } from "./token-budget-enforcer.js";
const log = createLogger("session-token-usage");
const cacheMetricsLog = createLogger("token-cache-metrics");
@@ -83,7 +84,7 @@ export async function accumulateSessionTokenUsage(
store: TaskStore,
taskId: string,
session: AgentSession,
options?: { agentId?: string; role?: AgentRole },
options?: { agentId?: string; role?: AgentRole; runContext?: RunMutationContext },
): Promise<void> {
try {
const stats = readSessionStats(session);
@@ -156,7 +157,8 @@ export async function accumulateSessionTokenUsage(
hitRatio: computeCacheHitRatio(tokenUsage.inputTokens, tokenUsage.cachedTokens),
}));
await store.updateTask(taskId, { tokenUsage });
await store.updateTask(taskId, { tokenUsage }, options?.runContext);
await enforceTaskTokenBudgetForPersist(store, taskId, options?.runContext);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`${taskId}: session token usage accumulate failed: ${message}`);

View File

@@ -1,10 +1,13 @@
import type { GlobalSettings, ProjectSettings, RunMutationContext, Task } from "@fusion/core";
import type { GlobalSettings, ProjectSettings, RunMutationContext, Task, TaskStore } from "@fusion/core";
import { createLogger } from "./logger.js";
import { getActiveNotificationService } from "./notifier.js";
const log = createLogger("token-budget-enforcer");
type BudgetSource = "task-override" | "project-per-size" | "project" | "global-per-size" | "global" | "none";
type TokenBudgetStore = Pick<TaskStore, "getTask" | "getSettingsByScope" | "updateTaskAtomic" | "pauseTask">;
export interface ResolvedTaskTokenBudget {
soft?: number;
hard?: number;
@@ -62,27 +65,92 @@ export function resolveTaskTokenBudget(
return { source: "none" };
}
/**
* FNXC:TokenBudget 2026-07-16-00:00:
* Budget limits measure newly processed input/output plus cache writes, not cache reads.
* Cache reads can dominate a session without representing new model work, so using them
* would unexpectedly pause existing tasks calibrated for actual work tokens.
*/
export function getTokenBudgetUsage(tokenUsage: Task["tokenUsage"]): number {
return (tokenUsage?.inputTokens ?? 0) + (tokenUsage?.outputTokens ?? 0) + (tokenUsage?.cacheWriteTokens ?? 0);
}
export async function enforceTaskTokenBudget(
params: { store: { updateTask: (id: string, updates: Record<string, unknown>, runContext?: RunMutationContext) => Promise<unknown>; pauseTask: (id: string, paused: boolean, runContext?: RunMutationContext) => Promise<unknown> }; task: Task } & EnforcementContext,
params: { store: Pick<TokenBudgetStore, "updateTaskAtomic" | "pauseTask">; task: Task } & EnforcementContext,
): Promise<void> {
const { store, task, projectSettings, globalSettings, runContext, notify } = params;
const total = task.tokenUsage?.totalTokens ?? 0;
const resolved = resolveTaskTokenBudget(task, projectSettings, globalSettings);
const { soft, hard } = resolved;
const total = getTokenBudgetUsage(task.tokenUsage);
const { soft, hard } = resolveTaskTokenBudget(task, projectSettings, globalSettings);
if (soft !== undefined && total >= soft && !task.tokenBudgetSoftAlertedAt) {
if (soft !== undefined && total >= soft) {
const now = new Date().toISOString();
await store.updateTask(task.id, { tokenBudgetSoftAlertedAt: now }, runContext);
log.warn(`${task.id}: soft token budget reached (${total}/${soft})`);
await notify({ kind: "soft", task, total, soft, hard });
let claimedSoft = false;
await store.updateTaskAtomic(task.id, (current) => {
if (current.tokenBudgetSoftAlertedAt) return null;
claimedSoft = true;
return { tokenBudgetSoftAlertedAt: now };
}, runContext);
if (claimedSoft) {
log.warn(`${task.id}: soft token budget reached (${total}/${soft})`);
await notify({ kind: "soft", task, total, soft, hard });
}
}
if (hard !== undefined && total >= hard && !task.tokenBudgetHardAlertedAt) {
if (hard !== undefined && total >= hard) {
const now = new Date().toISOString();
await store.updateTask(task.id, { tokenBudgetHardAlertedAt: now }, runContext);
await store.pauseTask(task.id, true, runContext);
await store.updateTask(task.id, { pausedReason: "token_budget_exceeded" }, runContext);
log.error(`${task.id}: hard token budget reached (${total}/${hard}), task paused`);
await notify({ kind: "hard", task, total, soft, hard });
let claimedHard = false;
await store.updateTaskAtomic(task.id, (current) => {
if (current.tokenBudgetHardAlertedAt) return null;
claimedHard = true;
return { tokenBudgetHardAlertedAt: now };
}, runContext);
if (claimedHard) {
try {
await store.pauseTask(task.id, true, runContext, { pausedReason: "token_budget_exceeded" });
} catch (err) {
await store.updateTaskAtomic(task.id, (current) =>
current.tokenBudgetHardAlertedAt === now ? { tokenBudgetHardAlertedAt: null } : null,
runContext).catch(() => {});
throw err;
}
log.error(`${task.id}: hard token budget reached (${total}/${hard}), task paused`);
await notify({ kind: "hard", task, total, soft, hard });
}
}
}
/**
* FNXC:TokenBudget 2026-07-16-00:00:
* FN-8056 found the enforcer was dead code; every persisted task.tokenUsage must enter
* this best-effort helper so documented soft alerts and hard pauses remain live.
*/
export async function enforceTaskTokenBudgetForPersist(
store: TokenBudgetStore,
taskId: string,
runContext?: RunMutationContext,
): Promise<void> {
try {
const [task, settings] = await Promise.all([store.getTask(taskId), store.getSettingsByScope()]);
await enforceTaskTokenBudget({
store,
task,
projectSettings: settings.project as ProjectSettings,
globalSettings: settings.global,
runContext,
notify: async ({ kind, task: notifiedTask, total, soft, hard }) => {
const notificationService = getActiveNotificationService();
if (!notificationService) return;
await notificationService.dispatch("token-budget", {
taskId: notifiedTask.id,
taskTitle: notifiedTask.title,
event: "token-budget",
timestamp: new Date().toISOString(),
metadata: { kind, total, soft, hard },
});
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`${taskId}: token budget enforcement failed: ${message}`);
}
}