FN-7596: regression-test the Coding (Ideas) manual-intake lifecycle end-to-end

Adds cross-layer regression coverage for the manual-intake parking lifecycle (create -> parked -> operator Start promotion -> poll-time todo-discovery), and clarifies the workflow-steps doc to describe the tested lifecycle.

- packages/core: covers store create -> moveTask promotion out of the parked intake column
- packages/engine: covers triage poll ordering/discovery of the still-unplanned bootstrap-stub card
- packages/dashboard: covers TaskCard's Start affordance for parked cards
- docs: documents the full regression-tested lifecycle for manual-intake column parking (FN-7596)

Files changed:
 docs/workflow-steps.md                             |   2 +-
 .../__tests__/store-create-intake-column.test.ts   |  26 ++++
 .../app/components/__tests__/TaskCard.test.tsx     | 153 +++++++++++++++++++++
 packages/engine/src/__tests__/triage.test.ts       | 119 +++++++++++++++-
 4 files changed, 298 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7596

Fusion-Task-Lineage: 267c3d9a-6181-4ca5-b871-7009c0204372

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 16:33:21 -07:00
parent 8b4e5224ea
commit 09a1c9d843
4 changed files with 298 additions and 2 deletions

View File

@@ -82,7 +82,7 @@ Use this inventory as the documentation map for current workflow behavior:
| Routing boundary | Agents may select/change a workflow only for explicit user requests or tasks they created; no-commit markers do not imply Quick fix or any other workflow. | This page, [Selecting workflows](#selecting-workflows); [Agents](./agents.md#interactive-cli-chat). |
| Dashboard board/list/graph selection | Board/List/Header/Graph share durable per-project workflow selection; stale saved ids fall back to a valid workflow. Board adds a dashboard-only **All workflows** aggregate and task workflow-name badges; Graph uses **All workflows** for the full active graph. | [Dashboard Guide → Board View](./dashboard-guide.md#board-view), [Graph View](./dashboard-guide.md#graph-view), and [Workflow Selection and Editor](./dashboard-guide.md#workflow-selection-and-editor). |
| Create/planning forwarding | Quick-create task creation, Planning Mode, Subtask Breakdown, and the New Task dialog forward the active real workflow id when creating tasks; **All workflows** quick-create chooses a real workflow intake/default column instead of saving a synthetic aggregate id. | [Dashboard Guide → Planning Mode](./dashboard-guide.md#planning-mode). |
| Manual-intake column parking | Dashboard create surfaces never send an explicit `column`; the store resolves the landing column from the (selected or project-default) workflow's intake column. A workflow whose intake column sets `autoTriage: false` (e.g. built-in Coding (Ideas)'s `ideas` column) parks new cards there instead of auto-planning them, until an operator promotes the card. | [Dashboard Guide → Create/Planning Forwarding](./dashboard-guide.md#planning-mode). |
| Manual-intake column parking | Dashboard create surfaces never send an explicit `column`; the store resolves the landing column from the (selected or project-default) workflow's intake column. A workflow whose intake column sets `autoTriage: false` (e.g. built-in Coding (Ideas)'s `ideas` column) parks new cards there instead of auto-planning them, until an operator promotes the card. The full lifecycle — create → parked → operator "Start" promotion → poll-time todo-discovery of the still-unplanned (bootstrap-stub) card — is regression-tested at the engine (triage poll ordering/discovery), UI (`TaskCard` Start affordance), and store (create → `moveTask` promotion) layers (FN-7596). | [Dashboard Guide → Create/Planning Forwarding](./dashboard-guide.md#planning-mode). |
### Skill-backed workflow steps

View File

@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Task } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import { buildBootstrapPrompt } from "../mesh-task-replication.js";
/*
FNXC:CodingIdeasWorkflow 2026-07-04-11:30:
@@ -80,4 +81,29 @@ describe("createTask intake-column wiring (Coding (Ideas))", () => {
// A direct todo create is NOT an intake column, so it must NOT get the bootstrap stub.
expect(prompt).not.toBe(`# ${task.id}\n\n${task.description}\n`);
});
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
FN-7596 pins the store-level contract the engine's todo-discovery poll (packages/engine/src/triage.ts eligibleTodoTasks) depends on: promoting a parked Ideas card via moveTask alone must NOT plan it. Only the triage service's bootstrap-prompt discovery loop plans a promoted-but-unplanned todo card; moveTask is a pure column transition.
*/
it("promotes an Ideas-parked task to todo without planning it (still bootstrap-stub PROMPT.md)", async () => {
const store = harness.store();
// Custom (non-legacy) column transitions are validated against the workflow IR
// only when the workflowColumns compatibility flag is enabled (KTD-1).
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const task = await store.createTask({
description: "ideas lifecycle promotion task",
workflowId: "builtin:coding-ideas",
});
expect(task.column).toBe("ideas");
const moved = await store.moveTask(task.id, "todo", { moveSource: "user" });
expect(moved.column).toBe("todo");
const prompt = await readFile(
join(harness.rootDir(), ".fusion", "tasks", task.id, "PROMPT.md"),
"utf-8",
);
expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description));
});
});

View File

