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>" }`.
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] : [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user