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:
7
.changeset/fn-9061-foreach-workspace-isolation-gate.md
Normal file
7
.changeset/fn-9061-foreach-workspace-isolation-gate.md
Normal 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.
|
||||
@@ -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).
|
||||
|
||||
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.
|
||||
|
||||
#### `step-review` node & rework edges
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -506,6 +506,62 @@ describe("WorkflowGraphExecutor foreach (U3)", () => {
|
||||
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 () => {
|
||||
// U10: parallel mode defaults to worktree isolation. Without the worktree /
|
||||
// integration deps wired, the foreach fails with a routable value rather than
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 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.
|
||||
*/
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import { isWorkspaceTask, type Task, type TaskStore } from "@fusion/core";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { getConflictedFiles } from "../merger.js";
|
||||
@@ -27,6 +27,8 @@ import { executorLog } from "../logger.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const WORKSPACE_ISOLATION_UNSUPPORTED_MESSAGE = "per-instance worktree isolation is not supported for workspace projects";
|
||||
|
||||
export type BuildForeachWorktreeDepsBag = {
|
||||
store: TaskStore;
|
||||
rootDir: string;
|
||||
@@ -37,6 +39,7 @@ export type BuildForeachWorktreeDepsBag = {
|
||||
startPoint?: string,
|
||||
) => Promise<{ path: string; branch: string }>;
|
||||
semaphoreAvailableCount: () => number;
|
||||
ensureWorkspaceConfig?: () => Promise<{ repos: string[] } | null>;
|
||||
};
|
||||
|
||||
export type ForeachWorktreeDeps = {
|
||||
@@ -45,6 +48,8 @@ export type ForeachWorktreeDeps = {
|
||||
base: string | undefined,
|
||||
) => Promise<{ worktreePath: string; branchName: string }>;
|
||||
resolveIntegrationBase: () => Promise<string | undefined>;
|
||||
/** Fail-fast diagnostic when a multi-repo workspace cannot allocate one instance worktree. */
|
||||
resolveWorktreeIsolationBlock: () => Promise<string | undefined>;
|
||||
integrationGitOps: IntegrationGitOps;
|
||||
integrationProjection: IntegrationProjection;
|
||||
semaphoreAvailability: () => number;
|
||||
@@ -104,6 +109,26 @@ export function buildForeachWorktreeDeps(
|
||||
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 {
|
||||
resolveIntegrationBase: async (): Promise<string | undefined> => {
|
||||
@@ -116,7 +141,14 @@ export function buildForeachWorktreeDeps(
|
||||
return await mainBranch();
|
||||
}
|
||||
},
|
||||
resolveWorktreeIsolationBlock,
|
||||
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 worktreePath = resolveTaskWorktreePath(
|
||||
deps.rootDir,
|
||||
|
||||
@@ -896,6 +896,7 @@ export function buildBuildForeachWorktreeDepsDeps(host: any): any {
|
||||
return {
|
||||
...facadeFields(host, ["store", "rootDir"]),
|
||||
...facadeMethods(host, ["createWorktree"]),
|
||||
ensureWorkspaceConfig: withWorkspaceResolver(host),
|
||||
semaphoreAvailableCount: () => host.options.semaphore?.availableCount ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,6 +308,8 @@ export interface WorkflowGraphExecutorDeps {
|
||||
/** Step-inversion (KTD-11, U10): resolve the current integration base (main tip)
|
||||
* so reworks land on the updated base. */
|
||||
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 /
|
||||
* cherry-pick + conflict detection via merger helpers). */
|
||||
integrationGitOps?: ForeachEnvironment["integrationGitOps"];
|
||||
@@ -833,6 +835,7 @@ export class WorkflowGraphExecutor {
|
||||
// Worktree isolation + parallel scheduling (KTD-11, U10).
|
||||
allocateInstanceWorktree: this.deps.allocateInstanceWorktree,
|
||||
resolveIntegrationBase: this.deps.resolveIntegrationBase,
|
||||
resolveWorktreeIsolationBlock: this.deps.resolveWorktreeIsolationBlock,
|
||||
integrationGitOps: this.deps.integrationGitOps,
|
||||
integrationProjection: this.deps.integrationProjection,
|
||||
semaphoreAvailability: this.deps.semaphoreAvailability,
|
||||
|
||||
@@ -189,6 +189,8 @@ export interface ForeachEnvironment {
|
||||
* (re)allocation so a rework lands on the UPDATED base (KTD-11). Optional —
|
||||
* defaults to undefined (the allocator's own default base). */
|
||||
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:
|
||||
* "worktree"`; the queue uses it to land branches in step order. */
|
||||
integrationGitOps?: IntegrationGitOps;
|
||||
@@ -504,6 +506,21 @@ async function runForeachWorktree(
|
||||
pinnedStepCount: number,
|
||||
visitedNodeIds: string[],
|
||||
): 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) {
|
||||
// Worktree isolation requires the full wiring; fail cleanly (routable) rather
|
||||
// than silently running shared-mode physics.
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface WorkflowGraphTaskRunnerDeps {
|
||||
* Additive; a shared-isolation foreach never invokes them. */
|
||||
allocateInstanceWorktree?: ForeachEnvironment["allocateInstanceWorktree"];
|
||||
resolveIntegrationBase?: ForeachEnvironment["resolveIntegrationBase"];
|
||||
resolveWorktreeIsolationBlock?: ForeachEnvironment["resolveWorktreeIsolationBlock"];
|
||||
integrationGitOps?: ForeachEnvironment["integrationGitOps"];
|
||||
integrationProjection?: ForeachEnvironment["integrationProjection"];
|
||||
semaphoreAvailability?: ForeachEnvironment["semaphoreAvailability"];
|
||||
@@ -409,6 +410,7 @@ export class WorkflowGraphTaskRunner {
|
||||
// Step-inversion (KTD-11, U10): worktree isolation + parallel scheduling.
|
||||
allocateInstanceWorktree: this.deps.allocateInstanceWorktree,
|
||||
resolveIntegrationBase: this.deps.resolveIntegrationBase,
|
||||
resolveWorktreeIsolationBlock: this.deps.resolveWorktreeIsolationBlock,
|
||||
integrationGitOps: this.deps.integrationGitOps,
|
||||
integrationProjection: this.deps.integrationProjection,
|
||||
semaphoreAvailability: this.deps.semaphoreAvailability,
|
||||
|
||||
Reference in New Issue
Block a user