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();