FN-7246: preserve explicit empty step dependencies
Preserve authored empty workflow step dependencies so parallel roots remain independent. - Keep parser and TaskStep projections from dropping explicit empty dependsOn arrays. - Treat missing dependsOn as the legacy previous-step fallback while allowing [] to mean no dependencies. - Cover heading and JSON parsers plus parallel foreach scheduling with regression tests and docs. Files changed: .changeset/fn-7246-workflow-depends-on-empty.md | 7 +++++++ docs/workflow-steps.md | 4 ++-- packages/core/src/__tests__/step-parsers.test.ts | 8 ++++---- packages/core/src/step-parsers.ts | 30 +++++++++++++++++++++--------- packages/core/src/types.ts | 13 +++++++++---- packages/engine/src/__tests__/workflow-parse-steps.test.ts | 12 ++++++++++++ packages/engine/src/__tests__/workflow-step-parallel.test.ts | 30 +++++++++++++++++++++++++++++- packages/engine/src/workflow-graph-foreach.ts | 9 +++++++-- packages/engine/src/workflow-node-handlers.ts | 6 +++++- 9 files changed, 96 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-7246 Fusion-Task-Lineage: a75687fa-5711-4509-b60f-43669323a6c0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7246-workflow-depends-on-empty.md
Normal file
7
.changeset/fn-7246-workflow-depends-on-empty.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Preserve explicit empty workflow step dependencies for parallel roots.
|
||||||
|
category: fix
|
||||||
|
dev: Keeps omitted dependsOn as previous-step fallback while treating [] as no dependencies.
|
||||||
@@ -265,7 +265,7 @@ The **step-inversion** track makes task *steps* themselves workflow-modelable. T
|
|||||||
|
|
||||||
`parse-steps` reads a declared **artifact** and runs a named **parser** to write the canonical step list (`Task.steps[]`). Config: `{ artifact: <key>, parser: "step-headings" | "json-steps" | "plugin:<id>:<parser>" }`.
|
`parse-steps` reads a declared **artifact** and runs a named **parser** to write the canonical step list (`Task.steps[]`). Config: `{ artifact: <key>, parser: "step-headings" | "json-steps" | "plugin:<id>:<parser>" }`.
|
||||||
|
|
||||||
- Built-in parsers: `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex) and `json-steps` (a `[{ name, depends? }]` JSON document). Plugins register additional parsers under `plugin:<pluginId>:<parserId>`.
|
- Built-in parsers: `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex) and `json-steps` (a `[{ name, depends? }]` JSON document). Both preserve the difference between an absent dependency annotation/key and an explicit empty dependency list. Plugins register additional parsers under `plugin:<pluginId>:<parserId>`.
|
||||||
- Outcomes: `success`, `outcome:no-steps` (parsed cleanly, zero steps — routable, defaults to success), `outcome:parse-error` (malformed artifact or a throwing/unavailable plugin parser — fail-closed, routable, defaults to failure). A plugin parser never crashes the run.
|
- Outcomes: `success`, `outcome:no-steps` (parsed cleanly, zero steps — routable, defaults to success), `outcome:parse-error` (malformed artifact or a throwing/unavailable plugin parser — fail-closed, routable, defaults to failure). A plugin parser never crashes the run.
|
||||||
- It is the **only** graph-side writer of the step list, and **must dominate** (precede on all paths) any `foreach(source:"task-steps")` — a validator rule that prevents merging a task that reached the foreach before steps were parsed.
|
- It is the **only** graph-side writer of the step list, and **must dominate** (precede on all paths) any `foreach(source:"task-steps")` — a validator rule that prevents merging a task that reached the foreach before steps were parsed.
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ The **step-inversion** track makes task *steps* themselves workflow-modelable. T
|
|||||||
|
|
||||||
`mode` and `isolation` are independent axes. `parallel + shared` is rejected (concurrent writers in one worktree are unguardable). Under `worktree` isolation each instance runs in its own worktree/branch off a common base, with an **ordered integration stage** that lands step branches in step order (done iff integrated); a rebase conflict routes `outcome:integration-conflict` (default: rework on the updated base, budget-counted).
|
`mode` and `isolation` are independent axes. `parallel + shared` is rejected (concurrent writers in one worktree are unguardable). Under `worktree` isolation each instance runs in its own worktree/branch off a common base, with an **ordered integration stage** that lands step branches in step order (done iff integrated); a rebase conflict routes `outcome:integration-conflict` (default: rework on the updated base, budget-counted).
|
||||||
|
|
||||||
Parallelism is opt-in *per step by the planner*, not asserted by the workflow author. A step depends on the previous step unless its PROMPT.md heading carries a `(depends: N,M)` annotation listing the 1-indexed steps it actually depends on — e.g. `### Step 3 (depends: 1): Title`. An unannotated plan is fully sequential regardless of `mode`. Annotate **conservatively**: only mark a step independent when it genuinely does not read or modify the prior step's output, or heavily-overlapping "independent" steps will loop integrate→conflict→rework until the budget exhausts.
|
Parallelism is opt-in *per step by the planner*, not asserted by the workflow author. A step depends on the previous step unless its PROMPT.md heading carries a `(depends: N,M)` annotation listing the 1-indexed steps it actually depends on — e.g. `### Step 3 (depends: 1): Title`. An explicit empty list (`### Step 3 (depends:): Title` or `json-steps` `"depends": []`) means the step has no dependencies and can be scheduled as an independent root. An absent annotation/key is different: it remains the legacy previous-step dependency, so an unannotated plan is fully sequential regardless of `mode`. Annotate **conservatively**: only mark a step independent when it genuinely does not read or modify the prior step's output, or heavily-overlapping "independent" steps will loop integrate→conflict→rework until the budget exhausts.
|
||||||
|
|
||||||
#### `step-review` node & rework edges
|
#### `step-review` node & rework edges
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ describe("step-parsers registry (U12, KTD-12)", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty depends list yields no dependsOn", () => {
|
it("empty depends list preserves explicit independent dependsOn", () => {
|
||||||
expect(headings().parse("### Step 2 (depends: ): T").steps).toEqual([
|
expect(headings().parse("### Step 2 (depends: ): T").steps).toEqual([
|
||||||
{ name: "T" },
|
{ name: "T", dependsOn: [] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,9 +130,9 @@ describe("step-parsers registry (U12, KTD-12)", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trims names and omits dependsOn when depends is empty", () => {
|
it("trims names and preserves explicit empty dependsOn when depends is empty", () => {
|
||||||
const content = JSON.stringify([{ name: " Spaced ", depends: [] }]);
|
const content = JSON.stringify([{ name: " Spaced ", depends: [] }]);
|
||||||
expect(json().parse(content).steps).toEqual([{ name: "Spaced" }]);
|
expect(json().parse(content).steps).toEqual([{ name: "Spaced", dependsOn: [] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parseJsonSteps is exported directly and matches the registry parser", () => {
|
it("parseJsonSteps is exported directly and matches the registry parser", () => {
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ import type { TaskStep } from "./types.js";
|
|||||||
|
|
||||||
// ── Parser contract ──────────────────────────────────────────────────────────
|
// ── Parser contract ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A parsed step as produced by a parser. `dependsOn` is 0-indexed (same
|
/**
|
||||||
* convention as the headings `(depends: …)` annotation). */
|
* A parsed step as produced by a parser. `dependsOn` is 0-indexed (same
|
||||||
|
* convention as the headings `(depends: …)` annotation).
|
||||||
|
*
|
||||||
|
* FNXC:WorkflowSteps 2026-06-29-17:55:
|
||||||
|
* Parser output must preserve array presence: omitted `dependsOn` means legacy previous-step fallback, while explicit `dependsOn: []` means an independent parallel root.
|
||||||
|
*/
|
||||||
export interface ParsedStep {
|
export interface ParsedStep {
|
||||||
name: string;
|
name: string;
|
||||||
dependsOn?: number[];
|
dependsOn?: number[];
|
||||||
@@ -165,7 +170,10 @@ export class StepParserRegistry {
|
|||||||
* The annotation `### Step N (depends: 1,2): Title` is parsed explicitly (the
|
* The annotation `### Step N (depends: 1,2): Title` is parsed explicitly (the
|
||||||
* legacy regex breaks on the colon inside `depends:`): depends values are
|
* legacy regex breaks on the colon inside `depends:`): depends values are
|
||||||
* 1-indexed step numbers in the document and are stored as 0-indexed indices on
|
* 1-indexed step numbers in the document and are stored as 0-indexed indices on
|
||||||
* `dependsOn` (deduped, sorted, dropping values <= 0).
|
* `dependsOn` (deduped, sorted, dropping values <= 0). An empty `(depends:)`
|
||||||
|
* annotation is preserved as `dependsOn: []` so planners can explicitly mark a
|
||||||
|
* non-first step as independent; an absent annotation remains implicit previous-step
|
||||||
|
* dependency.
|
||||||
*
|
*
|
||||||
* Malformed `(depends: …)` annotations fall back deterministically: the heading
|
* Malformed `(depends: …)` annotations fall back deterministically: the heading
|
||||||
* is treated as `### Step N:` with the name starting after the FIRST colon
|
* is treated as `### Step N:` with the name starting after the FIRST colon
|
||||||
@@ -198,8 +206,11 @@ export function parseStepHeadings(content: string): TaskStep[] {
|
|||||||
const parsed = parseDependsList(annotated[1]);
|
const parsed = parseDependsList(annotated[1]);
|
||||||
const name = annotated[2].trim();
|
const name = annotated[2].trim();
|
||||||
if (parsed !== null) {
|
if (parsed !== null) {
|
||||||
if (parsed.length > 0) steps.push({ name, status: "pending", dependsOn: parsed });
|
/*
|
||||||
else steps.push({ name, status: "pending" });
|
FNXC:WorkflowSteps 2026-06-29-22:49:
|
||||||
|
Empty depends annotations are explicit planner intent, not missing metadata. Preserve `dependsOn: []` so parallel foreach scheduling treats this step as an independent root while unannotated headings still fall back to previous-step ordering.
|
||||||
|
*/
|
||||||
|
steps.push({ name, status: "pending", dependsOn: parsed });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,8 +253,9 @@ function parseDependsList(raw: string): number[] | null {
|
|||||||
* Parse a JSON document: an array of `{ name: string, depends?: number[] }`.
|
* Parse a JSON document: an array of `{ name: string, depends?: number[] }`.
|
||||||
* `depends` values are 1-indexed step numbers in the document (same convention
|
* `depends` values are 1-indexed step numbers in the document (same convention
|
||||||
* as the headings annotation), converted to 0-indexed `dependsOn` (deduped,
|
* as the headings annotation), converted to 0-indexed `dependsOn` (deduped,
|
||||||
* sorted). Throws a descriptive error on any malformed input (not JSON, not an
|
* sorted). Omitted `depends` means implicit previous-step dependency; explicit
|
||||||
* array, missing/blank name, bad depends).
|
* `depends: []` is preserved as no dependencies. Throws a descriptive error on
|
||||||
|
* any malformed input (not JSON, not an array, missing/blank name, bad depends).
|
||||||
*/
|
*/
|
||||||
export function parseJsonSteps(content: string): StepParseResult {
|
export function parseJsonSteps(content: string): StepParseResult {
|
||||||
let doc: unknown;
|
let doc: unknown;
|
||||||
@@ -290,7 +302,7 @@ export function parseJsonSteps(content: string): StepParseResult {
|
|||||||
out.add(raw - 1);
|
out.add(raw - 1);
|
||||||
}
|
}
|
||||||
const dependsOn = [...out].sort((a, b) => a - b);
|
const dependsOn = [...out].sort((a, b) => a - b);
|
||||||
if (dependsOn.length > 0) step.dependsOn = dependsOn;
|
step.dependsOn = dependsOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
steps.push(step);
|
steps.push(step);
|
||||||
@@ -309,7 +321,7 @@ const BUILTIN_STEP_PARSERS: StepParser[] = [
|
|||||||
// (dropping the `status` field, which the caller re-applies).
|
// (dropping the `status` field, which the caller re-applies).
|
||||||
const steps = parseStepHeadings(content).map((s) => {
|
const steps = parseStepHeadings(content).map((s) => {
|
||||||
const out: ParsedStep = { name: s.name };
|
const out: ParsedStep = { name: s.name };
|
||||||
if (s.dependsOn) out.dependsOn = s.dependsOn;
|
if (Array.isArray(s.dependsOn)) out.dependsOn = s.dependsOn;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
return { steps };
|
return { steps };
|
||||||
|
|||||||
@@ -1057,10 +1057,15 @@ export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
|
|||||||
export interface TaskStep {
|
export interface TaskStep {
|
||||||
name: string;
|
name: string;
|
||||||
status: StepStatus;
|
status: StepStatus;
|
||||||
/** Step-inversion (KTD-11): 0-indexed indices of steps this step depends on,
|
/**
|
||||||
* parsed from the PROMPT.md `### Step N (depends: 1,2): Title` annotation
|
* Step-inversion (KTD-11): 0-indexed indices of steps this step depends on,
|
||||||
* (1-indexed step numbers in the doc → 0-indexed indices here). Absent for
|
* parsed from the PROMPT.md `### Step N (depends: 1,2): Title` annotation
|
||||||
* unannotated steps. */
|
* or structured parser output (1-indexed step numbers in authored content →
|
||||||
|
* 0-indexed indices here).
|
||||||
|
*
|
||||||
|
* FNXC:WorkflowSteps 2026-06-29-17:52:
|
||||||
|
* Absence and emptiness are different planner contracts. Absent means unannotated and therefore implicitly depends on the previous step; an explicit empty array means this step has no dependencies and may run as a parallel root.
|
||||||
|
*/
|
||||||
dependsOn?: number[];
|
dependsOn?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ describe("parse-steps node handler (U12, KTD-12)", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves explicit empty dependsOn arrays through parse-step projection", async () => {
|
||||||
|
const { deps, written } = makeDeps({
|
||||||
|
readArtifact: async () => JSON.stringify([{ name: "x" }, { name: "y", depends: [] }]),
|
||||||
|
});
|
||||||
|
const result = await runParse(parseIr("json-steps"), deps);
|
||||||
|
expect(result.outcome).toBe("success");
|
||||||
|
expect(written[0]).toEqual([
|
||||||
|
{ name: "x", status: "pending" },
|
||||||
|
{ name: "y", status: "pending", dependsOn: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("unknown parser → parse-error (audited), no write", async () => {
|
it("unknown parser → parse-error (audited), no write", async () => {
|
||||||
const { deps, written, audits } = makeDeps();
|
const { deps, written, audits } = makeDeps();
|
||||||
// Route outcome:parse-error so the run does not just propagate failure off end.
|
// Route outcome:parse-error so the run does not just propagate failure off end.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function taskWithSteps(specs: Array<{ dependsOn?: number[] }> | number): TaskDet
|
|||||||
const steps: TaskStep[] = list.map((s, i) => ({
|
const steps: TaskStep[] = list.map((s, i) => ({
|
||||||
name: `Step ${i + 1}`,
|
name: `Step ${i + 1}`,
|
||||||
status: "pending" as const,
|
status: "pending" as const,
|
||||||
...(s.dependsOn ? { dependsOn: s.dependsOn } : {}),
|
...(Array.isArray(s.dependsOn) ? { dependsOn: s.dependsOn } : {}),
|
||||||
}));
|
}));
|
||||||
return { id: "FN-PAR", steps } as unknown as TaskDetail;
|
return { id: "FN-PAR", steps } as unknown as TaskDetail;
|
||||||
}
|
}
|
||||||
@@ -337,6 +337,34 @@ describe("WorkflowGraphExecutor parallel/worktree foreach (U10)", () => {
|
|||||||
expect(backend.integrationOrder).toEqual([0, 1, 2]);
|
expect(backend.integrationOrder).toEqual([0, 1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("explicit empty dependsOn steps run as independent parallel roots", async () => {
|
||||||
|
const task = taskWithSteps([{ dependsOn: [] }, { dependsOn: [] }, { dependsOn: [] }]);
|
||||||
|
const backend = makeFakeBackend();
|
||||||
|
const concurrentPeak = { value: 0 };
|
||||||
|
const order: number[] = [];
|
||||||
|
let active = 0;
|
||||||
|
const { result } = await runScenario(
|
||||||
|
task,
|
||||||
|
{ mode: "parallel", isolation: "worktree", concurrency: 3 },
|
||||||
|
backend,
|
||||||
|
{
|
||||||
|
stepExecute: async (_t, ctx) => {
|
||||||
|
const a = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
|
||||||
|
order.push(a.stepIndex);
|
||||||
|
active += 1;
|
||||||
|
concurrentPeak.value = Math.max(concurrentPeak.value, active);
|
||||||
|
await Promise.resolve();
|
||||||
|
active -= 1;
|
||||||
|
return { outcome: "success", value: "step-done" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe("success");
|
||||||
|
expect(new Set(order)).toEqual(new Set([0, 1, 2]));
|
||||||
|
expect(concurrentPeak.value).toBeGreaterThan(1);
|
||||||
|
expect(backend.integrationOrder).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
it("unannotated plan stays fully sequential at concurrency 4", async () => {
|
it("unannotated plan stays fully sequential at concurrency 4", async () => {
|
||||||
const task = taskWithSteps(4); // no dependsOn → each implicitly depends on prev.
|
const task = taskWithSteps(4); // no dependsOn → each implicitly depends on prev.
|
||||||
const backend = makeFakeBackend();
|
const backend = makeFakeBackend();
|
||||||
|
|||||||
@@ -289,10 +289,15 @@ function resolveForeachConfig(node: WorkflowIrNode): {
|
|||||||
|
|
||||||
/** The 0-indexed predecessor step indices instance `stepIndex` depends on. A step
|
/** The 0-indexed predecessor step indices instance `stepIndex` depends on. A step
|
||||||
* with no annotation implicitly depends on the previous step (KTD-3), so an
|
* with no annotation implicitly depends on the previous step (KTD-3), so an
|
||||||
* unannotated plan is fully sequential regardless of mode. */
|
* unannotated plan is fully sequential regardless of mode. An explicit empty
|
||||||
|
* array means no dependencies. */
|
||||||
function resolveDependsOn(steps: TaskStep[], stepIndex: number): number[] {
|
function resolveDependsOn(steps: TaskStep[], stepIndex: number): number[] {
|
||||||
const deps = steps[stepIndex]?.dependsOn;
|
const deps = steps[stepIndex]?.dependsOn;
|
||||||
if (Array.isArray(deps) && deps.length > 0) return deps;
|
/*
|
||||||
|
FNXC:WorkflowSteps 2026-06-29-22:51:
|
||||||
|
Empty dependency arrays are planner-authored independence, while missing `dependsOn` remains legacy sequential fallback. The scheduler must branch on array presence rather than length to avoid serializing explicit roots.
|
||||||
|
*/
|
||||||
|
if (Array.isArray(deps)) return deps;
|
||||||
return stepIndex > 0 ? [stepIndex - 1] : [];
|
return stepIndex > 0 ? [stepIndex - 1] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -721,7 +721,11 @@ export function createParseStepsHandler(deps: ParseStepsHandlerDeps): WorkflowNo
|
|||||||
// preserved. This is the single graph-side step-list write (KTD-12).
|
// preserved. This is the single graph-side step-list write (KTD-12).
|
||||||
const steps: TaskStep[] = parsedSteps.map((s) => {
|
const steps: TaskStep[] = parsedSteps.map((s) => {
|
||||||
const step: TaskStep = { name: s.name, status: "pending" };
|
const step: TaskStep = { name: s.name, status: "pending" };
|
||||||
if (s.dependsOn && s.dependsOn.length > 0) step.dependsOn = s.dependsOn;
|
/*
|
||||||
|
FNXC:WorkflowSteps 2026-06-29-22:50:
|
||||||
|
A parser returning `dependsOn: []` is an explicit no-dependency declaration. Preserve array presence through the projection so scheduling can distinguish it from omitted `dependsOn`, which keeps the previous-step fallback.
|
||||||
|
*/
|
||||||
|
if (Array.isArray(s.dependsOn)) step.dependsOn = s.dependsOn;
|
||||||
return step;
|
return step;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user