diff --git a/.changeset/ideas-planning-starts-immediately.md b/.changeset/ideas-planning-starts-immediately.md
index 71138f1476..2f7f5810d9 100644
--- a/.changeset/ideas-planning-starts-immediately.md
+++ b/.changeset/ideas-planning-starts-immediately.md
@@ -2,6 +2,6 @@
"@runfusion/fusion": patch
---
-summary: Starting a task now begins planning immediately instead of waiting for the next engine poll.
+summary: Starting a task begins planning immediately, and cards waiting on a planning slot now say so.
category: fix
-dev: TriageProcessor gains `requestImmediatePoll()` plus a store-event wake (`task:updated`/`task:created`) that fires when a task lands in `todo`/`triage`, debounced 150ms with a mid-poll replay — so every move surface (board drag, context menu, CLI, tools, `POST /tasks/:id/move`) wakes planning rather than waiting out `pollIntervalMs` (15s default). Planning discovery now admits a `todo` task whose `PROMPT.md` is missing (ENOENT) instead of dropping it via a silent `catch {}`, and logs unreadable prompts. `isUnplannedSeedPrompt` normalizes line endings/trailing whitespace before comparing, and `scheduler.ts`'s dispatch filter now uses that shared predicate instead of an open-coded strict bootstrap compare that disagreed with triage on the refinement-seed shape.
+dev: TriageProcessor gains `requestImmediatePoll()` plus a store-event wake (`task:updated`/`task:created`) that fires when a task lands in `todo`/`triage`, debounced 150ms with a mid-poll replay — so every move surface (board drag, context menu, CLI, tools, `POST /tasks/:id/move`) wakes planning rather than waiting out `pollIntervalMs` (15s default). Planning discovery now admits a `todo` task whose `PROMPT.md` is missing (ENOENT) instead of dropping it via a silent `catch {}`, and logs unreadable prompts. `isUnplannedSeedPrompt` normalizes line endings/trailing whitespace before comparing, and `scheduler.ts`'s dispatch filter now uses that shared predicate instead of an open-coded strict bootstrap compare that disagreed with triage on the refinement-seed shape. Dashboard: an unplanned idle Todo card shows a "Queued to plan" badge (the complement of "Ready"), and the Start toast now reads "Queued {id} for planning" instead of claiming planning began.
diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx
index 5aea134c14..9723ad6a22 100644
--- a/packages/dashboard/app/components/QuickEntryBox.tsx
+++ b/packages/dashboard/app/components/QuickEntryBox.tsx
@@ -848,7 +848,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
if (target && onMoveTask) {
try {
await onMoveTask(createdTask.id, target as ColumnId);
- addToast(t("tasks.startedPlanning", "Started planning {{taskId}}", { taskId: createdTask.id }), "success");
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
+ Honest copy: this path performs a column move, not a plan dispatch. The old "Started
+ planning" claim was optimistic — planning begins when the engine admits the card, which
+ a busy maxConcurrent pool can defer indefinitely. Same wording as TaskCard's Start.
+ */
+ addToast(t("tasks.queuedForPlanning", "Queued {{taskId}} for planning", { taskId: createdTask.id }), "success");
} catch (moveError) {
addToast(getErrorMessage(moveError) || t("tasks.createFailed", "Failed to create task"), "error");
}
diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css
index fe0bf672d5..b47196fed4 100644
--- a/packages/dashboard/app/components/TaskCard.css
+++ b/packages/dashboard/app/components/TaskCard.css
@@ -369,6 +369,16 @@ Code Review / Browser Verification reuse the same reviewing token while the card
background: var(--status-todo-bg);
color: var(--todo);
}
+/*
+FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
+"Queued to plan" is a waiting state, not a ready one, so it reads quieter than the Ready badge it
+sits beside in the same slot — muted text over a washed-out todo tint via color-mix, no new tokens
+and no hardcoded color. Same geometry as every other status badge; only the fill changes.
+*/
+.card-status-badge--todo.queued-to-plan {
+ background: color-mix(in srgb, var(--todo) 12%, transparent);
+ color: var(--text-muted);
+}
.card-status-badge--in-progress {
background: var(--status-in-progress-bg);
color: var(--in-progress);
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index 7e2241bdb7..fccf74893c 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -1417,6 +1417,24 @@ function TaskCardComponent({
&& (task.steps?.length ?? 0) > 0
&& !planReviewRunning
&& !isAgentActive;
+ /*
+ 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
+ NO steps yet, so it is unplanned and waiting for a PLANNING slot rather than a WIP slot. Without
+ it a started card that the concurrency pool has not admitted is visually identical to a card
+ nothing is going to happen to — the throttle was only observable in the engine log
+ ("Plan throttled by running-agent cap|global semaphore"), which is why a busy pool read as a bug.
+
+ 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.
+ */
+ const showQueuedToPlanBadge = !isPaused
+ && task.column === "todo"
+ && !visualStatus
+ && (task.steps?.length ?? 0) === 0
+ && !planReviewRunning
+ && !isAgentActive;
// 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
@@ -2739,7 +2757,14 @@ function TaskCardComponent({
setIsStarting(true);
try {
await onMoveTask(task.id, startTargetColumn);
- addToast(t("tasks.startedPlanning", "Started planning {{taskId}}", { taskId: task.id }), "success");
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
+ Honest copy: Start performs a column move, not a plan dispatch. "Started planning" claimed an
+ outcome this handler cannot observe — the engine still has to admit the card, and a busy
+ concurrency pool (maxConcurrent / globalMaxConcurrent) can defer that indefinitely, which
+ made a throttled card look broken. The card's "Queued to plan" badge carries the live state.
+ */
+ addToast(t("tasks.queuedForPlanning", "Queued {{taskId}} for planning", { taskId: task.id }), "success");
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
@@ -3034,6 +3059,10 @@ function TaskCardComponent({
|| showStatusBadge
|| showOptionalGateBadge
|| showReadyBadge
+ // FNXC:CodingIdeasWorkflow 2026-07-25-12:05: the header wrapper only renders when it has a
+ // real child, so a new badge must be declared here or it never mounts (Queued to plan is the
+ // only badge on an unplanned idle Todo card — without this the whole cluster stays absent).
+ || showQueuedToPlanBadge
|| Boolean(hasInReviewStall && stallCopy)
|| cliWaitingOnInput
|| cliNeedsAttention
@@ -3231,6 +3260,24 @@ function TaskCardComponent({
{t("tasks.ready", "Ready")}
)}
+ {/*
+ FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
+ Started-but-not-yet-planned. Reuses the Ready badge's primitives with the queued modifier
+ rather than forking a new badge variant. The title names both caps, since the per-project
+ maxConcurrent and the cross-project globalMaxConcurrent can each be the binding one.
+ */}
+ {showQueuedToPlanBadge && (
+
+ {t("tasks.queuedToPlan", "Queued to plan")}
+
+ )}
{hasInReviewStall && stallCopy && (
{
expect(container.querySelector('[data-testid="card-ready-FN-READY-QUEUED"]')).toBeNull();
});
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
+ "Queued to plan" makes the planning-capacity wait visible. Symptom it fixes: a started card that
+ the concurrency pool had not admitted looked identical to a card nothing would happen to — the
+ throttle was only observable in the engine log.
+
+ Surface enumeration (invariant: exactly one of {planning status, Queued to plan, Ready} shows on
+ an idle Todo card, chosen by whether the card has steps and whether work is live):
+ - Unplanned idle Todo card -> Queued to plan, never Ready.
+ - Planned idle Todo card -> Ready, never Queued to plan (asserted in the Ready tests above).
+ - Planning in flight (status set) -> neither badge; the status badge owns the card.
+ - Plan Review running, agent-active, queued, and paused -> neither badge.
+ - Non-todo columns -> neither badge.
+ */
+ describe("Queued to plan badge", () => {
+ const queuedToPlanTask = (overrides: Partial = {}) => makeTask({
+ id: "FN-QUEUED-PLAN",
+ column: "todo",
+ status: null as any,
+ steps: [] as Task["steps"],
+ ...overrides,
+ });
+ const badge = (container: HTMLElement) =>
+ container.querySelector('[data-testid="card-queued-to-plan-FN-QUEUED-PLAN"]');
+
+ it("renders on an idle unplanned Todo card", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(badge(container)).toHaveTextContent("Queued to plan");
+ // Mutually exclusive with Ready — a card is never both unplanned and planned.
+ expect(container.querySelector('[data-testid="card-ready-FN-QUEUED-PLAN"]')).toBeNull();
+ });
+
+ it("does not render once planning is in flight", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(badge(container)).toBeNull();
+ });
+
+ it("does not render while Plan Review is running", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(badge(container)).toBeNull();
+ });
+
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
+ Pause suppression matches the Ready badge exactly (`!isPaused`), for both pause flavors. The
+ board-level `queued` gate is deliberately NOT special-cased here, because Ready does not
+ special-case it either — a queued card is still genuinely waiting for a planning slot, and
+ forking a different suppression rule for the sibling badge in the same slot is the drift the
+ reuse rule exists to prevent.
+ */
+ it("does not render on a paused card, matching Ready", () => {
+ for (const pauseFlag of ["paused", "userPaused"] as const) {
+ const { container, unmount } = render(
+ ,
+ );
+ expect(badge(container), pauseFlag).toBeNull();
+ unmount();
+ }
+ });
+
+ it("does not render outside the todo column", () => {
+ for (const column of ["triage", "in-progress", "in-review", "done"] as const) {
+ const { container, unmount } = render(
+ ,
+ );
+ expect(badge(container), column).toBeNull();
+ unmount();
+ }
+ });
+ });
+
it("renders the status badge after the card ID in DOM order", () => {
const { container } = render(
{
await waitFor(() => expect(startButton).not.toBeDisabled());
});
+ /*
+ FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
+ The Start toast must not claim planning has begun. Start performs a bare column move — it cannot
+ observe admission — so "Started planning {id}" reported an outcome that a busy concurrency pool
+ could defer indefinitely, which is what made a throttled card look broken.
+ */
+ it("reports the Start move as queued, not as planning already started", async () => {
+ const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "todo" }));
+ const addToast = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("card-start-FN-001"));
+
+ await waitFor(() => expect(addToast).toHaveBeenCalledWith("Queued FN-001 for planning", "success"));
+ expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("Started planning"), expect.anything());
+ });
+
it("shows an error toast when the Start move fails", async () => {
const onMoveTask = vi.fn().mockRejectedValue(new Error("move blocked"));
const addToast = vi.fn();