@@ -6411,3 +6411,156 @@ describe("TaskCard custom field badges (U13/KTD-14)", () => {
expect(screen.queryByTestId("card-field-badges")).toBeNull();
});
});
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
FN-7596 regression-tests the TaskCard "Start" affordance that promotes a Coding (Ideas) manual-intake card. `showStartAction` requires taskColumnFlags.intake and a non-"triage" column; `startTargetColumn` derives the destination from `taskMoveColumns` (first non-intake/non-archived/non-hiddenFromBoard column) rather than a hard-coded "todo" string, per the FNXC comment at its call site.
*/
describe("TaskCard Start affordance (FN-7596)", () => {
it("renders the Start button for a manual-intake column with onMoveTask provided", () => {
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true }}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
expect(screen.getByTestId("card-start-FN-001")).toBeInTheDocument();
});
it("omits the Start button when the column is not flagged as an intake", () => {
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: false }}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
});
it("omits the Start button for the triage column even when intake is flagged", () => {
render(
<TaskCard
task={makeTask({ column: "triage" })}
taskColumnFlags={{ intake: true }}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
});
it("omits the Start button when no onMoveTask handler is provided", () => {
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
});
it("derives the Start target from taskMoveColumns instead of a hard-coded 'todo' string", async () => {
const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "custom-working-stage" as any }));
const addToast = vi.fn();
// The intake column itself, plus a non-intake working column that is NOT literally
// named "todo", must win over any coincidental fallback — proving derivation, not a hard-coded string.
const taskMoveColumns = [
{ id: "ideas" as any, label: "Ideas", flags: { intake: true } },
{ id: "custom-working-stage" as any, label: "Custom Working Stage", flags: {} },
{ id: "todo" as any, label: "Todo", flags: {} },
];
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true }}
taskMoveColumns={taskMoveColumns}
onOpenDetail={noop}
addToast={addToast}
onMoveTask={onMoveTask}
/>,
);
fireEvent.click(screen.getByTestId("card-start-FN-001"));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "custom-working-stage"));
});
it("falls back to 'todo' when taskMoveColumns metadata is unavailable", async () => {
const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "todo" }));
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true }}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
/>,
);
fireEvent.click(screen.getByTestId("card-start-FN-001"));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo"));
});
it("disables the button and shows the Starting label while the move is in flight, then shows a success toast", async () => {
let resolveMove: (task: ReturnType<typeof makeTask>) => void = () => {};
const onMoveTask = vi.fn().mockImplementation(
() => new Promise((resolve) => { resolveMove = resolve; }),
);
const addToast = vi.fn();
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true }}
onOpenDetail={noop}
addToast={addToast}
onMoveTask={onMoveTask}
/>,
);
const startButton = screen.getByTestId("card-start-FN-001");
fireEvent.click(startButton);
await waitFor(() => expect(startButton).toBeDisabled());
expect(startButton.textContent).toContain("Starting");
resolveMove(makeTask({ column: "todo" }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("FN-001"), "success"));
await waitFor(() => expect(startButton).not.toBeDisabled());
});
it("shows an error toast when the Start move fails", async () => {
const onMoveTask = vi.fn().mockRejectedValue(new Error("move blocked"));
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("move blocked", "error"));
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
import { builtinSeamPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
import { builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
import {
TriageProcessor,
buildSpecificationPrompt,
@@ -2392,6 +2392,123 @@ describe("TriageProcessor", () => {
});
});
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
FN-7596 pins the Coding (Ideas) manual-intake lifecycle at the poll-dispatch boundary: an `ideas`-column card must stay parked (never auto-dispatched via `eligibleTriageTasks`, which only matches `column === "triage"`), while a promoted `todo`-column card whose PROMPT.md is still the bootstrap stub must be discovered and specified via `eligibleTodoTasks`'s bootstrap-prompt file check. A `todo` card with a real (non-bootstrap) spec must NOT be re-dispatched, guarding against double-specifying an already-planned card.
*/
describe("Coding (Ideas) manual-intake discovery (FN-7596)", () => {
it("excludes a parked ideas-column task from the poll's specify-dispatch set", async () => {
const tasks: Task[] = [
createTriageTask({ id: "FN-IDEAS-PARKED", column: "ideas" as any, priority: "urgent" }),
];
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const triageProcessor = new TriageProcessor(triageStore, rootDir);
const specifySpy = vi
.spyOn(triageProcessor, "specifyTask")
.mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
expect(specifySpy).not.toHaveBeenCalled();
});
it("discovers a promoted todo-column task whose PROMPT.md is still the bootstrap stub", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-discovery-");
const promotedId = "FN-IDEAS-PROMOTED";
try {
const promotedTask = createTriageTask({
id: promotedId,
title: "Promoted from Ideas intake",
description: "Promoted intake task",
column: "todo",
priority: "urgent",
});
await mkdir(join(tempRoot, ".fusion", "tasks", promotedId), { recursive: true });
await writeFile(
join(tempRoot, ".fusion", "tasks", promotedId, "PROMPT.md"),
buildBootstrapPrompt(promotedId, promotedTask.title, promotedTask.description),
"utf-8",
);
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([promotedTask]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
const specifySpy = vi
.spyOn(triageProcessor, "specifyTask")
.mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
expect(specifySpy).toHaveBeenCalledTimes(1);
expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: promotedId }));
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it("does not re-dispatch a todo-column task whose PROMPT.md already carries a real (non-bootstrap) spec", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-planned-");
const plannedId = "FN-IDEAS-PLANNED";
try {
const plannedTask = createTriageTask({
id: plannedId,
title: "Already planned todo task",
description: "Already planned intake task",
column: "todo",
priority: "urgent",
});
await mkdir(join(tempRoot, ".fusion", "tasks", plannedId), { recursive: true });
await writeFile(
join(tempRoot, ".fusion", "tasks", plannedId, "PROMPT.md"),
`# Task: ${plannedId} - Already planned todo task\n\n## Mission\n\nThis task carries a real spec, not the bootstrap stub.\n`,
"utf-8",
);
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([plannedTask]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
const specifySpy = vi
.spyOn(triageProcessor, "specifyTask")
.mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
expect(specifySpy).not.toHaveBeenCalled();
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
});
it("runs deterministic validation without calling the spec reviewer", async () => {
const taskId = "FN-001";
const testRootDir = await createTriageFixtureRoot("fusion-triage-plan-validation-");