FN-9061: gate workspace foreach worktree isolation

Reject unsupported workspace projects before per-instance foreach worktree allocation.

- Add a workspace-isolation guard at graph execution and worktree allocation seams.
- Propagate workspace configuration probing through foreach dependencies.
- Cover workspace and non-workspace behavior, and document the limitation.

Files changed: .../fn-9061-foreach-workspace-isolation-gate.md    |  7 +++
 docs/workflow-steps.md                             |  2 +
 .../workflow-foreach-workspace-isolation.test.ts   | 72 ++++++++++++++++++++++
 .../src/__tests__/workflow-graph-foreach.test.ts   | 56 +++++++++++++++++
 .../src/executor/build-foreach-worktree-deps.ts    | 34 +++++++++-
 packages/engine/src/executor/deps-bags.ts          |  1 +
 .../src/workflows/workflow-graph-executor.ts       |  3 +
 .../engine/src/workflows/workflow-graph-foreach.ts | 17 +++++
 .../src/workflows/workflow-graph-task-runner.ts    |  2 +
 9 files changed, 193 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-9061

Fusion-Task-Lineage: 38772936-a45f-41f1-b7ee-7999997404db

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-14 21:44:18 -07:00
parent d6da58d89a
commit 495845023d
9 changed files with 193 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fail fast when workspace projects use per-instance foreach worktrees.
category: fix
dev: Routes workspace foreach isolation to worktree-isolation-unsupported-workspace with an explicit diagnostic.

View File

@@ -366,6 +366,8 @@ 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).
Per-instance `worktree` isolation, including the implicit default for `mode: "parallel"`, is not supported for workspace (multi-repo) projects. It fails fast with `worktree-isolation-unsupported-workspace`; use `mode: "sequential"` with `isolation: "shared"` for workspace projects.
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. 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

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { TaskStore } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { WORKSPACE_ISOLATION_UNSUPPORTED_MESSAGE } from "../executor/build-foreach-worktree-deps.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const describeIfGit = hasGit ? describe : describe.skip;
/**
* FNXC:Workspace 2026-08-15-04:22:
* Workspace roots are intentionally non-git directories. These tests use real sub-repos to prove the isolation gate rejects before allocation can issue git operations against that root.
*/
describeIfGit("foreach workspace worktree-isolation gate", () => {
let fixture: WorkspaceFixture | undefined;
afterEach(() => fixture?.cleanup());
function executorFor(task: Record<string, unknown>, rootDir: string) {
const store = Object.assign(new EventEmitter(), {
getTask: vi.fn().mockResolvedValue(task),
}) as unknown as TaskStore;
const executor = new TaskExecutor(store, rootDir, {}) as any;
executor.createWorktree = vi.fn(async (branch: string, path: string) => ({ path, branch }));
return { executor, deps: executor.buildForeachWorktreeDeps(task) };
}
it("rejects a populated workspace task before createWorktree allocation", async () => {
fixture = await createWorkspaceFixture();
const task = {
id: "FN-WS",
worktree: null,
branch: null,
workspaceWorktrees: {
"repo-a": { worktreePath: fixture.repoPath("repo-a"), branch: "fusion/FN-WS" },
},
};
const { executor, deps } = executorFor(task, fixture.rootDir);
await expect(deps.allocateInstanceWorktree(0, undefined)).rejects.toThrow(WORKSPACE_ISOLATION_UNSUPPORTED_MESSAGE);
expect(executor.createWorktree).not.toHaveBeenCalled();
});
it("blocks before workspace sub-repo acquisition using project configuration", async () => {
fixture = await createWorkspaceFixture();
const { deps } = executorFor({
id: "FN-WS-PRE",
worktree: null,
branch: null,
workspaceWorktrees: {},
}, fixture.rootDir);
await expect(deps.resolveWorktreeIsolationBlock()).resolves.toContain(WORKSPACE_ISOLATION_UNSUPPORTED_MESSAGE);
});
it("leaves single-repo allocation unchanged", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const task = {
id: "FN-SINGLE",
worktree: fixture.repoPath("repo-a"),
branch: "main",
workspaceWorktrees: undefined,
};
// The repo itself has no workspace.json, so the config probe is inert.
const { executor, deps } = executorFor(task, fixture.repoPath("repo-a"));
await expect(deps.resolveWorktreeIsolationBlock()).resolves.toBeUndefined();
await deps.allocateInstanceWorktree(0, undefined);
expect(executor.createWorktree).toHaveBeenCalledTimes(1);
});
});

