diff --git a/.changeset/board-hold-lane-enrichment.md b/.changeset/board-hold-lane-enrichment.md new file mode 100644 index 0000000000..f31c6a1d05 --- /dev/null +++ b/.changeset/board-hold-lane-enrichment.md @@ -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`. diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts index d07624ecd3..8a5d4902b0 100644 --- a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts @@ -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>; + 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 () => { // Best-effort contract: a store without getTaskDir must not fail the board load. const task = makeTask({ id: "FN-NODIR" }); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index e858a7d09e..73040040dd 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -71,6 +71,7 @@ import { validateTaskDocumentPreconditions, getPlannerInterventionTimeline, isBuiltinWorkflowId, + resolveProjectColumnsForRoles, type NearDuplicateCandidate, type ThinkingLevel, } from "@fusion/core"; @@ -1227,25 +1228,34 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork */ try { /* - FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8, DELIBERATELY NOT CONVERTED): - This filter names `todo`, so a workflow whose waiting lane is called something else - 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. + FNXC:WorkflowResolvedColumns 2026-07-30-23:10 (converting R8's stated deferral): + The hold lanes, resolved ONCE PER BOARD LOAD rather than once per card. - Converting it properly needs the hold column resolved per WORKFLOW from data the - board payload already carries, not per task from the store. That is a real change - with a measurable cost, not a rename, so it is left for one — with the cost stated - rather than the conversion quietly skipped. + The note this replaces deferred the conversion for a good reason and named the shape the + fix had to take: "the hold column resolved per WORKFLOW from data the board payload already + carries, not per task from the store". A per-task resolve is what made the first attempt a + 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 enrichable = todoRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT); - if (todoRows.length > enrichable.length) { + const holdColumns = await resolveProjectColumnsForRoles(scopedStore, ["hold"]); + const holdRows = tasks.filter((task) => holdColumns.has(task.column)); + const enrichable = holdRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT); + if (holdRows.length > enrichable.length) { 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)", ); } diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 866fee581c..0771f997cc 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -10,7 +10,6 @@ "packages/engine/src/restart-recovery-coordinator.ts": 4, "packages/core/src/async-mission-store-queries.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/core/src/agent-store.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/dashboard/app/utils/taskRevert.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/core/src/eval-signal-collector.ts": 1, "packages/core/src/in-review-stall.ts": 1,