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.
|
* 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.
|
* 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[];
|
steps: TaskStep[];
|
||||||
currentStep: number;
|
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 {
|
export interface ActiveSessionRegistration {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|||||||
@@ -22,15 +22,31 @@ const execAsync = promisify(exec);
|
|||||||
*
|
*
|
||||||
* Returns `undefined` only when every git invocation fails (caller treats a
|
* Returns `undefined` only when every git invocation fails (caller treats a
|
||||||
* missing base as non-fatal).
|
* 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(
|
export async function resolveCapturedBaseCommitSha(
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
logger?: { warn: (msg: string) => void },
|
logger?: { warn: (msg: string) => void },
|
||||||
|
integrationBranch: string = "main",
|
||||||
): Promise<string | undefined> {
|
): 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;
|
let baseCommitSha: string | undefined;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(
|
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" },
|
{ cwd: worktreePath, encoding: "utf-8" },
|
||||||
);
|
);
|
||||||
baseCommitSha = stdout.trim() || undefined;
|
baseCommitSha = stdout.trim() || undefined;
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ export type GitMutationType =
|
|||||||
| "worktree:incomplete-detected"
|
| "worktree:incomplete-detected"
|
||||||
| "worktree:reanchored"
|
| "worktree:reanchored"
|
||||||
| "worktree:auto-recovered"
|
| "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:
|
* worktrunk run-audit metadata shape:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ import {
|
|||||||
import type { RunAuditor } from "./run-audit.js";
|
import type { RunAuditor } from "./run-audit.js";
|
||||||
import { writeSecretsEnvFile } from "./secrets-env-writer.js";
|
import { writeSecretsEnvFile } from "./secrets-env-writer.js";
|
||||||
import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.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);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -604,47 +608,184 @@ export interface AcquireWorkspaceRepoWorktreeOptions {
|
|||||||
settings: Partial<Settings>;
|
settings: Partial<Settings>;
|
||||||
logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void };
|
logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void };
|
||||||
secretsStore?: Pick<SecretsStore, "listEnvExportable">;
|
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(
|
export async function acquireWorkspaceRepoWorktree(
|
||||||
opts: AcquireWorkspaceRepoWorktreeOptions,
|
opts: AcquireWorkspaceRepoWorktreeOptions,
|
||||||
): Promise<{ worktreePath: string; branch: string; alreadyAcquired: boolean }> {
|
): Promise<{ worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean }> {
|
||||||
const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore } = opts;
|
const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext } = opts;
|
||||||
|
const registry = opts.registry ?? activeSessionRegistry;
|
||||||
const { join } = await import("node:path");
|
const { join } = await import("node:path");
|
||||||
|
|
||||||
const existing = task.workspaceWorktrees?.[repoRelPath];
|
const existing = task.workspaceWorktrees?.[repoRelPath];
|
||||||
if (existing) {
|
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 };
|
return { ...existing, alreadyAcquired: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoAbsPath = join(workspaceRootDir, repoRelPath);
|
const repoAbsPath = join(workspaceRootDir, repoRelPath);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:WorkspaceWorktree 2026-06-21-19:05:
|
FNXC:Workspace 2026-06-21-20:10:
|
||||||
Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree`
|
Same-sub-repo exclusivity (KTD4): register the sub-repo absolute path in the
|
||||||
is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites
|
path-keyed activeSessionRegistry BEFORE acquiring so two concurrent workspace
|
||||||
those singular fields on the task row after each acquisition. Passing the live task straight
|
tasks contending for the SAME sub-repo are serialized. WorktreePool is a recycle
|
||||||
through means the second repo's acquisition sees the first repo's `task.worktree` (which exists
|
cache, not a cross-task lock, and disjoint-scope contention on one sub-repo is
|
||||||
on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo
|
otherwise unprotected (file-scope leases don't catch it). The entry is keyed by
|
||||||
contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo
|
the sub-repo path with a distinct ownerKey so it does not collide with the
|
||||||
helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in
|
executor's later session registration on the produced worktree path. We release
|
||||||
`task.workspaceWorktrees`, not the singular column.
|
it once acquisition completes (success or failure) — it guards the acquisition
|
||||||
|
critical section, not the whole task lifetime.
|
||||||
*/
|
*/
|
||||||
const result = await acquireTaskWorktree({
|
const exclusivityHolder = registry.lookupByPath(repoAbsPath);
|
||||||
task: { ...task, worktree: undefined, branch: undefined },
|
if (exclusivityHolder && exclusivityHolder.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY && exclusivityHolder.taskId !== task.id) {
|
||||||
rootDir: repoAbsPath,
|
const message = `sub-repo ${repoRelPath} is being acquired by ${exclusivityHolder.taskId}; serializing concurrent workspace acquisition`;
|
||||||
store,
|
logger?.warn(`${task.id}: ${message}`);
|
||||||
settings,
|
await store.logEntry(task.id, message, undefined, runContext);
|
||||||
logger,
|
const err = new WorkspaceRepoAcquireBusyError(repoRelPath, exclusivityHolder.taskId, task.id);
|
||||||
secretsStore,
|
await audit?.git({
|
||||||
runInitCommand: true,
|
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 }> = {
|
try {
|
||||||
...(task.workspaceWorktrees ?? {}),
|
/*
|
||||||
[repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch },
|
FNXC:WorkspaceWorktree 2026-06-21-19:05:
|
||||||
};
|
Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree`
|
||||||
await store.updateTask(task.id, { workspaceWorktrees: updated });
|
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