From 12d33c512d95760c3f7e2dda8e61d45c3e9afeae Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:17:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20Phase=20A=20U2=20=E2=80=94?= =?UTF-8?q?=20per-repo=20acquisition=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acquireWorkspaceRepoWorktree now hardens each sub-repo worktree at acquisition: (1) installs the identity guard with the executor's settings args (commitMsgHookEnabled/taskPrefix/taskAttributionTrailerName) for single-repo parity — it was installing no guard before; (2) captures a per-repo baseCommitSha local-first against the repo's resolved integration branch via resolveIntegrationBranch(repoAbsPath, {...settings, integrationBranch: undefined}) — stripping the shared override so each sub-repo falls through to its own origin/HEAD, not a project-wide branch; (3) persists baseCommitSha into the workspaceWorktrees[repo] entry (Task type extended); (4) registers same-sub-repo exclusivity on the sub-repo path via activeSessionRegistry under a distinct "workspace-repo-acquire" kind (released in finally), so two concurrent workspace tasks contending for the same sub-repo are serialized (throws WorkspaceRepoAcquireBusyError). Idempotent re-acquire short-circuits. resolveCapturedBaseCommitSha gains an optional trailing integrationBranch param defaulting to "main", so existing single-repo callers + base-commit-capture real-git tests stay green. New audit events worktree:workspace-repo-acquire-busy /-failed. 6 new real-fixture tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...orkspace-per-repo-acquisition-hardening.md | 5 + packages/core/src/types.ts | 8 +- .../worktree-acquisition-workspace.test.ts | 273 ++++++++++++++++++ .../engine/src/active-session-registry.ts | 11 +- packages/engine/src/base-commit-capture.ts | 18 +- packages/engine/src/run-audit.ts | 5 + packages/engine/src/worktree-acquisition.ts | 191 ++++++++++-- 7 files changed, 483 insertions(+), 28 deletions(-) create mode 100644 .changeset/workspace-per-repo-acquisition-hardening.md create mode 100644 packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts diff --git a/.changeset/workspace-per-repo-acquisition-hardening.md b/.changeset/workspace-per-repo-acquisition-hardening.md new file mode 100644 index 0000000000..3ce549f618 --- /dev/null +++ b/.changeset/workspace-per-repo-acquisition-hardening.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase A / U2): harden per-repo worktree acquisition. Each sub-repo worktree now gets the task identity guard installed (single-repo parity), a per-repo base commit SHA captured local-first against that sub-repo's resolved integration branch (shared `integrationBranch` override stripped so each repo falls through to its own `origin/HEAD`), and same-sub-repo acquisition exclusivity registered in the path-keyed active-session registry. Re-acquiring an already-acquired `(taskId, repo)` is idempotent, and acquisition failures surface an error plus an audit event instead of silently stalling. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9425818116..6e40bad2d0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2246,8 +2246,14 @@ export interface Task { /** * Workspace mode only. Keyed by repo path relative to workspace rootDir. * Each entry records the on-disk worktree path and git branch for one sub-repo. + * + * FNXC:Workspace 2026-06-21-20:10: + * `baseCommitSha` is the per-repo fork-point captured at acquisition (U2/KTD3) + * against that sub-repo's RESOLVED integration branch, local-first. It is the + * per-repo analogue of the single-repo base-commit capture and prevents + * cross-repo files-changed inflation when local integration is ahead of origin. */ - workspaceWorktrees?: Record; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts new file mode 100644 index 0000000000..d3e9e95349 --- /dev/null +++ b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts @@ -0,0 +1,273 @@ +/* +FNXC:Workspace 2026-06-21-20:10: +U2 per-repo acquisition hardening tests. A REAL two-repo git fixture is required +because the invariants under test are git-shaped: local-ahead-of-origin base +capture, a resolved-per-repo (non-shared) integration branch, and a working +identity-guard hook that actually rejects a commit. The shared harness from +./_workspace-fixture.ts builds genuine on-disk repos under a NON-git workspace +root. The TaskStore is an in-memory fake (no DB / no network) per FN-5048 — real +git only where the invariant needs it; everything else is a narrow seam. +*/ +import { execSync, spawnSync } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Settings, Task, TaskStore } from "@fusion/core"; +import { + acquireWorkspaceRepoWorktree, + WorkspaceRepoAcquireBusyError, +} from "../worktree-acquisition.js"; +import { ActiveSessionRegistry } from "../active-session-registry.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +/** + * Minimal in-memory TaskStore covering exactly what acquireWorkspaceRepoWorktree + * and its acquireTaskWorktree callee touch: updateTask (merge-in-place so the + * idempotency re-read sees persisted workspaceWorktrees), logEntry, getTask. + */ +function makeFakeStore(task: Task): { store: TaskStore; current: () => Task; logs: string[] } { + let current = task; + const logs: string[] = []; + const store = { + async updateTask(id: string, patch: Partial): Promise { + if (id === current.id) current = { ...current, ...patch }; + }, + async logEntry(_id: string, message: string): Promise { + logs.push(message); + }, + async getTask(id: string): Promise { + return id === current.id ? current : null; + }, + } as unknown as TaskStore; + return { store, current: () => current, logs }; +} + +function makeTask(id: string): Task { + return { + id, + title: `task ${id}`, + description: "workspace task", + status: "in-progress", + } as unknown as Task; +} + +const SETTINGS: Partial = { + worktreeNaming: "task-id", + commitMsgHookEnabled: true, + taskPrefix: "FN", + taskAttributionTrailerNames: ["Fusion-Task-Id"], +}; + +describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout: 60_000 }, () => { + let fixture: WorkspaceFixture; + + afterEach(() => { + fixture?.cleanup(); + }); + + it("captures the LOCAL integration tip as baseCommitSha even when origin is behind (inflation invariant)", async () => { + // Give repo-a a real origin so origin/main can lag behind local main. + fixture = await createWorkspaceFixture(["repo-a"]); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git push -u origin main"); + + // Local main advances by an unpushed predecessor commit (FN-5937 shape). + git(repoA, "git commit --allow-empty -m 'FN-9000: unpushed predecessor'"); + const localTip = git(repoA, "git rev-parse HEAD"); + const originTip = git(repoA, "git rev-parse origin/main"); + expect(localTip).not.toBe(originTip); + + const { store, current } = makeFakeStore(makeTask("FN-1")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + + // Base must be the LOCAL tip, never the behind origin tip. + expect(result.baseCommitSha).toBe(localTip); + expect(current().workspaceWorktrees?.["repo-a"]?.baseCommitSha).toBe(localTip); + }); + + it("captures against a NON-main integration branch and does not inherit a shared settings.integrationBranch (KTD3)", async () => { + // repo-a's default branch is 'develop'; origin/HEAD points at it. A shared + // settings.integrationBranch override must be STRIPPED so per-repo resolution + // falls through to this repo's own origin/HEAD. + fixture = await createWorkspaceFixture(["repo-a"], "develop"); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git push -u origin develop"); + // Point origin/HEAD at develop so resolveIntegrationBranch resolves it. + git(repoA, "git remote set-head origin develop"); + const developTip = git(repoA, "git rev-parse develop"); + + const { store, current } = makeFakeStore(makeTask("FN-2")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + // A SHARED integration branch that does NOT exist in this sub-repo. If it + // leaked through, base capture would resolve against 'shared-trunk' and + // (absent that branch) fall back to HEAD — not develop's tip. + settings: { ...SETTINGS, integrationBranch: "shared-trunk" }, + registry, + }); + + expect(result.baseCommitSha).toBe(developTip); + }); + + it("installs the identity-guard hook so a commit on a non-fusion branch is rejected", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current } = makeFakeStore(makeTask("FN-3")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + settings: SETTINGS, + store, + registry, + }); + + const wt = result.worktreePath; + expect(existsSync(join(wt, ".git"))).toBe(true); + git(wt, 'git config user.email "test@example.com"'); + git(wt, 'git config user.name "Test"'); + + // On the fusion/ branch the guard permits a commit (real staged change, + // so the FN-5345 empty-commit guard also installed by the identity guard + // does not refuse it). + git(wt, "git checkout fusion/fn-3"); + writeFileSync(join(wt, "own.txt"), "own work\n", "utf-8"); + git(wt, "git add own.txt"); + git(wt, "git commit -m 'FN-3: ok on own branch'"); + + // Switch to a foreign branch; the pre-commit identity guard must refuse. + git(wt, "git checkout -B rogue-branch"); + writeFileSync(join(wt, "rogue.txt"), "rogue work\n", "utf-8"); + git(wt, "git add rogue.txt"); + const attempt = spawnSync("git", ["commit", "-m", "rogue"], { + cwd: wt, + encoding: "utf-8", + }); + expect(attempt.status).not.toBe(0); + expect(`${attempt.stderr}`).toMatch(/refusing commit/i); + }); + + it("serializes two concurrent acquisitions of the SAME sub-repo via the exclusivity registry (KTD4)", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const repoAbs = fixture.repoPath("repo-a"); + const registry = new ActiveSessionRegistry(); + + // Pre-register the sub-repo path as if task FN-A is mid-acquisition, then + // prove a second task is rejected while it is held. + registry.registerPath(repoAbs, { taskId: "FN-A", kind: "workspace-repo-acquire", ownerKey: "workspace-repo-acquire" }); + + const { store, current } = makeFakeStore(makeTask("FN-B")); + await expect( + acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }), + ).rejects.toBeInstanceOf(WorkspaceRepoAcquireBusyError); + + // The holder's entry is untouched by the rejected loser. + expect(registry.lookupByPath(repoAbs)?.taskId).toBe("FN-A"); + + // Once released, the same task acquires cleanly and the registry is freed. + registry.unregisterPath(repoAbs); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(result.alreadyAcquired).toBe(false); + // Acquisition releases its own exclusivity entry on completion. + expect(registry.isPathActive(repoAbs)).toBe(false); + }); + + it("is idempotent across (taskId, repo): re-acquire returns the existing entry without re-capture", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current } = makeFakeStore(makeTask("FN-4")); + const registry = new ActiveSessionRegistry(); + + const first = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(first.alreadyAcquired).toBe(false); + + // Re-acquire with the now-populated task: returns the persisted entry, + // does not re-register exclusivity, does not re-create a worktree. + const second = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(second.alreadyAcquired).toBe(true); + expect(second.worktreePath).toBe(first.worktreePath); + expect(second.baseCommitSha).toBe(first.baseCommitSha); + expect(registry.isPathActive(fixture.repoPath("repo-a"))).toBe(false); + }); + + it("surfaces an error and persists an audit event when acquisition fails (no swallowed stall)", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current, logs } = makeFakeStore(makeTask("FN-5")); + const registry = new ActiveSessionRegistry(); + const auditEvents: Array<{ type: string }> = []; + const audit = { + async git(e: { type: string }): Promise { + auditEvents.push(e); + }, + async filesystem(): Promise {}, + }; + + await expect( + acquireWorkspaceRepoWorktree({ + repoRelPath: "does-not-exist", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + audit: audit as never, + }), + ).rejects.toThrow(); + + expect(auditEvents.some((e) => e.type === "worktree:workspace-repo-acquire-failed")).toBe(true); + expect(logs.some((m) => /acquisition failed/i.test(m))).toBe(true); + // The exclusivity entry is released even on the failure path. + expect(registry.isPathActive(join(fixture.rootDir, "does-not-exist"))).toBe(false); + }); +}); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index ec28db0158..12168c0cea 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -1,4 +1,13 @@ -export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge"; +/* +FNXC:Workspace 2026-06-21-20:10: +"workspace-repo-acquire" is a DISTINCT registry kind reserved for the +acquisition-time same-sub-repo exclusivity entry (U2/KTD4). It is keyed by the +sub-repo absolute path (NOT the worktree path) so two concurrent workspace tasks +contending for the SAME sub-repo are serialized. Keeping it distinct from +"executor"/"step-session" means it does not collide with the executor's later +session registration on the produced worktree path. +*/ +export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge" | "workspace-repo-acquire"; export interface ActiveSessionRegistration { taskId: string; diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index c449a97558..4d9e778774 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -22,15 +22,31 @@ const execAsync = promisify(exec); * * Returns `undefined` only when every git invocation fails (caller treats a * missing base as non-fatal). + * + * FNXC:Workspace 2026-06-21-20:10: + * `integrationBranch` is an OPTIONAL TRAILING param defaulting to the historic + * "main" literal so the single-repo executor caller and the real-git tests stay + * green without change. Workspace mode (U2/KTD3) passes each sub-repo's RESOLVED + * integration branch so per-repo base capture forks against the right branch + * instead of a hardcoded "main". The local-first ordering (merge-base HEAD + * then origin/) is preserved per-branch to keep the + * inflation-prevention invariant (FN-5937) intact for non-main integration + * branches too. */ export async function resolveCapturedBaseCommitSha( worktreePath: string, logger?: { warn: (msg: string) => void }, + integrationBranch: string = "main", ): Promise { + const branch = integrationBranch.trim() || "main"; + // Shell-quote defensively; integration branch names are normalized upstream + // but may carry slashes (e.g. "release/2026-06") that are valid in refs. + const localRef = JSON.stringify(branch); + const originRef = JSON.stringify(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( - "git merge-base HEAD main 2>/dev/null || git merge-base HEAD origin/main", + `git merge-base HEAD ${localRef} 2>/dev/null || git merge-base HEAD ${originRef}`, { cwd: worktreePath, encoding: "utf-8" }, ); baseCommitSha = stdout.trim() || undefined; diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 1baaf8da1d..e92d23656d 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -99,6 +99,11 @@ export type GitMutationType = | "worktree:incomplete-detected" | "worktree:reanchored" | "worktree:auto-recovered" + // FNXC:Workspace 2026-06-21-20:10: workspace per-repo acquisition audit events (U2). + // -busy: another task holds the same sub-repo's acquisition exclusivity lock (KTD4). + // -failed: a sub-repo worktree acquisition threw; surfaced + audited, never swallowed. + | "worktree:workspace-repo-acquire-busy" + | "worktree:workspace-repo-acquire-failed" /** * worktrunk run-audit metadata shape: * diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 794413ca5d..07ce2d722a 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -34,6 +34,10 @@ import { import type { RunAuditor } from "./run-audit.js"; import { writeSecretsEnvFile } from "./secrets-env-writer.js"; import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js"; +import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; +import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; +import { resolveIntegrationBranch } from "./integration-branch.js"; +import { activeSessionRegistry, type ActiveSessionRegistry } from "./active-session-registry.js"; const execAsync = promisify(exec); @@ -604,47 +608,184 @@ export interface AcquireWorkspaceRepoWorktreeOptions { settings: Partial; logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void }; secretsStore?: Pick; + audit?: Pick; + runContext?: RunMutationContext; + /** Test seam: inject the path-keyed exclusivity registry (defaults to the process singleton). */ + registry?: ActiveSessionRegistry; } +/* +FNXC:Workspace 2026-06-21-20:10: +Acquisition-time exclusivity owner key for the same-sub-repo lock (U2/KTD4). The +registry record is keyed by the sub-repo ABSOLUTE path and carries this distinct +ownerKey so it never collides with the executor's later "executor"/"step-session" +registration on the produced WORKTREE path. +*/ +const WORKSPACE_REPO_ACQUIRE_OWNER_KEY = "workspace-repo-acquire"; + export async function acquireWorkspaceRepoWorktree( opts: AcquireWorkspaceRepoWorktreeOptions, -): Promise<{ worktreePath: string; branch: string; alreadyAcquired: boolean }> { - const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore } = opts; +): Promise<{ worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean }> { + const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext } = opts; + const registry = opts.registry ?? activeSessionRegistry; const { join } = await import("node:path"); const existing = task.workspaceWorktrees?.[repoRelPath]; if (existing) { + /* + FNXC:Workspace 2026-06-21-20:10: + Idempotency across (taskId, repo): a re-acquire of an already-acquired sub-repo + returns the persisted entry verbatim — no second identity-guard install, no + re-capture of the base SHA, no second exclusivity registration. + */ return { ...existing, alreadyAcquired: true }; } const repoAbsPath = join(workspaceRootDir, repoRelPath); /* - FNXC:WorkspaceWorktree 2026-06-21-19:05: - Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` - is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites - those singular fields on the task row after each acquisition. Passing the live task straight - through means the second repo's acquisition sees the first repo's `task.worktree` (which exists - on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo - contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo - helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in - `task.workspaceWorktrees`, not the singular column. + FNXC:Workspace 2026-06-21-20:10: + Same-sub-repo exclusivity (KTD4): register the sub-repo absolute path in the + path-keyed activeSessionRegistry BEFORE acquiring so two concurrent workspace + tasks contending for the SAME sub-repo are serialized. WorktreePool is a recycle + cache, not a cross-task lock, and disjoint-scope contention on one sub-repo is + otherwise unprotected (file-scope leases don't catch it). The entry is keyed by + the sub-repo path with a distinct ownerKey so it does not collide with the + executor's later session registration on the produced worktree path. We release + it once acquisition completes (success or failure) — it guards the acquisition + critical section, not the whole task lifetime. */ - const result = await acquireTaskWorktree({ - task: { ...task, worktree: undefined, branch: undefined }, - rootDir: repoAbsPath, - store, - settings, - logger, - secretsStore, - runInitCommand: true, + const exclusivityHolder = registry.lookupByPath(repoAbsPath); + if (exclusivityHolder && exclusivityHolder.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY && exclusivityHolder.taskId !== task.id) { + const message = `sub-repo ${repoRelPath} is being acquired by ${exclusivityHolder.taskId}; serializing concurrent workspace acquisition`; + logger?.warn(`${task.id}: ${message}`); + await store.logEntry(task.id, message, undefined, runContext); + const err = new WorkspaceRepoAcquireBusyError(repoRelPath, exclusivityHolder.taskId, task.id); + await audit?.git({ + type: "worktree:workspace-repo-acquire-busy", + target: repoAbsPath, + metadata: { repoRelPath, holderTaskId: exclusivityHolder.taskId, requestingTaskId: task.id }, + }); + throw err; + } + registry.registerPath(repoAbsPath, { + taskId: task.id, + kind: "workspace-repo-acquire", + ownerKey: WORKSPACE_REPO_ACQUIRE_OWNER_KEY, }); - const updated: Record = { - ...(task.workspaceWorktrees ?? {}), - [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, - }; - await store.updateTask(task.id, { workspaceWorktrees: updated }); + try { + /* + FNXC:WorkspaceWorktree 2026-06-21-19:05: + Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` + is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites + those singular fields on the task row after each acquisition. Passing the live task straight + through means the second repo's acquisition sees the first repo's `task.worktree` (which exists + on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo + contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo + helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in + `task.workspaceWorktrees`, not the singular column. + */ + const result = await acquireTaskWorktree({ + task: { ...task, worktree: undefined, branch: undefined }, + rootDir: repoAbsPath, + store, + settings, + logger, + secretsStore, + audit, + runContext, + runInitCommand: true, + }); - return { worktreePath: result.worktreePath, branch: result.branch, alreadyAcquired: false }; + /* + FNXC:Workspace 2026-06-21-20:10: + Identity guard (single-repo parity): acquireTaskWorktree above runs WITHOUT a + createWorktree override, so the default native backend installs NO identity + hooks for a sub-repo worktree. Install the same guard the executor installs for + single-repo tasks (executor.ts identity-guard call), passing the SAME settings + args (commitMsgHookEnabled / taskPrefix / first taskAttributionTrailerName) so a + commit on a non-fusion/ branch is refused inside every sub-repo worktree too. + */ + await installTaskWorktreeIdentityGuard({ + worktreePath: result.worktreePath, + taskId: task.id, + commitMsgHookEnabled: settings.commitMsgHookEnabled, + taskPrefix: settings.taskPrefix, + taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], + }); + + /* + FNXC:Workspace 2026-06-21-20:10: + Per-repo base SHA (KTD3): resolve THIS sub-repo's integration branch with the + shared settings.integrationBranch override STRIPPED. resolveIntegrationBranch + checks settings.integrationBranch FIRST, so without stripping it every sub-repo + would resolve to the shared workspace branch — defeating per-repo resolution. + With it undefined, each sub-repo falls through to its own origin/HEAD. Capture + the base local-first against that branch so local-ahead-of-origin integration + tips don't inflate the per-repo diff (FN-5937 invariant, per sub-repo). + */ + const integrationBranch = await resolveIntegrationBranch( + repoAbsPath, + { ...settings, integrationBranch: undefined }, + { logger }, + ); + const baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); + + const updated: Record = { + ...(task.workspaceWorktrees ?? {}), + [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha }, + }; + await store.updateTask(task.id, { workspaceWorktrees: updated }); + + return { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha, alreadyAcquired: false }; + } catch (err) { + /* + FNXC:Workspace 2026-06-21-20:10: + Acquisition failure must surface an error and leave an audit trail (no swallowed + stall): persist the failure as an audit event + task log, then re-throw so the + caller observes the failure rather than silently proceeding with an unacquired + sub-repo. + */ + if (!(err instanceof WorkspaceRepoAcquireBusyError)) { + const message = err instanceof Error ? err.message : String(err); + logger?.error?.(`${task.id}: workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`); + await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message }, + }); + } + throw err; + } finally { + /* + FNXC:Workspace 2026-06-21-20:10: + Release the acquisition-time exclusivity entry only when WE hold it. The busy-path + throw above does NOT enter this try (it short-circuits before registerPath), so a + serialized loser never unregisters the winner's entry. + */ + const held = registry.lookupByPath(repoAbsPath); + if (held && held.taskId === task.id && held.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY) { + registry.unregisterPath(repoAbsPath); + } + } +} + +/* +FNXC:Workspace 2026-06-21-20:10: +Thrown when a second workspace task tries to acquire a sub-repo already inside +another task's acquisition critical section (KTD4). Distinct from generic +acquisition failures so the caller (and tests) can tell "serialized, retry later" +apart from "this sub-repo is broken". +*/ +export class WorkspaceRepoAcquireBusyError extends Error { + constructor( + public readonly repoRelPath: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super(`workspace sub-repo ${repoRelPath} acquisition is in progress for task ${holderTaskId}`); + this.name = "WorkspaceRepoAcquireBusyError"; + } }