fix: make Queued to plan / Ready badges agree with the planning lane

TaskCard inferred "unplanned" from steps.length === 0 while triage's
todo-discovery and the scheduler's dispatch filter both decide from
PROMPT.md seed-ness, so the badges disagreed with the engine in both
directions: a real spec that parsed to zero steps read as "Queued to
plan" while the scheduler already treated it as a WIP-slot candidate, and
a re-seeded card still carrying old steps read as "Ready" while triage
was about to plan it. Either way the badge sent operators to the wrong
cap.

Adds the shared isTaskAwaitingPlanning predicate (replan park, missing
spec, seed-vs-real content) used by both triage's discovery and a new
best-effort `awaitingPlanning` enrichment on GET /api/tasks. TaskCard
derives both badges from that one value — strict complements — and keeps
the step count only as a fallback for SSE payloads and older servers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-26 17:48:46 -07:00
parent 01b27b13f1
commit beebd270bd
11 changed files with 549 additions and 12 deletions

View File

@@ -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.

View File

@@ -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);
}
});
});

View File

@@ -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);
}

View File

@@ -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. */

View File

@@ -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

View File

@@ -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(
<TaskCard
task={queuedToPlanTask({ steps: [] as Task["steps"], awaitingPlanning: false })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={queuedToPlanTask({
steps: [{ name: "stale step", status: "pending" }] as Task["steps"],
awaitingPlanning: true,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard task={queuedToPlanTask({ steps: [] as Task["steps"] })} onOpenDetail={noop} addToast={noop} />,
);
expect(badge(stepless.container)).toHaveTextContent("Queued to plan");
expect(readyBadge(stepless.container)).toBeNull();
stepless.unmount();
const withSteps = render(
<TaskCard
task={queuedToPlanTask({ steps: [{ name: "Step 1", status: "pending" }] as Task["steps"] })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(badge(withSteps.container)).toBeNull();
expect(readyBadge(withSteps.container)).toHaveTextContent("Ready");
});
});
});
it("renders the status badge after the card ID in DOM order", () => {

View File

@@ -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<string, unknown>) => createMockTask({
id: "FN-001",
column: "todo" as Column,
steps: [],
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
});
async function mountWith(initial: Record<string, unknown>) {
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({

View File

@@ -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,

View File

@@ -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> = {}): 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<void> {
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<Array<Record<string, unknown>>> {
const { app } = createHarness(tasks);
const res = await REQUEST(app, "GET", "/api/tasks");
expect(res.status).toBe(200);
return res.body as Array<Record<string, unknown>>;
}
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<Task>);
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<Task>);
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<Record<string, unknown>>;
expect(rows).toHaveLength(1);
expect(rows[0]).not.toHaveProperty("awaitingPlanning");
});
});

View File

@@ -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<string, boolean>();
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) {

View File

@@ -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) {