From eed8ca55fcf87639848d92cdad13c9eea9bef6a9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 15:16:24 -0700 Subject: [PATCH] =?UTF-8?q?batch-dashboard-src:=20the=20planner=20metrics?= =?UTF-8?q?=20tool=20froze=20active=20runtime=20on=20a=20renamed=20executi?= =?UTF-8?q?on=20lane=20(186=20=E2=86=92=20185)=20(#2842)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/cli` and the plugin packages are at **zero** lifecycle guards, so this picks up the nearest unowned work: the `packages/dashboard/src/` remainder. ## The defect `activeRuntimeMs` adds the wall-clock since `executionStartedAt` only while the card is accruing work — the **WIP role** — but it was keyed on the literal `in-progress`. On a board whose execution lane is renamed, that live tail was dropped, so `fn_task_planner_get_task_metrics` reported active time frozen at whatever the last completed segment left in `cumulativeActiveMs`. The number stayed plausible, which is why nothing surfaced it. ## The part worth reading: the wiring had no watcher, from either direction I wired the producer (`chat.ts` resolves the task's own lanes via `wipColumnsForTask`) in the same commit, then checked whether that wiring was actually covered. It was not: - **Deleting the `wipColumns:` argument left the entire 3830-test dashboard suite green.** The formatter's own tests inject the set by hand, so they prove the *guard* and are structurally blind to whether production fills it. - **`check-inert-flag-seams.mjs` does not see it either.** It tracks trailing optional **parameters**; this is a property inside an options bag. That is a real gap in the checker — every seam expressed as an options-bag property is currently unguarded. Reported here rather than fixed, because #2822 and #2830 both already modify that script and a third change would guarantee a three-way conflict. So `createTaskPlannerMetricsTool` is exported and a second test drives it, letting it do its **own** resolution against a renamed board. Deleting the argument now fails 1 of 2. ## Census | | before | after | |---|---|---| | COLUMN guards | 186 | **185** | | `packages/dashboard/src/task-planner-chat-metrics.ts` | 1 | **0** | Baseline re-recorded; `--strict` exits 0. ## Two findings I did NOT act on, deliberately **1. `github-tracking-state.ts` keeps 2 counted guards and should.** They are the documented degraded-mode arms of a fully-resolved classifier (`completeLanes === undefined ? columnId === "done" : ...`). Marking them `DELIBERATE-LITERAL` would drop the count by **reclassification rather than conversion** — the exact move the census's own strict-check warns about. Related: the census reports `0 are trait-fallback branches (already converted)`, yet these are precisely that shape, so the trait-fallback classifier appears not to recognise a ternary whose fallback arm is the literal. Worth a look by whoever owns the census. **2. Three pre-existing failures in `packages/dashboard/src/__tests__`, unrelated to this change** — measured identically on `origin/main` before and after: - `planning-browser-e2e.test.ts:353` - `register-model-routes-kimi-k3-supplemental.test.ts:60` - `routes-tasks-near-duplicate.test.ts:274` Flagging rather than touching them; per the standing rule they are quarantine candidates, not appeasement candidates. ## Verification Dashboard `tsc` clean, `pnpm lint` clean, census `--strict` 0, `check-inert-flag-seams` 21/21 supplied, changeset lint clean. Targeted suites: `task-planner-chat-metrics.test.ts` 8/8, `task-planner-metrics-tool-wip-lanes.test.ts` 2/2. --------- Co-authored-by: Claude Opus 5 (1M context) --- .changeset/planner-metrics-wip-lane.md | 7 ++ .../reopen-semantics-by-role.test.ts | 55 +++++++++++ .../task-planner-chat-metrics.test.ts | 52 ++++++++++ ...ask-planner-metrics-tool-wip-lanes.test.ts | 96 +++++++++++++++++++ packages/dashboard/src/chat.ts | 20 +++- ...-workflow-routes.awaiting-planning.test.ts | 72 +++++++++++++- .../src/task-planner-chat-metrics.ts | 33 ++++++- .../lib/lifecycle-column-census-baseline.json | 1 - 8 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 .changeset/planner-metrics-wip-lane.md create mode 100644 packages/dashboard/src/__tests__/task-planner-metrics-tool-wip-lanes.test.ts diff --git a/.changeset/planner-metrics-wip-lane.md b/.changeset/planner-metrics-wip-lane.md new file mode 100644 index 0000000000..1deef4d5c4 --- /dev/null +++ b/.changeset/planner-metrics-wip-lane.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix frozen active-runtime metrics for tasks on boards with a renamed execution column. +category: fix +dev: `formatTaskPlannerChatMetrics` gained a `wipColumns` option and `chat.ts`'s `fn_task_planner_get_task_metrics` tool resolves it from the task's own workflow via `wipColumnsForTask`. Previously `activeRuntimeMs` added the live tail since `executionStartedAt` only when `task.column === "in-progress"`, so on a renamed board it reported whatever `cumulativeActiveMs` held from the last completed segment. `createTaskPlannerMetricsTool` is exported so the resolver side of the seam is testable. 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 b400a73ed6..4e51cb46c4 100644 --- a/packages/core/src/__tests__/reopen-semantics-by-role.test.ts +++ b/packages/core/src/__tests__/reopen-semantics-by-role.test.ts @@ -38,6 +38,7 @@ import { registerDefaultWorkflowHooks, type DefaultWorkflowMoveContext, } from "../default-workflow-hooks.js"; +import { getTotalAgentActiveMs } from "../task-timing.js"; import { resolveLifecycleColumns } from "../workflow-lifecycle-traits.js"; import type { WorkflowIr } from "../workflow-ir-types.js"; import type { Task } from "../types.js"; @@ -295,6 +296,60 @@ 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. + */ + it("KNOWN DEFECT: a WIP round trip leaves the old executionStartedAt, so the live tail double-counts", () => { + 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", + 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"); + + /* + 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); + }); + it("stamps executionCompletedAt on entry to the complete lane on both lineages", () => { for (const { label, ir, review, complete } of LINEAGES) { const ctx = makeCtx(ir, review, complete); 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 d6f39a59e6..04501dc952 100644 --- a/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts +++ b/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts @@ -262,3 +262,55 @@ describe("formatTaskPlannerChatMetrics", () => { expect(result.metrics.timing.malformedTimestamps).toContain("not-a-date"); }); }); + +/* +FNXC:WorkflowResolvedColumns 2026-07-30-16:25 (batch-dashboard-src): + +THE LIVE TAIL OF ACTIVE RUNTIME MUST ACCRUE ON A RENAMED EXECUTION LANE. + +`activeRuntimeMs` adds the wall-clock since `executionStartedAt` only while the card is in a WIP +lane. Keyed on the literal `in-progress`, that tail was dropped for every card on a board whose +execution lane is named anything else, so the planner's own metrics tool reported active time frozen +at whatever the last completed segment left in `cumulativeActiveMs`. The number stayed plausible, +which is why nothing surfaced it. + +WHY THE CASES COME IN PAIRS. A "renamed lane accrues" test alone also passes if the guard is deleted +outright and every column accrues; the negative is what proves the WIP question is still being asked. +The `undefined` case pins the documented degraded answer so a future change cannot quietly turn the +no-metadata path into "accrue everywhere" either. +*/ +describe("formatTaskPlannerChatMetrics: active runtime keys on the WIP role", () => { + const runningTask = (column: string) => makeTask({ + column, + executionStartedAt: "2026-07-01T10:00:00.000Z", + cumulativeActiveMs: 60_000, + }); + const at = { nowMs: Date.parse("2026-07-01T10:05:00.000Z") }; + + it("accrues the live tail in a RENAMED wip lane when the caller supplies its lanes", () => { + const result = formatTaskPlannerChatMetrics(runningTask("building"), { + ...at, + wipColumns: new Set(["building"]), + }); + + /* 60s banked + 300s since executionStartedAt. With the literal this was 60_000 — frozen. */ + expect(result.metrics.timing.activeRuntimeMs).toBe(360_000); + }); + + it("does NOT accrue for a card outside its board's wip lanes", () => { + const result = formatTaskPlannerChatMetrics(runningTask("checking"), { + ...at, + wipColumns: new Set(["building"]), + }); + + expect(result.metrics.timing.activeRuntimeMs).toBe(60_000); + }); + + it("falls back to the legacy id when no lanes are supplied", () => { + /* The pure formatter is callable without a store; that path keeps today's answer exactly. */ + expect(formatTaskPlannerChatMetrics(runningTask("in-progress"), at).metrics.timing.activeRuntimeMs) + .toBe(360_000); + expect(formatTaskPlannerChatMetrics(runningTask("building"), at).metrics.timing.activeRuntimeMs) + .toBe(60_000); + }); +}); diff --git a/packages/dashboard/src/__tests__/task-planner-metrics-tool-wip-lanes.test.ts b/packages/dashboard/src/__tests__/task-planner-metrics-tool-wip-lanes.test.ts new file mode 100644 index 0000000000..1b7e0e3c23 --- /dev/null +++ b/packages/dashboard/src/__tests__/task-planner-metrics-tool-wip-lanes.test.ts @@ -0,0 +1,96 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-30-16:45 (batch-dashboard-src): + +THE PRODUCER SIDE OF THE METRICS SEAM — the half its own tests cannot see. + +`formatTaskPlannerChatMetrics` takes a `wipColumns` set so "is this card still accruing active +runtime?" is the WIP role rather than the id `in-progress`. Its unit tests inject that set by hand, +which proves the FORMATTER and says nothing about whether anything in production fills it. That is +the inert-injection shape this program keeps re-finding: the guard reads as converted, the test +passes by supplying the interesting value itself, and the literal stays live on every real call. + +MEASURED, not assumed. With this file absent, deleting `wipColumns:` from the tool's call site left +the entire 3830-test dashboard suite green. `check-inert-flag-seams.mjs` did not catch it either — it +tracks trailing optional PARAMETERS, and this is a property inside an options bag. So the wiring had +no watcher at all, from either direction. + +This test therefore drives `createTaskPlannerMetricsTool` and lets it do its OWN resolution against a +store whose workflow renames the execution lane. When a fix moves data from producer to consumer, the +test has to sit on the producer. +*/ +import { describe, expect, it, vi } from "vitest"; +import { createTaskPlannerMetricsTool } from "../chat.js"; +import "@fusion/core"; // registers the built-in column traits so flags resolve + +/** `building` carries the wip trait; this board declares no `in-progress` column at all. */ +const RENAMED_IR = { + version: "v2", + id: "wf-renamed", + name: "renamed", + nodes: [], + edges: [], + columns: [ + { id: "drafting", name: "Drafting", traits: [{ trait: "intake" }] }, + { id: "building", name: "Building", traits: [{ trait: "wip" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], +}; + +const NOW = "2026-07-01T10:05:00.000Z"; + +function storeFor(column: string) { + const selection = { workflowId: "wf-renamed", stepIds: [] as string[] }; + const task = { + id: "FN-1", + title: "running card", + description: "", + column, + status: "executing", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + executionStartedAt: "2026-07-01T10:00:00.000Z", + cumulativeActiveMs: 60_000, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + }; + return { + getTask: vi.fn(async () => task), + getTaskWorkflowSelection: () => selection, + getTaskWorkflowSelectionAsync: async () => selection, + getWorkflowDefinition: async () => ({ id: "wf-renamed", ir: RENAMED_IR }), + } as never; +} + +async function activeRuntimeMsFrom(column: string): Promise { + const tool = createTaskPlannerMetricsTool(storeFor(column), "FN-1", async () => undefined); + vi.setSystemTime(new Date(NOW)); + try { + const result = await tool.execute(); + return (result as { details: { timing: { activeRuntimeMs: number | null } } }).details.timing + .activeRuntimeMs; + } finally { + vi.useRealTimers(); + } +} + +describe("the planner metrics tool resolves the task's own wip lanes", () => { + it("accrues the live tail for a card in a RENAMED execution lane", async () => { + /* + 60s banked plus 300s since `executionStartedAt`. Without the wiring the formatter falls back to + the legacy `in-progress`, `building` does not match, and this reports 60_000 — a running task + whose active time is frozen at its last completed segment, which looks plausible enough that + nothing surfaces it. + */ + expect(await activeRuntimeMsFrom("building")).toBe(360_000); + }); + + it("does NOT accrue for a card outside its board's wip lanes", async () => { + /* + The paired negative. Without it, wiring that resolved to "every column" would pass the case above + and silently accrue active runtime for finished work. + */ + expect(await activeRuntimeMsFrom("shipped")).toBe(60_000); + }); +}); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index c629e9719b..223c9bbc77 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -601,7 +601,17 @@ export function dedupeChatTools(tools: ChatCustomTool[]): ChatCustomTool[] { }); } -function createTaskPlannerMetricsTool(taskStore: TaskStore, taskId: string, getPricingOverrides: () => Promise) { +/* +FNXC:WorkflowResolvedColumns 2026-07-30-16:40 (batch-dashboard-src): +EXPORTED so the RESOLVER side of the metrics seam is testable, not just the guard. + +The formatter takes `wipColumns` and its own tests inject that set by hand — which proves the guard +and says nothing about whether production fills it. Measured: deleting the `wipColumns` argument +below left the whole 3830-test dashboard suite green. An options-bag property is also invisible to +`check-inert-flag-seams.mjs`, which only tracks trailing optional PARAMETERS, so nothing else was +watching this either. Exporting the factory is the cheapest way to put a test on the producer. +*/ +export function createTaskPlannerMetricsTool(taskStore: TaskStore, taskId: string, getPricingOverrides: () => Promise) { return { name: "fn_task_planner_get_task_metrics", label: "Get Current Task Metrics", @@ -614,9 +624,17 @@ function createTaskPlannerMetricsTool(taskStore: TaskStore, taskId: string, getP execute: async () => { try { const task = await taskStore.getTask(taskId, { activityLogLimit: 100 }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-16:10 (batch-dashboard-src): + Supplies the task's OWN wip lanes. This is the production path for the metrics tool, so + wiring it here is what makes the option live rather than one only tests fill — without it + the formatter keeps the legacy `in-progress` and a renamed execution lane reports a frozen + active runtime. + */ const metrics = formatTaskPlannerChatMetrics(task, { pricingOverrides: await getPricingOverrides(), nowMs: Date.now(), + wipColumns: await wipColumnsForTask(taskStore, taskId), }); return { content: [{ type: "text" as const, text: metrics.summaryText }], diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts index 8a5d4902b0..6fffa7f0e4 100644 --- a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts @@ -57,13 +57,31 @@ async function seedTaskDir(taskId: string, content?: string): Promise { if (content !== undefined) await writeFile(join(dir, "PROMPT.md"), content); } -function createHarness(tasks: Task[]) { +/** + * A board whose waiting lane is `queued`, carrying the SAME hold trait the builtins put on `todo`. + * `listWorkflowDefinitions` is what `resolveProjectColumnsForRoles` reads — omit it and the helper + * degrades to the legacy ids, so a fixture without it cannot distinguish the fix from the literal. + */ +const RENAMED_HOLD_IR = { + version: "v2", + name: "renamed-hold", + columns: [ + { id: "planning", name: "Planning", traits: [{ trait: "intake" }] }, + { id: "queued", name: "Queued", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "building", name: "Building", traits: [{ trait: "wip" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "planning" }], + edges: [], +}; + +function createHarness(tasks: Task[], workflowIrs?: unknown[]) { const store: TaskStore = { getRootDir: vi.fn(() => process.cwd()), getProjectScopedPluginMcpServers: vi.fn(async () => []), getTaskDir: vi.fn((id: string) => join(tasksRoot, id)), getSettingsFast: vi.fn(async () => ({})), listTasks: vi.fn(async () => tasks), + ...(workflowIrs ? { listWorkflowDefinitions: vi.fn(async () => workflowIrs.map((ir) => ({ ir }))) } : {}), } as unknown as TaskStore; const app = express(); @@ -72,8 +90,8 @@ function createHarness(tasks: Task[]) { return { app }; } -async function fetchTasks(tasks: Task[]): Promise>> { - const { app } = createHarness(tasks); +async function fetchTasks(tasks: Task[], workflowIrs?: unknown[]): Promise>> { + const { app } = createHarness(tasks, workflowIrs); const res = await REQUEST(app, "GET", "/api/tasks"); expect(res.status).toBe(200); return res.body as Array>; @@ -231,4 +249,52 @@ describe("GET /tasks awaitingPlanning enrichment", () => { expect(rows).toHaveLength(1); expect(rows[0]).not.toHaveProperty("awaitingPlanning"); }); + + /* + FNXC:WorkflowResolvedColumns 2026-07-30-21:55 (batch-dashboard-src): + + THE WAITING LANE IS THE `hold` ROLE, NOT THE ID `todo`. + + The enrichment filter named `todo`, so on a board whose waiting lane is called anything else NO card + was enriched and every one of them silently fell back to TaskCard's step-count heuristic — the exact + disagreement between badge and engine this enrichment was added to remove. It failed quietly, which + is why it survived: the board still rendered, with badges that were merely wrong. + + A per-task workflow read was tried once and reverted for cost, on a board-load path whose own note + warns about turning a load into thousands of reads. `resolveProjectColumnsForRoles` is one read for + the whole board, so the objection was to the arity, not the idea. + + THE NEGATIVE MATTERS AS MUCH: widening to "enrich everything" would pass the case above and put a + planning badge on in-progress and review cards, whose payloads are contractually byte-identical + without the field. + */ + it("enriches a card in a RENAMED hold lane", async () => { + const task = makeTask({ id: "FN-RENAMED", column: "queued" }); + await seedTaskDir("FN-RENAMED", buildBootstrapPrompt("FN-RENAMED", task.title, task.description)); + + const [row] = await fetchTasks([task], [RENAMED_HOLD_IR]); + + /* Keyed on the literal this row had no `awaitingPlanning` at all. */ + expect(row.awaitingPlanning).toBe(true); + }); + + it("does NOT enrich a card outside the board's hold lanes, even on a renamed board", async () => { + const task = makeTask({ id: "FN-WIP", column: "building" }); + await seedTaskDir("FN-WIP", buildBootstrapPrompt("FN-WIP", task.title, task.description)); + + const [row] = await fetchTasks([task], [RENAMED_HOLD_IR]); + + expect(row).not.toHaveProperty("awaitingPlanning"); + }); + + it("still enriches the legacy `todo` lane on a renamed board — the union keeps a mid-rename board working", async () => { + /* `resolveProjectColumnsForRoles` unions the legacy ids deliberately: rows still stored under the + old id during a rename must not lose their badge. */ + const task = makeTask({ id: "FN-LEGACY", column: "todo" }); + await seedTaskDir("FN-LEGACY", buildBootstrapPrompt("FN-LEGACY", task.title, task.description)); + + const [row] = await fetchTasks([task], [RENAMED_HOLD_IR]); + + expect(row.awaitingPlanning).toBe(true); + }); }); diff --git a/packages/dashboard/src/task-planner-chat-metrics.ts b/packages/dashboard/src/task-planner-chat-metrics.ts index a74cfe54fd..1c4479acd6 100644 --- a/packages/dashboard/src/task-planner-chat-metrics.ts +++ b/packages/dashboard/src/task-planner-chat-metrics.ts @@ -344,7 +344,11 @@ function buildWorkflowStepTimings(results: WorkflowStepResult[] | undefined, now }); } -function buildTimingMetrics(task: MetricsTask, nowMs: number): TaskPlannerChatMetricsPayload["timing"] { +function buildTimingMetrics( + task: MetricsTask, + nowMs: number, + options: { wipColumns?: ReadonlySet } = {}, +): TaskPlannerChatMetricsPayload["timing"] { const malformedTimestamps: string[] = []; const executionStartedMs = parseTimestampToMs(task.executionStartedAt, malformedTimestamps); const executionCompletedMs = parseTimestampToMs(task.executionCompletedAt, malformedTimestamps); @@ -360,7 +364,23 @@ function buildTimingMetrics(task: MetricsTask, nowMs: number): TaskPlannerChatMe ? null : Math.max(0, (executionCompletedMs ?? nowMs) - firstExecutionMs); const cumulativeActiveMs = optionalFiniteNumber(task.cumulativeActiveMs); - const activeRuntimeMs = task.column === "in-progress" && executionStartedMs != null + /* + FNXC:WorkflowResolvedColumns 2026-07-30-16:10 (batch-dashboard-src): + "Is this card still accruing active runtime?" is the WIP role, not the id `in-progress`. + + Keyed on the literal, the live tail — the wall-clock since `executionStartedAt` — was omitted for + every card on a board whose execution lane is renamed, so the planner's own metrics tool reported + a running task's active time frozen at whatever the last completed segment left in + `cumulativeActiveMs`. The number looked plausible, which is why nothing surfaced it. + + `wipColumns` is REQUIRED to be supplied by the production caller to mean anything: an optional + parameter nobody fills reads as converted, passes its test by injection, and leaves the literal + live. `chat.ts` resolves it from the task's own workflow via `wipColumnsForTask`. It stays optional + in the signature only so the pure formatter is callable without a store, and that path degrades to + 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 ? (cumulativeActiveMs ?? 0) + Math.max(0, nowMs - executionStartedMs) : cumulativeActiveMs; @@ -427,7 +447,12 @@ function buildTimingMetrics(task: MetricsTask, nowMs: number): TaskPlannerChatMe */ export function formatTaskPlannerChatMetrics( task: MetricsTask, - options: { pricingOverrides?: ModelPricingOverrides; nowMs?: number } = {}, + options: { + pricingOverrides?: ModelPricingOverrides; + nowMs?: number; + /** The task's own WIP lanes, resolved by the caller. See the note at `activeRuntimeMs`. */ + wipColumns?: ReadonlySet; + } = {}, ): TaskPlannerChatMetricsResult { const nowMs = options.nowMs ?? Date.now(); const metrics: TaskPlannerChatMetricsPayload = { @@ -436,7 +461,7 @@ export function formatTaskPlannerChatMetrics( column: task.column, status: task.status, tokens: buildTokenMetrics(task, options.pricingOverrides, nowMs), - timing: buildTimingMetrics(task, nowMs), + timing: buildTimingMetrics(task, nowMs, { wipColumns: options.wipColumns }), }; const tokenSummary = metrics.tokens.available diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 0771f997cc..4cd80ca5a1 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -32,7 +32,6 @@ "packages/core/src/task-store/merge-queue-ops.ts": 1, "packages/dashboard/app/components/ResearchTaskActionModal.tsx": 1, "packages/dashboard/app/components/TaskCard.tsx": 1, - "packages/dashboard/src/task-planner-chat-metrics.ts": 1, "packages/engine/src/backlog-pressure-reporter.ts": 1, "packages/engine/src/ephemeral-worker-manager.ts": 1, "packages/engine/src/merger.ts": 1,