feat(dashboard): surface the planning-capacity wait and stop overclaiming Start

Start performs a bare column move, so "Started planning {id}" reported an
outcome the handler cannot observe: the engine still has to admit the card, and
a busy pool (per-project maxConcurrent or cross-project globalMaxConcurrent) can
defer that indefinitely. The wait was only visible in the engine log
("Plan throttled by running-agent cap|global semaphore"), so a throttled card
looked like a bug.

- Add a "Queued to plan" badge: the exact complement of "Ready" (same idle-Todo
  conditions, but no steps yet, so it waits for a PLANNING slot rather than a WIP
  slot). Three Todo states are now distinguishable: planning in flight, queued to
  plan, and ready. Pause suppression matches Ready; the badge reuses the existing
  status-badge primitives with a color-mix tint, no new tokens.
- Retitle the Start toast to "Queued {id} for planning" in both call sites
  (TaskCard Start and QuickEntryBox quick-add Start).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 09:14:01 -07:00
parent b5578448fa
commit 9cbed745f9
5 changed files with 185 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@@ -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")}
</span>
)}
{/*
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 && (
<span
className="card-status-badge card-status-badge--todo queued-to-plan"
data-testid={`card-queued-to-plan-${task.id}`}
title={t(
"tasks.queuedToPlanTitle",
"Waiting for a planning slot — planning starts when a concurrency slot frees up (maxConcurrent / globalMaxConcurrent)",
)}
>
{t("tasks.queuedToPlan", "Queued to plan")}
</span>
)}
{hasInReviewStall && stallCopy && (
<span
className={`card-status-badge card-status-badge--in-review in-review-stall in-review-stall--${stallCopy.code}`}

View File

@@ -2628,6 +2628,98 @@ describe("TaskCard", () => {
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<Task> = {}) => 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(
<TaskCard task={queuedToPlanTask()} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard task={queuedToPlanTask({ status: "planning" as any })} onOpenDetail={noop} addToast={noop} />,
);
expect(badge(container)).toBeNull();
});
it("does not render while Plan Review is running", () => {
const { container } = render(
<TaskCard
task={queuedToPlanTask({
enabledWorkflowSteps: ["plan-review"],
workflowStepResults: [{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "pending",
startedAt: "2026-07-11T12:00:00.000Z",
}],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard task={queuedToPlanTask({ [pauseFlag]: true })} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard task={queuedToPlanTask({ column })} onOpenDetail={noop} addToast={noop} />,
);
expect(badge(container), column).toBeNull();
unmount();
}
});
});
it("renders the status badge after the card ID in DOM order", () => {
const { container } = render(
<TaskCard
@@ -7888,6 +7980,32 @@ describe("TaskCard Start affordance (FN-7596)", () => {
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(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true }}
onOpenDetail={noop}
addToast={addToast}
onMoveTask={onMoveTask}
/>,
);
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();