Files
fusion/packages/dashboard/app/utils/taskTiming.ts
gsxdsm 7ed1c39a67 FN-9102: fix active runtime segment accounting
Prevent reopened tasks from double-counting closed execution segments while preserving pre-execution planning time.

- Clear the live execution anchor whenever a WIP segment is banked.
- Clamp legacy poisoned runtime values with separate execution and combined-work wall-clock ceilings.
- Cover cards, detail statistics, core totals, and planner metrics across WIP round trips and historical rows.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-9102-task-runtime-double-count.md    |  7 ++
 .../src/__tests__/reopen-semantics-by-role.test.ts | 95 ++++++++++++++--------
 packages/core/src/tasks/task-timing.ts             | 20 ++++-
 .../core/src/workflows/default-workflow-hooks.ts   | 14 +++-
 .../app/components/__tests__/TaskCard.test.tsx     | 27 ++++++
 .../__tests__/TaskTokenStatsPanel.test.tsx         | 27 ++++++
 .../app/utils/__tests__/taskTiming.test.ts         | 40 +++++++++
 packages/dashboard/app/utils/taskTiming.ts         | 28 +++++--
 .../__tests__/task-planner-chat-metrics.test.ts    | 28 +++++++
 .../dashboard/src/task-planner-chat-metrics.ts     | 22 ++++-
 10 files changed, 260 insertions(+), 48 deletions(-)

Fusion-Task-Id: FN-9102

Fusion-Task-Lineage: 3b42e81f-4e11-4232-80f6-052f51b83f1c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-15 13:39:24 -07:00

176 lines
6.8 KiB
TypeScript

import { isWipColumnRole, type ColumnRoleFlags } from "./columnRoles";
import type { Task, TaskLogEntry, WorkflowStepResult } 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;
}
export function parseTimestampToMs(value?: string): number | null {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
export function getWorkflowRuntimeMs(results: WorkflowStepResult[] | undefined, nowMs: number): number | null {
if (!results || results.length === 0) return null;
let total = 0;
let counted = 0;
for (const step of results) {
if (!step.startedAt) continue;
const startedMs = parseTimestampToMs(step.startedAt);
if (startedMs == null) continue;
let endMs: number;
if (step.completedAt) {
const completedMs = parseTimestampToMs(step.completedAt);
if (completedMs == null || completedMs < startedMs) continue;
endMs = completedMs;
} else {
endMs = Math.max(startedMs, nowMs);
}
total += endMs - startedMs;
counted += 1;
}
return counted > 0 ? total : null;
}
export function getEndToEndDurationMs(
executionStartedAt: string | undefined,
executionCompletedAt: string | undefined,
nowMs: number,
): number | null {
const startedMs = parseTimestampToMs(executionStartedAt);
if (startedMs == null) return null;
const completedMs = parseTimestampToMs(executionCompletedAt);
const endMs = completedMs != null && completedMs >= startedMs ? completedMs : nowMs;
return Math.max(0, endMs - startedMs);
}
/*
FNXC:TaskRuntimeSegments 2026-08-15-20:34:
The card and detail panel must cap legacy cumulative values at a durable wall-clock age. Execution-
only values use first execution, while totals that include pre-execution planning use task creation.
This is a reader-only guard for rows written before closed WIP segments cleared their anchor.
*/
function clampRuntimeToWallClock(totalMs: number, ageAnchor: string | undefined, nowMs: number): number {
const ageAnchorMs = parseTimestampToMs(ageAnchor);
return ageAnchorMs == null ? totalMs : Math.min(totalMs, Math.max(0, nowMs - ageAnchorMs));
}
export function getActiveRuntimeMs(
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "columnMovedAt">
& Partial<Pick<Task, "firstExecutionAt" | "createdAt">>,
nowMs: number,
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-10:10:
THE DASHBOARD HAS ITS OWN COPY OF THIS FUNCTION, and converting core's did not touch it.
`@fusion/core`'s `task-timing.ts` exports a `getTotalAgentActiveMs` that was converted onto
`isWipColumnRole` — but the card chip imports THIS module instead, so that conversion never
reached the surface an operator actually looks at. Two implementations of one calculation, one
converted and one not, is the same drift `column-roles.ts` was created to end.
Keyed on the literal, the LIVE execution segment was dropped on a renamed board, so the card
under-reported the run in flight by exactly its elapsed time — and healed itself the moment the
card moved on and the segment was persisted.
Omitted flags keep the legacy id via `isWipColumnRole`'s own degraded mode.
*/
columnFlags?: ColumnRoleFlags,
): number | null {
const persisted = task.cumulativeActiveMs;
const base = persisted ?? 0;
if (isWipColumnRole(columnFlags, task.column)) {
const startedMs = parseTimestampToMs(task.executionStartedAt);
if (startedMs != null) {
return clampRuntimeToWallClock(base + Math.max(0, nowMs - startedMs), task.firstExecutionAt ?? task.createdAt, nowMs);
}
}
if (persisted != null) {
return clampRuntimeToWallClock(Math.max(0, persisted), task.firstExecutionAt ?? task.createdAt, nowMs);
}
return null;
}
/** FNXC:TaskTiming 2026-07-20-10:00: rendered task totals include planning AI
* segments while getActiveRuntimeMs intentionally remains execution-only. */
export function getTotalAgentActiveMs(
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "cumulativePlanningMs" | "planningStartedAt">
& Partial<Pick<Task, "firstExecutionAt" | "createdAt">>,
nowMs: number,
/** Resolved trait flags for the card's column; omitted keeps the legacy id. */
columnFlags?: ColumnRoleFlags,
): number | null {
const execution = getActiveRuntimeMs(task as never, nowMs, columnFlags) ?? 0;
const planningStart = parseTimestampToMs(task.planningStartedAt);
const planning = Math.max(0, task.cumulativePlanningMs ?? 0) + (planningStart != null ? Math.max(0, nowMs - planningStart) : 0);
if (task.cumulativeActiveMs == null && task.cumulativePlanningMs == null && !(isWipColumnRole(columnFlags, task.column) && parseTimestampToMs(task.executionStartedAt) != null) && planningStart == null) {
return null;
}
return clampRuntimeToWallClock(execution + planning, task.createdAt ?? task.firstExecutionAt, nowMs);
}
export function getWallClockSinceFirstExecutionMs(
firstExecutionAt: string | undefined,
executionCompletedAt: string | undefined,
nowMs: number,
): number | null {
const firstMs = parseTimestampToMs(firstExecutionAt);
if (firstMs == null) return null;
const completedMs = parseTimestampToMs(executionCompletedAt);
const endMs = completedMs != null ? completedMs : nowMs;
return Math.max(0, endMs - firstMs);
}