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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user