From 7ed1c39a67a7ee86dd18dc88d78cc75130bc0062 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 13:39:24 -0700 Subject: [PATCH] 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) --- .../fn-9102-task-runtime-double-count.md | 7 ++ .../reopen-semantics-by-role.test.ts | 99 ++++++++++++------- packages/core/src/tasks/task-timing.ts | 20 +++- .../src/workflows/default-workflow-hooks.ts | 14 ++- .../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 ++++-- .../task-planner-chat-metrics.test.ts | 28 ++++++ .../src/task-planner-chat-metrics.ts | 22 ++++- 10 files changed, 262 insertions(+), 50 deletions(-) create mode 100644 .changeset/fn-9102-task-runtime-double-count.md diff --git a/.changeset/fn-9102-task-runtime-double-count.md b/.changeset/fn-9102-task-runtime-double-count.md new file mode 100644 index 0000000000..a76c43e92d --- /dev/null +++ b/.changeset/fn-9102-task-runtime-double-count.md @@ -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. diff --git a/packages/core/src/__tests__/reopen-semantics-by-role.test.ts b/packages/core/src/__tests__/reopen-semantics-by-role.test.ts index 6746596ef6..fb4d52c464 100644 --- a/packages/core/src/__tests__/reopen-semantics-by-role.test.ts +++ b/packages/core/src/__tests__/reopen-semantics-by-role.test.ts @@ -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", () => { diff --git a/packages/core/src/tasks/task-timing.ts b/packages/core/src/tasks/task-timing.ts index d2bc508d9d..1b3a78ba92 100644 --- a/packages/core/src/tasks/task-timing.ts +++ b/packages/core/src/tasks/task-timing.ts @@ -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: Pick + & Partial>, 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>(task: T, nowMs = Date.now()): { planningStartedAt?: string } { diff --git a/packages/core/src/workflows/default-workflow-hooks.ts b/packages/core/src/workflows/default-workflow-hooks.ts index b070f4171d..51efb0978d 100644 --- a/packages/core/src/workflows/default-workflow-hooks.ts +++ b/packages/core/src/workflows/default-workflow-hooks.ts @@ -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; diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 01383d5083..aacbc98361 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( + , + ); + + 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")); diff --git a/packages/dashboard/app/components/__tests__/TaskTokenStatsPanel.test.tsx b/packages/dashboard/app/components/__tests__/TaskTokenStatsPanel.test.tsx index 53d8351c11..28d3c62d11 100644 --- a/packages/dashboard/app/components/__tests__/TaskTokenStatsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskTokenStatsPanel.test.tsx @@ -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( + , + ); + + 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( { 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", diff --git a/packages/dashboard/app/utils/taskTiming.ts b/packages/dashboard/app/utils/taskTiming.ts index d1ea596caa..701188b5c8 100644 --- a/packages/dashboard/app/utils/taskTiming.ts +++ b/packages/dashboard/app/utils/taskTiming.ts @@ -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: Pick + & Partial>, 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: Pick + & Partial>, 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( diff --git a/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts b/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts index 04501dc952..b496431fa5 100644 --- a/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts +++ b/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts @@ -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, diff --git a/packages/dashboard/src/task-planner-chat-metrics.ts b/packages/dashboard/src/task-planner-chat-metrics.ts index 1c4479acd6..66e104c5de 100644 --- a/packages/dashboard/src/task-planner-chat-metrics.ts +++ b/packages/dashboard/src/task-planner-chat-metrics.ts @@ -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);