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:
gsxdsm
2026-07-30 15:16:24 -07:00
committed by GitHub
parent 5a3541315c
commit eed8ca55fc
8 changed files with 327 additions and 9 deletions

View 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.

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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 }],

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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,