fix(core): task-duration stats were computed from an empty set on a renamed board (#2870)

Third of the 14 lane-bound SQL sites from #2839, after #2864 and #2866.
Independent of both.

## The defect

`aggregateProductivityAnalytics` filtered its duration query on
`"column" = 'done'`. On a renamed board that matches nothing, so the
entire task-duration distribution — median, p90, average, total — is
computed from an **empty row set** and reports zeros while the project
ships work. Nothing errors.

Same shape and fix as the previous two: resolve per **project** via
`resolveProjectColumnsForRoles`, bind an `IN` list, thread the store
from the single Command Center caller so the parameter has a supplier
immediately rather than becoming an inert seam.

## Measured

Reverted, only the renamed case flips:

```
✓ default vocabulary: a finished task contributes to the duration stats
× renamed vocabulary: a task in the RENAMED complete lane contributes
✓ renamed vocabulary: a task still in the WIP lane does NOT contribute
✓ without a lane store, the legacy id still answers
  Tests  1 failed | 3 passed (4)
```

## The negative asserts the median, not just the count

This fix's failure mode is **worse than the bug it fixes**. Resolving
too many lanes would pull unfinished work into the distribution and
produce a plausible-but-wrong median — a number nobody questions — where
the bug produces an obvious zero. So the WIP-lane case asserts
`medianMs` is null as well as `completedTasks` being 0.

## A fixture error worth naming

My first version asserted `taskDuration.count`. `TaskDurationSummary`
exposes `completedTasks`. Every case failed with `expected undefined to
be 1` — **including the controls** — which reads exactly like a broken
product until you notice the control is failing too. A control that
fails is a fixture bug, not a finding; that asymmetry is the fastest way
to tell them apart.

## Scope

The sync SQLite arm keeps its literal: it throws in backend mode and has
no production caller, the same dead-arm conclusion as
`cleanupStaleMergeQueueRowsImpl` on #2839.

## Verification

`pnpm test:gate` green · both Command Center analytics suites 8/8 ·
`tsc` core 0, dashboard 0 · lint 0 · changeset included.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 16:50:00 -07:00
committed by GitHub
parent 995b52d21d
commit 216632bd3a
5 changed files with 172 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Task-duration stats now include finished work on renamed boards.
category: fix
dev: `aggregateProductivityAnalytics` takes an optional lane store and resolves the complete columns via `resolveProjectColumnsForRoles`; its duration query previously filtered on the literal `'done'`.

View File

@@ -0,0 +1,135 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-30-19:30 (task-duration stats computed from an empty set):
`aggregateProductivityAnalytics` filtered its duration query on `"column" = 'done'`. On a renamed
board that matches nothing, so the whole task-duration distribution — median, p90, the spread — is
computed from an EMPTY row set and reports zeros while the project ships work. Nothing errors.
WHY NO EXISTING CHECK SAW IT. The lifecycle census parses TypeScript comparisons; this id lives
inside a SQL string. The sweep that converted this file's TypeScript guards left the query alone and
the file scored as converted.
The cases are DIFFERENTIAL: the same finished task, aggregated under two vocabularies whose roles are
identical and only the ids differ. `shipped` collides with no legacy id, so a surviving `'done'`
cannot pass by luck.
*/
import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
import { sql } from "drizzle-orm";
import {
pgDescribe,
createSharedPgTaskStoreTestHarness,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import { aggregateProductivityAnalytics } from "../../productivity-analytics.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js";
const IN_RANGE = "2026-06-15T12:00:00.000Z";
const RANGE = { from: "2026-06-01T00:00:00.000Z", to: "2026-06-30T23:59:59.999Z" };
pgDescribe("productivity analytics under a renamed board vocabulary", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_productivity_lanes",
});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
async function seedRenamedWorkflow(): Promise<void> {
const RENAME: Record<string, string> = {
todo: "drafting",
"in-progress": "building",
"in-review": "checking",
done: "shipped",
};
const rename = (id: string | undefined) => (id && RENAME[id]) ?? id;
const ir = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as {
id: string;
nodes?: { column?: string }[];
columns?: { id: string }[];
};
ir.id = "custom:renamed-productivity";
for (const node of ir.nodes ?? []) node.column = rename(node.column);
for (const column of ir.columns ?? []) column.id = rename(column.id) as string;
const ids = (ir.columns ?? []).map((column) => column.id);
expect(ids).toContain("shipped");
expect(ids).not.toContain("done");
await h.store().createWorkflowDefinition({ name: "Renamed", kind: "workflow", ir } as never);
}
/** A finished task with real active/planning time, so it lands in the duration distribution. */
async function seedFinishedTask(lane: string): Promise<void> {
const store = h.store();
await store.createTaskWithReservedId(
{ description: "KB-DUR", column: "todo" },
{ taskId: "KB-DUR", createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false },
);
/* Seeded directly: the query needs execution_completed_at inside the range plus non-zero
cumulative time, neither of which moveTask would produce. */
await h.adminDb().execute(sql`
UPDATE project.tasks
SET "column" = ${lane},
execution_completed_at = ${IN_RANGE},
cumulative_active_ms = 60000,
cumulative_planning_ms = 30000,
updated_at = ${IN_RANGE}
WHERE id = 'KB-DUR'`);
store.taskCache.delete("KB-DUR");
}
const run = (withStore: boolean) => aggregateProductivityAnalytics(
Object.assign(h.layer(), { projectId: "p1" }),
RANGE,
withStore ? h.store() : undefined,
);
/* Control: the default vocabulary sees the finished task. Passes before and after the fix. */
it("default vocabulary: a finished task contributes to the duration stats", async () => {
await seedFinishedTask("done");
const result = await run(true);
expect(result.taskDuration.completedTasks).toBe(1);
expect(result.taskDuration.medianMs).toBe(90_000);
});
/* The defect: before the fix this row set was empty on a renamed board. */
it("renamed vocabulary: a task in the RENAMED complete lane contributes", async () => {
await seedRenamedWorkflow();
await seedFinishedTask("shipped");
const result = await run(true);
expect(result.taskDuration.completedTasks).toBe(1);
expect(result.taskDuration.medianMs).toBe(90_000);
});
/*
The paired negative: resolving the real lanes must not degrade into "every column is complete".
Unfinished work must stay out of the duration distribution, or the fix trades a zero for a wrong
median — worse, because a plausible number invites no scrutiny.
*/
it("renamed vocabulary: a task still in the WIP lane does NOT contribute", async () => {
await seedRenamedWorkflow();
await seedFinishedTask("building");
const result = await run(true);
expect(result.taskDuration.completedTasks).toBe(0);
expect(result.taskDuration.medianMs).toBeNull();
});
/* Omitting the store must keep the legacy answer, so an unconverted caller is byte-identical. */
it("without a lane store, the legacy id still answers", async () => {
await seedFinishedTask("done");
const result = await run(false);
expect(result.taskDuration.completedTasks).toBe(1);
expect(result.taskDuration.medianMs).toBe(90_000);
});
});

