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:
7
.changeset/fn-9102-task-runtime-double-count.md
Normal file
7
.changeset/fn-9102-task-runtime-double-count.md
Normal 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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user