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:
gsxdsm
2026-06-29 18:05:44 -07:00
parent 6f2b8ab6e5
commit 90b62b7884
9 changed files with 96 additions and 23 deletions

View 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.

View File

@@ -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>" }`.
- 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.
- 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).
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

View File

@@ -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([
{ 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: [] }]);
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", () => {

View File

@@ -23,8 +23,13 @@ import type { TaskStep } from "./types.js";
// ── 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 {
name: string;
dependsOn?: number[];
@@ -165,7 +170,10 @@ export class StepParserRegistry {
* The annotation `### Step N (depends: 1,2): Title` is parsed explicitly (the
* 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
* `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
* 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 name = annotated[2].trim();
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;
}
}
@@ -242,8 +253,9 @@ function parseDependsList(raw: string): number[] | null {
* Parse a JSON document: an array of `{ name: string, depends?: number[] }`.
* `depends` values are 1-indexed step numbers in the document (same convention
* as the headings annotation), converted to 0-indexed `dependsOn` (deduped,
* sorted). Throws a descriptive error on any malformed input (not JSON, not an
* array, missing/blank name, bad depends).
* sorted). Omitted `depends` means implicit previous-step dependency; explicit
* `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 {
let doc: unknown;
@@ -290,7 +302,7 @@ export function parseJsonSteps(content: string): StepParseResult {
out.add(raw - 1);
}
const dependsOn = [...out].sort((a, b) => a - b);
if (dependsOn.length > 0) step.dependsOn = dependsOn;
step.dependsOn = dependsOn;
}
steps.push(step);
@@ -309,7 +321,7 @@ const BUILTIN_STEP_PARSERS: StepParser[] = [
// (dropping the `status` field, which the caller re-applies).
const steps = parseStepHeadings(content).map((s) => {
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 { steps };

View File

@@ -1057,10 +1057,15 @@ export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
export interface TaskStep {
name: string;
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
* (1-indexed step numbers in the doc → 0-indexed indices here). Absent for
* unannotated steps. */
/**
* 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
* 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[];
}

View File

@@ -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 () => {
const { deps, written, audits } = makeDeps();
// Route outcome:parse-error so the run does not just propagate failure off end.

View File

@@ -26,7 +26,7 @@ function taskWithSteps(specs: Array<{ dependsOn?: number[] }> | number): TaskDet
const steps: TaskStep[] = list.map((s, i) => ({
name: `Step ${i + 1}`,
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;
}
@@ -337,6 +337,34 @@ describe("WorkflowGraphExecutor parallel/worktree foreach (U10)", () => {
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 () => {
const task = taskWithSteps(4); // no dependsOn → each implicitly depends on prev.
const backend = makeFakeBackend();

View File

@@ -289,10 +289,15 @@ function resolveForeachConfig(node: WorkflowIrNode): {
/** 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
* 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[] {
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] : [];
}

View File

@@ -721,7 +721,11 @@ export function createParseStepsHandler(deps: ParseStepsHandlerDeps): WorkflowNo
// preserved. This is the single graph-side step-list write (KTD-12).
const steps: TaskStep[] = parsedSteps.map((s) => {
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;
});
try {