View File

@@ -1,5 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import type { Database } from "./db.js"; import type { Database } from "./db.js";
import { resolveProjectColumnsForRoles, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
import type { AsyncDataLayer } from "./postgres/data-layer.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js";
/** /**
@@ -161,6 +162,22 @@ function nearestRankPercentile(sortedValues: readonly number[], percentile: numb
export async function aggregateProductivityAnalytics( export async function aggregateProductivityAnalytics(
dbOrLayer: Database | AsyncDataLayer, dbOrLayer: Database | AsyncDataLayer,
query: ProductivityAnalyticsQuery = {}, query: ProductivityAnalyticsQuery = {},
/*
FNXC:WorkflowResolvedColumns 2026-07-30-19:20:
The store, used ONLY to resolve which columns carry the `complete` trait.
The duration query filtered on `"column" = 'done'`. That id lives inside a SQL string, which the
lifecycle census cannot see (it parses TypeScript comparisons), so the sweep that converted this
file's TS guards left it alone and the file scored as converted. On a renamed board the
task-duration distribution — median, p90, the whole spread — is computed from an EMPTY row set and
reports zeros while the project ships work.
Resolved per PROJECT: this aggregates a whole project, so the union of the complete columns across
its workflows is the right set and a bound IN list is enough.
Omitted, the legacy id answers, so an unconverted caller is byte-identical.
*/
laneStore?: ProjectLaneVocabularyStore,
): Promise<ProductivityAnalytics> { ): Promise<ProductivityAnalytics> {
// FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00: // FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
// Backend (PostgreSQL) path. The async connection does not put `project` on // Backend (PostgreSQL) path. The async connection does not put `project` on
@@ -172,7 +189,7 @@ export async function aggregateProductivityAnalytics(
// epoch ms. Semantics (range columns, COALESCE/SUM/COUNT, statsRows gate, LOC // epoch ms. Semantics (range columns, COALESCE/SUM/COUNT, statsRows gate, LOC
// unavailable sentinel, duration percentiles) mirror the sync branch exactly. // unavailable sentinel, duration percentiles) mirror the sync branch exactly.
if ("ping" in dbOrLayer) { if ("ping" in dbOrLayer) {
return aggregateProductivityAnalyticsAsync(dbOrLayer, query); return aggregateProductivityAnalyticsAsync(dbOrLayer, query, laneStore);
} }
const db = dbOrLayer as Database; const db = dbOrLayer as Database;
// Modified files: read the JSON array off tasks updated in range. // Modified files: read the JSON array off tasks updated in range.
@@ -347,7 +364,14 @@ export async function aggregateProductivityAnalytics(
async function aggregateProductivityAnalyticsAsync( async function aggregateProductivityAnalyticsAsync(
layer: AsyncDataLayer, layer: AsyncDataLayer,
query: ProductivityAnalyticsQuery, query: ProductivityAnalyticsQuery,
laneStore?: ProjectLaneVocabularyStore,
): Promise<ProductivityAnalytics> { ): Promise<ProductivityAnalytics> {
const completeLanes = laneStore
? [...await resolveProjectColumnsForRoles(laneStore, ["complete"])]
: ["done"];
/* An IN list of bound parameters, not `= ANY(${array})`: drizzle expands a JS array in a template
into a tuple, which PostgreSQL rejects for ANY. Each id stays a parameter. */
const completeIn = sql.join(completeLanes.map((lane) => sql`${lane}`), sql`, `);
// Modified files: tasks updated in range whose modified_files is a non-empty // Modified files: tasks updated in range whose modified_files is a non-empty
// jsonb array. postgres-js returns jsonb already parsed. // jsonb array. postgres-js returns jsonb already parsed.
const mfFrom = query.from !== undefined ? sql`AND updated_at >= ${query.from}` : sql``; const mfFrom = query.from !== undefined ? sql`AND updated_at >= ${query.from}` : sql``;
@@ -403,7 +427,7 @@ async function aggregateProductivityAnalyticsAsync(
const durationRows = (await layer.db.execute( const durationRows = (await layer.db.execute(
sql`SELECT cumulative_active_ms AS "cumulativeActiveMs", cumulative_planning_ms AS "cumulativePlanningMs", execution_completed_at AS "executionCompletedAt" sql`SELECT cumulative_active_ms AS "cumulativeActiveMs", cumulative_planning_ms AS "cumulativePlanningMs", execution_completed_at AS "executionCompletedAt"
FROM project.tasks FROM project.tasks
WHERE "column" = 'done' WHERE "column" IN (${completeIn})
AND execution_completed_at IS NOT NULL AND execution_completed_at IS NOT NULL
AND (COALESCE(cumulative_active_ms, 0) + COALESCE(cumulative_planning_ms, 0)) > 0 AND (COALESCE(cumulative_active_ms, 0) + COALESCE(cumulative_planning_ms, 0)) > 0
${dFrom} ${dTo} ${dFrom} ${dTo}

View File

@@ -348,7 +348,9 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
const result = await aggregateProductivityAnalytics(requireAsyncLayer(store, "Command Center productivity analytics"), { const result = await aggregateProductivityAnalytics(requireAsyncLayer(store, "Command Center productivity analytics"), {
from: range.from, from: range.from,
to: range.to, to: range.to,
}); /* FNXC:WorkflowResolvedColumns 2026-07-30-19:25: store supplied so the duration distribution
resolves the board's complete lanes instead of the literal 'done'. */
}, store);
if (wantsCsv(req.query)) { if (wantsCsv(req.query)) {
sendCsv( sendCsv(
res, res,

View File

@@ -4,7 +4,7 @@
"packages/core/src/github-issue-analytics.ts": 2, "packages/core/src/github-issue-analytics.ts": 2,
"packages/core/src/gitlab-issue-analytics.ts": 2, "packages/core/src/gitlab-issue-analytics.ts": 2,
"packages/core/src/mission-store.ts": 1, "packages/core/src/mission-store.ts": 1,
"packages/core/src/productivity-analytics.ts": 2, "packages/core/src/productivity-analytics.ts": 1,
"packages/core/src/task-store/async-archive-lineage.ts": 3, "packages/core/src/task-store/async-archive-lineage.ts": 3,
"packages/core/src/task-store/async-maintenance.ts": 1, "packages/core/src/task-store/async-maintenance.ts": 1,
"packages/core/src/task-store/async-merge-coordination.ts": 1, "packages/core/src/task-store/async-merge-coordination.ts": 1,