Files
fusion/packages/engine/src/executor/workspace-config-resolver.ts
Fusion Agent 3717fc5af1 FN-107: block single-repository worktree acquisition
Prevent single-repository worktree acquisition from proceeding without explicit multi-repository workspace context.

- Require workspace-aware acquisition for single-repository tasks.
- Propagate acquisition origin and workspace configuration through executor, merger, and recovery paths.
- Add regression coverage, documentation, and a release changeset for the blocked behavior.

Files changed:
 .changeset/fn-107-single-repo-worktree.md          |   7 ++
 docs/architecture.md                               |   4 +
 docs/testing.md                                    |   4 +
 packages/core/src/branch/branch-assignment.ts      |  12 +++
 packages/core/src/index.gate.ts                    |   1 +
 packages/core/src/index.ts                         |   1 +
 packages/core/src/task-store/task-creation.ts      |   4 +-
 packages/core/src/task-store/task-update.ts        |   4 +-
 .../src/routes/register-planning-subtask-routes.ts |   1 +
 .../engine/src/__tests__/executor-test-helpers.ts  |  12 +++
 .../engine/src/__tests__/executor-worktree.test.ts |  44 ++++++---
 .../src/__tests__/node-worktree-isolation.test.ts  |   7 +-
 .../task-branch-write-origin-census.test.ts        |  49 ++++++++++
 .../worktree-acquisition-workspace.test.ts         |   6 +-
 .../src/__tests__/worktree-acquisition.test.ts     | 103 ++++++++++++++++++---
 .../engine/src/executor/handle-graph-failure.ts    |  16 +++
 .../src/executor/workspace-config-resolver.ts      |   1 +
 .../executor/worktree-branch-conflict-handle.ts    |   1 +
 packages/engine/src/merger.ts                      |   3 +-
 packages/engine/src/self-healing.ts                |   6 +
 .../self-healing/auto-recover-worktree-session.ts  |  10 +-
 .../src/workflows/workflow-graph-executor.ts       |   7 +-
 .../engine/src/worktree/worktree-acquisition.ts    |  73 +++++++++++++-
 23 files changed, 334 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-107

Fusion-Task-Lineage: 80df9dae-59a0-416e-8a02-6ab2dba9bb00

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-21 14:41:37 +00:00

84 lines
3.4 KiB
TypeScript

import { loadWorkspaceConfig, type Task, type TaskStore, type WorkspaceConfig } from "@fusion/core";
/**
* FNXC:Workspace 2026-08-14-21:06:
* Workspace detection has one host-owned writer: a per-lane copy silently routes a multi-repo
* project through its non-git root. Memoization is per host so concurrent projects and tests
* cannot share configuration, and a config with no usable repositories is single-repo mode.
*/
const inFlightWorkspaceConfigLoads = new WeakMap<object, Promise<WorkspaceConfig | null>>();
const workspaceConfigEpochs = new WeakMap<object, number>();
/**
* FNXC:Workspace 2026-08-15-05:28:
* A settings toggle can change workspace.json while a prior disk read is pending. Bump an owner
* epoch as well as deleting its promise so that stale completion cannot repopulate the old mode.
*/
export function invalidateWorkspaceConfigCache(owner: object): void {
inFlightWorkspaceConfigLoads.delete(owner);
workspaceConfigEpochs.set(owner, (workspaceConfigEpochs.get(owner) ?? 0) + 1);
}
/*
FNXC:WorkspaceRootRouting 2026-08-19-12:15:
Resolve stale singular routing through the TaskStore's atomic PostgreSQL mutation before a review or
recovery path interprets task.worktree/branch/session metadata. The compatibility fallback updates
only singular fields and never replaces workspaceWorktrees, preserving sub-repository siblings in
structural test stores that predate the persistence seam.
*/
export async function normalizeWorkspaceTaskRouting(store: TaskStore, taskId: string): Promise<Task> {
const normalize = (store as TaskStore & {
normalizeWorkspaceTaskWorktreeMetadata?: (id: string) => Promise<Task>;
}).normalizeWorkspaceTaskWorktreeMetadata;
if (typeof normalize === "function") return normalize.call(store, taskId);
let task = await store.getTask(taskId);
if (task.worktree || task.branch || task.executionStartBranch || task.baseCommitSha) {
if (typeof (store as Partial<TaskStore>).updateTask !== "function") return task;
await store.updateTask(taskId, {
worktree: null,
branch: null,
branchWriteOrigin: "engine" as const,
executionStartBranch: null,
baseCommitSha: null,
sessionFile: null,
});
task = await store.getTask(taskId);
}
return task;
}
export type WorkspaceConfigResolverDeps = {
rootDir: string;
workspaceConfigOwner: object;
getWorkspaceConfig: () => WorkspaceConfig | null | undefined;
setWorkspaceConfig: (config: WorkspaceConfig | null) => void;
};
export async function resolveWorkspaceConfigOnce(
deps: WorkspaceConfigResolverDeps,
): Promise<WorkspaceConfig | null> {
const current = deps.getWorkspaceConfig();
if (current !== undefined) return current;
const existing = inFlightWorkspaceConfigLoads.get(deps.workspaceConfigOwner);
if (existing) return existing;
const epoch = workspaceConfigEpochs.get(deps.workspaceConfigOwner) ?? 0;
const promise = loadWorkspaceConfig(deps.rootDir).then((config) => {
const normalized = config && config.repos.length > 0 ? config : null;
if ((workspaceConfigEpochs.get(deps.workspaceConfigOwner) ?? 0) === epoch) {
deps.setWorkspaceConfig(normalized);
}
return normalized;
});
inFlightWorkspaceConfigLoads.set(deps.workspaceConfigOwner, promise);
try {
return await promise;
} finally {
if (inFlightWorkspaceConfigLoads.get(deps.workspaceConfigOwner) === promise) {
inFlightWorkspaceConfigLoads.delete(deps.workspaceConfigOwner);
}
}
}