feat(FN-4396): merge fusion/fn-4396
This commit is contained in:
5
.changeset/fn-4396-task-token-budget.md
Normal file
5
.changeset/fn-4396-task-token-budget.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add per-task token-budget alerts. Soft cap emits a single notification; hard cap pauses the task with `pausedReason: token_budget_exceeded`. New project/global setting `taskTokenBudget` with optional per-size (S/M/L) overrides; new per-task `tokenBudgetOverride` set on resume. New optional `token-budget` ntfy event.
|
||||||
11
AGENTS.md
11
AGENTS.md
@@ -589,6 +589,17 @@ See [docs/settings-reference.md](./docs/settings-reference.md) for the complete
|
|||||||
5. Global `defaultProvider`/`defaultModelId`
|
5. Global `defaultProvider`/`defaultModelId`
|
||||||
6. Automatic provider/model resolution
|
6. Automatic provider/model resolution
|
||||||
|
|
||||||
|
### Per-Task Token Budget Precedence
|
||||||
|
|
||||||
|
Task token budgets resolve in this order:
|
||||||
|
1. Per-task `task.tokenBudgetOverride`
|
||||||
|
2. Project `taskTokenBudget.perSize[task.size]`
|
||||||
|
3. Project `taskTokenBudget.soft/hard`
|
||||||
|
4. Global `taskTokenBudget.perSize[task.size]`
|
||||||
|
5. Global `taskTokenBudget.soft/hard`
|
||||||
|
|
||||||
|
Hard-cap hits pause the task with `pausedReason: "token_budget_exceeded"`; soft-cap hits emit a one-shot alert per task.
|
||||||
|
|
||||||
## Per-Task Model Overrides
|
## Per-Task Model Overrides
|
||||||
|
|
||||||
Tasks can override project/global AI model settings on a per-task basis:
|
Tasks can override project/global AI model settings on a per-task basis:
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
||||||
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
||||||
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
||||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
|
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "token-budget")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `token-budget` fires when a task crosses token soft/hard caps. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
|
||||||
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
||||||
|
| `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Global fallback per-task token budget policy. Project `taskTokenBudget` overrides this. |
|
||||||
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
||||||
|
|
||||||
In **Settings → Notifications**, use **Test message inbox** or **Test room reply** to exercise the full message-dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
|
In **Settings → Notifications**, use **Test message inbox** or **Test room reply** to exercise the full message-dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
|
||||||
@@ -322,6 +323,7 @@ Override precedence for direct merges is:
|
|||||||
| `memoryDreamsEnabled` | `boolean` | `false` | Enable dream processing that synthesizes daily notes and promotes durable lessons. |
|
| `memoryDreamsEnabled` | `boolean` | `false` | Enable dream processing that synthesizes daily notes and promotes durable lessons. |
|
||||||
| `memoryDreamsSchedule` | `string` | `"0 4 * * *"` | Cron schedule for dream processing. |
|
| `memoryDreamsSchedule` | `string` | `"0 4 * * *"` | Cron schedule for dream processing. |
|
||||||
| `tokenCap` | `number` | `undefined` | Proactive token threshold for context compaction. |
|
| `tokenCap` | `number` | `undefined` | Proactive token threshold for context compaction. |
|
||||||
|
| `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Per-task token budget policy. Soft cap sends a one-time alert per task; hard cap pauses the task with `pausedReason: "token_budget_exceeded"`. |
|
||||||
| `runStepsInNewSessions` | `boolean` | `false` | Run each task step in a fresh agent session. |
|
| `runStepsInNewSessions` | `boolean` | `false` | Run each task step in a fresh agent session. |
|
||||||
| `maxParallelSteps` | `number` | `2` | Max concurrent step sessions when per-step sessions are enabled. |
|
| `maxParallelSteps` | `number` | `2` | Max concurrent step sessions when per-step sessions are enabled. |
|
||||||
| `missionStaleThresholdMs` | `number` | `600000` | Mission stale threshold in ms while `activating` (10 min). |
|
| `missionStaleThresholdMs` | `number` | `600000` | Mission stale threshold in ms while `activating` (10 min). |
|
||||||
@@ -344,6 +346,32 @@ Override precedence for direct merges is:
|
|||||||
| `researchMaxSourcesPerRun` | `number` | `undefined` | Project-level max sources per run. |
|
| `researchMaxSourcesPerRun` | `number` | `undefined` | Project-level max sources per run. |
|
||||||
| `researchMaxSynthesisRounds` | `number` | `undefined` | Project-level max synthesis rounds. |
|
| `researchMaxSynthesisRounds` | `number` | `undefined` | Project-level max synthesis rounds. |
|
||||||
|
|
||||||
|
### Per-task token budget
|
||||||
|
|
||||||
|
`taskTokenBudget` can be configured in both global and project settings. Resolution precedence at runtime is:
|
||||||
|
|
||||||
|
1. Task override (`task.tokenBudgetOverride`)
|
||||||
|
2. Project per-size (`project.taskTokenBudget.perSize[task.size]`)
|
||||||
|
3. Project base (`project.taskTokenBudget.soft/hard`)
|
||||||
|
4. Global per-size (`global.taskTokenBudget.perSize[task.size]`)
|
||||||
|
5. Global base (`global.taskTokenBudget.soft/hard`)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"taskTokenBudget": {
|
||||||
|
"soft": 8000000,
|
||||||
|
"hard": 12000000,
|
||||||
|
"perSize": {
|
||||||
|
"S": { "soft": 2000000, "hard": 4000000 },
|
||||||
|
"M": { "soft": 6000000, "hard": 9000000 },
|
||||||
|
"L": { "soft": 12000000, "hard": 18000000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Research settings hierarchy and credentials
|
### Research settings hierarchy and credentials
|
||||||
|
|
||||||
Research configuration resolves through `resolveResearchSettings(settings)` in `@fusion/core` with this precedence:
|
Research configuration resolves through `resolveResearchSettings(settings)` in `@fusion/core` with this precedence:
|
||||||
|
|||||||
@@ -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 }>;
|
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([
|
expect(rows).toEqual([
|
||||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
{ 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);
|
expect(db.getSchemaVersion()).toBe(77);
|
||||||
|
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ describe("GlobalSettingsStore", () => {
|
|||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
"token-budget",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ describe("NotificationDispatcher", () => {
|
|||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
"token-budget",
|
||||||
]);
|
]);
|
||||||
expect(DEFAULT_GLOBAL_SETTINGS.notificationProviders).toEqual([]);
|
expect(DEFAULT_GLOBAL_SETTINGS.notificationProviders).toEqual([]);
|
||||||
expect(DEFAULT_GLOBAL_SETTINGS.failureNotificationDelayMs).toBe(30000);
|
expect(DEFAULT_GLOBAL_SETTINGS.failureNotificationDelayMs).toBe(30000);
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ describe("settings key parity", () => {
|
|||||||
it("only intentional shared keys appear in both global and project scopes", () => {
|
it("only intentional shared keys appear in both global and project scopes", () => {
|
||||||
const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
|
const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
|
||||||
const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key));
|
const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key));
|
||||||
expect(overlap).toEqual(["githubTrackingDefaultRepo", "agentMemoryInclusionMode"]);
|
expect(overlap).toEqual(["taskTokenBudget", "githubTrackingDefaultRepo", "agentMemoryInclusionMode"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2593,7 +2593,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
phase: "pre-merge",
|
phase: "pre-merge",
|
||||||
gateMode: "advisory",
|
gateMode: "advisory",
|
||||||
prompt: template.prompt,
|
prompt: template.prompt,
|
||||||
gateMode: "advisory",
|
|
||||||
toolMode: template.toolMode || "readonly",
|
toolMode: template.toolMode || "readonly",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -2610,7 +2609,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
phase: string | null;
|
phase: string | null;
|
||||||
gateMode: string | null;
|
gateMode: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
gateMode: string | null;
|
|
||||||
toolMode: string | null;
|
toolMode: string | null;
|
||||||
scriptName: string | null;
|
scriptName: string | null;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
@@ -2631,9 +2629,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
? row.gateMode
|
? row.gateMode
|
||||||
: (row.mode === "script" ? "gate" : "advisory"),
|
: (row.mode === "script" ? "gate" : "advisory"),
|
||||||
prompt: row.prompt || "",
|
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,
|
toolMode: row.toolMode === "coding" || row.toolMode === "readonly" ? row.toolMode : undefined,
|
||||||
scriptName: row.scriptName ?? undefined,
|
scriptName: row.scriptName ?? undefined,
|
||||||
enabled: Boolean(row.enabled),
|
enabled: Boolean(row.enabled),
|
||||||
@@ -7202,7 +7197,6 @@ ${stepsSection}`;
|
|||||||
phase: input.phase || "pre-merge",
|
phase: input.phase || "pre-merge",
|
||||||
gateMode,
|
gateMode,
|
||||||
prompt: mode === "prompt" ? (input.prompt || "") : "",
|
prompt: mode === "prompt" ? (input.prompt || "") : "",
|
||||||
gateMode: input.gateMode || (mode === "script" ? "gate" : "advisory"),
|
|
||||||
toolMode: mode === "prompt" ? (input.toolMode || "readonly") : undefined,
|
toolMode: mode === "prompt" ? (input.toolMode || "readonly") : undefined,
|
||||||
scriptName: mode === "script" ? input.scriptName : undefined,
|
scriptName: mode === "script" ? input.scriptName : undefined,
|
||||||
enabled: input.enabled !== undefined ? input.enabled : true,
|
enabled: input.enabled !== undefined ? input.enabled : true,
|
||||||
@@ -7223,7 +7217,6 @@ ${stepsSection}`;
|
|||||||
phase,
|
phase,
|
||||||
gateMode,
|
gateMode,
|
||||||
prompt,
|
prompt,
|
||||||
gateMode,
|
|
||||||
toolMode,
|
toolMode,
|
||||||
scriptName,
|
scriptName,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -7242,7 +7235,6 @@ ${stepsSection}`;
|
|||||||
step.phase || "pre-merge",
|
step.phase || "pre-merge",
|
||||||
step.gateMode,
|
step.gateMode,
|
||||||
step.prompt,
|
step.prompt,
|
||||||
step.gateMode ?? (step.mode === "script" ? "gate" : "advisory"),
|
|
||||||
step.toolMode ?? null,
|
step.toolMode ?? null,
|
||||||
step.scriptName ?? null,
|
step.scriptName ?? null,
|
||||||
step.enabled ? 1 : 0,
|
step.enabled ? 1 : 0,
|
||||||
@@ -7284,9 +7276,8 @@ ${stepsSection}`;
|
|||||||
description: entry.template.description,
|
description: entry.template.description,
|
||||||
mode: entry.template.mode ?? "prompt",
|
mode: entry.template.mode ?? "prompt",
|
||||||
phase: entry.template.phase ?? "pre-merge",
|
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"),
|
gateMode: entry.template.gateMode ?? ((entry.template.mode ?? "prompt") === "script" ? "gate" : "advisory"),
|
||||||
|
prompt: entry.template.prompt ?? "",
|
||||||
scriptName: entry.template.scriptName,
|
scriptName: entry.template.scriptName,
|
||||||
toolMode: entry.template.toolMode,
|
toolMode: entry.template.toolMode,
|
||||||
enabled: entry.template.enabled ?? true,
|
enabled: entry.template.enabled ?? true,
|
||||||
@@ -7351,7 +7342,6 @@ ${stepsSection}`;
|
|||||||
phase: string | null;
|
phase: string | null;
|
||||||
gateMode: string | null;
|
gateMode: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
gateMode: string | null;
|
|
||||||
toolMode: string | null;
|
toolMode: string | null;
|
||||||
scriptName: string | null;
|
scriptName: string | null;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
@@ -7378,7 +7368,6 @@ ${stepsSection}`;
|
|||||||
phase: string | null;
|
phase: string | null;
|
||||||
gateMode: string | null;
|
gateMode: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
gateMode: string | null;
|
|
||||||
toolMode: string | null;
|
toolMode: string | null;
|
||||||
scriptName: string | null;
|
scriptName: string | null;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
@@ -7412,7 +7401,6 @@ ${stepsSection}`;
|
|||||||
phase: string | null;
|
phase: string | null;
|
||||||
gateMode: string | null;
|
gateMode: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
gateMode: string | null;
|
|
||||||
toolMode: string | null;
|
toolMode: string | null;
|
||||||
scriptName: string | null;
|
scriptName: string | null;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
|
|||||||
@@ -242,7 +242,6 @@ export type WorkflowStepGateMode = "gate" | "advisory";
|
|||||||
|
|
||||||
/** Lifecycle phase for workflow step execution. */
|
/** Lifecycle phase for workflow step execution. */
|
||||||
export type WorkflowStepPhase = "pre-merge" | "post-merge";
|
export type WorkflowStepPhase = "pre-merge" | "post-merge";
|
||||||
export type WorkflowStepGateMode = "gate" | "advisory";
|
|
||||||
|
|
||||||
export interface WorkflowStep {
|
export interface WorkflowStep {
|
||||||
/** Unique identifier (e.g., "WS-001") */
|
/** Unique identifier (e.g., "WS-001") */
|
||||||
@@ -261,8 +260,6 @@ export interface WorkflowStep {
|
|||||||
gateMode: WorkflowStepGateMode;
|
gateMode: WorkflowStepGateMode;
|
||||||
/** Full agent prompt to execute when this step runs (used when mode is "prompt") */
|
/** Full agent prompt to execute when this step runs (used when mode is "prompt") */
|
||||||
prompt: string;
|
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. */
|
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
|
||||||
toolMode?: WorkflowStepToolMode;
|
toolMode?: WorkflowStepToolMode;
|
||||||
/** Name of a script from project settings `scripts` map to execute (required when mode is "script") */
|
/** 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;
|
gateMode?: WorkflowStepGateMode;
|
||||||
/** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */
|
/** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */
|
||||||
prompt?: string;
|
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. */
|
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
|
||||||
toolMode?: WorkflowStepToolMode;
|
toolMode?: WorkflowStepToolMode;
|
||||||
/** Script name from project settings (required when mode is "script").
|
/** Script name from project settings (required when mode is "script").
|
||||||
|
|||||||
@@ -228,8 +228,8 @@ describe("WorkflowResultsTab", () => {
|
|||||||
|
|
||||||
const polishNotes = screen.getByTestId("workflow-polish-notes");
|
const polishNotes = screen.getByTestId("workflow-polish-notes");
|
||||||
expect(polishNotes).toHaveTextContent("Polish notes");
|
expect(polishNotes).toHaveTextContent("Polish notes");
|
||||||
expect(polishNotes).toHaveTextContent("Non-blocking findings");
|
expect(polishNotes).toHaveTextContent("non-blocking improvements");
|
||||||
expect(screen.getByTestId("workflow-polish-note-WS-006")).toHaveTextContent("Frontend UX Design");
|
expect(polishNotes).toHaveTextContent("Frontend UX Design");
|
||||||
|
|
||||||
const summary = screen.getByTestId("workflow-results-summary");
|
const summary = screen.getByTestId("workflow-results-summary");
|
||||||
expect(summary).toHaveTextContent("1 advisory");
|
expect(summary).toHaveTextContent("1 advisory");
|
||||||
|
|||||||
@@ -2134,7 +2134,6 @@ describe("Workflow Steps Execution", () => {
|
|||||||
description: "Check for vulnerabilities",
|
description: "Check for vulnerabilities",
|
||||||
gateMode: "gate",
|
gateMode: "gate",
|
||||||
prompt: "Scan for security issues.",
|
prompt: "Scan for security issues.",
|
||||||
gateMode: "gate",
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: 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.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"FN-001",
|
"FN-001",
|
||||||
expect.stringContaining("gateMode=advisory"),
|
expect.stringContaining("Advisory workflow step failed"),
|
||||||
);
|
);
|
||||||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||||
"FN-001",
|
"FN-001",
|
||||||
@@ -2367,7 +2366,6 @@ describe("Workflow Steps Execution", () => {
|
|||||||
description: "Check for vulnerabilities",
|
description: "Check for vulnerabilities",
|
||||||
gateMode: "gate",
|
gateMode: "gate",
|
||||||
prompt: "Scan for security issues.",
|
prompt: "Scan for security issues.",
|
||||||
gateMode: "gate",
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -2466,7 +2464,6 @@ describe("Workflow Steps Execution", () => {
|
|||||||
description: "Check for vulnerabilities",
|
description: "Check for vulnerabilities",
|
||||||
gateMode: "gate",
|
gateMode: "gate",
|
||||||
prompt: "Scan for security issues.",
|
prompt: "Scan for security issues.",
|
||||||
gateMode: "gate",
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -2572,7 +2569,6 @@ describe("Workflow Steps Execution", () => {
|
|||||||
description: "Check for vulnerabilities",
|
description: "Check for vulnerabilities",
|
||||||
gateMode: "gate",
|
gateMode: "gate",
|
||||||
prompt: "Scan for security issues.",
|
prompt: "Scan for security issues.",
|
||||||
gateMode: "gate",
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("schema version", () => {
|
describe("schema version", () => {
|
||||||
it("schema version is 76 after init", () => {
|
it("schema version is 77 after init", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(76);
|
expect(db.getSchemaVersion()).toBe(77);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user