FN-7192: Document custom workflow capacity dispatch

Clarify the custom workflow migration boundary and lock dispatch behavior with tests.

- Document that pure-v1 custom workflows keep trait-less default columns for rollback compatibility.
- Show the v2 hold(capacity) and wip traits required for custom workflow capacity dispatch.
- Add core and engine regression coverage for pure-v1 stranding and authored-v2 release behavior.

Files changed:
 docs/workflow-editor.md                            |  34 +++++
 docs/workflow-steps.md                             |  40 ++++++
 .../__tests__/custom-v1-workflow-dispatch.test.ts  | 154 +++++++++++++++++++++
 packages/engine/src/__tests__/hold-release.test.ts |  81 ++++++++++-
 4 files changed, 306 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7192

Fusion-Task-Lineage: ff7af258-c0cb-4b81-8919-9e076c3ac1df

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 09:59:57 -07:00
parent f0b3003c0a
commit ac4c3b7a01
4 changed files with 306 additions and 3 deletions

View File

@@ -74,6 +74,40 @@ The editor prevents ordinary cycles while connecting nodes. If a graph branches
The **Columns** panel edits workflow-defined swimlanes. A column has an id, name, ordered position, and composable traits. The panel can add, rename, reorder, and remove columns for custom workflows; built-ins show the same data read-only.
<!--
FNXC:Workflows 2026-06-28-09:45:
FN-7190/FN-7192 made the workflow-columns dispatch contract explicit: `runHoldReleaseSweep` only releases cards resting in a hold column, so custom workflows that queue work in `todo` must author a v2 `hold(capacity)` todo column instead of relying on pure-v1 default columns.
-->
For task dispatch, the queue column that holds ready work must carry a `hold` trait with `release: "capacity"`, and the downstream active column must carry a `wip` trait. Pure-v1 custom graphs still upgrade to the legacy default column ids with empty trait sets for rollback compatibility, so a task selecting that workflow can remain in `todo` until the workflow is migrated to v2 columns. When authoring or migrating a custom coding workflow, mirror this minimum shape:
```json
{
"version": "v2",
"columns": [
{
"id": "todo",
"name": "todo",
"traits": [
{ "trait": "hold", "config": { "release": "capacity" } },
{ "trait": "reset-on-entry" }
]
},
{
"id": "in-progress",
"name": "in-progress",
"traits": [
{ "trait": "wip", "config": { "limit": "settings.maxConcurrent" } },
{ "trait": "abort-on-exit" },
{ "trait": "timing" }
]
}
]
}
```
Copying a selectable built-in workflow is the easiest way to inherit the full canonical trait set (`todo`, `in-progress`, `in-review`, `done`, `archived`) before customizing nodes or prompts.
When column-agent support is enabled by the required experimental features, a column can also assign a permanent agent with one of two modes:
- **defer:** use the column agent only when the work has no more specific agent/model setting.

View File

@@ -69,6 +69,46 @@ Skill-backed prompt/gate nodes run through the same workflow-step session builde
Use the dashboard [Workflow Editor](./workflow-editor.md) to inspect built-ins, tune built-in prompts, duplicate workflows, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface.
<!--
FNXC:Workflows 2026-06-28-09:50:
Pure-v1 custom graphs remain rollback-compatible by upgrading to trait-less default columns. Capacity dispatch after the workflow-columns cutover is therefore an explicit v2 authoring requirement, not an implicit v1 upgrade side effect.
-->
#### Capacity dispatch for custom workflows
After the workflow-columns cutover, the only automatic queued-work dispatcher is the engine's hold/release sweep. It releases a task from `todo` only when that column resolves as a `hold` column and its hold config uses `release: "capacity"`; it then moves the card to the nearest downstream `wip` column with available capacity.
Pure-v1 custom workflow definitions (`start` / `prompt` / `script` / `gate` / `end` nodes with default columns) still parse and upgrade by synthesizing the legacy column ids with empty trait sets. That shape is intentional for FN-5769 / issue #1405 rollback compatibility: it can be downgraded back to v1 for older binaries. The tradeoff is that a pure-v1 custom workflow's `todo` column is not a hold column, so tasks can sit in `todo` instead of dispatching to `in-progress`.
For capacity-dispatched custom workflows, author or migrate the workflow as IR v2 and give `todo` and `in-progress` the canonical dispatch traits (the same minimum used by the built-in coding workflow):
```json
{
"version": "v2",
"columns": [
{
"id": "todo",
"name": "todo",
"traits": [
{ "trait": "hold", "config": { "release": "capacity" } },
{ "trait": "reset-on-entry" }
]
},
{
"id": "in-progress",
"name": "in-progress",
"traits": [
{ "trait": "wip", "config": { "limit": "settings.maxConcurrent" } },
{ "trait": "abort-on-exit" },
{ "trait": "timing" }
]
}
]
}
```
If you need the full lifecycle behavior, duplicate `builtin:coding` (or another selectable built-in) and edit the copy so `in-review`, `done`, and `archived` keep their merge/review/completion traits. FN-7190 keeps selectable built-ins on canonical traits; FN-7192 documents and tests the custom-v1 migration boundary.
### Workflow graph integrity validation
<!--

