diff --git a/.changeset/fn-9061-foreach-workspace-isolation-gate.md b/.changeset/fn-9061-foreach-workspace-isolation-gate.md new file mode 100644 index 0000000000..4e0c3ae209 --- /dev/null +++ b/.changeset/fn-9061-foreach-workspace-isolation-gate.md @@ -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. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index c376536745..68169b1c05 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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 diff --git a/packages/engine/src/__tests__/workflow-foreach-workspace-isolation.test.ts b/packages/engine/src/__tests__/workflow-foreach-workspace-isolation.test.ts new file mode 100644 index 0000000000..cb871279e9 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-foreach-workspace-isolation.test.ts @@ -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, 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); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-graph-foreach.test.ts b/packages/engine/src/__tests__/workflow-graph-foreach.test.ts index 05cb6888f4..f9bb4cfb09 100644 --- a/packages/engine/src/__tests__/workflow-graph-foreach.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-foreach.test.ts @@ -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 diff --git a/packages/engine/src/executor/build-foreach-worktree-deps.ts b/packages/engine/src/executor/build-foreach-worktree-deps.ts index 427e7dfd3b..f96043f366 100644 --- a/packages/engine/src/executor/build-foreach-worktree-deps.ts +++ b/packages/engine/src/executor/build-foreach-worktree-deps.ts @@ -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; + /** Fail-fast diagnostic when a multi-repo workspace cannot allocate one instance worktree. */ + resolveWorktreeIsolationBlock: () => Promise; integrationGitOps: IntegrationGitOps; integrationProjection: IntegrationProjection; semaphoreAvailability: () => number; @@ -104,6 +109,26 @@ export function buildForeachWorktreeDeps( return resolveTaskWorkingBranch(task); } }; + const resolveWorktreeIsolationBlock = async (): Promise => { + 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 => { @@ -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, diff --git a/packages/engine/src/executor/deps-bags.ts b/packages/engine/src/executor/deps-bags.ts index 60b0b39b06..7dc240e2e2 100644 --- a/packages/engine/src/executor/deps-bags.ts +++ b/packages/engine/src/executor/deps-bags.ts @@ -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, }; } diff --git a/packages/engine/src/workflows/workflow-graph-executor.ts b/packages/engine/src/workflows/workflow-graph-executor.ts index c2db671aaa..b9ac05c137 100644 --- a/packages/engine/src/workflows/workflow-graph-executor.ts +++ b/packages/engine/src/workflows/workflow-graph-executor.ts @@ -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, diff --git a/packages/engine/src/workflows/workflow-graph-foreach.ts b/packages/engine/src/workflows/workflow-graph-foreach.ts index 4729bfb222..2a08ed203a 100644 --- a/packages/engine/src/workflows/workflow-graph-foreach.ts +++ b/packages/engine/src/workflows/workflow-graph-foreach.ts @@ -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; + /** Optional workspace gate for worktree isolation; absent preserves legacy injections. */ + resolveWorktreeIsolationBlock?: () => Promise; /** 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 { + /* + 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. diff --git a/packages/engine/src/workflows/workflow-graph-task-runner.ts b/packages/engine/src/workflows/workflow-graph-task-runner.ts index 08ffa4d9c1..cc87cdc32d 100644 --- a/packages/engine/src/workflows/workflow-graph-task-runner.ts +++ b/packages/engine/src/workflows/workflow-graph-task-runner.ts @@ -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,