diff --git a/.changeset/queued-to-plan-badge-engine-agreement.md b/.changeset/queued-to-plan-badge-engine-agreement.md new file mode 100644 index 0000000000..e2e84cc886 --- /dev/null +++ b/.changeset/queued-to-plan-badge-engine-agreement.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: "Queued to plan" and "Ready" badges now match what the engine will actually do with the card. +category: fix +dev: New shared `isTaskAwaitingPlanning` predicate (PROMPT.md seed-ness + replan park) replaces TaskCard's `steps.length` proxy; `GET /api/tasks` attaches transient `awaitingPlanning` for Todo rows (best-effort, capped at 200 reads/request), carried across same-column SSE updates while the step count is unchanged. diff --git a/packages/core/src/__tests__/mesh-task-replication.test.ts b/packages/core/src/__tests__/mesh-task-replication.test.ts index f97174377a..b2e145fca9 100644 --- a/packages/core/src/__tests__/mesh-task-replication.test.ts +++ b/packages/core/src/__tests__/mesh-task-replication.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; import { buildBootstrapPrompt, buildRefinementSeedPrompt, + isTaskAwaitingPlanning, isUnplannedSeedPrompt, } from "../mesh-task-replication.js"; import { applyOriginalDescription } from "../original-description-policy.js"; @@ -89,3 +90,46 @@ describe("mesh-task-replication", () => { }); }); }); + +/* +FNXC:CodingIdeasWorkflow 2026-07-26-15:30: +`isTaskAwaitingPlanning` is the single answer to "is this plan-in-place card waiting for a PLANNING +slot?", shared by triage's todo-discovery and the `GET /api/tasks` board enrichment that drives the +"Queued to plan" / "Ready" badge pair. Before it, the board inferred the answer from `steps.length` +and disagreed with the engine in both directions. + +The three clauses are exactly triage's three todo-discovery branches, so each is pinned here: +status park, missing spec, and seed-vs-real content. The step count is deliberately NOT an input — +that is the whole point — so the content cases assert both step shapes. +*/ +describe("isTaskAwaitingPlanning", () => { + const task = (overrides: Partial<{ id: string; title?: string; description: string; status?: string | null }> = {}) => ({ + id: "FN-1", + title: "Title", + description: "desc", + ...overrides, + }); + + it("is true for a parked replan regardless of a real spec on disk", () => { + expect(isTaskAwaitingPlanning(task({ status: "needs-replan" }), "# FN-1: Title\n\n## Mission\n\nReal spec.\n")).toBe(true); + }); + + it("is true when PROMPT.md is missing", () => { + expect(isTaskAwaitingPlanning(task(), null)).toBe(true); + }); + + it("is true for either seed shape and false for a real spec", () => { + expect(isTaskAwaitingPlanning(task(), buildBootstrapPrompt("FN-1", "Title", "desc"))).toBe(true); + expect(isTaskAwaitingPlanning(task(), buildRefinementSeedPrompt("Title", "desc"))).toBe(true); + expect(isTaskAwaitingPlanning(task(), "# FN-1: Title\n\n## Steps\n\n1. Do it\n")).toBe(false); + }); + + it("ignores statuses that are not planning parks", () => { + for (const status of [undefined, null, "planning", "executing", "failed"]) { + expect( + isTaskAwaitingPlanning(task({ status }), "# FN-1: Title\n\n## Steps\n\n1. Do it\n"), + String(status), + ).toBe(false); + } + }); +}); diff --git a/packages/core/src/mesh-task-replication.ts b/packages/core/src/mesh-task-replication.ts index 387cf8cf26..b921b3044b 100644 --- a/packages/core/src/mesh-task-replication.ts +++ b/packages/core/src/mesh-task-replication.ts @@ -68,3 +68,39 @@ export function isUnplannedSeedPrompt( return title !== undefined && normalized === normalizeSeedText(buildRefinementSeedPrompt(title, description)); } + +/** + * Durable statuses that park a plan-in-place card for another planning pass regardless of what its + * PROMPT.md currently says. Mirrors triage todo-discovery's `needs-replan` branch — Plan Review + * rejected the current spec, so the real (rejected) prompt must not read as "already planned". + */ +const AWAITING_PLANNING_STATUSES = new Set(["needs-replan"]); + +/* +FNXC:CodingIdeasWorkflow 2026-07-26-15:30: +Whether a plan-in-place (Todo) card is waiting for a PLANNING slot rather than a WIP slot — the +question the "Queued to plan" / "Ready" badge pair answers. + +Requirement: the badge must not contradict the engine. It used to infer "unplanned" from +`steps.length === 0`, while every engine lane decides from PROMPT.md seed-ness, so the two disagreed +in both directions: a card with a real spec but no parsed steps was labelled "Queued to plan" while +the scheduler was actually treating it as a dispatch candidate (waiting for a WIP slot, i.e. Ready), +and a re-seeded card still carrying steps from a previous pass was labelled "Ready" while triage was +about to plan it. The badge exists to make the FN-8600 planning-capacity wait legible, so a wrong +label sends operators to the wrong cap. + +`promptContent === null` means PROMPT.md is missing, which triage treats as unplanned (it +regenerates the spec) rather than as planned. Kept pure — the fs read belongs to the caller, so +this stays usable from the API layer, the engine, and tests alike. + +Callers: the `GET /api/tasks` board enrichment (`awaitingPlanning`) and triage's todo-discovery +content branch. Do not re-open-code the status set or the seed check. +*/ +export function isTaskAwaitingPlanning( + task: { id: string; title?: string; description: string; status?: string | null }, + promptContent: string | null, +): boolean { + if (task.status != null && AWAITING_PLANNING_STATUSES.has(task.status)) return true; + if (promptContent === null) return true; + return isUnplannedSeedPrompt(promptContent, task.id, task.title, task.description); +} diff --git a/packages/core/src/types/task-core.ts b/packages/core/src/types/task-core.ts index 4c3071430d..97ff9a1eba 100644 --- a/packages/core/src/types/task-core.ts +++ b/packages/core/src/types/task-core.ts @@ -1068,6 +1068,17 @@ export interface Task { * store or task.json. Consumed by FN-7516's `TaskCard` badge. */ plannerOverseerState?: PlannerOverseerRuntimeSnapshot; + /** + * FNXC:CodingIdeasWorkflow 2026-07-26-15:30: + * Transient, API-populated answer to "is this plan-in-place card waiting for a PLANNING slot + * (rather than a WIP slot)?", derived from PROMPT.md seed-ness via `isTaskAwaitingPlanning` — the + * same predicate triage's todo-discovery uses. Attached best-effort to the `GET /api/tasks` + * payload for pre-WIP cards only (mirroring the additive `branchProgress` / `plannerOverseerState` + * board-payload convention) — NEVER written to the store or task.json, and absent on SSE payloads. + * Consumed by TaskCard's "Queued to plan" / "Ready" badge pair, which falls back to its old + * step-count heuristic when the field is absent. + */ + awaitingPlanning?: boolean; /** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */ assignedAgentId?: string; /** Per-task node override. When set, this task routes to the specified node instead of the project's default node. Undefined means use the project default. Use empty string to explicitly clear. */ diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index d233d5bb62..0528815ede 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -1412,12 +1412,28 @@ function TaskCardComponent({ FNXC:CodingIdeasWorkflow 2026-07-21-22:18: Ready is the idle capacity-hold signal for Coding (Ideas) Todo cards that already have steps and no task.status. Plan Review (and any other agent-active work) also runs in Todo with status often cleared to null first, so Ready must suppress while plan-review is running or the card is agent-active — otherwise operators see both Ready and Reviewing on the same card. */ - const showReadyBadge = !isPaused + /* + FNXC:CodingIdeasWorkflow 2026-07-26-15:30: + Which cap an idle Todo card is waiting on comes from the server's `awaitingPlanning` (derived from + PROMPT.md seed-ness by the SAME `isTaskAwaitingPlanning` predicate triage's todo-discovery uses), + because the client cannot read PROMPT.md. The old `steps.length` proxy disagreed with the engine in + both directions: a real spec that parsed to zero steps read as "Queued to plan" while the scheduler + was already treating it as a WIP-slot candidate, and a re-seeded card still carrying steps from a + previous pass read as "Ready" while triage was about to plan it. + + The step-count heuristic remains the FALLBACK, not a second opinion: SSE task payloads are not + enriched, so a status-only live update or an older server leaves the field absent, and a card with + no steps is unplanned in the overwhelmingly common case. Deriving both badges from this one value + makes them strict complements — exactly one shows — instead of relying on the step count to keep + two independent conditions disjoint. + */ + const awaitingPlanning = task.awaitingPlanning ?? ((task.steps?.length ?? 0) === 0); + const showIdleTodoBadge = !isPaused && task.column === "todo" && !visualStatus - && (task.steps?.length ?? 0) > 0 && !planReviewRunning && !isAgentActive; + const showReadyBadge = showIdleTodoBadge && !awaitingPlanning; /* FNXC:CodingIdeasWorkflow 2026-07-25-12:05: "Queued to plan" is the exact complement of Ready: same idle-in-Todo conditions, but the card has @@ -1428,14 +1444,13 @@ function TaskCardComponent({ Three Todo states are now distinguishable: planning in flight (the "planning" status badge), unplanned and waiting for a planning slot (this badge), planned and waiting for a WIP slot - (Ready). The conditions are mutually exclusive by the steps count, so no card shows both. + (Ready). + + FNXC:CodingIdeasWorkflow 2026-07-26-15:30: + Both badges now derive from the single `awaitingPlanning` value above, so "exact complement" is + structural rather than a property of the step count that two independent conditions had to agree on. */ - const showQueuedToPlanBadge = !isPaused - && task.column === "todo" - && !visualStatus - && (task.steps?.length ?? 0) === 0 - && !planReviewRunning - && !isAgentActive; + const showQueuedToPlanBadge = showIdleTodoBadge && awaitingPlanning; // Native HTML5 drag is desktop-mouse only — it doesn't move cards via touch. // On touch-primary devices the `draggable` attribute still arms the browser's // touch-drag heuristic, which intermittently hijacks horizontal swipes meant diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 2f200094ee..db72f487da 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -2718,6 +2718,73 @@ describe("TaskCard", () => { unmount(); } }); + + /* + FNXC:CodingIdeasWorkflow 2026-07-26-15:30: + Original symptom: this badge claimed a card was waiting for a PLANNING slot when the engine was + never going to plan it. The step count is TaskCard's own proxy for "unplanned", while every engine + lane decides from PROMPT.md seed-ness, so the two disagreed in both directions. The server now + ships that answer as `awaitingPlanning` (same `isTaskAwaitingPlanning` predicate as triage's + todo-discovery) and it OUTRANKS the step count. + + Surface enumeration — every combination of (flag present/absent) x (steps present/absent): + - flag false + no steps -> Ready (the reported repro: real spec that parsed to zero steps). + - flag true + steps -> Queued to plan (the reverse mislabel: re-seeded card with stale steps). + - flag absent -> step-count fallback preserved in both directions (SSE payloads, older server). + - exactly one of the two badges renders in every case, since both derive from one value. + */ + describe("server awaitingPlanning outranks the step count", () => { + const readyBadge = (container: HTMLElement) => + container.querySelector('[data-testid="card-ready-FN-QUEUED-PLAN"]'); + + it("renders Ready for a stepless card the server says is already planned", () => { + const { container } = render( + , + ); + + expect(badge(container)).toBeNull(); + expect(readyBadge(container)).toHaveTextContent("Ready"); + }); + + it("renders Queued to plan for a card with stale steps the server says is unplanned", () => { + const { container } = render( + , + ); + + expect(badge(container)).toHaveTextContent("Queued to plan"); + expect(readyBadge(container)).toBeNull(); + }); + + it("falls back to the step count in both directions when the field is absent", () => { + const stepless = render( + , + ); + expect(badge(stepless.container)).toHaveTextContent("Queued to plan"); + expect(readyBadge(stepless.container)).toBeNull(); + stepless.unmount(); + + const withSteps = render( + , + ); + expect(badge(withSteps.container)).toBeNull(); + expect(readyBadge(withSteps.container)).toHaveTextContent("Ready"); + }); + }); }); it("renders the status badge after the card ID in DOM order", () => { diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index e268fa9b1d..108a7c9ed3 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -1267,6 +1267,88 @@ describe("useTasks", () => { expect(result.current.tasks[0].status).toBe("executing"); }); + /* + FNXC:CodingIdeasWorkflow 2026-07-26-15:30: + `awaitingPlanning` is attached by GET /api/tasks only, so an SSE update would wipe it and flip + TaskCard's badge back to its step-count fallback mid-stall. It is carried across same-column + updates, but must be DROPPED when the step count changes — planning finishing is exactly that, + and a stale `true` surviving it would keep claiming "Queued to plan" for a now-Ready card. + */ + describe("awaitingPlanning enrichment across SSE updates", () => { + const todoTask = (overrides: Record) => createMockTask({ + id: "FN-001", + column: "todo" as Column, + steps: [], + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }); + + async function mountWith(initial: Record) { + mockFetchTasks.mockResolvedValueOnce([todoTask(initial)]); + const { result } = renderHook(() => useTasks()); + await waitFor(() => { + expect(result.current.tasks).toHaveLength(1); + }); + return result; + } + + it("survives a status-only update that omits the field", async () => { + const result = await mountWith({ awaitingPlanning: true }); + + act(() => { + MockEventSource.instances[0]._emit("task:updated", todoTask({ + status: "planning", + updatedAt: "2026-01-02T00:00:00Z", + })); + }); + + expect(result.current.tasks[0].awaitingPlanning).toBe(true); + }); + + it("is dropped when planning lands steps, so the fallback answers Ready", async () => { + const result = await mountWith({ awaitingPlanning: true }); + + act(() => { + MockEventSource.instances[0]._emit("task:updated", todoTask({ + steps: [{ name: "Step 1", status: "pending" }], + updatedAt: "2026-01-02T00:00:00Z", + })); + }); + + expect(result.current.tasks[0].awaitingPlanning).toBeUndefined(); + expect(result.current.tasks[0].steps).toHaveLength(1); + }); + + it("is dropped when steps are cleared, so the fallback answers queued", async () => { + const result = await mountWith({ + awaitingPlanning: false, + steps: [{ name: "Step 1", status: "pending" }], + }); + + act(() => { + MockEventSource.instances[0]._emit("task:updated", todoTask({ + steps: [], + updatedAt: "2026-01-02T00:00:00Z", + })); + }); + + expect(result.current.tasks[0].awaitingPlanning).toBeUndefined(); + }); + + it("prefers a server value on the incoming payload over the carried one", async () => { + const result = await mountWith({ awaitingPlanning: true }); + + act(() => { + MockEventSource.instances[0]._emit("task:updated", todoTask({ + awaitingPlanning: false, + updatedAt: "2026-01-02T00:00:00Z", + })); + }); + + expect(result.current.tasks[0].awaitingPlanning).toBe(false); + }); + }); + it("rapid status updates after column move are not rejected", async () => { // Task starts in todo const initialTask = createMockTask({ diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 1e2a82fb79..b64811fbe5 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -168,9 +168,26 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number return a.localeCompare(b); } +/* +FNXC:CodingIdeasWorkflow 2026-07-26-15:30: +`awaitingPlanning` is attached by `GET /api/tasks` only — SSE task payloads come straight from the +store, so a status-only live update would otherwise wipe it and flip TaskCard's badge back to its +step-count fallback mid-stall. Carry it across same-column updates, but ONLY while the step count is +unchanged: planning finishing is exactly a step-count change, and a stale `true` surviving that would +keep claiming "Queued to plan" for a card that is now Ready until the next full board fetch. When it +is dropped the fallback answers correctly in both directions (steps landed -> Ready, steps cleared -> +queued), so the degraded state is never the wrong label. +*/ +function carryAwaitingPlanning(current: Task, incoming: Task): boolean | undefined { + if (incoming.awaitingPlanning !== undefined) return incoming.awaitingPlanning; + const stepCountUnchanged = (current.steps?.length ?? 0) === (incoming.steps?.length ?? 0); + return stepCountUnchanged ? current.awaitingPlanning : undefined; +} + function mergeSameColumnTask(current: Task, incoming: Task): Task { return { ...incoming, + awaitingPlanning: carryAwaitingPlanning(current, incoming), // Preserve stable execution metadata when a same-column live update arrives // without the full task payload (common during status/log-only SSE updates). columnMovedAt: current.columnMovedAt ?? incoming.columnMovedAt, 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 new file mode 100644 index 0000000000..d07624ecd3 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment node + +/* +FNXC:CodingIdeasWorkflow 2026-07-26-15:30: +Original symptom: a Todo card sat on the "Queued to plan" badge while the engine had no intention of +planning it — TaskCard inferred "unplanned" from `steps.length === 0`, but triage's todo-discovery and +the scheduler's dispatch filter both decide from PROMPT.md seed-ness, so the board named the wrong cap. + +These tests pin the server half: `GET /api/tasks` annotates Todo rows with `awaitingPlanning` derived +from the shared `isTaskAwaitingPlanning` predicate. + +Surface enumeration (the invariant, not just the reported repro): + - seed PROMPT.md (bootstrap stub) -> true, whatever the step count says. + - real spec -> false, INCLUDING the reported repro shape (real spec, zero steps). + - re-seeded card still carrying steps from a previous pass -> true (the reverse mislabel). + - missing PROMPT.md -> true (triage regenerates the spec). + - unreadable-for-another-reason PROMPT.md -> field omitted, so the client falls back rather than + asserting a wrong label. + - `needs-replan` -> true even though its PROMPT.md is a real (rejected) spec. + - non-Todo rows -> field omitted entirely (payload stays byte-identical for them). +*/ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import express from "express"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TaskStore, Task } from "@fusion/core"; +import { buildBootstrapPrompt } from "@fusion/core"; +import { createApiRoutes } from "../../routes.js"; +import { request as REQUEST } from "../../test-request.js"; + +const REAL_SPEC = "# FN-X: Real spec\n\n## Steps\n\n1. Do the thing\n\n## File Scope\n\n- src/a.ts\n"; + +let tasksRoot: string; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "FN-001", + title: "A card", + description: "A description", + column: "todo", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-05-15T00:00:00.000Z", + updatedAt: "2026-05-15T00:00:00.000Z", + ...overrides, + } as Task; +} + +/** Write PROMPT.md for a task; omit `content` to leave the file missing. */ +async function seedTaskDir(taskId: string, content?: string): Promise { + const dir = join(tasksRoot, taskId); + await mkdir(dir, { recursive: true }); + if (content !== undefined) await writeFile(join(dir, "PROMPT.md"), content); +} + +function createHarness(tasks: Task[]) { + 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), + } as unknown as TaskStore; + + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + return { app }; +} + +async function fetchTasks(tasks: Task[]): Promise>> { + const { app } = createHarness(tasks); + const res = await REQUEST(app, "GET", "/api/tasks"); + expect(res.status).toBe(200); + return res.body as Array>; +} + +beforeEach(async () => { + tasksRoot = await mkdtemp(join(tmpdir(), "fusion-awaiting-planning-")); +}); + +afterEach(async () => { + await rm(tasksRoot, { recursive: true, force: true }); +}); + +describe("GET /tasks awaitingPlanning enrichment", () => { + it("marks a seed-prompt Todo card as awaiting planning", async () => { + const task = makeTask({ id: "FN-SEED" }); + await seedTaskDir("FN-SEED", buildBootstrapPrompt("FN-SEED", task.title, task.description)); + + const [row] = await fetchTasks([task]); + + expect(row!.awaitingPlanning).toBe(true); + }); + + it("marks a real spec with ZERO parsed steps as NOT awaiting planning (the reported repro)", async () => { + // The exact shape that produced the wrong badge: no steps, but a real spec on disk, so the + // scheduler already treats the card as a dispatch candidate — it waits for a WIP slot. + const task = makeTask({ id: "FN-SPEC", steps: [] }); + await seedTaskDir("FN-SPEC", REAL_SPEC); + + const [row] = await fetchTasks([task]); + + expect(row!.awaitingPlanning).toBe(false); + }); + + it("marks a re-seeded card that still carries old steps as awaiting planning", async () => { + // The reverse mislabel: steps survive from a previous pass, so the step-count heuristic said + // "Ready" while triage was about to plan the card. + const task = makeTask({ + id: "FN-RESEED", + steps: [{ name: "stale step from a previous pass", status: "todo" }], + } as Partial); + await seedTaskDir("FN-RESEED", buildBootstrapPrompt("FN-RESEED", task.title, task.description)); + + const [row] = await fetchTasks([task]); + + expect(row!.awaitingPlanning).toBe(true); + }); + + it("treats a missing PROMPT.md as awaiting planning", async () => { + const task = makeTask({ id: "FN-NOPROMPT" }); + await seedTaskDir("FN-NOPROMPT"); + + const [row] = await fetchTasks([task]); + + expect(row!.awaitingPlanning).toBe(true); + }); + + it("omits the field when PROMPT.md is unreadable for another reason", async () => { + // A directory where the file should be: EISDIR, not ENOENT. That is not evidence either way, so + // the client must fall back instead of being handed a fabricated label. + const task = makeTask({ id: "FN-EISDIR" }); + await mkdir(join(tasksRoot, "FN-EISDIR", "PROMPT.md"), { recursive: true }); + + const [row] = await fetchTasks([task]); + + expect(row).not.toHaveProperty("awaitingPlanning"); + }); + + it("marks a needs-replan card as awaiting planning despite a real spec on disk", async () => { + const task = makeTask({ id: "FN-REPLAN", status: "needs-replan" } as Partial); + await seedTaskDir("FN-REPLAN", REAL_SPEC); + + const [row] = await fetchTasks([task]); + + expect(row!.awaitingPlanning).toBe(true); + }); + + it("leaves non-Todo rows untouched", async () => { + const inProgress = makeTask({ id: "FN-WIP", column: "in-progress" }); + const inReview = makeTask({ id: "FN-REVIEW", column: "in-review" }); + await seedTaskDir("FN-WIP", buildBootstrapPrompt("FN-WIP", inProgress.title, inProgress.description)); + await seedTaskDir("FN-REVIEW", REAL_SPEC); + + const rows = await fetchTasks([inProgress, inReview]); + + for (const row of rows) { + expect(row).not.toHaveProperty("awaitingPlanning"); + } + }); + + 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" }); + const store = { + getRootDir: vi.fn(() => process.cwd()), + getProjectScopedPluginMcpServers: vi.fn(async () => []), + getSettingsFast: vi.fn(async () => ({})), + listTasks: vi.fn(async () => [task]), + } 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).toHaveLength(1); + expect(rows[0]).not.toHaveProperty("awaitingPlanning"); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index e50c3572bc..14282fa73d 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1,8 +1,16 @@ import { createLogger } from "@fusion/core"; const severityAuditLog = createLogger("dashboard-register-task-workflow-routes"); + +/** + * FNXC:CodingIdeasWorkflow 2026-07-26-15:30: + * Per-request ceiling on `awaitingPlanning` PROMPT.md reads (one per Todo row). Boards this large + * are pathological; beyond the cap the remaining cards keep TaskCard's step-count fallback rather + * than turning one board load into thousands of file reads. Truncation is logged, never silent. + */ +const AWAITING_PLANNING_ENRICH_LIMIT = 200; import { createReadStream } from "node:fs"; -import { stat } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import type { TaskStore, @@ -88,7 +96,7 @@ import { import { buildBoardWorkflowsPayload } from "./board-workflows.js"; import { resolveNativeStructurePreview } from "../native-structure-preview.js"; import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js"; -import { computePlanApprovalFingerprint, isWorkspaceTask, type RunAuditEventInput } from "@fusion/core"; +import { computePlanApprovalFingerprint, isTaskAwaitingPlanning, isWorkspaceTask, type RunAuditEventInput } from "@fusion/core"; import { FUSION_CLIENT_HEADER, resolveHttpDeleteCallerKind } from "@fusion/core"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; // FNXC:TaskLookup404 2026-07-26-11:40: shared task-miss -> 404 mapping seam. @@ -1031,6 +1039,60 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // fail the board load — fall through with the un-enriched task list. } + /* + FNXC:CodingIdeasWorkflow 2026-07-26-15:30: + Attach `awaitingPlanning` for plan-in-place (Todo) cards so the "Queued to plan" / "Ready" + badge pair names the cap the card is actually waiting on. Same additive, best-effort, + never-fail-the-board contract as the two enrichments above; the field is omitted (rather than + `false`) for every other row, so those payloads stay byte-identical. + + Requirement: the badges must agree with the engine. TaskCard could only infer "unplanned" from + `steps.length === 0`, while triage's todo-discovery and the scheduler's dispatch filter both + decide from PROMPT.md seed-ness — so a card with a real spec but no parsed steps was labelled + "Queued to plan" while the scheduler was already treating it as a WIP-slot candidate, and a + re-seeded card still carrying old steps was labelled "Ready" while triage was about to plan it. + `isTaskAwaitingPlanning` is the shared predicate, so there is one answer per card. + + Cost: one small file read per Todo row, only on this route (SSE payloads are not enriched — + TaskCard falls back to its step-count heuristic when the field is absent). Bounded by + AWAITING_PLANNING_ENRICH_LIMIT and logged when it truncates, so a huge Todo column degrades to + the heuristic instead of turning a board load into thousands of reads. + */ + try { + const todoRows = tasks.filter((task) => task.column === "todo"); + const enrichable = todoRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT); + if (todoRows.length > enrichable.length) { + severityAuditLog.warn( + `awaitingPlanning enrichment truncated: ${enrichable.length}/${todoRows.length} todo tasks ` + + "annotated (remaining cards fall back to the client step-count heuristic)", + ); + } + if (enrichable.length > 0) { + const flagByTask = new Map(); + await Promise.all(enrichable.map(async (task) => { + let promptContent: string | null = null; + try { + promptContent = await readFile(join(scopedStore.getTaskDir(task.id), "PROMPT.md"), "utf-8"); + } catch (err: unknown) { + // A MISSING spec means unplanned (triage regenerates it), which the predicate encodes + // as `null`. Any other read fault is not evidence either way, so omit the field and + // let the client fall back rather than assert a wrong label. + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") return; + } + flagByTask.set(task.id, isTaskAwaitingPlanning(task, promptContent)); + })); + if (flagByTask.size > 0) { + tasks = tasks.map((task) => { + const awaitingPlanning = flagByTask.get(task.id); + return awaitingPlanning === undefined ? task : { ...task, awaitingPlanning }; + }); + } + } + } catch { + // Awaiting-planning enrichment is best-effort and must never fail the + // board load — fall through with the un-enriched task list. + } + res.json(tasks); } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 66b9f00a86..b8b6f84495 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -17,6 +17,7 @@ import { TaskDeletedError, buildTriageMemoryInstructions, isUnplannedSeedPrompt, + isTaskAwaitingPlanning, getTaskDuplicateLineage, parseExplicitDuplicateMarker, resolveAgentPrompt, @@ -1362,10 +1363,18 @@ export class TriageProcessor { Only ENOENT is treated as unplanned; a genuine read fault (permissions, a directory in the file's place) still skips the card, but now says so in the log instead of vanishing. */ + /* + FNXC:CodingIdeasWorkflow 2026-07-26-15:30: + Shared with the `GET /api/tasks` `awaitingPlanning` enrichment that drives the + "Queued to plan" / "Ready" badge pair, so the board cannot label a card's wait differently + from the lane that actually decides it. The three clauses of `isTaskAwaitingPlanning` are + exactly this loop's three branches: the `needs-replan` early-continue above, this content + check, and the ENOENT branch below (the helper's `null` case). + */ try { const promptPath = join(this.rootDir, ".fusion", "tasks", todoTask.id, "PROMPT.md"); const content = await readFile(promptPath, "utf-8"); - if (isUnplannedSeedPrompt(content, todoTask.id, todoTask.title, todoTask.description)) { + if (isTaskAwaitingPlanning(todoTask, content)) { eligibleTodoTasks.push(todoTask); } } catch (err) {