feat(workspace): Phase A U2 — per-repo acquisition hardening

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 21:17:08 -07:00
parent 1fa3691f1f
commit 12d33c512d
7 changed files with 483 additions and 28 deletions

View File

@@ -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.

View File

@@ -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<string, { worktreePath: string; branch: string }>;
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string; baseCommitSha?: string }>;
steps: TaskStep[];
currentStep: number;
/**

View File

@@ -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<Task>): Promise<void> {
if (id === current.id) current = { ...current, ...patch };
},
async logEntry(_id: string, message: string): Promise<void> {
logs.push(message);
},
async getTask(id: string): Promise<Task | null> {
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<Settings> = {
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/<id> 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<void> {
auditEvents.push(e);
},
async filesystem(): Promise<void> {},
};
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);
});
});

View File

@@ -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;

View File

@@ -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
* <local> then origin/<branch>) 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<string | undefined> {
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;

View File

@@ -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:
*

View File

@@ -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<Settings>;
logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void };
secretsStore?: Pick<SecretsStore, "listEnvExportable">;
audit?: Pick<RunAuditor, "git" | "filesystem">;
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<string, { worktreePath: string; branch: string }> = {
...(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/<id> 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<string, { worktreePath: string; branch: string; baseCommitSha?: string }> = {
...(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";
}
}