diff --git a/.changeset/fn-7246-workflow-depends-on-empty.md b/.changeset/fn-7246-workflow-depends-on-empty.md new file mode 100644 index 0000000000..1fc59ccc91 --- /dev/null +++ b/.changeset/fn-7246-workflow-depends-on-empty.md @@ -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. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index d22437f8bf..d8f92e0b24 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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: , parser: "step-headings" | "json-steps" | "plugin::" }`. -- 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::`. +- 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::`. - 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 diff --git a/packages/core/src/__tests__/step-parsers.test.ts b/packages/core/src/__tests__/step-parsers.test.ts index 195b13e016..251cd44d4d 100644 --- a/packages/core/src/__tests__/step-parsers.test.ts +++ b/packages/core/src/__tests__/step-parsers.test.ts @@ -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", () => { diff --git a/packages/core/src/step-parsers.ts b/packages/core/src/step-parsers.ts index 4962c3b63d..bea91f2aa5 100644 --- a/packages/core/src/step-parsers.ts +++ b/packages/core/src/step-parsers.ts @@ -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 }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bbfe3c5275..bab2be5f48 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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[]; } diff --git a/packages/engine/src/__tests__/workflow-parse-steps.test.ts b/packages/engine/src/__tests__/workflow-parse-steps.test.ts index e449dbaec4..62a2e1295f 100644 --- a/packages/engine/src/__tests__/workflow-parse-steps.test.ts +++ b/packages/engine/src/__tests__/workflow-parse-steps.test.ts @@ -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. diff --git a/packages/engine/src/__tests__/workflow-step-parallel.test.ts b/packages/engine/src/__tests__/workflow-step-parallel.test.ts index ab32165ebd..e26e7262ff 100644 --- a/packages/engine/src/__tests__/workflow-step-parallel.test.ts +++ b/packages/engine/src/__tests__/workflow-step-parallel.test.ts @@ -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(); diff --git a/packages/engine/src/workflow-graph-foreach.ts b/packages/engine/src/workflow-graph-foreach.ts index 0a7c6234f9..dd04b4020e 100644 --- a/packages/engine/src/workflow-graph-foreach.ts +++ b/packages/engine/src/workflow-graph-foreach.ts @@ -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] : []; } diff --git a/packages/engine/src/workflow-node-handlers.ts b/packages/engine/src/workflow-node-handlers.ts index b5c98c4d92..e890f37050 100644 --- a/packages/engine/src/workflow-node-handlers.ts +++ b/packages/engine/src/workflow-node-handlers.ts @@ -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 {