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>
159 lines
6.0 KiB
TypeScript
159 lines
6.0 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { getActiveRuntimeMs, getTotalAgentActiveMs, getWallClockSinceFirstExecutionMs } from "../taskTiming";
|
|
|
|
describe("taskTiming helpers", () => {
|
|
it("returns persisted plus live segment for in-progress tasks", () => {
|
|
const nowMs = Date.parse("2026-05-15T13:16:00.000Z");
|
|
const runtime = getActiveRuntimeMs(
|
|
{
|
|
column: "in-progress",
|
|
cumulativeActiveMs: 240_000,
|
|
executionStartedAt: "2026-05-15T13:15:00.000Z",
|
|
columnMovedAt: "2026-05-15T13:15:00.000Z",
|
|
},
|
|
nowMs,
|
|
);
|
|
|
|
expect(runtime).toBe(300_000);
|
|
});
|
|
|
|
it("sums planning and execution segments without using idle dwell", () => {
|
|
expect(getTotalAgentActiveMs({
|
|
column: "done", cumulativeActiveMs: 120_000, executionStartedAt: undefined,
|
|
cumulativePlanningMs: 180_000, planningStartedAt: undefined,
|
|
}, Date.parse("2026-05-15T13:16:00.000Z"))).toBe(300_000);
|
|
});
|
|
|
|
it("returns null when there is no active-runtime signal", () => {
|
|
const runtime = getActiveRuntimeMs(
|
|
{
|
|
column: "todo",
|
|
cumulativeActiveMs: undefined,
|
|
executionStartedAt: undefined,
|
|
columnMovedAt: undefined,
|
|
},
|
|
Date.now(),
|
|
);
|
|
|
|
expect(runtime).toBeNull();
|
|
});
|
|
|
|
it("uses shifted executionStartedAt so the active badge excludes engine-down time", () => {
|
|
const t0 = Date.parse("2026-06-25T00:00:00.000Z");
|
|
const runtime = getActiveRuntimeMs(
|
|
{
|
|
column: "in-progress",
|
|
cumulativeActiveMs: undefined,
|
|
executionStartedAt: new Date(t0 + 60 * 60_000).toISOString(),
|
|
columnMovedAt: new Date(t0).toISOString(),
|
|
},
|
|
t0 + 65 * 60_000,
|
|
);
|
|
|
|
expect(runtime).toBe(5 * 60_000);
|
|
expect(getActiveRuntimeMs({ column: "in-progress", cumulativeActiveMs: undefined, executionStartedAt: undefined, columnMovedAt: undefined }, t0)).toBeNull();
|
|
});
|
|
|
|
it("caps a poisoned cumulative total at the task wall-clock age", () => {
|
|
const createdAt = "2026-05-15T08:00:00.000Z";
|
|
const nowMs = Date.parse("2026-05-15T15:00:00.000Z");
|
|
const task = {
|
|
column: "in-review",
|
|
cumulativeActiveMs: 4 * 24 * 60 * 60_000,
|
|
executionStartedAt: undefined,
|
|
createdAt,
|
|
};
|
|
|
|
expect(getActiveRuntimeMs(task, nowMs)).toBe(7 * 60 * 60_000);
|
|
expect(getTotalAgentActiveMs({ ...task, cumulativePlanningMs: 0, planningStartedAt: undefined }, nowMs))
|
|
.toBe(7 * 60 * 60_000);
|
|
});
|
|
|
|
it("retains planning accrued before first execution when applying the wall-clock ceiling", () => {
|
|
const nowMs = Date.parse("2026-05-15T10:00:00.000Z");
|
|
expect(getTotalAgentActiveMs({
|
|
column: "in-progress",
|
|
createdAt: "2026-05-15T09:00:00.000Z",
|
|
firstExecutionAt: "2026-05-15T10:00:00.000Z",
|
|
cumulativeActiveMs: 0,
|
|
executionStartedAt: "2026-05-15T10:00:00.000Z",
|
|
cumulativePlanningMs: 30 * 60_000,
|
|
planningStartedAt: undefined,
|
|
}, nowMs)).toBe(30 * 60_000);
|
|
});
|
|
|
|
it("counts only the banked and current segments after a WIP round trip", () => {
|
|
const nowMs = Date.parse("2026-05-15T08:20:00.000Z");
|
|
expect(getTotalAgentActiveMs({
|
|
column: "in-progress",
|
|
cumulativeActiveMs: 5 * 60_000,
|
|
executionStartedAt: "2026-05-15T08:15:00.000Z",
|
|
cumulativePlanningMs: undefined,
|
|
planningStartedAt: undefined,
|
|
firstExecutionAt: "2026-05-15T08:00:00.000Z",
|
|
}, nowMs)).toBe(10 * 60_000);
|
|
});
|
|
|
|
it("returns wall-clock runtime since first execution", () => {
|
|
const wallClock = getWallClockSinceFirstExecutionMs(
|
|
"2026-05-15T08:42:00.000Z",
|
|
"2026-05-15T13:17:00.000Z",
|
|
Date.parse("2026-05-15T13:20:00.000Z"),
|
|
);
|
|
|
|
expect(wallClock).toBe(16_500_000);
|
|
});
|
|
});
|
|
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-31-10:10:
|
|
|
|
THE INVARIANT: the card's active-time chip counts the live run from the card's OWN wip lane.
|
|
|
|
THE FINDING THAT MATTERS MORE THAN THE FIX: `@fusion/core` exports its own `getTotalAgentActiveMs`,
|
|
and it was already converted onto `isWipColumnRole`. The card chip imports THIS module instead — a
|
|
second implementation of the same calculation in a different package — so that conversion never
|
|
reached the surface an operator looks at. The census counted core's site as done while the rendered
|
|
number stayed wrong. Two implementations of one rule, one converted and one not, is exactly the drift
|
|
`column-roles.ts` exists to end.
|
|
|
|
Keyed on the literal, the live execution segment was dropped on a renamed board: the chip
|
|
under-reported the run in flight by exactly its elapsed time, then healed itself the moment the card
|
|
moved on and the segment was persisted into `cumulativeActiveMs`. A number that is wrong only while
|
|
you are watching it.
|
|
|
|
REVERT PROOF, measured: restore `task.column === "in-progress"` in `getActiveRuntimeMs` and the
|
|
renamed-lane cases below fail.
|
|
*/
|
|
describe("active-time resolves the card's own wip lane", () => {
|
|
const WIP_FLAGS = { countsTowardWip: true } as never;
|
|
const NOW = Date.parse("2026-07-31T12:00:00Z");
|
|
const STARTED = "2026-07-31T11:00:00Z";
|
|
const HOUR = 60 * 60 * 1000;
|
|
|
|
it("counts the in-flight run for a RENAMED wip lane", () => {
|
|
expect(getActiveRuntimeMs(
|
|
{ column: "building", cumulativeActiveMs: 0, executionStartedAt: STARTED } as never, NOW, WIP_FLAGS,
|
|
)).toBe(HOUR);
|
|
});
|
|
|
|
it("includes it in the rendered total", () => {
|
|
expect(getTotalAgentActiveMs(
|
|
{ column: "building", cumulativeActiveMs: 0, executionStartedAt: STARTED } as never, NOW, WIP_FLAGS,
|
|
)).toBe(HOUR);
|
|
});
|
|
|
|
it("does NOT count a live segment outside the wip lane", () => {
|
|
// A stale executionStartedAt on a review card is not active time.
|
|
expect(getActiveRuntimeMs(
|
|
{ column: "signoff", cumulativeActiveMs: 0, executionStartedAt: STARTED } as never, NOW, { mergeBlocker: true } as never,
|
|
)).toBe(0);
|
|
});
|
|
|
|
it("keeps the legacy id when no flags are supplied", () => {
|
|
expect(getActiveRuntimeMs(
|
|
{ column: "in-progress", cumulativeActiveMs: 0, executionStartedAt: STARTED } as never, NOW,
|
|
)).toBe(HOUR);
|
|
});
|
|
});
|