fix: resolve late-acquire lifecycle and plugin API drift (#3492)

## Summary
- resolves renamed review, complete, and archived workflow columns
before admitting late workspace repositories
- avoids workflow resolution when an existing repository or landing
state already decides the result
- syncs the bundled dependency-graph plugin with the current TaskCard
and scoped-storage APIs

## Test plan
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/workspace-add-repo-midflight.test.ts --silent=passed-only
--reporter=dot`
- `pnpm --filter @fusion/engine typecheck`
- `pnpm --filter @fusion-plugin-examples/dependency-graph test`
- `pnpm --filter @fusion-plugin-examples/dependency-graph build`
- `pnpm check:plugin-interop-drift`
- `pnpm check:lifecycle-columns`
- `pnpm check:changesets`
- `pnpm lint`
- `pnpm build`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Late workspace repository acquisition now respects renamed review,
complete, and archived workflow columns.
* Tasks entering a blocked lifecycle state during acquisition are
refused safely.
* Existing safeguards for merge status, landed worktrees, legacy
terminal identifiers, and existing worktrees remain supported.
* Concurrent acquisition attempts now avoid duplicate worktrees and
preserve task updates reliably.

* **Improvements**
* Improved dependency-graph dashboard interoperability with current
task-card and storage APIs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Phil Larson
2026-08-21 07:48:23 -07:00
committed by GitHub
parent 704d74c9ba
commit 5ec47e58de
13 changed files with 454 additions and 71 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep the bundled dependency-graph plugin aligned with the dashboard TaskCard and scoped-storage APIs.
category: fix
dev: Remove the retired disableDrag prop and mirror the current optional capped-write argument and boolean result.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Respect renamed review and terminal workflow columns when refusing late workspace repository acquisition.
category: fix
dev: Resolve review, complete, and archived membership from the task's selected workflow while retaining legacy fail-safe ids.

View File

@@ -2079,8 +2079,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async mergeWorkspaceWorktreeEntry(
id: string,
repoRelPath: string,
patch: Partial<import("./types.js").WorkspaceWorktreeEntry>,
options?: { requireExistingEntry?: boolean; clearSingularWorktree?: boolean },
patch: Partial<import("./types.js").WorkspaceWorktreeEntry>
| ((current: Task) => Promise<Partial<import("./types.js").WorkspaceWorktreeEntry>>),
options?: {
requireExistingEntry?: boolean;
clearSingularWorktree?: boolean;
validateBeforePersist?: (current: Task) => Promise<void>;
},
): Promise<Task> {
return mergeWorkspaceWorktreeEntryImpl(this, id, repoRelPath, patch, options);
}

View File

@@ -529,10 +529,33 @@ export async function mergeWorkspaceWorktreeEntryImpl(
store: TaskStore,
id: string,
repoRelPath: string,
patch: Partial<WorkspaceWorktreeEntry>,
options: { requireExistingEntry?: boolean; clearSingularWorktree?: boolean } = {},
patch: Partial<WorkspaceWorktreeEntry> | ((current: Task) => Promise<Partial<WorkspaceWorktreeEntry>>),
options: {
requireExistingEntry?: boolean;
clearSingularWorktree?: boolean;
validateBeforePersist?: (current: Task) => Promise<void>;
} = {},
): Promise<Task> {
return store.withTaskLock(id, async () => {
let resolvedPatch: Partial<WorkspaceWorktreeEntry>;
if (typeof patch === "function") {
const callbackTask = await store.getTask(id, { includeDeleted: true });
if (callbackTask.deletedAt) throw new TaskDeletedError(id, callbackTask.deletedAt);
const callbackExisting = callbackTask.workspaceWorktrees?.[repoRelPath];
if (options.requireExistingEntry && !callbackExisting) return callbackTask;
/*
FNXC:WorkspaceWorktree 2026-08-20-07:08:
Filesystem creation, configured init commands, hydration, and secrets materialization must not
occupy a PostgreSQL transaction or one of the small runtime connection pool's sessions. The
in-process task mutex and durable repository-acquisition lease serialize preparation; the short
transaction below revalidates authoritative lifecycle state under the cross-process advisory
lock immediately before persisting the prepared entry.
*/
resolvedPatch = await patch(callbackTask);
} else {
resolvedPatch = patch;
}
const layer = store.asyncLayer!;
const outcome = await layer.transactionImmediate(async (tx) => {
await acquireTaskAdvisoryXactLock(tx, layer.projectId, id);
@@ -544,12 +567,18 @@ export async function mergeWorkspaceWorktreeEntryImpl(
const workspaceWorktrees = current.workspaceWorktrees ?? {};
const existing = workspaceWorktrees[repoRelPath];
if (options.requireExistingEntry && !existing) return { task: current, mutated: false };
await options.validateBeforePersist?.(current);
/*
FNXC:WorkspaceWorktree 2026-08-20-07:08: Persist the prepared entry only after the authoritative
task row passes lifecycle revalidation under the database advisory lock. A cross-process move
that wins during filesystem preparation therefore blocks the row update instead of attaching a
late worktree to review, complete, or archived state.
*/
const updatedAt = new Date().toISOString();
const [updatedRow] = await tx
.update(schema.project.tasks)
.set({
workspaceWorktrees: { ...workspaceWorktrees, [repoRelPath]: { ...existing, ...patch } },
workspaceWorktrees: { ...workspaceWorktrees, [repoRelPath]: { ...existing, ...resolvedPatch } },
...(options.clearSingularWorktree
? {
worktree: null,

View File

@@ -8,9 +8,10 @@ vi.mock("../worktree/worktree-acquisition.js", async (importOriginal) => {
return { ...actual, acquireWorkspaceRepoWorktree: acquisition.acquire };
});
import { createAcquireRepoWorktreeTool } from "../agent-tools.js";
import { createAcquireRepoWorktreeTool, isLateAcquireColumnBlocked } from "../agent-tools.js";
import { buildRunImplementationDeps } from "../executor/deps-bags.js";
import { invalidateWorkspaceConfigCache } from "../executor/workspace-config-resolver.js";
import { lifecycleIr, RENAMED_VOCAB } from "./_workflow-vocabulary-fixture.js";
const fixtures: WorkspaceFixture[] = [];
afterEach(() => {
@@ -30,6 +31,11 @@ function toolFor(currentTask: any, repos: string[], resolveWorkspaceRepos?: () =
task: currentTask,
store: {
getTask: vi.fn(async () => currentTask),
/*
FNXC:RepositoryScope 2026-08-21-05:15:
Acquisition fixtures expose the post-acquire scope mutation seam because successful late admission now persists repository intent.
*/
mutateTaskRepositoryScope: vi.fn(async () => currentTask),
logEntry: vi.fn(async () => undefined),
} as any,
settings: {},
@@ -43,6 +49,20 @@ A late workspace member must be admitted by the same tool instance after its dis
review/landing states instead require a follow-up so the merge loop cannot miss a repository.
*/
describe.runIf(hasGit)("workspace membership acquired mid-flight", () => {
it("refuses renamed review and terminal lifecycle columns", () => {
const workflowIr = lifecycleIr(RENAMED_VOCAB, "workspace-renamed", { mergeOrchestration: true });
if (workflowIr.version !== "v2") throw new Error("expected v2 workflow fixture");
workflowIr.columns.push({ id: "retired", name: "Archived", traits: [{ trait: "archived" }] });
expect(isLateAcquireColumnBlocked(workflowIr, RENAMED_VOCAB.wip)).toBe(false);
expect(isLateAcquireColumnBlocked(workflowIr, RENAMED_VOCAB.review)).toBe(true);
expect(isLateAcquireColumnBlocked(workflowIr, RENAMED_VOCAB.complete)).toBe(true);
expect(isLateAcquireColumnBlocked(workflowIr, "retired")).toBe(true);
expect(isLateAcquireColumnBlocked(workflowIr, "in-review")).toBe(true);
expect(isLateAcquireColumnBlocked(workflowIr, "done")).toBe(true);
expect(isLateAcquireColumnBlocked(workflowIr, "archived")).toBe(true);
});
it("refreshes a running host from disk and admits the newly added repository", async () => {
const fixture = await createWorkspaceFixture(["repo-a", "repo-b"]);
fixtures.push(fixture);
@@ -112,6 +132,21 @@ describe.runIf(hasGit)("workspace membership acquired mid-flight", () => {
await expect(acquire.execute("call", { repo: "repo-b" } as never)).resolves.not.toMatchObject({ isError: true });
});
it("revalidates lifecycle inside the acquisition critical section", async () => {
const currentTask = task();
acquisition.acquire.mockImplementation(async (options: any) => {
currentTask.column = "in-review";
await options.validateTaskBeforeCreate?.(currentTask);
return { worktreePath: "/worktrees/repo-b", branch: "fusion/FN-9163", alreadyAcquired: false };
});
const acquire = toolFor(currentTask, ["repo-a", "repo-b"]);
const refused = await acquire.execute("call", { repo: "repo-b" } as never);
expect(refused).toMatchObject({ isError: true });
expect(refused.content[0]?.text).toContain("follow-up task");
});
it("retains the prior host snapshot for empty disk membership", async () => {
const fixture = await createWorkspaceFixture(["repo-a"]);
fixtures.push(fixture);

View File

@@ -46,12 +46,16 @@ function makeFakeStore(
failWhen?: (patch: Partial<Task>) => boolean;
beforeWorkspaceMerge?: (repoRelPath: string) => Promise<void>;
} = {},
): { store: TaskStore; current: () => Task; logs: string[]; patches: Partial<Task>[] } {
): { store: TaskStore; current: () => Task; logs: string[]; patches: Partial<Task>[]; mutationsDuringMerge: () => number } {
let current = task;
let mergeTail = Promise.resolve();
let mergeCallbackActive = false;
let nestedMutationCount = 0;
const logs: string[] = [];
const patches: Partial<Task>[] = [];
const store = {
async updateTask(id: string, patch: Partial<Task>): Promise<void> {
if (mergeCallbackActive) nestedMutationCount += 1;
// Deliberately retain wholesale replacement: the concurrent regression below must fail
// if production returns to updateTask({ workspaceWorktrees }) instead of the key merge.
patches.push(patch);
@@ -61,32 +65,56 @@ function makeFakeStore(
async mergeWorkspaceWorktreeEntry(
id: string,
repoRelPath: string,
patch: Partial<NonNullable<Task["workspaceWorktrees"]>[string]>,
mergeOptions?: { requireExistingEntry?: boolean; clearSingularWorktree?: boolean },
patch:
| Partial<NonNullable<Task["workspaceWorktrees"]>[string]>
| ((freshTask: Task) => Promise<Partial<NonNullable<Task["workspaceWorktrees"]>[string]>>),
mergeOptions?: {
requireExistingEntry?: boolean;
clearSingularWorktree?: boolean;
validateBeforePersist?: (freshTask: Task) => Promise<void>;
},
): Promise<Task> {
if (id !== current.id) throw new Error(`Task ${id} not found`);
await options.beforeWorkspaceMerge?.(repoRelPath);
// Read only after the deterministic gate: this mirrors the store primitive's locked fresh read.
const workspaceWorktrees = current.workspaceWorktrees ?? {};
const existing = workspaceWorktrees[repoRelPath];
if (mergeOptions?.requireExistingEntry && !existing) return current;
const mergedPatch: Partial<Task> = {
workspaceWorktrees: { ...workspaceWorktrees, [repoRelPath]: { ...existing, ...patch } },
...(mergeOptions?.clearSingularWorktree ? { worktree: undefined, branch: undefined } : {}),
};
patches.push(mergedPatch);
if (options.failWhen?.(mergedPatch)) throw new Error("injected update failure");
current = { ...current, ...mergedPatch };
return current;
// Mirror TaskStore.withTaskLock after the deterministic overlap gate so both contenders
// can arrive, then serialize the fresh read + callback + key merge exactly like production.
const previousMerge = mergeTail;
let releaseMerge!: () => void;
mergeTail = new Promise<void>((resolve) => { releaseMerge = resolve; });
await previousMerge;
try {
const workspaceWorktrees = current.workspaceWorktrees ?? {};
const existing = workspaceWorktrees[repoRelPath];
if (mergeOptions?.requireExistingEntry && !existing) return current;
mergeCallbackActive = true;
let resolvedPatch: Partial<NonNullable<Task["workspaceWorktrees"]>[string]>;
try {
resolvedPatch = typeof patch === "function" ? await patch(current) : patch;
} finally {
mergeCallbackActive = false;
}
await mergeOptions?.validateBeforePersist?.(current);
const mergedPatch: Partial<Task> = {
workspaceWorktrees: { ...workspaceWorktrees, [repoRelPath]: { ...existing, ...resolvedPatch } },
...(mergeOptions?.clearSingularWorktree ? { worktree: undefined, branch: undefined } : {}),
};
patches.push(mergedPatch);
if (options.failWhen?.(mergedPatch)) throw new Error("injected update failure");
current = { ...current, ...mergedPatch };
return current;
} finally {
releaseMerge();
}
},
async logEntry(_id: string, message: string): Promise<void> {
if (mergeCallbackActive) nestedMutationCount += 1;
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, patches };
return { store, current: () => current, logs, patches, mutationsDuringMerge: () => nestedMutationCount };
}
function makeTask(id: string): Task {
@@ -332,6 +360,110 @@ describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout:
expect(registry.isPathActive(fixture.repoPath("repo-a"))).toBe(false);
});
it("defers task mutations until the lifecycle-locked workspace merge callback has returned", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const { store, current, logs, mutationsDuringMerge } = makeFakeStore(makeTask("FN-4-lock"));
await acquireWorkspaceRepoWorktree({
repoRelPath: "repo-a",
workspaceRootDir: fixture.rootDir,
task: current(),
store,
settings: SETTINGS,
registry: new ActiveSessionRegistry(),
});
expect(mutationsDuringMerge()).toBe(0);
expect(logs.some((message) => message.includes("Worktree created at"))).toBe(true);
});
it("creates one durable worktree when the same task acquires the same repository concurrently", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const initial = makeTask("FN-4-concurrent");
const { store, current } = makeFakeStore(initial);
const registry = new ActiveSessionRegistry();
const auditEvents: Array<{ type: string }> = [];
const audit = {
async git(event: { type: string }): Promise<void> { auditEvents.push(event); },
async filesystem(): Promise<void> {},
};
const [first, second] = await Promise.all([
acquireWorkspaceRepoWorktree({
repoRelPath: "repo-a", workspaceRootDir: fixture.rootDir, task: initial, store,
settings: SETTINGS, registry, audit,
}),
acquireWorkspaceRepoWorktree({
repoRelPath: "repo-a", workspaceRootDir: fixture.rootDir, task: initial, store,
settings: SETTINGS, registry, audit,
}),
]);
expect([first.alreadyAcquired, second.alreadyAcquired].sort()).toEqual([false, true]);
expect(first.worktreePath).toBe(second.worktreePath);
expect(Object.keys(current().workspaceWorktrees ?? {})).toEqual(["repo-a"]);
expect(auditEvents.filter((event) => event.type === "worktree:create")).toHaveLength(1);
});
it("replaces a concurrently persisted directory that is not a usable git worktree", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const initial = makeTask("FN-4-stale-concurrent");
let store!: TaskStore;
const fake = makeFakeStore(initial, {
beforeWorkspaceMerge: async () => {
await store.updateTask(initial.id, {
workspaceWorktrees: {
"repo-a": { worktreePath: fixture.rootDir, branch: "fusion/stale-directory" },
},
});
},
});
store = fake.store;
const result = await acquireWorkspaceRepoWorktree({
repoRelPath: "repo-a",
workspaceRootDir: fixture.rootDir,
task: initial,
store,
settings: SETTINGS,
registry: new ActiveSessionRegistry(),
});
expect(result.alreadyAcquired).toBe(false);
expect(result.worktreePath).not.toBe(fixture.rootDir);
expect(fake.current().workspaceWorktrees?.["repo-a"]?.worktreePath).toBe(result.worktreePath);
});
it("removes a prepared worktree when authoritative pre-persist validation rejects it", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const { store, current } = makeFakeStore(makeTask("FN-4-rejected-persist"));
const auditEvents: Array<{ type: string; target?: string }> = [];
let validationCalls = 0;
await expect(acquireWorkspaceRepoWorktree({
repoRelPath: "repo-a",
workspaceRootDir: fixture.rootDir,
task: current(),
store,
settings: SETTINGS,
registry: new ActiveSessionRegistry(),
audit: {
async git(event: { type: string; target?: string }): Promise<void> { auditEvents.push(event); },
async filesystem(): Promise<void> {},
},
validateTaskBeforeCreate: async () => {
validationCalls += 1;
if (validationCalls === 2) throw new Error("lifecycle moved before persistence");
},
})).rejects.toThrow("lifecycle moved before persistence");
const createdPath = auditEvents.find((event) => event.type === "worktree:create")?.target;
expect(validationCalls).toBe(2);
expect(createdPath).toBeTruthy();
expect(existsSync(createdPath!)).toBe(false);
expect(current().workspaceWorktrees?.["repo-a"]).toBeUndefined();
});
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"));

View File

@@ -6447,6 +6447,33 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri
};
}
export function isLateAcquireColumnBlocked(workflowIr: fusionCore.WorkflowIr, column: string): boolean {
const blockedColumns = new Set<string>([
"in-review",
"done",
"archived",
...fusionCore.resolveReviewColumns(workflowIr),
...fusionCore.columnsWithFlag(workflowIr, "complete"),
...fusionCore.columnsWithFlag(workflowIr, "archived"),
]);
return blockedColumns.has(column);
}
class LateWorkspaceRepoAcquireError extends Error {
constructor(public readonly repo: string) {
super(`Cannot acquire new repository ${repo} after review or landing has started`);
this.name = "LateWorkspaceRepoAcquireError";
}
}
async function isWorkspaceRepoLateAcquireBlocked(store: TaskStore, currentTask: import("@fusion/core").Task, repo: string): Promise<boolean> {
if (currentTask.workspaceWorktrees?.[repo]) return false;
if (["merging", "merging-pr", "merging-fix"].includes(currentTask.status ?? "")) return true;
if (Object.values(currentTask.workspaceWorktrees ?? {}).some((entry) => Boolean(entry.landedSha))) return true;
const workflowIr = await fusionCore.resolveWorkflowIrForTask(store, currentTask.id);
return isLateAcquireColumnBlocked(workflowIr, currentTask.column);
}
export function createAcquireRepoWorktreeTool(opts: {
workspaceRootDir: string;
workspaceRepos: string[];
@@ -6499,15 +6526,22 @@ export function createAcquireRepoWorktreeTool(opts: {
isError: true,
};
}
const existing = freshTask.workspaceWorktrees?.[repo];
const lateAcquireBlocked = freshTask.column === "in-review" || freshTask.column === "done" || freshTask.column === "archived" || ["merging", "merging-pr", "merging-fix"].includes(freshTask.status ?? "") || Object.values(freshTask.workspaceWorktrees ?? {}).some((entry) => Boolean(entry.landedSha));
if (!existing && lateAcquireBlocked) {
const refuseLateAcquisition = async () => {
await store.logEntry(task.id, `fn_acquire_repo_worktree: refused late acquisition of ${repo}; task is already in review or landing`, undefined, runContext);
return {
content: [{ type: "text" as const, text: `ERROR: Cannot acquire new repository "${repo}" after review or landing has started. Create a follow-up task with fn_task_create for this repository.` }],
details: {},
isError: true,
};
};
/*
FNXC:WorkflowResolvedColumns 2026-08-20-04:35:
A renamed review/terminal lane must close late repository admission exactly like the built-in
`in-review`/`done`/`archived` lanes. Resolve membership from the task's own workflow while
retaining the legacy ids as a fail-safe for malformed or partially migrated task state.
*/
if (await isWorkspaceRepoLateAcquireBlocked(store, freshTask, repo)) {
return refuseLateAcquisition();
}
/*
FNXC:Workspace 2026-06-21-22:30:
@@ -6532,8 +6566,16 @@ export function createAcquireRepoWorktreeTool(opts: {
runContext,
runConfiguredCommand,
taskEnv,
validateTaskBeforeCreate: async (latestTask) => {
if (await isWorkspaceRepoLateAcquireBlocked(store, latestTask, repo)) {
throw new LateWorkspaceRepoAcquireError(repo);
}
},
});
} catch (err) {
if (err instanceof LateWorkspaceRepoAcquireError) {
return refuseLateAcquisition();
}
if (err instanceof WorkspaceRepoAcquireBusyError) {
return {
content: [{ type: "text" as const, text: `Sub-repo ${repo} is temporarily locked by another task's acquisition; retry fn_acquire_repo_worktree shortly.` }],

View File

@@ -1408,6 +1408,12 @@ export interface AcquireWorkspaceRepoWorktreeOptions {
registry?: ActiveSessionRegistry;
runConfiguredCommand?: AcquireTaskWorktreeOptions["runConfiguredCommand"];
taskEnv?: NodeJS.ProcessEnv;
/**
* FNXC:WorkspaceWorktree 2026-08-20-06:26:34: Revalidate caller-owned admission policy immediately
* before worktree creation. The callback runs under the TaskStore's local mutex and durable task
* advisory transaction lock.
*/
validateTaskBeforeCreate?: (freshTask: Task) => Promise<void>;
}
/*
@@ -1420,6 +1426,21 @@ function assertInRootRepoRelPath(repoRelPath: string): void {
assertWorkspaceRepoRelPath(repoRelPath);
}
/*
FNXC:WorkspaceWorktree 2026-08-20-07:02:
Both the caller snapshot and the task-locked re-read must apply the same remembered-worktree
liveness contract. Path existence alone is insufficient because a pruned or interrupted checkout
can leave a directory that is no longer a registered, usable git worktree.
*/
async function isRememberedWorkspaceWorktreeLive(repoRootDir: string, worktreePath: string): Promise<boolean> {
if (!existsSync(worktreePath)) return false;
try {
return (await classifyTaskWorktree(repoRootDir, worktreePath)).ok;
} catch {
return false;
}
}
/*
FNXC:Workspace 2026-06-21-20:10:
Acquisition-time exclusivity owner key for the same-sub-repo lock (U2/KTD4). The
@@ -1432,7 +1453,7 @@ const WORKSPACE_REPO_ACQUIRE_OWNER_KEY = "workspace-repo-acquire";
export async function acquireWorkspaceRepoWorktree(
opts: AcquireWorkspaceRepoWorktreeOptions,
): Promise<{ worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean }> {
const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext, runConfiguredCommand, taskEnv } = opts;
const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext, runConfiguredCommand, taskEnv, validateTaskBeforeCreate } = opts;
const registry = opts.registry ?? activeSessionRegistry;
const { join } = await import("node:path");
@@ -1458,15 +1479,7 @@ export async function acquireWorkspaceRepoWorktree(
returns the persisted entry verbatim — no second identity-guard install, no
re-capture of the base SHA, no second exclusivity registration.
*/
let live = existsSync(existing.worktreePath);
if (live) {
try {
const classification = await classifyTaskWorktree(repoAbsPath, existing.worktreePath);
live = classification.ok;
} catch {
live = false;
}
}
const live = await isRememberedWorkspaceWorktreeLive(repoAbsPath, existing.worktreePath);
if (live) {
return { ...existing, alreadyAcquired: true };
}
@@ -1594,32 +1607,96 @@ export async function acquireWorkspaceRepoWorktree(
sibling absent from this sub-repo. Resolve task.baseBranch per repo instead, then overwrite the
copied task's start point so acquireTaskWorktree never forwards that sibling ref to git worktree add.
*/
const baseResolution = await resolveWorkspaceRepoBaseBranch({
let acquisitionResult: { worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean } | undefined;
let baseResolution: Awaited<ReturnType<typeof resolveWorkspaceRepoBaseBranch>> | undefined;
const deferredTaskMutations: Array<() => Promise<unknown>> = [];
let mergeError: unknown;
try {
await store.mergeWorkspaceWorktreeEntry(
task.id,
repoRelPath,
async (freshTask) => {
/*
FNXC:WorkspaceWorktree 2026-08-20-06:26:34: The per-repository lock is already held.
mergeWorkspaceWorktreeEntry adds the TaskStore's in-process mutex and PostgreSQL task
advisory transaction lock, the same lock lifecycle moves use. Revalidate after both locks
are held, then keep them through creation and persistence. The acquisition helper normally
logs through TaskStore and may request metadata cleanup; defer those lock-taking mutations
until this callback releases the non-reentrant task lock.
*/
const callbackStore = new Proxy(store, {
get(target, property) {
if (property === "logEntry") {
return async (...args: Parameters<TaskStore["logEntry"]>): Promise<void> => {
deferredTaskMutations.push(() => store.logEntry(...args));
};
}
if (property === "updateTask") {
return async (...args: Parameters<TaskStore["updateTask"]>): Promise<void> => {
const [id, patch] = args;
if (id !== task.id) throw new Error(`Workspace acquisition attempted to mutate unexpected task ${id}`);
deferredTaskMutations.push(() => store.updateTask(...args));
Object.assign(freshTask, patch);
};
}
if (property === "pauseTask") {
return async (...args: Parameters<TaskStore["pauseTask"]>): Promise<Task> => {
const [id, paused] = args;
if (id !== task.id) throw new Error(`Workspace acquisition attempted to pause unexpected task ${id}`);
deferredTaskMutations.push(() => store.pauseTask(...args));
Object.assign(freshTask, { paused });
return freshTask;
};
}
const value = Reflect.get(target, property, target);
if (typeof value !== "function") return value;
if (property === "getTask") return value.bind(target);
/*
FNXC:WorkspaceWorktree 2026-08-20-07:02:
The acquisition callback holds a non-reentrant task lock. Forwarding a newly added
TaskStore method by default could silently reintroduce same-task deadlock, so only the
audited read seam is forwarded and every other method fails closed until classified.
*/
return async (): Promise<never> => {
throw new Error(`Workspace acquisition cannot call unsupported TaskStore method ${String(property)} while the task lock is held`);
};
},
}) as TaskStore;
const concurrentExisting = freshTask.workspaceWorktrees?.[repoRelPath];
if (concurrentExisting && await isRememberedWorkspaceWorktreeLive(repoAbsPath, concurrentExisting.worktreePath)) {
acquisitionResult = { ...concurrentExisting, alreadyAcquired: true };
return concurrentExisting;
}
await validateTaskBeforeCreate?.(freshTask);
const resolvedBase = await resolveWorkspaceRepoBaseBranch({
mode: "acquire",
repoRootDir: repoAbsPath,
repoRelPath,
task,
task: freshTask,
settings,
logger,
});
baseResolution = resolvedBase;
/*
FNXC:WorkspaceBranches 2026-08-20-03:38:
FN-9161 uses one explicit operator branch in every workspace repository.
Keep only that branch through the singular-worktree isolation copy; derived
and canonical assignments retain the existing per-repository behavior.
*/
const workspaceWorkingBranch = resolveTaskWorkingBranchWithOrigin(task);
const workspaceWorkingBranch = resolveTaskWorkingBranchWithOrigin(freshTask);
const result = await acquireTaskWorktree({
task: {
...task,
...freshTask,
worktree: undefined,
branch: workspaceWorkingBranch.origin === "operator-supplied" ? workspaceWorkingBranch.branch : undefined,
executionStartBranch: baseResolution.branch,
executionStartBranch: resolvedBase.branch,
},
suppressSingularWorktreePersist: true,
workspaceContext: { workspaceRootDir, repoRelPath },
rootDir: repoAbsPath,
store,
store: callbackStore,
// FNXC:Workspace 2026-07-07-08:40 (FN-7360 regression — strip shared branch overrides for per-repo start-point):
// FN-7360 pinned fresh task worktree creation to `resolveIntegrationBranch(rootDir, settings)`
// when no executionStartBranch is present, so new branches never inherit an ambient root HEAD.
@@ -1681,7 +1758,7 @@ export async function acquireWorkspaceRepoWorktree(
// and re-escalate this deliberately NON-FATAL step into a fatal acquisition error,
// stranding the already-created worktree. Suppress observability failures via safeObserve.
await safeObserve(async () => {
await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext);
await callbackStore.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext);
await audit?.git({
type: "worktree:workspace-repo-acquire-failed",
target: repoAbsPath,
@@ -1698,7 +1775,7 @@ export async function acquireWorkspaceRepoWorktree(
*/
let baseCommitSha: string | undefined;
try {
baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, baseResolution.branch);
baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, resolvedBase.branch);
} catch (baseErr) {
// FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state.
// FNXC:Workspace 2026-06-22-00:00: guard the best-effort logEntry/audit so a logging throw cannot promote this
@@ -1708,7 +1785,7 @@ export async function acquireWorkspaceRepoWorktree(
// FNXC:Workspace 2026-06-22-09:00: same non-fatal contract as the identity-guard catch —
// the awaited observability writes must not re-escalate a non-fatal base-capture failure.
await safeObserve(async () => {
await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext);
await callbackStore.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext);
await audit?.git({
type: "worktree:workspace-repo-acquire-failed",
target: repoAbsPath,
@@ -1732,22 +1809,64 @@ export async function acquireWorkspaceRepoWorktree(
never makes dashboard workspace rendering, self-healing, or executor dispatch read it as
single-repo.
*/
await store.mergeWorkspaceWorktreeEntry(
task.id,
repoRelPath,
{
worktreePath: result.worktreePath,
branch: result.branch,
baseCommitSha,
...(baseResolution.requested
? {
baseBranch: baseResolution.branch,
...(baseResolution.fallbackReason ? { baseBranchFallbackFrom: baseResolution.requested } : {}),
}
: {}),
},
{ clearSingularWorktree: true },
);
acquisitionResult = {
worktreePath: result.worktreePath,
branch: result.branch,
baseCommitSha,
alreadyAcquired: false,
};
return {
worktreePath: result.worktreePath,
branch: result.branch,
baseCommitSha,
...(resolvedBase.requested
? {
baseBranch: resolvedBase.branch,
...(resolvedBase.fallbackReason ? { baseBranchFallbackFrom: resolvedBase.requested } : {}),
}
: {}),
};
},
{ clearSingularWorktree: true, validateBeforePersist: validateTaskBeforeCreate },
);
} catch (error) {
mergeError = error;
}
let deferredMutationError: unknown;
try {
for (const mutation of deferredTaskMutations) await mutation();
} catch (error) {
deferredMutationError = error;
}
if (mergeError) {
/*
FNXC:WorkspaceWorktree 2026-08-20-07:08:
A cross-process lifecycle move can win while filesystem preparation runs outside the database
transaction. If authoritative pre-persist validation then refuses the row update, remove only
the newly created, not-yet-published worktree so the rejected acquisition cannot leave an orphan.
*/
if (acquisitionResult && !acquisitionResult.alreadyAcquired) {
await removeWorktree({
rootDir: repoAbsPath,
worktreePath: acquisitionResult.worktreePath,
settings,
reason: RemovalReason.WorkspaceAcquireRollback,
taskId: task.id,
force: true,
}).catch((cleanupError: unknown) => {
logger?.warn(`${task.id}: failed to roll back rejected workspace acquisition at ${acquisitionResult?.worktreePath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
});
}
throw mergeError;
}
if (deferredMutationError) throw deferredMutationError;
if (!acquisitionResult) {
throw new Error(`Workspace sub-repo acquisition for ${repoRelPath} completed without a durable result`);
}
if (acquisitionResult.alreadyAcquired) return acquisitionResult;
if (!baseResolution) {
throw new Error(`Workspace sub-repo acquisition for ${repoRelPath} completed without a base resolution`);
}
await recordWorkspaceBaseBranchDecision({
store,
audit,
@@ -1759,7 +1878,7 @@ export async function acquireWorkspaceRepoWorktree(
runContext,
});
return { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha, alreadyAcquired: false };
return acquisitionResult;
} catch (err) {
/*
FNXC:Workspace 2026-06-21-20:10:

View File

@@ -1059,6 +1059,7 @@ export const RemovalReason = {
SelfHealingIdleSweep: "self-healing-idle-sweep",
PoolPrune: "pool-prune",
TaskReset: "task-reset",
WorkspaceAcquireRollback: "workspace-acquire-rollback",
} as const;
export type RemovalReason = typeof RemovalReason[keyof typeof RemovalReason];
@@ -1068,6 +1069,7 @@ const ALLOWED_FORCE_REASONS = new Set<RemovalReason>([
RemovalReason.ExecutorDispose,
RemovalReason.ExecutorTransientRetry,
RemovalReason.ExecutorStuckKilled,
RemovalReason.WorkspaceAcquireRollback,
]);
export class InvalidForceUsageError extends Error {
@@ -1091,8 +1093,9 @@ export class ActiveSessionWorktreeRemovalError extends Error {
}
/**
* Remove a worktree via configured backend.
* Only executor-owned hard-cancel/dispose paths may use force=true.
* FNXC:WorkspaceWorktree 2026-08-20-07:08:
* Force removal is reserved for explicit executor teardown paths and workspace-acquisition rollback
* before the rejected checkout has been published to task state.
*/
export async function removeWorktree(input: {
worktreePath: string;

View File

@@ -166,7 +166,7 @@ export function GraphTaskNode({
<span className="graph-task-active-indicator-text">{getStatusLabel(task.status)}</span>
</div>
) : null}
<TaskCard {...taskCardProps} taskColumnFlags={taskColumnFlags} onOpenDetail={() => {}} disableDrag={true} />
<TaskCard {...taskCardProps} taskColumnFlags={taskColumnFlags} onOpenDetail={() => {}} />
</div>
);
}

View File

@@ -22,8 +22,8 @@ let cssStyleElement: HTMLStyleElement | null = null;
const dependencyGraphCss = readFileSync("src/DependencyGraph.css", "utf8");
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
<button data-testid={`task-${task.id}`} draggable={!disableDrag} onClick={() => onOpenDetail(task)}>{task.id}</button>
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: (task: Task) => void }) => (
<button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>{task.id}</button>
),
}));

View File

@@ -67,7 +67,7 @@ describe("GraphTaskNode", () => {
expect(node).toBeTruthy();
expect(container.querySelector(".card-title")?.textContent).toContain("Task description");
expect(node.getAttribute("draggable")).toBe("false");
expect(container.querySelector(".card")?.getAttribute("draggable")).toBe("false");
expect(container.querySelector(".card")?.getAttribute("draggable")).toBeNull();
});
it("shows active indicator with capitalized status for in-progress executing tasks", () => {
@@ -350,7 +350,7 @@ describe("GraphTaskNode", () => {
const { container } = render(
<div>
<TaskCard {...props} disableDrag={true} />
<TaskCard {...props} />
<GraphTaskNode {...props} />
</div>,
);

View File

@@ -49,7 +49,6 @@ declare module "@fusion/dashboard/app/components/TaskCard" {
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: the prop the host card already accepts; without it
declared here a plugin-drawn card could not be given the board's traits at all. */
taskColumnFlags?: Partial<TraitFlags>;
disableDrag?: boolean;
}
export function TaskCard(props: TaskCardProps): ReactElement;
@@ -57,6 +56,11 @@ declare module "@fusion/dashboard/app/components/TaskCard" {
declare module "@fusion/dashboard/app/utils/projectStorage" {
export function getScopedItem(baseKey: string, projectId?: string): string | null;
export function setScopedItem(baseKey: string, value: string, projectId?: string): void;
export function setScopedItem(
baseKey: string,
value: string,
projectId?: string,
options?: { maxBytes?: number },
): boolean;
export function removeScopedItem(baseKey: string, projectId?: string): void;
}