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:
7
.changeset/board-hold-lane-enrichment.md
Normal file
7
.changeset/board-hold-lane-enrichment.md
Normal 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`.
|
||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user