FN-7190: fix linear built-in workflow dispatch

Ensure selectable linear built-in workflows carry the default queue traits needed for Todo release.

- Convert linear built-in workflow IR to v2 with cloned canonical coding workflow columns.
- Guard synthesized linear built-ins so todo retains hold-capacity dispatch traits.
- Cover quick-fix, review-heavy, design, and compound-engineering Todo release behavior with regression tests.
- Document that selectable built-ins dispatch through capacity-released queue columns.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7190-linear-builtin-todo-hold.md     |   7 ++
 docs/workflow-editor.md                            |   2 +-
 docs/workflow-steps.md                             |   5 +
 .../core/src/__tests__/builtin-workflows.test.ts   | 110 +++++++++++++++++++++
 packages/core/src/builtin-workflows.ts             |  46 ++++++---
 packages/engine/src/__tests__/hold-release.test.ts |  28 ++++++
 6 files changed, 181 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7190

Fusion-Task-Lineage: a0a40365-4bd5-49a8-8ae4-5e313cf1d4e0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 08:19:01 -07:00
parent fecb27e768
commit fb509b1ed3
6 changed files with 181 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Compound Engineering, Quick fix, and Review-heavy workflow tasks getting stuck in Todo.
category: fix
dev: linear() built-in workflows now synthesize the canonical default column traits (hold(capacity) on todo, wip on in-progress, merge on in-review) matching BUILTIN_CODING_WORKFLOW_IR, so the hold/release sweep dispatches their todo cards. Fixes FN-7190.

View File

@@ -138,7 +138,7 @@ Fusion ships built-in workflows as read-only references:
- `builtin:stepwise-coding` — a graph variant that models per-step parse, execute, review, and rework structure.
- `builtin:design` — a UI-heavy work path with a gated design/UX review before standard review and merge.
Built-ins can be viewed, exported, and used as templates, but their graph, columns, field declarations, and setting declarations are not editable. Their per-project setting **values** are editable from the Settings panel's Values tab.
Built-ins can be viewed, exported, and used as templates, but their graph, columns, field declarations, and setting declarations are not editable. Their per-project setting **values** are editable from the Settings panel's Values tab. Selectable built-ins all use a capacity-released queue column (`todo` or a workflow-specific backlog) that dispatches to the active WIP column through the standard hold/release sweep.
To customize behavior, create a workflow from **Blank** or copy a built-in/custom workflow with **Duplicate to customize**. Tasks select a workflow by workflow id. Agents and automation can discover workflows with `fn_workflow_list`, assign one to an existing task with `fn_workflow_select`, or pass `workflow_id` when creating tasks through `fn_task_create` / delegation tools.

View File

