fix(board): the awaitingPlanning badge only ever worked on a lane named "todo" (#2845)

Converts the one site in `register-task-workflow-routes.ts` that a
previous pass **deliberately deferred**, and does it in the shape that
note asked for.

## What the deferral said

```
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8, DELIBERATELY NOT CONVERTED):
… I converted it and then REVERTED: resolving each task's hold column needs a
per-task workflow read, and this is the board-load path whose own comment above
exists because unbounded reads here "turn a board load into thousands of reads".

Converting it properly needs the hold column resolved per WORKFLOW from data the
board payload already carries, not per task from the store.
```

That was the right call and the right diagnosis.
`resolveProjectColumnsForRoles(store, ["hold"])` is exactly the
project-scoped shape it names: **one** `listWorkflowDefinitions()` read
per board load, flat in task count. The expensive part — a PROMPT.md
read per row — is untouched and still bounded by
`AWAITING_PLANNING_ENRICH_LIMIT`.

The test asserts the flatness directly (`listWorkflowDefinitions` called
exactly once), so a future per-task regression fails here rather than
being discovered as board latency.

## What was broken

The filter named `todo`, so on a board whose waiting lane is called
anything else **no row was enriched at all** — no error, no log line,
just a silent fall back to the client's `steps.length === 0` heuristic.
That heuristic is precisely what this enrichment was added to correct,
so the card most likely to be mislabelled — real spec, zero parsed
steps, already a scheduler dispatch candidate — sat on "Queued to plan"
indefinitely.

Over-inclusion is the safe direction and is chosen deliberately: a card
in some other workflow's hold lane gets annotated as waiting, which is
what a waiting card in a waiting lane should show.

## Revert proof (measured)

Restore `task.column === "todo"`:

```
FAIL  register-task-workflow-routes.awaiting-planning.test.ts
  > enriches a card in a RENAMED hold lane, not only one literally named todo
  expected undefined to be false
```

The other 8 cases in the file stay green — their harness store declares
no `listWorkflowDefinitions`, so they run the degraded legacy-`todo`
path. That compatibility is half the contract, which is why the new case
brings its own store rather than widening the shared harness.

## Census

| file | before | after |
|---|---|---|
| `packages/dashboard/src/routes/register-task-workflow-routes.ts` | 3 |
2 |

The 2 remaining in that file are documented trait-fallback branches, not
unconverted debt. The baseline also picks up
`packages/core/src/task-store/moves.ts` 2 -> 0, already true on main and
not from this diff.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit -p tsconfig.json` (`@fusion/dashboard`) — clean
- `node scripts/lifecycle-column-census.mjs --strict` — exit 0
- targeted: `register-task-workflow-routes.awaiting-planning.test.ts`
9/9

🤖 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 14:48:00 -07:00
committed by GitHub
parent 3a5058edd8
commit 51934931e1
4 changed files with 81 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: The "Queued to plan" / "Ready" badges now work on boards whose waiting lane is not called Todo.
category: fix
dev: `GET /api/tasks`'s `awaitingPlanning` enrichment filtered on the literal `todo`. It now resolves the project's `hold` lanes once per board load via `resolveProjectColumnsForRoles` — one `listWorkflowDefinitions()` read, flat in task count, with the per-row PROMPT.md reads still bounded by `AWAITING_PLANNING_ENRICH_LIMIT`.

View File

@@ -164,6 +164,53 @@ describe("GET /tasks awaitingPlanning enrichment", () => {
} }
}); });
/*
FNXC:WorkflowResolvedColumns 2026-07-30-23:10:
THE INVARIANT: the enrichment finds the board's HOLD lane, whatever that lane is named.
The filter named `todo`, so a workflow whose waiting lane is called anything else got no
enrichment at all — no error, no log, just a silent fall back to the client's step-count
heuristic, which is the exact mislabel this whole enrichment exists to correct. The card most
likely to be wrong (real spec, zero steps) is the one that read "Queued to plan" forever.
The store here declares `listWorkflowDefinitions`, which the harness above deliberately does not:
every other case in this file exercises the degraded path where the legacy `todo` is all there is,
and they must stay green — that compatibility is half the contract.
REVERT PROOF, measured: restore `task.column === "todo"` and this case fails with
`expected undefined to be false`, because the row is not enriched at all. Every other case in the
file stays green.
*/
it("enriches a card in a RENAMED hold lane, not only one literally named todo", async () => {
const task = makeTask({ id: "FN-RENAMED", column: "backlog" as Task["column"], steps: [] });
await seedTaskDir("FN-RENAMED", REAL_SPEC);
const store = {
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 () => [task]),
listWorkflowDefinitions: vi.fn(async () => [{
ir: {
version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [],
columns: [{ id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] }],
},
}]),
} as unknown as TaskStore;
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "GET", "/api/tasks");
expect(res.status).toBe(200);
const rows = res.body as Array<Record<string, unknown>>;
expect(rows[0]!.awaitingPlanning).toBe(false);
// Flat cost, not per-row: the lane vocabulary is resolved once for the whole board load.
expect((store as unknown as { listWorkflowDefinitions: { mock: { calls: unknown[] } } }).listWorkflowDefinitions.mock.calls).toHaveLength(1);
});
it("still returns the board when the enrichment cannot resolve task directories", async () => { it("still returns the board when the enrichment cannot resolve task directories", async () => {
// Best-effort contract: a store without getTaskDir must not fail the board load. // Best-effort contract: a store without getTaskDir must not fail the board load.
const task = makeTask({ id: "FN-NODIR" }); const task = makeTask({ id: "FN-NODIR" });

View File

@@ -71,6 +71,7 @@ import {
validateTaskDocumentPreconditions, validateTaskDocumentPreconditions,
getPlannerInterventionTimeline, getPlannerInterventionTimeline,
isBuiltinWorkflowId, isBuiltinWorkflowId,
resolveProjectColumnsForRoles,
type NearDuplicateCandidate, type NearDuplicateCandidate,
type ThinkingLevel, type ThinkingLevel,
} from "@fusion/core"; } from "@fusion/core";
@@ -1227,25 +1228,34 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
*/ */
try { try {
/* /*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8, DELIBERATELY NOT CONVERTED): FNXC:WorkflowResolvedColumns 2026-07-30-23:10 (converting R8's stated deferral):
This filter names `todo`, so a workflow whose waiting lane is called something else The hold lanes, resolved ONCE PER BOARD LOAD rather than once per card.
gets no enrichment and silently falls back to the heuristic. I converted it and then
REVERTED: resolving each task's hold column needs a per-task workflow read, and this
is the board-load path whose own comment above exists because unbounded reads here
"turn a board load into thousands of reads". My version did those reads for every
task BEFORE the enrich limit applied — trading a silent degradation for a load-time
regression on every board.
Converting it properly needs the hold column resolved per WORKFLOW from data the The note this replaces deferred the conversion for a good reason and named the shape the
board payload already carries, not per task from the store. That is a real change fix had to take: "the hold column resolved per WORKFLOW from data the board payload already
with a measurable cost, not a rename, so it is left for one — with the cost stated carries, not per task from the store". A per-task resolve is what made the first attempt a
rather than the conversion quietly skipped. load-time regression — it read a workflow for every row BEFORE the enrich limit applied, on
the very path whose comment above exists because unbounded reads here "turn a board load
into thousands of reads".
`resolveProjectColumnsForRoles` is the project-scoped shape: ONE `listWorkflowDefinitions()`
read, independent of task count, returning every column any workflow calls `hold` unioned
with the legacy `todo`. Measured cost is therefore one extra query per board load — flat,
not per-row — and the enrich limit still bounds the PROMPT.md reads, which are the expensive
part and are unchanged.
Over-inclusion is the safe direction here, deliberately: a card sitting in some other
workflow's hold lane is annotated with `awaitingPlanning`, which is what a waiting card in a
waiting lane should show. Under-inclusion is what the literal did — no enrichment at all, no
error, and a silent fall back to the client's step-count heuristic that this enrichment
exists to correct.
*/ */
const todoRows = tasks.filter((task) => task.column === "todo"); const holdColumns = await resolveProjectColumnsForRoles(scopedStore, ["hold"]);
const enrichable = todoRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT); const holdRows = tasks.filter((task) => holdColumns.has(task.column));
if (todoRows.length > enrichable.length) { const enrichable = holdRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT);
if (holdRows.length > enrichable.length) {
severityAuditLog.warn( severityAuditLog.warn(
`awaitingPlanning enrichment truncated: ${enrichable.length}/${todoRows.length} todo tasks ` + `awaitingPlanning enrichment truncated: ${enrichable.length}/${holdRows.length} hold-lane tasks ` +
"annotated (remaining cards fall back to the client step-count heuristic)", "annotated (remaining cards fall back to the client step-count heuristic)",
); );
} }

View File

@@ -10,7 +10,6 @@
"packages/engine/src/restart-recovery-coordinator.ts": 4, "packages/engine/src/restart-recovery-coordinator.ts": 4,
"packages/core/src/async-mission-store-queries.ts": 3, "packages/core/src/async-mission-store-queries.ts": 3,
"packages/core/src/task-store/task-artifacts-ops.ts": 3, "packages/core/src/task-store/task-artifacts-ops.ts": 3,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 3,
"packages/engine/src/planner-overseer.ts": 3, "packages/engine/src/planner-overseer.ts": 3,
"packages/core/src/agent-store.ts": 2, "packages/core/src/agent-store.ts": 2,
"packages/core/src/task-store/audit-ops.ts": 2, "packages/core/src/task-store/audit-ops.ts": 2,
@@ -20,6 +19,7 @@
"packages/core/src/task-store/task-id-integrity.ts": 2, "packages/core/src/task-store/task-id-integrity.ts": 2,
"packages/dashboard/app/utils/taskRevert.ts": 2, "packages/dashboard/app/utils/taskRevert.ts": 2,
"packages/dashboard/src/github-tracking-state.ts": 2, "packages/dashboard/src/github-tracking-state.ts": 2,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 2,
"packages/engine/src/auto-merge-finalization.ts": 2, "packages/engine/src/auto-merge-finalization.ts": 2,
"packages/core/src/eval-signal-collector.ts": 1, "packages/core/src/eval-signal-collector.ts": 1,
"packages/core/src/in-review-stall.ts": 1, "packages/core/src/in-review-stall.ts": 1,