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
|
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", () => {
|
||||||
|
|||||||
@@ -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 } {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user