batch-dashboard-src: the planner metrics tool froze active runtime on a renamed execution lane (186 → 185) (#2842)
`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) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/planner-metrics-wip-lane.md
Normal file
7
.changeset/planner-metrics-wip-lane.md
Normal file
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number | null> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -601,7 +601,17 @@ export function dedupeChatTools(tools: ChatCustomTool[]): ChatCustomTool[] {
|
||||
});
|
||||
}
|
||||
|
||||
function createTaskPlannerMetricsTool(taskStore: TaskStore, taskId: string, getPricingOverrides: () => Promise<Settings["modelPricingOverrides"] | undefined>) {
|
||||
/*
|
||||
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<Settings["modelPricingOverrides"] | undefined>) {
|
||||
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 }],
|
||||
|
||||
@@ -57,13 +57,31 @@ async function seedTaskDir(taskId: string, content?: string): Promise<void> {
|
||||
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<Array<Record<string, unknown>>> {
|
||||
const { app } = createHarness(tasks);
|
||||
async function fetchTasks(tasks: Task[], workflowIrs?: unknown[]): Promise<Array<Record<string, unknown>>> {
|
||||
const { app } = createHarness(tasks, workflowIrs);
|
||||
const res = await REQUEST(app, "GET", "/api/tasks");
|
||||
expect(res.status).toBe(200);
|
||||
return res.body as Array<Record<string, unknown>>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> } = {},
|
||||
): 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<string>;
|
||||
} = {},
|
||||
): 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user