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>
This commit is contained in:
gsxdsm
2026-08-15 13:39:24 -07:00
parent 743251a5d3
commit 7ed1c39a67
10 changed files with 262 additions and 50 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix task runtime chips that over-counted active time after review/replan round-trips.
category: fix
dev: Clear executionStartedAt when banking cumulativeActiveMs on WIP exit; clamp active-time readers to wall-clock age.

View File

@@ -297,57 +297,88 @@ describe("timing, completion and in-review effects are keyed on ROLES", () => {
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:40 (#2842 review — greptile P1, "preserved segment start
double-counts runtime"):
THIS PINS A REAL DEFECT AND DOES NOT FIX IT. `applyTimingEffects` banks the segment on WIP EXIT but
never clears `executionStartedAt`, and its re-entry arm is `if (!task.executionStartedAt)` — so the
original start survives the round trip. Every later live-tail reader (`getTotalAgentActiveMs`, the
planner metrics tool, the dashboard duration displays) then computes `now - originalStart`, which
re-adds the banked segment PLUS all the non-WIP time in between.
NOT A RENAMED-BOARD BUG, and that is why it is pinned rather than folded into a conversion PR: the
case below runs the DEFAULT lineage, where every id is legacy. It is an accounting bug in core that
predates this program, and correcting it changes numbers on `productivity-analytics.ts` and every
duration display — a behaviour change that deserves its own review, not a line in a batch that says
it only converts vocabulary.
THE FIX, so it is not lost: clear `executionStartedAt` in the exit arm right after banking the
segment. The re-entry arm already re-stamps it from `columnMovedAt`, so the next segment starts at
the re-entry moment, which is the definition the field's own doc-comment gives.
When that lands this expectation flips from 10 to 5 minutes and this note goes with it.
FNXC:TaskRuntimeSegments 2026-08-15-20:10:
A WIP exit must close its segment by clearing `executionStartedAt`; the re-entry arm stamps the
next segment from `columnMovedAt`. These default-lineage checks protect the operator-visible
runtime invariant independently from renamed-lane conversion coverage.
*/
it("KNOWN DEFECT: a WIP round trip leaves the old executionStartedAt, so the live tail double-counts", () => {
it("reports only banked and current WIP segments after a review round trip", () => {
const { ir, wip, review } = LINEAGES[0];
const task = {
id: "FN-ROUNDTRIP",
column: review,
columnMovedAt: "2026-07-30T00:05:00.000Z",
executionStartedAt: "2026-07-30T00:00:00.000Z",
firstExecutionAt: "2026-07-30T00:00:00.000Z",
steps: [],
dependencies: [],
workflowStepResults: [],
} as unknown as Task;
applyTimingEffects(makeCtx(ir, wip, review, { task }));
expect(task.cumulativeActiveMs).toBe(5 * 60_000);
expect(task.executionStartedAt).toBeUndefined();
task.column = wip;
task.columnMovedAt = "2026-07-30T00:15:00.000Z";
applyTimingEffects(makeCtx(ir, review, wip, { task }));
expect(task.executionStartedAt).toBe("2026-07-30T00:15:00.000Z");
expect(task.column).toBe(wip);
expect(getTotalAgentActiveMs(
task,
Date.parse("2026-07-30T00:20:00.000Z"),
{ countsTowardWip: true } as never,
)).toBe(10 * 60_000);
});
it("caps historical poisoned active totals at the task wall-clock age", () => {
const task = {
column: "in-review",
cumulativeActiveMs: 4 * 24 * 60 * 60_000,
createdAt: "2026-07-30T00:00:00.000Z",
} as Task;
expect(getTotalAgentActiveMs(task, Date.parse("2026-07-30T07:00:00.000Z")))
.toBe(7 * 60 * 60_000);
});
it("retains planning accrued before first execution when applying the wall-clock ceiling", () => {
const task = {
column: "in-progress",
createdAt: "2026-07-30T09:00:00.000Z",
firstExecutionAt: "2026-07-30T10:00:00.000Z",
cumulativePlanningMs: 30 * 60_000,
cumulativeActiveMs: 0,
executionStartedAt: "2026-07-30T10:00:00.000Z",
} as Task;
expect(getTotalAgentActiveMs(task, Date.parse("2026-07-30T10:00:00.000Z")))
.toBe(30 * 60_000);
});
it("banks each closed WIP visit once rather than from the first visit", () => {
const { ir, wip, review } = LINEAGES[0];
const task = {
id: "FN-MULTI-VISIT",
column: review,
columnMovedAt: "2026-07-30T00:05:00.000Z",
executionStartedAt: "2026-07-30T00:00:00.000Z",
steps: [],
dependencies: [],
workflowStepResults: [],
} as unknown as Task;
/* Exit: five minutes of work is banked. */
applyTimingEffects(makeCtx(ir, wip, review, { task }));
expect(task.cumulativeActiveMs).toBe(5 * 60_000);
/* Re-entry ten minutes later. The start should move to the re-entry moment; it does not. */
task.column = wip;
task.columnMovedAt = "2026-07-30T00:15:00.000Z";
applyTimingEffects(makeCtx(ir, review, wip, { task }));
expect(task.executionStartedAt).toBe("2026-07-30T00:00:00.000Z");
task.column = review;
task.columnMovedAt = "2026-07-30T00:18:00.000Z";
applyTimingEffects(makeCtx(ir, wip, review, { task }));
/*
The consequence, stated as the number an operator sees. At 00:20 the card has done 5 minutes of
banked work plus 5 minutes of live work — 10 total. `getTotalAgentActiveMs` reports 20: the banked
5, plus `now - 00:00` which is itself 20 minutes of wall-clock including the 10 minutes the card
spent in review.
*/
expect(getTotalAgentActiveMs(task, Date.parse("2026-07-30T00:20:00.000Z")))
.toBe(5 * 60_000 + 20 * 60_000);
expect(task.cumulativeActiveMs).toBe(8 * 60_000);
expect(task.executionStartedAt).toBeUndefined();
});
it("stamps executionCompletedAt on entry to the complete lane on both lineages", () => {

View File

@@ -8,7 +8,8 @@ import { isWipColumnRole, type ColumnRoleTraitFlags } from "../column-roles.js";
* must never be substituted for an agent session anchor.
*/
export function getTotalAgentActiveMs(
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "cumulativePlanningMs" | "planningStartedAt">,
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "cumulativePlanningMs" | "planningStartedAt">
& Partial<Pick<Task, "firstExecutionAt" | "createdAt">>,
nowMs: number,
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-03:20 (batch-core feed):
@@ -29,9 +30,20 @@ export function getTotalAgentActiveMs(
const planningBase = Math.max(0, task.cumulativePlanningMs ?? 0);
const planningStartMs = Date.parse(task.planningStartedAt ?? "");
const planning = planningBase + (Number.isFinite(planningStartMs) ? Math.max(0, nowMs - planningStartMs) : 0);
return task.cumulativeActiveMs != null || task.cumulativePlanningMs != null || Number.isFinite(executionStartMs) || Number.isFinite(planningStartMs)
? execution + planning
: null;
if (task.cumulativeActiveMs == null && task.cumulativePlanningMs == null && !Number.isFinite(executionStartMs) && !Number.isFinite(planningStartMs)) {
return null;
}
/*
FNXC:TaskRuntimeSegments 2026-08-15-20:34:
Closed segments can be historically poisoned by the former sticky execution anchor. Never expose
their accumulated total beyond the task's real age; this display defense heals old rows while the
move hook prevents new poison. This total includes planning recorded before first execution, so
creation is the earliest durable wall-clock ceiling and must take precedence over firstExecutionAt.
*/
const ageAnchorMs = Date.parse(task.createdAt ?? task.firstExecutionAt ?? "");
const total = execution + planning;
return Number.isFinite(ageAnchorMs) ? Math.min(total, Math.max(0, nowMs - ageAnchorMs)) : total;
}
export function startPlanningSegment<T extends Pick<Task, "planningStartedAt">>(task: T, nowMs = Date.now()): { planningStartedAt?: string } {

View File

@@ -150,9 +150,9 @@ function inRole(
* segments, closed on each exit. Since the pre-merge review gates moved into `in-review`, gate
* runtime is NOT included: the segment closes when the card crosses into review, and no new
* segment opens until remediation re-enters `in-progress`. Read it as "implementation time",
* not "wall clock from start to merge" — consumers that want the latter must use
* `executionStartedAt`/`executionCompletedAt`, which still span the whole run and therefore
* legitimately diverge from this sum.
* not "wall clock from start to merge" — consumers that want the latter must use immutable
* `firstExecutionAt` through `executionCompletedAt`. `executionStartedAt` is only the current
* open WIP segment and is cleared as soon as that segment is banked.
* Deliberately NOT fixed by adding the `timing` trait to `in-review`: that column also holds the
* arbitrary human merge-wait, so counting it would overstate active time by hours of idle
* latency — a worse distortion than omitting the gate's own minutes. Attributing gate runtime
@@ -179,6 +179,14 @@ export function applyTimingEffects(ctx: DefaultWorkflowMoveContext): void {
? Math.max(0, segmentEndMs - segmentStartMs)
: 0;
task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs;
/*
FNXC:TaskRuntimeSegments 2026-08-15-20:10:
A banked WIP interval is closed, so its start cannot remain as a live-segment anchor. Keeping
it caused every re-entry reader and later exit to recount closed work plus non-WIP idle time.
`firstExecutionAt` remains the immutable wall-clock anchor; entry below stamps this field only
for the next actual WIP segment.
*/
task.executionStartedAt = undefined;
}
if (isWip(toColumn)) {
task.cumulativeActiveMs ??= 0;

View File

@@ -6582,6 +6582,33 @@ describe("TaskCard", () => {
expect(timer?.getAttribute("title")).not.toContain("Completed");
});
it("caps poisoned active runtime chips at task age in WIP, review, and done lanes", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-15T15:00:00.000Z"));
try {
for (const column of ["in-progress", "in-review", "done"]) {
const { container, unmount } = render(
<TaskCard
task={makeTask({
column,
createdAt: "2026-05-15T08:00:00.000Z",
cumulativeActiveMs: 4 * 24 * 60 * 60_000,
executionStartedAt: column === "in-progress" ? "2026-05-15T14:55:00.000Z" : undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("7h");
expect(container.querySelector(".card-time-indicator")?.textContent).not.toContain("4d");
unmount();
}
} finally {
vi.useRealTimers();
}
});
it("keeps the in-review timer live from executionStartedAt when present", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z"));

View File

@@ -224,6 +224,33 @@ describe("TaskTokenStatsPanel", () => {
}
});
it("caps poisoned total execution time at task age", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-15T15:00:00.000Z"));
try {
render(
<TaskTokenStatsPanel
loading={false}
tokenUsage={undefined}
task={makeTask({
column: "in-review",
createdAt: "2026-05-15T08:00:00.000Z",
cumulativeActiveMs: 4 * 24 * 60 * 60_000,
executionStartedAt: undefined,
workflowStepResults: [],
log: [],
})}
/>,
);
const metric = screen.getByText("Total execution time").closest(".task-token-stats-panel__metric");
expect(metric).toHaveTextContent("420m 0s");
expect(metric).not.toHaveTextContent("4d");
} finally {
vi.useRealTimers();
}
});
it("does not double count workflow runtime when timedExecutionMs is present", () => {
render(
<TaskTokenStatsPanel

View File

@@ -54,6 +54,46 @@ describe("taskTiming helpers", () => {
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",

View File

@@ -94,8 +94,20 @@ export function getEndToEndDurationMs(
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">,
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "columnMovedAt">
& Partial<Pick<Task, "firstExecutionAt" | "createdAt">>,
nowMs: number,
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-10:10:
@@ -120,12 +132,12 @@ export function getActiveRuntimeMs(
if (isWipColumnRole(columnFlags, task.column)) {
const startedMs = parseTimestampToMs(task.executionStartedAt);
if (startedMs != null) {
return base + Math.max(0, nowMs - startedMs);
return clampRuntimeToWallClock(base + Math.max(0, nowMs - startedMs), task.firstExecutionAt ?? task.createdAt, nowMs);
}
}
if (persisted != null) {
return Math.max(0, persisted);
return clampRuntimeToWallClock(Math.max(0, persisted), task.firstExecutionAt ?? task.createdAt, nowMs);
}
return null;
@@ -134,7 +146,8 @@ export function getActiveRuntimeMs(
/** 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">,
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,
@@ -142,9 +155,10 @@ export function getTotalAgentActiveMs(
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);
return task.cumulativeActiveMs != null || task.cumulativePlanningMs != null || (isWipColumnRole(columnFlags, task.column) && parseTimestampToMs(task.executionStartedAt) != null) || planningStart != null
? execution + planning
: null;
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(

View File

@@ -297,6 +297,34 @@ describe("formatTaskPlannerChatMetrics: active runtime keys on the WIP role", ()
expect(result.metrics.timing.activeRuntimeMs).toBe(360_000);
});
it("caps poisoned active totals at first execution while retaining a live renamed segment", () => {
const result = formatTaskPlannerChatMetrics(makeTask({
column: "building",
firstExecutionAt: "2026-07-01T10:00:00.000Z",
cumulativeActiveMs: 4 * 24 * 60 * 60_000,
executionStartedAt: "2026-07-01T10:15:00.000Z",
cumulativePlanningMs: 60_000,
planningStartedAt: "2026-07-01T10:19:00.000Z",
}), { nowMs: Date.parse("2026-07-01T10:20:00.000Z"), wipColumns: new Set(["building"]) });
expect(result.metrics.timing.activeRuntimeMs).toBe(20 * 60_000);
expect(result.metrics.timing.totalExecutionMs).toBe(10 * 60 * 60_000 + 20 * 60_000);
});
it("retains planning accrued before first execution in the combined total", () => {
const result = formatTaskPlannerChatMetrics(makeTask({
column: "in-progress",
createdAt: "2026-07-01T09:00:00.000Z",
firstExecutionAt: "2026-07-01T10:00:00.000Z",
cumulativeActiveMs: 0,
executionStartedAt: "2026-07-01T10:00:00.000Z",
cumulativePlanningMs: 30 * 60_000,
}), { nowMs: Date.parse("2026-07-01T10:00:00.000Z") });
expect(result.metrics.timing.activeRuntimeMs).toBe(0);
expect(result.metrics.timing.totalExecutionMs).toBe(30 * 60_000);
});
it("does NOT accrue for a card outside its board's wip lanes", () => {
const result = formatTaskPlannerChatMetrics(runningTask("checking"), {
...at,

View File

@@ -14,6 +14,7 @@ type MetricsTask = Pick<
| "executionStartedAt"
| "executionCompletedAt"
| "firstExecutionAt"
| "createdAt"
| "cumulativeActiveMs"
| "cumulativePlanningMs"
| "planningStartedAt"
@@ -353,6 +354,7 @@ function buildTimingMetrics(
const executionStartedMs = parseTimestampToMs(task.executionStartedAt, malformedTimestamps);
const executionCompletedMs = parseTimestampToMs(task.executionCompletedAt, malformedTimestamps);
const firstExecutionMs = parseTimestampToMs(task.firstExecutionAt, malformedTimestamps);
const createdMs = parseTimestampToMs(task.createdAt, malformedTimestamps);
const executionStartedAt = executionStartedMs == null ? null : task.executionStartedAt ?? null;
const executionCompletedAt = executionCompletedMs == null ? null : task.executionCompletedAt ?? null;
const firstExecutionAt = firstExecutionMs == null ? null : task.firstExecutionAt ?? null;
@@ -380,14 +382,30 @@ function buildTimingMetrics(
the legacy id — the documented no-metadata answer, not a floor.
*/
const wipColumns = options.wipColumns ?? new Set(["in-progress"]);
const activeRuntimeMs = wipColumns.has(task.column) && executionStartedMs != null
const unboundedActiveRuntimeMs = wipColumns.has(task.column) && executionStartedMs != null
? (cumulativeActiveMs ?? 0) + Math.max(0, nowMs - executionStartedMs)
: cumulativeActiveMs;
/*
FNXC:TaskRuntimeSegments 2026-08-15-20:34:
Planner metrics share the card's closed-segment contract. Execution-only values are bounded by
first execution, but combined totals use creation because planning legitimately starts earlier.
*/
const clampToWallClockAge = (value: number, ageAnchorMs: number | null) => ageAnchorMs == null
? value
: Math.min(value, Math.max(0, nowMs - ageAnchorMs));
const activeAgeAnchorMs = firstExecutionMs ?? createdMs;
const totalAgeAnchorMs = createdMs ?? firstExecutionMs;
const activeRuntimeMs = unboundedActiveRuntimeMs == null
? null
: clampToWallClockAge(unboundedActiveRuntimeMs, activeAgeAnchorMs);
const cumulativePlanningMs = optionalFiniteNumber(task.cumulativePlanningMs);
const planningStartedMs = parseTimestampToMs(task.planningStartedAt, malformedTimestamps);
const totalActiveMs = (activeRuntimeMs != null || cumulativePlanningMs != null || planningStartedMs != null)
? (activeRuntimeMs ?? 0) + (cumulativePlanningMs ?? 0) + (planningStartedMs != null ? Math.max(0, nowMs - planningStartedMs) : 0)
? clampToWallClockAge(
(unboundedActiveRuntimeMs ?? 0) + (cumulativePlanningMs ?? 0) + (planningStartedMs != null ? Math.max(0, nowMs - planningStartedMs) : 0),
totalAgeAnchorMs,
)
: null;
const timingEvents = extractTimingEvents(task.log);