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:
5
.changeset/workspace-per-repo-acquisition-hardening.md
Normal file
5
.changeset/workspace-per-repo-acquisition-hardening.md
Normal 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.
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user