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:
@@ -13,6 +13,7 @@ import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { getTimedDurationMs } from "../utils/taskTiming";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
|
||||
@@ -154,12 +155,8 @@ function formatElapsedDuration(elapsedMs: number): string {
|
||||
|
||||
if (elapsedMs < 60_000) return "<1m";
|
||||
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) {
|
||||
const remSeconds = Math.round(elapsedSeconds % 60);
|
||||
return remSeconds > 0 ? `${elapsedMinutes}m ${remSeconds}s` : `${elapsedMinutes}m`;
|
||||
}
|
||||
const elapsedMinutes = Math.floor(elapsedMs / 60_000);
|
||||
if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
|
||||
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
if (elapsedHours < 24) return `${elapsedHours}h`;
|
||||
@@ -700,6 +697,18 @@ function TaskCardComponent({
|
||||
}
|
||||
|
||||
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);
|
||||
if (startMs == null) {
|
||||
return null;
|
||||
@@ -717,14 +726,29 @@ function TaskCardComponent({
|
||||
};
|
||||
}
|
||||
|
||||
// Done cards report agent execution time (sum of workflow step durations),
|
||||
// matching the Workflow runtime metric in the stats tab. Fall back to
|
||||
// wallclock processing duration when no workflow timing data is available.
|
||||
// Done cards report the same "Timed duration" metric shown in the stats tab
|
||||
// (sum of [timing]-tagged log events). Fall back to workflow step runtime,
|
||||
// then to wallclock processing duration when no instrumentation exists.
|
||||
const completionMs = getDoneCompletionMs(task);
|
||||
if (completionMs == 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);
|
||||
if (workflowRuntimeMs != null) {
|
||||
const elapsedLabel = formatElapsedDuration(workflowRuntimeMs);
|
||||
@@ -756,7 +780,7 @@ function TaskCardComponent({
|
||||
title: `Processing took ${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(() => {
|
||||
if (!hasGitHubBadge || !isInViewport) {
|
||||
|
||||
@@ -296,7 +296,12 @@ export function TaskDetailModal({
|
||||
|
||||
// Derive a working task that always has all available fields.
|
||||
// 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 =
|
||||
task.status === "failed" ||
|
||||
task.status === "stuck-killed" ||
|
||||
|
||||
@@ -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";
|
||||
|
||||
interface TaskTokenStatsPanelProps {
|
||||
@@ -27,12 +28,6 @@ interface TaskTokenStatsPanelProps {
|
||||
>;
|
||||
}
|
||||
|
||||
interface TimingEvent {
|
||||
timestamp: string;
|
||||
durationMs?: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface WorkflowTimingSummary {
|
||||
timedStepCount: number;
|
||||
totalDurationMs: number;
|
||||
@@ -64,50 +59,31 @@ function formatDuration(valueMs: number): string {
|
||||
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 {
|
||||
const nowMs = Date.now();
|
||||
const timedResults = results
|
||||
.map((step) => {
|
||||
if (!step.startedAt || !step.completedAt) {
|
||||
if (!step.startedAt) {
|
||||
return null;
|
||||
}
|
||||
const startedMs = new Date(step.startedAt).getTime();
|
||||
const completedMs = new Date(step.completedAt).getTime();
|
||||
if (Number.isNaN(startedMs) || Number.isNaN(completedMs) || completedMs < startedMs) {
|
||||
if (Number.isNaN(startedMs)) {
|
||||
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 {
|
||||
name: step.workflowStepName || step.workflowStepId,
|
||||
durationMs: completedMs - startedMs,
|
||||
durationMs: endMs - startedMs,
|
||||
};
|
||||
})
|
||||
.filter((value): value is { name: string; durationMs: number } => value !== null);
|
||||
|
||||
49
packages/dashboard/app/utils/taskTiming.ts
Normal file
49
packages/dashboard/app/utils/taskTiming.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user