View File

@@ -506,6 +506,62 @@ describe("WorkflowGraphExecutor foreach (U3)", () => {
expect(result.outcome).toBe("failure"); expect(result.outcome).toBe("failure");
}); });
it.each([
["explicit worktree isolation", { isolation: "worktree" }],
["parallel's implicit worktree isolation", { mode: "parallel", concurrency: 2 }],
])("%s fails fast for workspace isolation before any allocation", async (_label, config) => {
const allocateInstanceWorktree = vi.fn();
const resolveIntegrationBase = vi.fn();
const resumeReconcile = vi.fn();
const logTaskEntry = vi.fn();
const executor = new WorkflowGraphExecutor({
seams: baseSeams({ stepExecute: async () => ({ outcome: "success", value: "step-done" }) }),
allocateInstanceWorktree,
resolveIntegrationBase,
resumeReconcile,
integrationGitOps: { integrate: vi.fn(), discardBranch: vi.fn() },
integrationProjection: { markStepDone: vi.fn(), markInstanceIntegrated: vi.fn() },
resolveWorktreeIsolationBlock: async () => "per-instance worktree isolation is not supported for workspace projects (task FN-FOREACH)",
logTaskEntry,
});
const result = await executor.run(taskWithSteps(1), settingsOn(), foreachIr(singleExecuteTemplate(), { config }));
expect(result.outcome).toBe("failure");
expect(result.context["node:fe:value"]).toBe("worktree-isolation-unsupported-workspace");
expect(allocateInstanceWorktree).not.toHaveBeenCalled();
expect(resolveIntegrationBase).not.toHaveBeenCalled();
expect(resumeReconcile).not.toHaveBeenCalled();
expect(logTaskEntry).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining("per-instance worktree isolation is not supported for workspace projects"),
);
});
it.each(["returns undefined", "is absent"])("keeps worktree isolation operational when the workspace gate %s", async (gate) => {
const allocateInstanceWorktree = vi.fn(async () => ({ worktreePath: "/instance", branchName: "fusion/FN-FOREACH-step-0" }));
const executor = new WorkflowGraphExecutor({
seams: baseSeams({ stepExecute: async () => ({ outcome: "success", value: "step-done" }) }),
allocateInstanceWorktree,
resolveIntegrationBase: async () => "base",
integrationGitOps: {
integrate: async () => ({ kind: "integrated" as const, integratedAt: "now" }),
discardBranch: async () => {},
},
integrationProjection: { markStepDone: async () => {}, markInstanceIntegrated: async () => {} },
...(gate === "returns undefined" ? { resolveWorktreeIsolationBlock: async () => undefined } : {}),
});
const result = await executor.run(
taskWithSteps(1),
settingsOn(),
foreachIr(singleExecuteTemplate(), { config: { isolation: "worktree" } }),
);
expect(result.outcome).toBe("success");
expect(allocateInstanceWorktree).toHaveBeenCalledTimes(1);
});
it("parallel mode (now worktree isolation, U10) fails cleanly without isolation wiring", async () => { it("parallel mode (now worktree isolation, U10) fails cleanly without isolation wiring", async () => {
// U10: parallel mode defaults to worktree isolation. Without the worktree / // U10: parallel mode defaults to worktree isolation. Without the worktree /
// integration deps wired, the foreach fails with a routable value rather than // integration deps wired, the foreach fails with a routable value rather than

View File

@@ -8,7 +8,7 @@
* integration rebases each branch in step order; projection flips done-iff-integrated. * integration rebases each branch in step order; projection flips done-iff-integrated.
* Best-effort: a git failure routes the foreach to a clean failure rather than crashing the run. * Best-effort: a git failure routes the foreach to a clean failure rather than crashing the run.
*/ */
import type { Task, TaskStore } from "@fusion/core"; import { isWorkspaceTask, type Task, type TaskStore } from "@fusion/core";
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { getConflictedFiles } from "../merger.js"; import { getConflictedFiles } from "../merger.js";
@@ -27,6 +27,8 @@ import { executorLog } from "../logger.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
export const WORKSPACE_ISOLATION_UNSUPPORTED_MESSAGE = "per-instance worktree isolation is not supported for workspace projects";
export type BuildForeachWorktreeDepsBag = { export type BuildForeachWorktreeDepsBag = {
store: TaskStore; store: TaskStore;
rootDir: string; rootDir: string;
@@ -37,6 +39,7 @@ export type BuildForeachWorktreeDepsBag = {
startPoint?: string, startPoint?: string,
) => Promise<{ path: string; branch: string }>; ) => Promise<{ path: string; branch: string }>;
semaphoreAvailableCount: () => number; semaphoreAvailableCount: () => number;
ensureWorkspaceConfig?: () => Promise<{ repos: string[] } | null>;
}; };
export type ForeachWorktreeDeps = { export type ForeachWorktreeDeps = {
@@ -45,6 +48,8 @@ export type ForeachWorktreeDeps = {
base: string | undefined, base: string | undefined,
) => Promise<{ worktreePath: string; branchName: string }>; ) => Promise<{ worktreePath: string; branchName: string }>;
resolveIntegrationBase: () => Promise<string | undefined>; resolveIntegrationBase: () => Promise<string | undefined>;
/** Fail-fast diagnostic when a multi-repo workspace cannot allocate one instance worktree. */
resolveWorktreeIsolationBlock: () => Promise<string | undefined>;
integrationGitOps: IntegrationGitOps; integrationGitOps: IntegrationGitOps;
integrationProjection: IntegrationProjection; integrationProjection: IntegrationProjection;
semaphoreAvailability: () => number; semaphoreAvailability: () => number;
@@ -104,6 +109,26 @@ export function buildForeachWorktreeDeps(
return resolveTaskWorkingBranch(task); return resolveTaskWorkingBranch(task);
} }
}; };
const resolveWorktreeIsolationBlock = async (): Promise<string | undefined> => {
let currentTask = task;
try {
currentTask = await deps.store.getTask(taskId);
} catch {
// The caller's task snapshot remains the conservative fallback.
}
if (isWorkspaceTask(currentTask)) {
return `${WORKSPACE_ISOLATION_UNSUPPORTED_MESSAGE} (task ${taskId}, workspace root ${deps.rootDir})`;
}
try {
const workspaceConfig = await deps.ensureWorkspaceConfig?.();
if (workspaceConfig?.repos.length) {
return `${WORKSPACE_ISOLATION_UNSUPPORTED_MESSAGE} (task ${taskId}, workspace root ${deps.rootDir})`;
}
} catch {
// Fail open when the optional project-config probe is unavailable.
}
return undefined;
};
return { return {
resolveIntegrationBase: async (): Promise<string | undefined> => { resolveIntegrationBase: async (): Promise<string | undefined> => {
@@ -116,7 +141,14 @@ export function buildForeachWorktreeDeps(
return await mainBranch(); return await mainBranch();
} }
}, },
resolveWorktreeIsolationBlock,
allocateInstanceWorktree: async (stepIndex, base): Promise<{ worktreePath: string; branchName: string }> => { allocateInstanceWorktree: async (stepIndex, base): Promise<{ worktreePath: string; branchName: string }> => {
/*
FNXC:Workspace 2026-08-15-04:22:
Workspace projects need one worktree per repository for every foreach instance and an N-way ordered integration queue. That design is out of scope, so reject before path or branch allocation rather than running git at the non-git workspace root.
*/
const block = await resolveWorktreeIsolationBlock();
if (block) throw new Error(block);
const branchName = canonicalStepInstanceBranchName(taskId, stepIndex); const branchName = canonicalStepInstanceBranchName(taskId, stepIndex);
const worktreePath = resolveTaskWorktreePath( const worktreePath = resolveTaskWorktreePath(
deps.rootDir, deps.rootDir,

View File

@@ -896,6 +896,7 @@ export function buildBuildForeachWorktreeDepsDeps(host: any): any {
return { return {
...facadeFields(host, ["store", "rootDir"]), ...facadeFields(host, ["store", "rootDir"]),
...facadeMethods(host, ["createWorktree"]), ...facadeMethods(host, ["createWorktree"]),
ensureWorkspaceConfig: withWorkspaceResolver(host),
semaphoreAvailableCount: () => host.options.semaphore?.availableCount ?? 1, semaphoreAvailableCount: () => host.options.semaphore?.availableCount ?? 1,
}; };
} }

View File

@@ -308,6 +308,8 @@ export interface WorkflowGraphExecutorDeps {
/** Step-inversion (KTD-11, U10): resolve the current integration base (main tip) /** Step-inversion (KTD-11, U10): resolve the current integration base (main tip)
* so reworks land on the updated base. */ * so reworks land on the updated base. */
resolveIntegrationBase?: ForeachEnvironment["resolveIntegrationBase"]; resolveIntegrationBase?: ForeachEnvironment["resolveIntegrationBase"];
/** Fail-fast workspace gate for per-instance worktree isolation. */
resolveWorktreeIsolationBlock?: ForeachEnvironment["resolveWorktreeIsolationBlock"];
/** Step-inversion (KTD-11, U10): ordered-integration git mechanics (rebase / /** Step-inversion (KTD-11, U10): ordered-integration git mechanics (rebase /
* cherry-pick + conflict detection via merger helpers). */ * cherry-pick + conflict detection via merger helpers). */
integrationGitOps?: ForeachEnvironment["integrationGitOps"]; integrationGitOps?: ForeachEnvironment["integrationGitOps"];
@@ -833,6 +835,7 @@ export class WorkflowGraphExecutor {
// Worktree isolation + parallel scheduling (KTD-11, U10). // Worktree isolation + parallel scheduling (KTD-11, U10).
allocateInstanceWorktree: this.deps.allocateInstanceWorktree, allocateInstanceWorktree: this.deps.allocateInstanceWorktree,
resolveIntegrationBase: this.deps.resolveIntegrationBase, resolveIntegrationBase: this.deps.resolveIntegrationBase,
resolveWorktreeIsolationBlock: this.deps.resolveWorktreeIsolationBlock,
integrationGitOps: this.deps.integrationGitOps, integrationGitOps: this.deps.integrationGitOps,
integrationProjection: this.deps.integrationProjection, integrationProjection: this.deps.integrationProjection,
semaphoreAvailability: this.deps.semaphoreAvailability, semaphoreAvailability: this.deps.semaphoreAvailability,

View File

@@ -189,6 +189,8 @@ export interface ForeachEnvironment {
* (re)allocation so a rework lands on the UPDATED base (KTD-11). Optional — * (re)allocation so a rework lands on the UPDATED base (KTD-11). Optional —
* defaults to undefined (the allocator's own default base). */ * defaults to undefined (the allocator's own default base). */
resolveIntegrationBase?: () => Promise<string | undefined>; resolveIntegrationBase?: () => Promise<string | undefined>;
/** Optional workspace gate for worktree isolation; absent preserves legacy injections. */
resolveWorktreeIsolationBlock?: () => Promise<string | undefined>;
/** Ordered-integration git mechanics (KTD-11). Required when `isolation: /** Ordered-integration git mechanics (KTD-11). Required when `isolation:
* "worktree"`; the queue uses it to land branches in step order. */ * "worktree"`; the queue uses it to land branches in step order. */
integrationGitOps?: IntegrationGitOps; integrationGitOps?: IntegrationGitOps;
@@ -504,6 +506,21 @@ async function runForeachWorktree(
pinnedStepCount: number, pinnedStepCount: number,
visitedNodeIds: string[], visitedNodeIds: string[],
): Promise<ForeachRunResult> { ): Promise<ForeachRunResult> {
/*
FNXC:WorkflowForeach 2026-08-15-04:22:
A multi-repo workspace must fail with an operator-visible diagnostic before foreach allocates against its non-git root. The graph gate is paired with the allocation seam so future callers cannot bypass this contract.
*/
const workspaceBlock = await env.resolveWorktreeIsolationBlock?.().catch(() => undefined);
if (workspaceBlock) {
schedulerLog.warn(`foreach ${foreachNode.id} for task ${env.task.id}: ${workspaceBlock}`);
try {
await env.logTaskEntry?.("worktree isolation unsupported for workspace project", workspaceBlock);
} catch {
// Task logging is diagnostic-only and cannot mask the routable failure.
}
return { outcome: "failure", value: "worktree-isolation-unsupported-workspace", visitedNodeIds };
}
if (!env.allocateInstanceWorktree || !env.integrationGitOps || !env.integrationProjection) { if (!env.allocateInstanceWorktree || !env.integrationGitOps || !env.integrationProjection) {
// Worktree isolation requires the full wiring; fail cleanly (routable) rather // Worktree isolation requires the full wiring; fail cleanly (routable) rather
// than silently running shared-mode physics. // than silently running shared-mode physics.

View File

@@ -131,6 +131,7 @@ export interface WorkflowGraphTaskRunnerDeps {
* Additive; a shared-isolation foreach never invokes them. */ * Additive; a shared-isolation foreach never invokes them. */
allocateInstanceWorktree?: ForeachEnvironment["allocateInstanceWorktree"]; allocateInstanceWorktree?: ForeachEnvironment["allocateInstanceWorktree"];
resolveIntegrationBase?: ForeachEnvironment["resolveIntegrationBase"]; resolveIntegrationBase?: ForeachEnvironment["resolveIntegrationBase"];
resolveWorktreeIsolationBlock?: ForeachEnvironment["resolveWorktreeIsolationBlock"];
integrationGitOps?: ForeachEnvironment["integrationGitOps"]; integrationGitOps?: ForeachEnvironment["integrationGitOps"];
integrationProjection?: ForeachEnvironment["integrationProjection"]; integrationProjection?: ForeachEnvironment["integrationProjection"];
semaphoreAvailability?: ForeachEnvironment["semaphoreAvailability"]; semaphoreAvailability?: ForeachEnvironment["semaphoreAvailability"];
@@ -409,6 +410,7 @@ export class WorkflowGraphTaskRunner {
// Step-inversion (KTD-11, U10): worktree isolation + parallel scheduling. // Step-inversion (KTD-11, U10): worktree isolation + parallel scheduling.
allocateInstanceWorktree: this.deps.allocateInstanceWorktree, allocateInstanceWorktree: this.deps.allocateInstanceWorktree,
resolveIntegrationBase: this.deps.resolveIntegrationBase, resolveIntegrationBase: this.deps.resolveIntegrationBase,
resolveWorktreeIsolationBlock: this.deps.resolveWorktreeIsolationBlock,
integrationGitOps: this.deps.integrationGitOps, integrationGitOps: this.deps.integrationGitOps,
integrationProjection: this.deps.integrationProjection, integrationProjection: this.deps.integrationProjection,
semaphoreAvailability: this.deps.semaphoreAvailability, semaphoreAvailability: this.deps.semaphoreAvailability,