feat(FN-4396): merge fusion/fn-4396

This commit is contained in:
gsxdsm
2026-05-14 08:24:01 -07:00
parent 28b0df85b6
commit 78b1842ddb
14 changed files with 187 additions and 30 deletions

View File

@@ -715,7 +715,7 @@ describe("schema migration", () => {
const rows = db.prepare("SELECT id, mode, gateMode FROM workflow_steps ORDER BY id ASC").all() as Array<{ id: string; mode: string; gateMode: string }>;
expect(rows).toEqual([
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "gate" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(77);

View File

@@ -348,6 +348,7 @@ describe("GlobalSettingsStore", () => {
"gridlock",
"fallback-used",
"memory-dreams-processed",
"token-budget",
]);
});

View File

@@ -158,6 +158,7 @@ describe("NotificationDispatcher", () => {
"gridlock",
"fallback-used",
"memory-dreams-processed",
"token-budget",
]);
expect(DEFAULT_GLOBAL_SETTINGS.notificationProviders).toEqual([]);
expect(DEFAULT_GLOBAL_SETTINGS.failureNotificationDelayMs).toBe(30000);

View File

@@ -190,7 +190,7 @@ describe("settings key parity", () => {
it("only intentional shared keys appear in both global and project scopes", () => {
const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key));
expect(overlap).toEqual(["githubTrackingDefaultRepo", "agentMemoryInclusionMode"]);
expect(overlap).toEqual(["taskTokenBudget", "githubTrackingDefaultRepo", "agentMemoryInclusionMode"]);
});
});

View File

@@ -2593,7 +2593,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
phase: "pre-merge",
gateMode: "advisory",
prompt: template.prompt,
gateMode: "advisory",
toolMode: template.toolMode || "readonly",
enabled: true,
createdAt: now,
@@ -2610,7 +2609,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
phase: string | null;
gateMode: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;
@@ -2631,9 +2629,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
? row.gateMode
: (row.mode === "script" ? "gate" : "advisory"),
prompt: row.prompt || "",
gateMode: row.gateMode === "gate" || row.gateMode === "advisory"
? row.gateMode
: (row.mode === "script" ? "gate" : "advisory"),
toolMode: row.toolMode === "coding" || row.toolMode === "readonly" ? row.toolMode : undefined,
scriptName: row.scriptName ?? undefined,
enabled: Boolean(row.enabled),
@@ -7202,7 +7197,6 @@ ${stepsSection}`;
phase: input.phase || "pre-merge",
gateMode,
prompt: mode === "prompt" ? (input.prompt || "") : "",
gateMode: input.gateMode || (mode === "script" ? "gate" : "advisory"),
toolMode: mode === "prompt" ? (input.toolMode || "readonly") : undefined,
scriptName: mode === "script" ? input.scriptName : undefined,
enabled: input.enabled !== undefined ? input.enabled : true,
@@ -7223,7 +7217,6 @@ ${stepsSection}`;
phase,
gateMode,
prompt,
gateMode,
toolMode,
scriptName,
enabled,
@@ -7242,7 +7235,6 @@ ${stepsSection}`;
step.phase || "pre-merge",
step.gateMode,
step.prompt,
step.gateMode ?? (step.mode === "script" ? "gate" : "advisory"),
step.toolMode ?? null,
step.scriptName ?? null,
step.enabled ? 1 : 0,
@@ -7284,9 +7276,8 @@ ${stepsSection}`;
description: entry.template.description,
mode: entry.template.mode ?? "prompt",
phase: entry.template.phase ?? "pre-merge",
gateMode: (entry.template.mode ?? "prompt") === "script" ? "gate" : "advisory",
prompt: entry.template.prompt ?? "",
gateMode: entry.template.gateMode ?? ((entry.template.mode ?? "prompt") === "script" ? "gate" : "advisory"),
prompt: entry.template.prompt ?? "",
scriptName: entry.template.scriptName,
toolMode: entry.template.toolMode,
enabled: entry.template.enabled ?? true,
@@ -7351,7 +7342,6 @@ ${stepsSection}`;
phase: string | null;
gateMode: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;
@@ -7378,7 +7368,6 @@ ${stepsSection}`;
phase: string | null;
gateMode: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;
@@ -7412,7 +7401,6 @@ ${stepsSection}`;
phase: string | null;
gateMode: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;

View File

@@ -242,7 +242,6 @@ export type WorkflowStepGateMode = "gate" | "advisory";
/** Lifecycle phase for workflow step execution. */
export type WorkflowStepPhase = "pre-merge" | "post-merge";
export type WorkflowStepGateMode = "gate" | "advisory";
export interface WorkflowStep {
/** Unique identifier (e.g., "WS-001") */
@@ -261,8 +260,6 @@ export interface WorkflowStep {
gateMode: WorkflowStepGateMode;
/** Full agent prompt to execute when this step runs (used when mode is "prompt") */
prompt: string;
/** Whether failures should block merge (`gate`) or be informational (`advisory`). */
gateMode?: WorkflowStepGateMode;
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
toolMode?: WorkflowStepToolMode;
/** Name of a script from project settings `scripts` map to execute (required when mode is "script") */
@@ -363,8 +360,6 @@ export interface WorkflowStepInput {
gateMode?: WorkflowStepGateMode;
/** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */
prompt?: string;
/** Failure behavior. Defaults to advisory for prompt mode and gate for script mode. */
gateMode?: WorkflowStepGateMode;
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
toolMode?: WorkflowStepToolMode;
/** Script name from project settings (required when mode is "script").

View File

@@ -228,8 +228,8 @@ describe("WorkflowResultsTab", () => {
const polishNotes = screen.getByTestId("workflow-polish-notes");
expect(polishNotes).toHaveTextContent("Polish notes");
expect(polishNotes).toHaveTextContent("Non-blocking findings");
expect(screen.getByTestId("workflow-polish-note-WS-006")).toHaveTextContent("Frontend UX Design");
expect(polishNotes).toHaveTextContent("non-blocking improvements");
expect(polishNotes).toHaveTextContent("Frontend UX Design");
const summary = screen.getByTestId("workflow-results-summary");
expect(summary).toHaveTextContent("1 advisory");

View File

@@ -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(),

View 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" }));
});
});

View 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 });
}
}