View File

@@ -0,0 +1,154 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore, type WorkflowIr, type WorkflowIrV1, type WorkflowIrV2 } from "../index.js";
import { resolveColumnFlags } from "../trait-registry.js";
import { downgradeIrToV1IfPure, parseWorkflowIr } from "../workflow-ir.js";
import { resolveWorkflowIrForTask } from "../workflow-ir-resolver.js";
import { stepsToWorkflowIr } from "../workflow-steps-to-ir.js";
const pureV1CustomWorkflow = (): WorkflowIrV1 => ({
version: "v1",
name: "pure-v1-custom",
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute", prompt: "Do the work" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "execute", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
],
});
const authoredV2CapacityWorkflow = (): WorkflowIrV2 => ({
version: "v2",
name: "authored-v2-capacity-workflow",
columns: [
{ id: "todo", name: "todo", traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }] },
{ id: "in-progress", name: "in-progress", traits: [{ trait: "wip", config: { limit: "settings.maxConcurrent" } }, { trait: "abort-on-exit" }, { trait: "timing" }] },
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute", prompt: "Do the work" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
],
});
function todoColumn(ir: WorkflowIr) {
if (ir.version !== "v2") throw new Error("expected upgraded v2 IR");
const column = ir.columns.find((candidate) => candidate.id === "todo");
if (!column) throw new Error("expected todo column");
return column;
}
function inProgressColumn(ir: WorkflowIr) {
if (ir.version !== "v2") throw new Error("expected v2 IR");
const column = ir.columns.find((candidate) => candidate.id === "in-progress");
if (!column) throw new Error("expected in-progress column");
return column;
}
function setSelection(store: TaskStore, taskId: string, workflowId: string): void {
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, '[]', ?)
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, updatedAt = excluded.updatedAt`,
).run(taskId, workflowId, new Date().toISOString());
}
function rawStoredWorkflowIr(store: TaskStore, workflowId: string): unknown {
const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => { ir: string } | undefined } } }).db;
const row = db.prepare("SELECT ir FROM workflows WHERE id = ?").get(workflowId);
if (!row) throw new Error(`missing workflow row ${workflowId}`);
return JSON.parse(row.ir);
}
/*
* FNXC:Workflows 2026-06-28-08:45:
* Pure-v1 custom workflows intentionally upgrade through synthesizeDefaultColumns(), whose columns are placement-only and trait-less for FN-5769/#1405 rollback compatibility. Capacity-dispatched custom workflows must author v2 columns with todo hold(capacity); the engine test suite asserts that documented remedy performs the actual sweep release.
*/
describe("custom v1 workflow dispatch characterization", () => {
let rootDir = "";
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fn7192-custom-v1-workflow-"));
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
});
it("documents that pure-v1 custom workflows resolve to a trait-less todo column", async () => {
const definition = await store.createWorkflowDefinition({
name: "pure v1 custom",
ir: pureV1CustomWorkflow(),
});
const task = await store.createTask({ description: "uses pure v1 custom workflow" });
setSelection(store, task.id, definition.id);
const resolved = await resolveWorkflowIrForTask(store, task.id);
const todo = todoColumn(resolved);
expect(todo.traits).toEqual([]);
expect(resolveColumnFlags(todo).hold).not.toBe(true);
});
it("proves the documented v2 remedy authors hold(capacity) on todo and wip capacity downstream", () => {
const resolved = parseWorkflowIr(authoredV2CapacityWorkflow());
const todo = todoColumn(resolved);
expect(todo.traits).toEqual(
expect.arrayContaining([{ trait: "hold", config: { release: "capacity" } }]),
);
expect(resolveColumnFlags(todo).hold).toBe(true);
const inProgress = inProgressColumn(resolved);
expect(resolveColumnFlags(inProgress).countsTowardWip).toBe(true);
});
it("keeps pure-v1 round-trip compatibility for v1 inputs and step-derived pure-v1 graphs", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
const fromRawV1 = await store.createWorkflowDefinition({
name: "persisted raw v1",
ir: pureV1CustomWorkflow(),
});
const storedRawV1 = rawStoredWorkflowIr(store, fromRawV1.id) as { version?: string };
expect(storedRawV1.version).toBe("v1");
const fromSteps = stepsToWorkflowIr([
{
name: "Plan",
mode: "prompt",
prompt: "Plan the work",
gateMode: "advisory",
},
], "step-derived pure v1");
expect(fromSteps.version).toBe("v2");
expect(downgradeIrToV1IfPure(fromSteps).version).toBe("v1");
const stepDerivedDefinition = await store.createWorkflowDefinition({
name: "persisted step-derived v1",
ir: fromSteps,
});
const storedFromSteps = rawStoredWorkflowIr(store, stepDerivedDefinition.id) as { version?: string };
expect(storedFromSteps.version).toBe("v1");
});
});

View File

@@ -19,7 +19,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { TaskStore, type Task, type WorkflowIr } from "@fusion/core";
import { getBuiltinWorkflow, resolveColumnFlags, TaskStore, type Task, type WorkflowIr } from "@fusion/core";
import {
runHoldReleaseSweep,
promoteHeldTask,
@@ -70,6 +70,46 @@ const LINEAR_BUILTIN_WORKFLOW_IDS = [
"builtin:design",
] as const;
function pureV1CustomWorkflowIr(): WorkflowIr {
return {
version: "v1",
name: "pure-v1-custom",
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute", prompt: "Do the work" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "execute", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
],
} as WorkflowIr;
}
function authoredV2CapacityWorkflowIr(): WorkflowIr {
return {
version: "v2",
name: "authored-v2-capacity-workflow",
columns: [
{ id: "todo", name: "todo", traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }] },
{ id: "in-progress", name: "in-progress", traits: [{ trait: "wip", config: { limit: "settings.maxConcurrent" } }, { trait: "abort-on-exit" }, { trait: "timing" }] },
{ id: "in-review", name: "in-review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }] },
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute", prompt: "Do the work" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
],
} as WorkflowIr;
}
describe("hold-release sweep (U6)", () => {
let rootDir = "";
let store: TaskStore;
@@ -107,8 +147,16 @@ describe("hold-release sweep (U6)", () => {
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 builtin = getBuiltinWorkflow(workflowId);
if (!builtin?.ir || builtin.ir.version !== "v2") throw new Error(`missing v2 built-in ${workflowId}`);
const todo = builtin.ir.columns.find((column) => column.id === "todo");
expect(todo).toBeDefined();
expect(resolveColumnFlags(todo!).hold).toBe(true);
}
// Pre-FN-7190, linear() synthesized trait-less default columns, so isHeldTask()
// returned false here and these selected built-in 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);
@@ -124,6 +172,33 @@ describe("hold-release sweep (U6)", () => {
}
});
it("documents custom v1 stranding and proves the authored v2 capacity-column remedy", async () => {
await store.updateSettings({ maxConcurrent: 10 } as Parameters<typeof store.updateSettings>[0]);
const defaultWorkflowTask = await seedTodoCard();
const v1Def = await store.createWorkflowDefinition({ name: "pure v1 custom", ir: pureV1CustomWorkflowIr() });
const v1Task = await store.createTask({ description: "pure-v1 custom card" });
setSelection(store, v1Task.id, v1Def.id);
setColumn(store, v1Task.id, "todo");
const v2Def = await store.createWorkflowDefinition({ name: "authored v2 capacity", ir: authoredV2CapacityWorkflowIr() });
const v2Task = await store.createTask({ description: "authored-v2 custom card" });
setSelection(store, v2Task.id, v2Def.id);
setColumn(store, v2Task.id, "todo");
/*
* FNXC:Workflows 2026-06-28-09:17:
* The migration contract documented in docs/workflow-editor.md is behavioral: pure-v1 custom workflows stay rollback-compatible and are not held, while authored-v2 workflows that put hold(capacity) on todo are released by the sole post-cutover dispatcher.
*/
const result = await runHoldReleaseSweep(store, noReserveDeps);
expect(result.released).toEqual(expect.arrayContaining([defaultWorkflowTask, v2Task.id]));
expect(result.released).not.toContain(v1Task.id);
expect((await store.getTask(defaultWorkflowTask))?.column).toBe("in-progress");
expect((await store.getTask(v2Task.id))?.column).toBe("in-progress");
expect((await store.getTask(v1Task.id))?.column).toBe("todo");
});
it("ignores stale workflowColumns=false and still releases held default-workflow cards", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
const id = await seedTodoCard();