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 FNXC:TaskRuntimeSegments 2026-08-15-20:10:
double-counts runtime"): 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
THIS PINS A REAL DEFECT AND DOES NOT FIX IT. `applyTimingEffects` banks the segment on WIP EXIT but runtime invariant independently from renamed-lane conversion coverage.
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.
*/ */
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 { ir, wip, review } = LINEAGES[0];
const task = { const task = {
id: "FN-ROUNDTRIP", id: "FN-ROUNDTRIP",
column: review, column: review,
columnMovedAt: "2026-07-30T00:05:00.000Z", columnMovedAt: "2026-07-30T00:05:00.000Z",
executionStartedAt: "2026-07-30T00:00: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: [], steps: [],
dependencies: [], dependencies: [],
workflowStepResults: [], workflowStepResults: [],
} as unknown as Task; } as unknown as Task;
/* Exit: five minutes of work is banked. */
applyTimingEffects(makeCtx(ir, wip, review, { task })); 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.column = wip;
task.columnMovedAt = "2026-07-30T00:15:00.000Z"; task.columnMovedAt = "2026-07-30T00:15:00.000Z";
applyTimingEffects(makeCtx(ir, review, wip, { task })); 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 }));
/* expect(task.cumulativeActiveMs).toBe(8 * 60_000);
The consequence, stated as the number an operator sees. At 00:20 the card has done 5 minutes of expect(task.executionStartedAt).toBeUndefined();
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);
}); });
it("stamps executionCompletedAt on entry to the complete lane on both lineages", () => { 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. * must never be substituted for an agent session anchor.
*/ */
export function getTotalAgentActiveMs( 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, nowMs: number,
/* /*
FNXC:WorkflowLifecycleColumns 2026-07-31-03:20 (batch-core feed): 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 planningBase = Math.max(0, task.cumulativePlanningMs ?? 0);
const planningStartMs = Date.parse(task.planningStartedAt ?? ""); const planningStartMs = Date.parse(task.planningStartedAt ?? "");
const planning = planningBase + (Number.isFinite(planningStartMs) ? Math.max(0, nowMs - planningStartMs) : 0); 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) if (task.cumulativeActiveMs == null && task.cumulativePlanningMs == null && !Number.isFinite(executionStartMs) && !Number.isFinite(planningStartMs)) {
? execution + planning return null;
: 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 } { 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 * 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 * 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", * 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 * not "wall clock from start to merge" — consumers that want the latter must use immutable
* `executionStartedAt`/`executionCompletedAt`, which still span the whole run and therefore * `firstExecutionAt` through `executionCompletedAt`. `executionStartedAt` is only the current
* legitimately diverge from this sum. * 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 * 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 * 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 * 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) ? Math.max(0, segmentEndMs - segmentStartMs)
: 0; : 0;
task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs; 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)) { if (isWip(toColumn)) {
task.cumulativeActiveMs ??= 0; task.cumulativeActiveMs ??= 0;

View File

@@ -6582,6 +6582,33 @@ describe("TaskCard", () => {
expect(timer?.getAttribute("title")).not.toContain("Completed"); 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", () => { it("keeps the in-review timer live from executionStartedAt when present", () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z")); 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", () => { it("does not double count workflow runtime when timedExecutionMs is present", () => {
render( render(
<TaskTokenStatsPanel <TaskTokenStatsPanel

View File

@@ -54,6 +54,46 @@ describe("taskTiming helpers", () => {
expect(getActiveRuntimeMs({ column: "in-progress", cumulativeActiveMs: undefined, executionStartedAt: undefined, columnMovedAt: undefined }, t0)).toBeNull(); 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", () => { it("returns wall-clock runtime since first execution", () => {
const wallClock = getWallClockSinceFirstExecutionMs( const wallClock = getWallClockSinceFirstExecutionMs(
"2026-05-15T08:42:00.000Z", "2026-05-15T08:42:00.000Z",

View File

@@ -94,8 +94,20 @@ export function getEndToEndDurationMs(
return Math.max(0, endMs - startedMs); 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( export function getActiveRuntimeMs(
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "columnMovedAt">, task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "columnMovedAt">
& Partial<Pick<Task, "firstExecutionAt" | "createdAt">>,
nowMs: number, nowMs: number,
/* /*
FNXC:WorkflowLifecycleColumns 2026-07-31-10:10: FNXC:WorkflowLifecycleColumns 2026-07-31-10:10:
@@ -120,12 +132,12 @@ export function getActiveRuntimeMs(
if (isWipColumnRole(columnFlags, task.column)) { if (isWipColumnRole(columnFlags, task.column)) {
const startedMs = parseTimestampToMs(task.executionStartedAt); const startedMs = parseTimestampToMs(task.executionStartedAt);
if (startedMs != null) { 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) { if (persisted != null) {
return Math.max(0, persisted); return clampRuntimeToWallClock(Math.max(0, persisted), task.firstExecutionAt ?? task.createdAt, nowMs);
} }
return null; return null;
@@ -134,7 +146,8 @@ export function getActiveRuntimeMs(
/** FNXC:TaskTiming 2026-07-20-10:00: rendered task totals include planning AI /** FNXC:TaskTiming 2026-07-20-10:00: rendered task totals include planning AI
* segments while getActiveRuntimeMs intentionally remains execution-only. */ * segments while getActiveRuntimeMs intentionally remains execution-only. */
export function getTotalAgentActiveMs( 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, nowMs: number,
/** Resolved trait flags for the card's column; omitted keeps the legacy id. */ /** Resolved trait flags for the card's column; omitted keeps the legacy id. */
columnFlags?: ColumnRoleFlags, columnFlags?: ColumnRoleFlags,
@@ -142,9 +155,10 @@ export function getTotalAgentActiveMs(
const execution = getActiveRuntimeMs(task as never, nowMs, columnFlags) ?? 0; const execution = getActiveRuntimeMs(task as never, nowMs, columnFlags) ?? 0;
const planningStart = parseTimestampToMs(task.planningStartedAt); const planningStart = parseTimestampToMs(task.planningStartedAt);
const planning = Math.max(0, task.cumulativePlanningMs ?? 0) + (planningStart != null ? Math.max(0, nowMs - planningStart) : 0); 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 if (task.cumulativeActiveMs == null && task.cumulativePlanningMs == null && !(isWipColumnRole(columnFlags, task.column) && parseTimestampToMs(task.executionStartedAt) != null) && planningStart == null) {
? execution + planning return null;
: null; }
return clampRuntimeToWallClock(execution + planning, task.createdAt ?? task.firstExecutionAt, nowMs);
} }
export function getWallClockSinceFirstExecutionMs( 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); 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", () => { it("does NOT accrue for a card outside its board's wip lanes", () => {
const result = formatTaskPlannerChatMetrics(runningTask("checking"), { const result = formatTaskPlannerChatMetrics(runningTask("checking"), {
...at, ...at,

View File

@@ -14,6 +14,7 @@ type MetricsTask = Pick<
| "executionStartedAt" | "executionStartedAt"
| "executionCompletedAt" | "executionCompletedAt"
| "firstExecutionAt" | "firstExecutionAt"
| "createdAt"
| "cumulativeActiveMs" | "cumulativeActiveMs"
| "cumulativePlanningMs" | "cumulativePlanningMs"
| "planningStartedAt" | "planningStartedAt"
@@ -353,6 +354,7 @@ function buildTimingMetrics(
const executionStartedMs = parseTimestampToMs(task.executionStartedAt, malformedTimestamps); const executionStartedMs = parseTimestampToMs(task.executionStartedAt, malformedTimestamps);
const executionCompletedMs = parseTimestampToMs(task.executionCompletedAt, malformedTimestamps); const executionCompletedMs = parseTimestampToMs(task.executionCompletedAt, malformedTimestamps);
const firstExecutionMs = parseTimestampToMs(task.firstExecutionAt, malformedTimestamps); const firstExecutionMs = parseTimestampToMs(task.firstExecutionAt, malformedTimestamps);
const createdMs = parseTimestampToMs(task.createdAt, malformedTimestamps);
const executionStartedAt = executionStartedMs == null ? null : task.executionStartedAt ?? null; const executionStartedAt = executionStartedMs == null ? null : task.executionStartedAt ?? null;
const executionCompletedAt = executionCompletedMs == null ? null : task.executionCompletedAt ?? null; const executionCompletedAt = executionCompletedMs == null ? null : task.executionCompletedAt ?? null;
const firstExecutionAt = firstExecutionMs == null ? null : task.firstExecutionAt ?? 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. the legacy id — the documented no-metadata answer, not a floor.
*/ */
const wipColumns = options.wipColumns ?? new Set(["in-progress"]); 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 ?? 0) + Math.max(0, nowMs - executionStartedMs)
: cumulativeActiveMs; : 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 cumulativePlanningMs = optionalFiniteNumber(task.cumulativePlanningMs);
const planningStartedMs = parseTimestampToMs(task.planningStartedAt, malformedTimestamps); const planningStartedMs = parseTimestampToMs(task.planningStartedAt, malformedTimestamps);
const totalActiveMs = (activeRuntimeMs != null || cumulativePlanningMs != null || planningStartedMs != null) 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; : null;
const timingEvents = extractTimingEvents(task.log); const timingEvents = extractTimingEvents(task.log);