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:
7
.changeset/fix-dependency-graph-dashboard-interop.md
Normal file
7
.changeset/fix-dependency-graph-dashboard-interop.md
Normal 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.
|
||||
7
.changeset/fix-workspace-late-acquire-lifecycle.md
Normal file
7
.changeset/fix-workspace-late-acquire-lifecycle.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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.` }],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user