feat: live task token usage and stats-tab fixes

Capture per-session token usage from pi-coding-agent's getSessionStats()
after each promptWithFallback in the executor and merger paths, so
task.tokenUsage populates live during runs and reflects final totals on
done tasks. Previously the executor never read session usage and only
the heartbeat path bumped agent token totals, leaving task.tokenUsage
undefined even after completion.

Stats panel and done-card timing also now reflect live state: the modal
overlays the SSE-updated task prop on top of the one-shot fullDetail
snapshot, in-progress workflow steps contribute live elapsed to the
Workflow runtime metric, and the done card uses Timed duration (matching
the stats tab) with workflow runtime as fallback. Time indicator labels
coarsened to <1m / Nm / Nh / Nd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 04:51:29 -07:00
parent 30df38efda
commit d30edfda5d
8 changed files with 334 additions and 52 deletions

View File

@@ -13,6 +13,7 @@ import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
import { useTaskDiffStats } from "../hooks/useTaskDiffStats"; import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
import { isTaskStuck } from "../utils/taskStuck"; import { isTaskStuck } from "../utils/taskStuck";
import { getUnifiedTaskProgress } from "../utils/taskProgress"; import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { getTimedDurationMs } from "../utils/taskTiming";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm"; import { useConfirm } from "../hooks/useConfirm";
@@ -154,12 +155,8 @@ function formatElapsedDuration(elapsedMs: number): string {
if (elapsedMs < 60_000) return "<1m"; if (elapsedMs < 60_000) return "<1m";
const elapsedSeconds = elapsedMs / 1000; const elapsedMinutes = Math.floor(elapsedMs / 60_000);
const elapsedMinutes = Math.floor(elapsedSeconds / 60); if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
if (elapsedMinutes < 60) {
const remSeconds = Math.round(elapsedSeconds % 60);
return remSeconds > 0 ? `${elapsedMinutes}m ${remSeconds}s` : `${elapsedMinutes}m`;
}
const elapsedHours = Math.floor(elapsedMinutes / 60); const elapsedHours = Math.floor(elapsedMinutes / 60);
if (elapsedHours < 24) return `${elapsedHours}h`; if (elapsedHours < 24) return `${elapsedHours}h`;
@@ -700,6 +697,18 @@ function TaskCardComponent({
} }
if (task.column === "in-progress") { if (task.column === "in-progress") {
const timedDurationMs = getTimedDurationMs(task.log);
if (timedDurationMs != null) {
const elapsedLabel = formatElapsedDuration(timedDurationMs);
if (elapsedLabel) {
return {
label: elapsedLabel,
title: `Timed duration ${elapsedLabel}`,
ariaLabel: `Timed duration ${elapsedLabel}`,
};
}
}
const startMs = getInProgressTimeIndicatorStartMs(task); const startMs = getInProgressTimeIndicatorStartMs(task);
if (startMs == null) { if (startMs == null) {
return null; return null;
@@ -717,14 +726,29 @@ function TaskCardComponent({
}; };
} }
// Done cards report agent execution time (sum of workflow step durations), // Done cards report the same "Timed duration" metric shown in the stats tab
// matching the Workflow runtime metric in the stats tab. Fall back to // (sum of [timing]-tagged log events). Fall back to workflow step runtime,
// wallclock processing duration when no workflow timing data is available. // then to wallclock processing duration when no instrumentation exists.
const completionMs = getDoneCompletionMs(task); const completionMs = getDoneCompletionMs(task);
if (completionMs == null) { if (completionMs == null) {
return null; return null;
} }
const timedDurationMs = getTimedDurationMs(task.log);
if (timedDurationMs != null) {
const elapsedLabel = formatElapsedDuration(timedDurationMs);
if (!elapsedLabel) {
return null;
}
const completedAt = new Date(completionMs).toLocaleString();
return {
label: elapsedLabel,
title: `Timed duration ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Timed duration ${elapsedLabel}. Completed ${completedAt}`,
};
}
const workflowRuntimeMs = getDoneWorkflowRuntimeMs(task); const workflowRuntimeMs = getDoneWorkflowRuntimeMs(task);
if (workflowRuntimeMs != null) { if (workflowRuntimeMs != null) {
const elapsedLabel = formatElapsedDuration(workflowRuntimeMs); const elapsedLabel = formatElapsedDuration(workflowRuntimeMs);
@@ -756,7 +780,7 @@ function TaskCardComponent({
title: `Processing took ${elapsedLabel}. Completed ${completedAt}`, title: `Processing took ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Completed processing duration ${elapsedLabel}. Completed ${completedAt}`, ariaLabel: `Completed processing duration ${elapsedLabel}. Completed ${completedAt}`,
}; };
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt, task.workflowStepResults, timeIndicatorNowMs]); }, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
useEffect(() => { useEffect(() => {
if (!hasGitHubBadge || !isInViewport) { if (!hasGitHubBadge || !isInViewport) {

View File

@@ -296,7 +296,12 @@ export function TaskDetailModal({
// Derive a working task that always has all available fields. // Derive a working task that always has all available fields.
// Falls back to the optimistic Task while loading, uses fullDetail once loaded. // Falls back to the optimistic Task while loading, uses fullDetail once loaded.
const workingTask: TaskDetail = fullDetail ?? { ...task, prompt: "" } as TaskDetail; // Live fields (tokenUsage, log, workflowStepResults, status, column, …) are
// taken from the parent `task` prop which receives SSE updates, so the stats
// tab keeps populating while a task runs after the modal was opened.
const workingTask: TaskDetail = fullDetail
? ({ ...fullDetail, ...task, prompt: fullDetail.prompt } as TaskDetail)
: ({ ...task, prompt: "" } as TaskDetail);
const canRetryTask = const canRetryTask =
task.status === "failed" || task.status === "failed" ||
task.status === "stuck-killed" || task.status === "stuck-killed" ||

View File

@@ -1,4 +1,5 @@
import type { Task, TaskLogEntry, TaskTokenUsage, WorkflowStepResult } from "@fusion/core"; import type { Task, TaskTokenUsage, WorkflowStepResult } from "@fusion/core";
import { extractTimingEvents, type TimingEvent } from "../utils/taskTiming";
import "./TaskTokenStatsPanel.css"; import "./TaskTokenStatsPanel.css";
interface TaskTokenStatsPanelProps { interface TaskTokenStatsPanelProps {
@@ -27,12 +28,6 @@ interface TaskTokenStatsPanelProps {
>; >;
} }
interface TimingEvent {
timestamp: string;
durationMs?: number;
summary: string;
}
interface WorkflowTimingSummary { interface WorkflowTimingSummary {
timedStepCount: number; timedStepCount: number;
totalDurationMs: number; totalDurationMs: number;
@@ -64,50 +59,31 @@ function formatDuration(valueMs: number): string {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
} }
function summarizeTimingLabel(entry: TaskLogEntry): string {
const timingText = entry.action || entry.outcome || "";
const stripped = timingText
.replace(/^\[timing\]\s*/i, "")
.replace(/^\[[^\]]+\]\s*/i, "")
.replace(/\s+in\s+\d+(?:\.\d+)?ms\b/i, "")
.replace(/\s+after\s+\d+(?:\.\d+)?ms\b/i, "")
.trim();
return stripped || "Timing event";
}
function extractTimingEvents(logEntries: TaskLogEntry[]): TimingEvent[] {
return logEntries
.filter((entry) => {
const actionText = typeof entry.action === "string" ? entry.action : "";
const outcomeText = typeof entry.outcome === "string" ? entry.outcome : "";
return actionText.includes("[timing]") || outcomeText.includes("[timing]");
})
.map((entry) => {
const haystack = `${entry.action ?? ""}\n${entry.outcome ?? ""}`;
const durationMatch = haystack.match(/(\d+(?:\.\d+)?)ms\b/i);
const durationMs = durationMatch ? Number(durationMatch[1]) : undefined;
return {
timestamp: entry.timestamp,
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
summary: summarizeTimingLabel(entry),
};
});
}
function summarizeWorkflowTiming(results: WorkflowStepResult[]): WorkflowTimingSummary { function summarizeWorkflowTiming(results: WorkflowStepResult[]): WorkflowTimingSummary {
const nowMs = Date.now();
const timedResults = results const timedResults = results
.map((step) => { .map((step) => {
if (!step.startedAt || !step.completedAt) { if (!step.startedAt) {
return null; return null;
} }
const startedMs = new Date(step.startedAt).getTime(); const startedMs = new Date(step.startedAt).getTime();
const completedMs = new Date(step.completedAt).getTime(); if (Number.isNaN(startedMs)) {
if (Number.isNaN(startedMs) || Number.isNaN(completedMs) || completedMs < startedMs) {
return null; return null;
} }
// Completed step → use completedAt. In-progress step → live elapsed.
let endMs: number;
if (step.completedAt) {
const completedMs = new Date(step.completedAt).getTime();
if (Number.isNaN(completedMs) || completedMs < startedMs) {
return null;
}
endMs = completedMs;
} else {
endMs = Math.max(startedMs, nowMs);
}
return { return {
name: step.workflowStepName || step.workflowStepId, name: step.workflowStepName || step.workflowStepId,
durationMs: completedMs - startedMs, durationMs: endMs - startedMs,
}; };
}) })
.filter((value): value is { name: string; durationMs: number } => value !== null); .filter((value): value is { name: string; durationMs: number } => value !== null);

View File

@@ -0,0 +1,49 @@
import type { TaskLogEntry } from "@fusion/core";
export interface TimingEvent {
timestamp: string;
durationMs?: number;
summary: string;
}
function summarizeTimingLabel(entry: TaskLogEntry): string {
const timingText = entry.action || entry.outcome || "";
const stripped = timingText
.replace(/^\[timing\]\s*/i, "")
.replace(/^\[[^\]]+\]\s*/i, "")
.replace(/\s+in\s+\d+(?:\.\d+)?ms\b/i, "")
.replace(/\s+after\s+\d+(?:\.\d+)?ms\b/i, "")
.trim();
return stripped || "Timing event";
}
export function extractTimingEvents(logEntries: TaskLogEntry[]): TimingEvent[] {
return logEntries
.filter((entry) => {
const actionText = typeof entry.action === "string" ? entry.action : "";
const outcomeText = typeof entry.outcome === "string" ? entry.outcome : "";
return actionText.includes("[timing]") || outcomeText.includes("[timing]");
})
.map((entry) => {
const haystack = `${entry.action ?? ""}\n${entry.outcome ?? ""}`;
const durationMatch = haystack.match(/(\d+(?:\.\d+)?)ms\b/i);
const durationMs = durationMatch ? Number(durationMatch[1]) : undefined;
return {
timestamp: entry.timestamp,
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
summary: summarizeTimingLabel(entry),
};
});
}
export function getTimedDurationMs(logEntries: TaskLogEntry[] | undefined): number | null {
if (!logEntries || logEntries.length === 0) return null;
let total = 0;
let counted = 0;
for (const event of extractTimingEvents(logEntries)) {
if (typeof event.durationMs !== "number") continue;
total += event.durationMs;
counted += 1;
}
return counted > 0 ? total : null;
}

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { accumulateSessionTokenUsage } from "../session-token-usage.js";
interface MockSessionStats {
tokens?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
}
function createSession(stats: MockSessionStats | undefined) {
return { getSessionStats: vi.fn(() => stats) } 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;
const updateTask = vi.fn(async (_id: string, updates: Partial<Task>) => {
if (updates.tokenUsage !== undefined) task.tokenUsage = updates.tokenUsage as Task["tokenUsage"];
return task;
});
const store = {
_task: task,
getTask: vi.fn(async () => task),
updateTask,
} as unknown as TaskStore & { _task: Task; updateTask: ReturnType<typeof vi.fn> };
return store;
}
describe("accumulateSessionTokenUsage", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("writes initial token usage when task has none", async () => {
const store = createStore(undefined);
const session = createSession({ tokens: { input: 100, output: 30, cacheRead: 5, cacheWrite: 2 } });
await accumulateSessionTokenUsage(store, "FN-1", session);
expect(store.updateTask).toHaveBeenCalledTimes(1);
const call = store.updateTask.mock.calls[0]![1] as { tokenUsage: Task["tokenUsage"] };
expect(call.tokenUsage).toMatchObject({
inputTokens: 102, // input + cacheWrite
outputTokens: 30,
cachedTokens: 5,
totalTokens: 137,
});
expect(typeof call.tokenUsage!.firstUsedAt).toBe("string");
expect(typeof call.tokenUsage!.lastUsedAt).toBe("string");
});
it("accumulates only the delta on subsequent calls for the same session", async () => {
const store = createStore(undefined);
const session = createSession({ tokens: { input: 100, output: 30, cacheRead: 0, cacheWrite: 0 } });
await accumulateSessionTokenUsage(store, "FN-1", session);
// Second call: session has progressed.
(session as unknown as { getSessionStats: () => MockSessionStats }).getSessionStats = () => ({
tokens: { input: 250, output: 80, cacheRead: 0, cacheWrite: 0 },
});
await accumulateSessionTokenUsage(store, "FN-1", session);
expect(store.updateTask).toHaveBeenCalledTimes(2);
const second = store.updateTask.mock.calls[1]![1] as { tokenUsage: Task["tokenUsage"] };
expect(second.tokenUsage).toMatchObject({
inputTokens: 250,
outputTokens: 80,
cachedTokens: 0,
totalTokens: 330,
});
});
it("preserves firstUsedAt across updates", async () => {
const store = createStore(undefined);
const session = createSession({ tokens: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0 } });
await accumulateSessionTokenUsage(store, "FN-1", session);
const first = store.updateTask.mock.calls[0]![1] as { tokenUsage: Task["tokenUsage"] };
const initialFirstUsed = first.tokenUsage!.firstUsedAt;
await new Promise((resolve) => setTimeout(resolve, 5));
(session as unknown as { getSessionStats: () => MockSessionStats }).getSessionStats = () => ({
tokens: { input: 20, output: 5, cacheRead: 0, cacheWrite: 0 },
});
await accumulateSessionTokenUsage(store, "FN-1", session);
const second = store.updateTask.mock.calls[1]![1] as { tokenUsage: Task["tokenUsage"] };
expect(second.tokenUsage!.firstUsedAt).toBe(initialFirstUsed);
expect(second.tokenUsage!.lastUsedAt >= initialFirstUsed).toBe(true);
});
it("does nothing when session has no getSessionStats", async () => {
const store = createStore(undefined);
const session = {} as Parameters<typeof accumulateSessionTokenUsage>[2];
await accumulateSessionTokenUsage(store, "FN-1", session);
expect(store.updateTask).not.toHaveBeenCalled();
});
it("does nothing when delta is zero", async () => {
const store = createStore({
inputTokens: 50,
outputTokens: 20,
cachedTokens: 0,
totalTokens: 70,
firstUsedAt: "2024-01-01T00:00:00.000Z",
lastUsedAt: "2024-01-01T00:00:00.000Z",
});
const session = createSession({ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } });
await accumulateSessionTokenUsage(store, "FN-1", session);
// Calling again with the same zero stats should not produce another update.
await accumulateSessionTokenUsage(store, "FN-1", session);
expect(store.updateTask).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"));
const session = createSession({ tokens: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 } });
await expect(accumulateSessionTokenUsage(store, "FN-1", session)).resolves.toBeUndefined();
});
});

View File

@@ -11,6 +11,7 @@ import { findWorktreeUser } from "./merger.js";
import { generateWorktreeName, slugify } from "./worktree-names.js"; import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai"; import { Type, type Static } from "@mariozechner/pi-ai";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js"; import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js"; import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js"; import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js"; import { reviewStep, type ReviewVerdict } from "./reviewer.js";
@@ -2015,6 +2016,7 @@ export class TaskExecutor {
// session.prompt() resolves normally even when retries are exhausted — // session.prompt() resolves normally even when retries are exhausted —
// the error is stored on session.state.error instead of being thrown. // the error is stored on session.state.error instead of being thrown.
checkSessionError(session); checkSessionError(session);
await accumulateSessionTokenUsage(this.store, task.id, session);
// Check if proactive context compaction is needed based on token cap setting. // Check if proactive context compaction is needed based on token cap setting.
// This runs after the main prompt completes to avoid interrupting active work. // This runs after the main prompt completes to avoid interrupting active work.
@@ -2072,6 +2074,7 @@ export class TaskExecutor {
await promptWithFallback(session, resumePrompt); await promptWithFallback(session, resumePrompt);
checkSessionError(session); checkSessionError(session);
await accumulateSessionTokenUsage(this.store, task.id, session);
} }
// If dependency was added during execution, discard worktree and move to triage // If dependency was added during execution, discard worktree and move to triage
@@ -2233,6 +2236,7 @@ export class TaskExecutor {
stuckDetector?.recordActivity(task.id); stuckDetector?.recordActivity(task.id);
await promptWithFallback(retrySession, retryPrompt); await promptWithFallback(retrySession, retryPrompt);
checkSessionError(retrySession); checkSessionError(retrySession);
await accumulateSessionTokenUsage(this.store, task.id, retrySession);
if (!taskDone) { if (!taskDone) {
const implicitCheck = await this.store.getTask(task.id); const implicitCheck = await this.store.getTask(task.id);
@@ -2439,6 +2443,7 @@ export class TaskExecutor {
await promptWithFallback(activeEntry.session, reducedPrompt); await promptWithFallback(activeEntry.session, reducedPrompt);
checkSessionError(activeEntry.session); checkSessionError(activeEntry.session);
await accumulateSessionTokenUsage(this.store, task.id, activeEntry.session);
// Reduced-prompt retry succeeded — return to let the finally block clean up // Reduced-prompt retry succeeded — return to let the finally block clean up
// without marking the task as failed. // without marking the task as failed.
@@ -3881,6 +3886,7 @@ and show an appropriate message to the user.\`
); );
checkSessionError(session); checkSessionError(session);
await accumulateSessionTokenUsage(this.store, task.id, session);
session.dispose(); session.dispose();
await agentLogger.flush(); await agentLogger.flush();

View File

@@ -128,6 +128,7 @@ import { join } from "node:path";
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core"; import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core"; import { resolveAgentPrompt } from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js"; import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js"; import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js"; import { buildSessionSkillContext } from "./session-skill-context.js";
import type { WorktreePool } from "./worktree-pool.js"; import type { WorktreePool } from "./worktree-pool.js";
@@ -976,6 +977,7 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
}, },
signal: options.signal, signal: options.signal,
}); });
await accumulateSessionTokenUsage(store, taskId, session);
// Re-run deterministic verification command after the fix attempt. // Re-run deterministic verification command after the fix attempt.
await store.logEntry( await store.logEntry(
@@ -1843,6 +1845,7 @@ You are assisting with a paused \`git pull --rebase\`.
}, },
signal: options?.signal, signal: options?.signal,
}); });
await accumulateSessionTokenUsage(store, taskId, session);
} finally { } finally {
session.dispose(); session.dispose();
} }
@@ -3634,6 +3637,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
throw err; throw err;
} finally { } finally {
await accumulateSessionTokenUsage(store, taskId, session);
await agentLogger.flush(); await agentLogger.flush();
session.dispose(); session.dispose();
} }
@@ -4022,6 +4026,7 @@ If issues are found that need attention, describe them clearly.`;
); );
checkSessionError(session); checkSessionError(session);
await accumulateSessionTokenUsage(store, taskId, session);
session.dispose(); session.dispose();
await agentLogger.flush(); await agentLogger.flush();

View File

@@ -0,0 +1,92 @@
import type { TaskStore } from "@fusion/core";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { createLogger } from "./logger.js";
const log = createLogger("session-token-usage");
interface SessionBaseline {
input: number;
output: number;
cached: number;
}
// Per-session cumulative-token baselines so repeated calls only persist deltas.
// The session object is keyed weakly so disposed sessions get garbage-collected.
const sessionBaselines = new WeakMap<AgentSession, SessionBaseline>();
interface SessionStatsLike {
tokens?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
};
}
function readSessionStats(session: AgentSession): SessionStatsLike | undefined {
const accessor = (session as unknown as { getSessionStats?: () => SessionStatsLike }).getSessionStats;
if (typeof accessor !== "function") return undefined;
try {
return accessor.call(session);
} catch {
return undefined;
}
}
/**
* Capture the session's cumulative token usage and accumulate any *new* deltas
* onto `task.tokenUsage`. Safe to call repeatedly on the same session — each
* call only persists what's been added since the previous call (per-session
* baseline tracking). Failures are logged and swallowed so token bookkeeping
* never blocks the task pipeline.
*/
export async function accumulateSessionTokenUsage(
store: TaskStore,
taskId: string,
session: AgentSession,
): Promise<void> {
try {
const stats = readSessionStats(session);
const tokens = stats?.tokens;
if (!tokens) return;
// Treat cache-write tokens as input (they're billed as input on first write
// and read back at a discount on subsequent turns).
const currentInput = (tokens.input ?? 0) + (tokens.cacheWrite ?? 0);
const currentOutput = tokens.output ?? 0;
const currentCached = tokens.cacheRead ?? 0;
const baseline = sessionBaselines.get(session) ?? { input: 0, output: 0, cached: 0 };
const inputDelta = Math.max(0, currentInput - baseline.input);
const outputDelta = Math.max(0, currentOutput - baseline.output);
const cachedDelta = Math.max(0, currentCached - baseline.cached);
sessionBaselines.set(session, {
input: currentInput,
output: currentOutput,
cached: currentCached,
});
if (inputDelta === 0 && outputDelta === 0 && cachedDelta === 0) return;
const task = await store.getTask(taskId);
const now = new Date().toISOString();
const newInput = (task.tokenUsage?.inputTokens ?? 0) + inputDelta;
const newOutput = (task.tokenUsage?.outputTokens ?? 0) + outputDelta;
const newCached = (task.tokenUsage?.cachedTokens ?? 0) + cachedDelta;
await store.updateTask(taskId, {
tokenUsage: {
inputTokens: newInput,
outputTokens: newOutput,
cachedTokens: newCached,
totalTokens: newInput + newOutput + newCached,
firstUsedAt: task.tokenUsage?.firstUsedAt ?? now,
lastUsedAt: now,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`${taskId}: session token usage accumulate failed: ${message}`);
}
}