feat(FN-2456): persist task token usage on task records

- Add schema v44 migration to persist task-level token usage totals and first/last usage timestamps on tasks
- Extend core task types, store create/update flows, and exports to round-trip token usage data
- Add migration and TaskStore regression tests for token usage persistence, null clearing, and reinitialization behavior
- Update dashboard async handling and tests to prevent post-unmount state updates and reduce flaky assertion timing
This commit is contained in:
Fusion
2026-04-24 09:44:44 -07:00
committed by gsxdsm
parent d37cb519d3
commit e7a8a952b7
17 changed files with 312 additions and 44 deletions

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
const index = db
.prepare(

View File

@@ -131,7 +131,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
});
it("seeds lastModified", () => {
@@ -154,7 +154,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
});
it("does not overwrite existing config on re-init", () => {
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
db.close();
});
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -836,6 +836,69 @@ describe("schema migrations", () => {
db.close();
});
it("migrates v43 databases by adding task token-usage aggregate columns with null-compatible defaults", () => {
tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(fusionDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
priority TEXT DEFAULT 'normal',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '43')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-2', 'legacy v43', 'todo', '2026-01-01', '2026-01-01')`);
db.init();
expect(db.getSchemaVersion()).toBe(44);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
expect(colNames).toContain("tokenUsageInputTokens");
expect(colNames).toContain("tokenUsageOutputTokens");
expect(colNames).toContain("tokenUsageCachedTokens");
expect(colNames).toContain("tokenUsageTotalTokens");
expect(colNames).toContain("tokenUsageFirstUsedAt");
expect(colNames).toContain("tokenUsageLastUsedAt");
const task = db.prepare(`
SELECT
tokenUsageInputTokens,
tokenUsageOutputTokens,
tokenUsageCachedTokens,
tokenUsageTotalTokens,
tokenUsageFirstUsedAt,
tokenUsageLastUsedAt
FROM tasks
WHERE id = 'FN-2'
`).get() as Record<string, null>;
expect(task.tokenUsageInputTokens).toBeNull();
expect(task.tokenUsageOutputTokens).toBeNull();
expect(task.tokenUsageCachedTokens).toBeNull();
expect(task.tokenUsageTotalTokens).toBeNull();
expect(task.tokenUsageFirstUsedAt).toBeNull();
expect(task.tokenUsageLastUsedAt).toBeNull();
db.close();
});
it("applies migration 14+15 by creating agentRatings and ai_sessions indexes", () => {
tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion");
@@ -847,7 +910,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -871,7 +934,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -975,7 +1038,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1344,7 +1407,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 43;
const SCHEMA_VERSION = 44;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -180,6 +180,12 @@ CREATE TABLE IF NOT EXISTS tasks (
summary TEXT,
thinkingLevel TEXT,
executionMode TEXT DEFAULT 'standard',
tokenUsageInputTokens INTEGER,
tokenUsageOutputTokens INTEGER,
tokenUsageCachedTokens INTEGER,
tokenUsageTotalTokens INTEGER,
tokenUsageFirstUsedAt TEXT,
tokenUsageLastUsedAt TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
columnMovedAt TEXT,
@@ -1714,6 +1720,21 @@ export class Database {
});
}
// Task-level token usage aggregate contract (FN-2456)
// Persists durable token totals and first/last usage timestamps on each task row.
// Existing rows are left null-compatible so legacy tasks deserialize without
// synthesizing usage data.
if (version < 44) {
this.applyMigration(44, () => {
this.addColumnIfMissing("tasks", "tokenUsageInputTokens", "INTEGER");
this.addColumnIfMissing("tasks", "tokenUsageOutputTokens", "INTEGER");
this.addColumnIfMissing("tasks", "tokenUsageCachedTokens", "INTEGER");
this.addColumnIfMissing("tasks", "tokenUsageTotalTokens", "INTEGER");
this.addColumnIfMissing("tasks", "tokenUsageFirstUsedAt", "TEXT");
this.addColumnIfMissing("tasks", "tokenUsageLastUsedAt", "TEXT");
});
}
}
/**

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -776,7 +776,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(43);
expect(db1.getSchemaVersion()).toBe(44);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -811,7 +811,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(43);
expect(db3.getSchemaVersion()).toBe(44);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -842,12 +842,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(43);
expect(db1.getSchemaVersion()).toBe(44);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(43);
expect(db2.getSchemaVersion()).toBe(44);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

@@ -2626,7 +2626,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
});
it("mission_features table has loop state columns", () => {

View File

@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(43);
expect(db.getSchemaVersion()).toBe(44);
});
});
});

View File

@@ -223,6 +223,106 @@ describe("TaskStore", () => {
});
});
describe("task token usage persistence", () => {
it("creates and reads tasks without token usage data as undefined", async () => {
const task = await store.createTask({
description: "Task without token usage",
});
expect(task.tokenUsage).toBeUndefined();
const detail = await store.getTask(task.id);
expect(detail.tokenUsage).toBeUndefined();
});
it("round-trips token usage totals and timestamps through create and read", async () => {
const tokenUsage = {
inputTokens: 120,
outputTokens: 45,
cachedTokens: 30,
totalTokens: 195,
firstUsedAt: "2026-04-23T10:00:00.000Z",
lastUsedAt: "2026-04-23T10:05:00.000Z",
};
const task = await store.createTask({
description: "Task with token usage",
tokenUsage,
});
expect(task.tokenUsage).toEqual(tokenUsage);
const detail = await store.getTask(task.id);
expect(detail.tokenUsage).toEqual(tokenUsage);
});
it("round-trips token usage through update and preserves exact values", async () => {
const task = await store.createTask({ description: "Update token usage" });
const tokenUsage = {
inputTokens: 210,
outputTokens: 80,
cachedTokens: 40,
totalTokens: 330,
firstUsedAt: "2026-04-23T12:00:00.000Z",
lastUsedAt: "2026-04-23T12:30:00.000Z",
};
const updated = await store.updateTask(task.id, { tokenUsage });
expect(updated.tokenUsage).toEqual(tokenUsage);
const detail = await store.getTask(task.id);
expect(detail.tokenUsage).toEqual(tokenUsage);
});
it("persists token usage across TaskStore reinitialization", async () => {
const tokenUsage = {
inputTokens: 300,
outputTokens: 120,
cachedTokens: 50,
totalTokens: 470,
firstUsedAt: "2026-04-23T13:00:00.000Z",
lastUsedAt: "2026-04-23T13:45:00.000Z",
};
const created = await store.createTask({
description: "Reinit token usage persistence",
tokenUsage,
});
store.close();
store = new TaskStore(rootDir, globalDir);
await store.init();
const reloaded = await store.getTask(created.id);
expect(reloaded.tokenUsage).toEqual(tokenUsage);
});
it("clears token usage via null update and keeps it absent after reload", async () => {
const task = await store.createTask({
description: "Clear token usage",
tokenUsage: {
inputTokens: 99,
outputTokens: 44,
cachedTokens: 11,
totalTokens: 154,
firstUsedAt: "2026-04-23T14:00:00.000Z",
lastUsedAt: "2026-04-23T14:01:00.000Z",
},
});
const cleared = await store.updateTask(task.id, { tokenUsage: null });
expect(cleared.tokenUsage).toBeUndefined();
store.close();
store = new TaskStore(rootDir, globalDir);
await store.init();
const reloaded = await store.getTask(task.id);
expect(reloaded.tokenUsage).toBeUndefined();
});
});
describe("breakIntoSubtasks task creation flag", () => {
it("persists breakIntoSubtasks=true when explicitly requested", async () => {
const task = await store.createTask({

View File

@@ -56,6 +56,12 @@ interface TaskRow {
summary: string | null;
thinkingLevel: string | null;
executionMode: string | null;
tokenUsageInputTokens: number | null;
tokenUsageOutputTokens: number | null;
tokenUsageCachedTokens: number | null;
tokenUsageTotalTokens: number | null;
tokenUsageFirstUsedAt: string | null;
tokenUsageLastUsedAt: string | null;
createdAt: string;
updatedAt: string;
columnMovedAt: string | null;
@@ -483,6 +489,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies: fromJson<string[]>(row.dependencies) || [],
steps: fromJson<import("./types.js").TaskStep[]>(row.steps) || [],
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
tokenUsage: (() => {
if (
row.tokenUsageInputTokens === null
|| row.tokenUsageOutputTokens === null
|| row.tokenUsageCachedTokens === null
|| row.tokenUsageTotalTokens === null
|| row.tokenUsageFirstUsedAt === null
|| row.tokenUsageLastUsedAt === null
) {
return undefined;
}
return {
inputTokens: row.tokenUsageInputTokens,
outputTokens: row.tokenUsageOutputTokens,
cachedTokens: row.tokenUsageCachedTokens,
totalTokens: row.tokenUsageTotalTokens,
firstUsedAt: row.tokenUsageFirstUsedAt,
lastUsedAt: row.tokenUsageLastUsedAt,
};
})(),
attachments: (() => { const a = fromJson<TaskAttachment[]>(row.attachments); return a && a.length > 0 ? a : undefined; })(),
steeringComments: (() => {
const sc = fromJson<import("./types.js").SteeringComment[]>(row.steeringComments);
@@ -726,6 +753,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"planningModelProvider", "planningModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt",
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "mergeDetails",
@@ -744,6 +772,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"planningModelProvider", "planningModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt",
"dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "mergeDetails",
@@ -784,13 +813,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, createdAt, updatedAt, columnMovedAt,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -825,6 +854,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
summary = excluded.summary,
thinkingLevel = excluded.thinkingLevel,
executionMode = excluded.executionMode,
tokenUsageInputTokens = excluded.tokenUsageInputTokens,
tokenUsageOutputTokens = excluded.tokenUsageOutputTokens,
tokenUsageCachedTokens = excluded.tokenUsageCachedTokens,
tokenUsageTotalTokens = excluded.tokenUsageTotalTokens,
tokenUsageFirstUsedAt = excluded.tokenUsageFirstUsedAt,
tokenUsageLastUsedAt = excluded.tokenUsageLastUsedAt,
createdAt = excluded.createdAt,
updatedAt = excluded.updatedAt,
columnMovedAt = excluded.columnMovedAt,
@@ -881,6 +916,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.summary ?? null,
task.thinkingLevel ?? null,
task.executionMode ?? null,
task.tokenUsage?.inputTokens ?? null,
task.tokenUsage?.outputTokens ?? null,
task.tokenUsage?.cachedTokens ?? null,
task.tokenUsage?.totalTokens ?? null,
task.tokenUsage?.firstUsedAt ?? null,
task.tokenUsage?.lastUsedAt ?? null,
task.createdAt,
task.updatedAt,
task.columnMovedAt ?? null,
@@ -1841,6 +1882,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
title,
description: input.description,
priority: normalizeTaskPriority(input.priority),
tokenUsage: input.tokenUsage,
column: input.column || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
@@ -2409,7 +2451,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -2603,6 +2645,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.mergeDetails !== undefined) {
task.mergeDetails = updates.mergeDetails;
}
if (updates.tokenUsage === null) {
task.tokenUsage = undefined;
} else if (updates.tokenUsage !== undefined) {
task.tokenUsage = updates.tokenUsage;
}
if (updates.modifiedFiles === null) {
task.modifiedFiles = undefined;
} else if (updates.modifiedFiles !== undefined) {

View File

@@ -645,6 +645,28 @@ export interface CheckoutLease {
checkedOutAt: string;
}
/**
* Durable task-level aggregate token usage totals persisted on the task row.
*
* This model captures cumulative usage across all agent/run activity linked to
* a task so usage survives process restarts and can be queried without joining
* transient run state.
*/
export interface TaskTokenUsage {
/** Cumulative prompt/input tokens consumed by the task. */
inputTokens: number;
/** Cumulative completion/output tokens consumed by the task. */
outputTokens: number;
/** Cumulative cache-hit tokens reported by providers. */
cachedTokens: number;
/** Cumulative total tokens for the task (input + output + cached semantics per provider reporting). */
totalTokens: number;
/** ISO-8601 timestamp of the first recorded usage event for this task. */
firstUsedAt: string;
/** ISO-8601 timestamp of the most recent recorded usage event for this task. */
lastUsedAt: string;
}
/** Thrown when a checkout is attempted on a task already checked out by another agent. */
export class CheckoutConflictError extends Error {
constructor(
@@ -710,6 +732,8 @@ export interface Task {
/** Issue information for tasks imported from GitHub issues */
issueInfo?: IssueInfo;
log: TaskLogEntry[];
/** Durable aggregate token usage totals for the task. Undefined when no usage has been recorded yet. */
tokenUsage?: TaskTokenUsage;
size?: "S" | "M" | "L";
reviewLevel?: number;
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
@@ -819,6 +843,8 @@ export interface InboxTask {
export interface TaskCreateInput {
title?: string;
description: string;
/** Optional persisted aggregate token usage snapshot for task creation/import paths. */
tokenUsage?: TaskTokenUsage;
/**
* Optional task importance level. Omitted values default to `normal`.
*/