@@ -21,6 +21,9 @@ Agents may select or change a workflow only when the user explicitly requested t
FNXC:Docs 2026-06-21-12:00:
FN-6906 makes non-coding built-in prompts artifact-oriented: marketing drafts, lead enrichment/outreach, and design previews are persisted with fn_task_document_write, while fn_artifact_register remains conditional until the artifact tool is available.
FNXC:WorkflowRuntime 2026-06-28-08:10:
Selectable built-in workflows must share the canonical dispatch traits: their held work enters through a capacity-released `todo`/backlog column and moves to the first WIP execution column via the hold/release sweep, so non-default built-ins do not need a separate dispatcher.
-->
Fusion workflows define the task lifecycle policy that moves work from an idea to delivery. The default coding path is **Plan/Triage → Execute → Workflow steps → Review → Merge**, but that path is now represented as a workflow selection rather than only as fixed engine behavior. A task with no explicit workflow resolves to `builtin:coding`; an explicit missing/corrupt custom workflow fails closed instead of silently falling back.
@@ -51,6 +54,8 @@ Decision-only or investigation tasks can also declare `noCommitsExpected` / `**N
| PR lifecycle | `builtin:pr-workflow` | Reusable PR lifecycle graph fragment (create PR → await review → respond → gate → merge); it is a fragment, not directly selectable as a task workflow. |
| Lead generation | `builtin:lead-generation` | Selectable business workflow for sourcing, qualifying, enriching, and contacting leads with custom lead fields, stage columns, and reviewable enrichment/outreach task documents; requires the workflow graph executor for custom board columns. |
Every selectable built-in workflow uses a capacity-released hold column (`todo` or a workflow-specific backlog) for queued work and a WIP execution column for active work, so the hold/release sweep performs the normal `todo`/backlog → in-progress dispatch across the catalog.
### Skill-backed workflow steps
<!--

View File

@@ -18,6 +18,12 @@ import { DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr, serializeWorkflowIr } fro
import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js";
const EXECUTE_NODE_MAX_RETRIES = 2;
const LINEAR_BUILTIN_IDS = [
"builtin:quick-fix",
"builtin:review-heavy",
"builtin:design",
"builtin:compound-engineering",
] as const;
function browserVerificationInnerConfig(ir: { nodes: Array<{ id: string; kind: string; config?: Record<string, unknown> }> }): Record<string, unknown> {
const group = ir.nodes.find((node) => node.id === BROWSER_VERIFICATION_GROUP_ID);
@@ -25,6 +31,13 @@ function browserVerificationInnerConfig(ir: { nodes: Array<{ id: string; kind: s
return template?.nodes?.find((node) => node.id === BROWSER_VERIFICATION_STEP_NODE_ID)?.config ?? {};
}
function columnTraitMatrix(ir: { columns: Array<{ id: string; traits: Array<{ trait: string; config?: unknown }> }> }): Array<{
id: string;
traits: Array<{ trait: string; config?: unknown }>;
}> {
return ir.columns.map((column) => ({ id: column.id, traits: column.traits }));
}
describe("built-in workflows", () => {
// Non-compiler built-ins model graph-only node kinds or reusable fragments the
// linear compiler cannot lower to a step list. They still must parse as valid IR.
@@ -142,6 +155,103 @@ describe("built-in workflows", () => {
expect(serializeWorkflowIr(coding!.ir)).toBe(serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR));
});
it("linear built-ins use the canonical trait-bearing default columns", () => {
expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2");
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected coding v2");
const canonicalColumns = columnTraitMatrix(BUILTIN_CODING_WORKFLOW_IR);
for (const workflowId of LINEAR_BUILTIN_IDS) {
const workflow = getBuiltinWorkflow(workflowId);
expect(workflow, workflowId).toBeDefined();
const ir = parseWorkflowIr(workflow!.ir);
expect(ir.version, workflowId).toBe("v2");
if (ir.version !== "v2") throw new Error(`expected ${workflowId} v2`);
expect(columnTraitMatrix(ir), workflowId).toEqual(canonicalColumns);
const todo = ir.columns.find((column) => column.id === "todo");
expect(todo?.traits).toContainEqual({ trait: "hold", config: { release: "capacity" } });
expect(todo?.traits).toContainEqual({ trait: "reset-on-entry" });
expect(ir.columns.find((column) => column.id === "in-progress")?.traits.map((trait) => trait.trait)).toContain("wip");
expect(ir.columns.find((column) => column.id === "in-review")?.traits.map((trait) => trait.trait)).toContain("merge");
}
const quickFix = parseWorkflowIr(getBuiltinWorkflow("builtin:quick-fix")!.ir);
if (quickFix.version !== "v2") throw new Error("expected quick-fix v2");
expect(quickFix.nodes.find((node) => node.id === "execute")?.column).toBe("in-progress");
expect(quickFix.nodes.find((node) => node.id === "merge")?.column).toBe("in-review");
});
it("hand-authored built-in workflow columns stay on their authored trait sets", () => {
const expected = new Map([
[
"builtin:coding",
[
{ id: "triage", traits: ["intake"] },
{ id: "todo", traits: ["hold", "reset-on-entry"] },
{ id: "in-progress", traits: ["wip", "abort-on-exit", "timing"] },
{ id: "in-review", traits: ["merge-blocker", "human-review", "stall-detection", "merge"] },
{ id: "done", traits: ["complete"] },
{ id: "archived", traits: ["archived"] },
],
],
[
"builtin:marketing",
[
{ id: "ideation", traits: ["intake"] },
{ id: "backlog", traits: ["hold", "reset-on-entry"] },
{ id: "drafting", traits: ["wip", "abort-on-exit", "timing"] },
{ id: "editorial-review", traits: ["merge-blocker", "human-review", "stall-detection", "merge"] },
{ id: "published", traits: ["complete"] },
{ id: "archived", traits: ["archived"] },
],
],
[
"builtin:stepwise-coding",
[
{ id: "triage", traits: ["intake"] },
{ id: "todo", traits: ["hold", "reset-on-entry"] },
{ id: "in-progress", traits: ["wip", "abort-on-exit", "timing"] },
{ id: "in-review", traits: ["merge-blocker", "human-review", "stall-detection", "merge"] },
{ id: "done", traits: ["complete"] },
{ id: "archived", traits: ["archived"] },
],
],
[
"builtin:lead-generation",
[
{ id: "triage", traits: ["intake"] },
{ id: "sourcing", traits: ["timing"] },
{ id: "qualification", traits: ["wip", "timing"] },
{ id: "enrichment", traits: ["timing"] },
{ id: "outreach", traits: ["human-review", "stall-detection"] },
{ id: "converted", traits: ["complete"] },
{ id: "archived", traits: ["archived"] },
],
],
[
"builtin:pr-workflow",
[
{ id: "triage", traits: ["intake"] },
{ id: "in-progress", traits: ["wip", "timing"] },
{ id: "await-review", traits: ["merge-blocker", "stall-detection"] },
{ id: "done", traits: ["complete"] },
{ id: "archived", traits: ["archived"] },
],
],
]);
for (const [workflowId, expectedColumns] of expected) {
const workflow = getBuiltinWorkflow(workflowId)!;
const ir = parseWorkflowIr(workflow.ir);
expect(ir.version, workflowId).toBe("v2");
if (ir.version !== "v2") throw new Error(`expected ${workflowId} v2`);
expect(
ir.columns.map((column) => ({ id: column.id, traits: column.traits.map((trait) => trait.trait) })),
workflowId,
).toEqual(expectedColumns);
}
});
it("builtin:coding catalog IR exposes canonical columns, placements, and settings", () => {
const coding = getBuiltinWorkflow("builtin:coding")!;
const ir = parseWorkflowIr(coding.ir);

View File

@@ -6,8 +6,8 @@ import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-w
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
import type { WorkflowDefinition } from "./workflow-definition-types.js";
import type { WorkflowIr, WorkflowIrNode } from "./workflow-ir-types.js";
import { DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr } from "./workflow-ir.js";
import type { WorkflowIr, WorkflowIrColumn, WorkflowIrNode } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
/** Prefix marking a workflow as a read-only built-in template. */
export const BUILTIN_WORKFLOW_ID_PREFIX = "builtin:";
@@ -51,8 +51,6 @@ interface BuiltinSpec {
nodes: Array<{ id: string; kind: WorkflowIr["nodes"][number]["kind"]; config?: Record<string, unknown> }>;
}
const V1_LINEAR_NODE_KINDS = new Set<WorkflowIrNode["kind"]>(["start", "prompt", "script", "gate", "end"]);
function defaultColumnForLinearNode(node: WorkflowIrNode): string {
const seam = node.config?.seam;
if (seam === "execute") return "in-progress";
@@ -61,6 +59,19 @@ function defaultColumnForLinearNode(node: WorkflowIrNode): string {
return "todo";
}
function canonicalBuiltinWorkflowColumns(): WorkflowIrColumn[] {
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") {
throw new Error("builtin coding workflow must be v2 to provide canonical columns");
}
return BUILTIN_CODING_WORKFLOW_IR.columns.map((column) => ({
...column,
traits: column.traits.map((trait) => ({
...trait,
config: trait.config ? { ...trait.config } : undefined,
})),
}));
}
/** Build a linear IR (start → nodes… → end) with simple x-spaced layout. */
function linear(spec: BuiltinSpec): WorkflowDefinition {
const nodes: WorkflowIr["nodes"] = [
@@ -82,20 +93,23 @@ function linear(spec: BuiltinSpec): WorkflowDefinition {
nodes.forEach((node, i) => {
layout[node.id] = { x: 60 + i * 170, y: 160 };
});
const hasV2OnlyNode = nodes.some((node) => !V1_LINEAR_NODE_KINDS.has(node.kind));
const ir = hasV2OnlyNode
? parseWorkflowIr({
version: "v2",
name: spec.name,
columns: DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })),
nodes: nodes.map((node) => (node.column ? node : { ...node, column: defaultColumnForLinearNode(node) })),
edges,
})
: parseWorkflowIr({ version: "v1", name: spec.name, nodes, edges });
/*
* FNXC:Workflows 2026-06-28-00:00:
* Linear built-ins must mirror BUILTIN_CODING_WORKFLOW_IR column traits because the post-cutover hold/release sweep is the only todo→in-progress dispatcher. Both formerly-v1 linear graphs (quick-fix, review-heavy, design) and v2-only compound-engineering need todo hold(capacity), in-progress wip, and in-review merge traits or their cards strand in Todo.
*/
const ir = parseWorkflowIr({
version: "v2",
name: spec.name,
columns: canonicalBuiltinWorkflowColumns(),
nodes: nodes.map((node) => (node.column ? node : { ...node, column: defaultColumnForLinearNode(node) })),
edges,
});
if (ir.version !== "v2" || !ir.columns.find((column) => column.id === "todo")?.traits.some((trait) => trait.trait === "hold")) {
throw new Error(`linear built-in workflow '${spec.id}' must synthesize a hold-capacity todo column`);
}
// Attach the moved-key settings catalog (U1/U3, R4) so every built-in workflow
// carries its declarations through the resolver path (resolveWorkflowIrById →
// resolveEffectiveSettings). v1 graphs upgrade to v2 on parse, so the parsed IR
// is v2 and can carry `settings`. Defaults are byte-equal to legacy
// resolveEffectiveSettings). Defaults are byte-equal to legacy
// DEFAULT_PROJECT_SETTINGS literals, so this is behavior-inert.
if (ir.version === "v2") {
ir.settings = BUILTIN_WORKFLOW_SETTINGS;

View File

@@ -63,6 +63,12 @@ function setTransitionPending(store: TaskStore, taskId: string, toColumn: string
}
const noReserveDeps: HoldReleaseDeps = { now: () => Date.now() };
const LINEAR_BUILTIN_WORKFLOW_IDS = [
"builtin:compound-engineering",
"builtin:quick-fix",
"builtin:review-heavy",
"builtin:design",
] as const;
describe("hold-release sweep (U6)", () => {
let rootDir = "";
@@ -96,6 +102,28 @@ describe("hold-release sweep (U6)", () => {
return task.id;
}
it("releases default and linear built-in workflow todo cards into in-progress", async () => {
await store.updateSettings({ maxConcurrent: 10 } as Parameters<typeof store.updateSettings>[0]);
const defaultWorkflowTask = await seedTodoCard();
const selectedTasks: string[] = [];
// Pre-fix, linear() synthesized trait-less default columns, so isHeldTask()
// returned false here and these selected tasks were silently skipped forever.
for (const workflowId of LINEAR_BUILTIN_WORKFLOW_IDS) {
const task = await store.createTask({ description: `card ${workflowId}` });
setSelection(store, task.id, workflowId);
setColumn(store, task.id, "todo");
selectedTasks.push(task.id);
}
const result = await runHoldReleaseSweep(store, noReserveDeps);
expect(result.released).toEqual(expect.arrayContaining([defaultWorkflowTask, ...selectedTasks]));
for (const taskId of [defaultWorkflowTask, ...selectedTasks]) {
expect((await store.getTask(taskId))?.column).toBe("in-progress");
}
});
it("ignores stale workflowColumns=false and still releases held default-workflow cards", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
const id = await seedTodoCard();