feat(FN-4396): merge fusion/fn-4396
This commit is contained in:
@@ -2134,7 +2134,6 @@ describe("Workflow Steps Execution", () => {
|
||||
description: "Check for vulnerabilities",
|
||||
gateMode: "gate",
|
||||
prompt: "Scan for security issues.",
|
||||
gateMode: "gate",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -2333,7 +2332,7 @@ describe("Workflow Steps Execution", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.stringContaining("gateMode=advisory"),
|
||||
expect.stringContaining("Advisory workflow step failed"),
|
||||
);
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
@@ -2367,7 +2366,6 @@ describe("Workflow Steps Execution", () => {
|
||||
description: "Check for vulnerabilities",
|
||||
gateMode: "gate",
|
||||
prompt: "Scan for security issues.",
|
||||
gateMode: "gate",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -2466,7 +2464,6 @@ describe("Workflow Steps Execution", () => {
|
||||
description: "Check for vulnerabilities",
|
||||
gateMode: "gate",
|
||||
prompt: "Scan for security issues.",
|
||||
gateMode: "gate",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -2572,7 +2569,6 @@ describe("Workflow Steps Execution", () => {
|
||||
description: "Check for vulnerabilities",
|
||||
gateMode: "gate",
|
||||
prompt: "Scan for security issues.",
|
||||
gateMode: "gate",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
||||
44
packages/engine/src/__tests__/token-budget-enforcer.test.ts
Normal file
44
packages/engine/src/__tests__/token-budget-enforcer.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { enforceTaskTokenBudget, resolveTaskTokenBudget } from "../token-budget-enforcer.js";
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
expect(updateTask).toHaveBeenCalled();
|
||||
expect(pauseTask).toHaveBeenCalledWith("FN-1", true, undefined);
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "hard" }));
|
||||
});
|
||||
});
|
||||
88
packages/engine/src/token-budget-enforcer.ts
Normal file
88
packages/engine/src/token-budget-enforcer.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { GlobalSettings, ProjectSettings, RunMutationContext, Task } from "@fusion/core";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
const log = createLogger("token-budget-enforcer");
|
||||
|
||||
type BudgetSource = "task-override" | "project-per-size" | "project" | "global-per-size" | "global" | "none";
|
||||
|
||||
export interface ResolvedTaskTokenBudget {
|
||||
soft?: number;
|
||||
hard?: number;
|
||||
source: BudgetSource;
|
||||
}
|
||||
|
||||
export interface TokenBudgetNotification {
|
||||
kind: "soft" | "hard";
|
||||
task: Task;
|
||||
total: number;
|
||||
soft?: number;
|
||||
hard?: number;
|
||||
}
|
||||
|
||||
export interface EnforcementContext {
|
||||
projectSettings: ProjectSettings;
|
||||
globalSettings: GlobalSettings;
|
||||
runContext?: RunMutationContext;
|
||||
notify: (event: TokenBudgetNotification) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function getPerSizeBudget(task: Task, budget: ProjectSettings["taskTokenBudget"] | GlobalSettings["taskTokenBudget"]) {
|
||||
const size = task.size;
|
||||
if (!size) return undefined;
|
||||
return budget?.perSize?.[size];
|
||||
}
|
||||
|
||||
export function resolveTaskTokenBudget(
|
||||
task: Task,
|
||||
projectSettings: ProjectSettings,
|
||||
globalSettings: GlobalSettings,
|
||||
): ResolvedTaskTokenBudget {
|
||||
if (task.tokenBudgetOverride && (task.tokenBudgetOverride.soft !== undefined || task.tokenBudgetOverride.hard !== undefined)) {
|
||||
return { soft: task.tokenBudgetOverride.soft, hard: task.tokenBudgetOverride.hard, source: "task-override" };
|
||||
}
|
||||
|
||||
const projectBudget = projectSettings.taskTokenBudget;
|
||||
const projectPerSize = getPerSizeBudget(task, projectBudget);
|
||||
if (projectPerSize && (projectPerSize.soft !== undefined || projectPerSize.hard !== undefined)) {
|
||||
return { soft: projectPerSize.soft ?? projectBudget?.soft, hard: projectPerSize.hard ?? projectBudget?.hard, source: "project-per-size" };
|
||||
}
|
||||
if (projectBudget && (projectBudget.soft !== undefined || projectBudget.hard !== undefined)) {
|
||||
return { soft: projectBudget.soft, hard: projectBudget.hard, source: "project" };
|
||||
}
|
||||
|
||||
const globalBudget = globalSettings.taskTokenBudget;
|
||||
const globalPerSize = getPerSizeBudget(task, globalBudget);
|
||||
if (globalPerSize && (globalPerSize.soft !== undefined || globalPerSize.hard !== undefined)) {
|
||||
return { soft: globalPerSize.soft ?? globalBudget?.soft, hard: globalPerSize.hard ?? globalBudget?.hard, source: "global-per-size" };
|
||||
}
|
||||
if (globalBudget && (globalBudget.soft !== undefined || globalBudget.hard !== undefined)) {
|
||||
return { soft: globalBudget.soft, hard: globalBudget.hard, source: "global" };
|
||||
}
|
||||
|
||||
return { source: "none" };
|
||||
}
|
||||
|
||||
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,
|
||||
): 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;
|
||||
|
||||
if (soft !== undefined && total >= soft && !task.tokenBudgetSoftAlertedAt) {
|
||||
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 });
|
||||
}
|
||||
|
||||
if (hard !== undefined && total >= hard && !task.tokenBudgetHardAlertedAt) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user