diff --git a/.changeset/fix-dependency-graph-dashboard-interop.md b/.changeset/fix-dependency-graph-dashboard-interop.md new file mode 100644 index 0000000000..53c69b1dc8 --- /dev/null +++ b/.changeset/fix-dependency-graph-dashboard-interop.md @@ -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. diff --git a/.changeset/fix-workspace-late-acquire-lifecycle.md b/.changeset/fix-workspace-late-acquire-lifecycle.md new file mode 100644 index 0000000000..3f19486dd0 --- /dev/null +++ b/.changeset/fix-workspace-late-acquire-lifecycle.md @@ -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. \ No newline at end of file diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index df369ef887..79fc73c8e0 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -2079,8 +2079,13 @@ export class TaskStore extends EventEmitter { async mergeWorkspaceWorktreeEntry( id: string, repoRelPath: string, - patch: Partial, - options?: { requireExistingEntry?: boolean; clearSingularWorktree?: boolean }, + patch: Partial + | ((current: Task) => Promise>), + options?: { + requireExistingEntry?: boolean; + clearSingularWorktree?: boolean; + validateBeforePersist?: (current: Task) => Promise; + }, ): Promise { return mergeWorkspaceWorktreeEntryImpl(this, id, repoRelPath, patch, options); } diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index f85a64fb92..00817416be 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -529,10 +529,33 @@ export async function mergeWorkspaceWorktreeEntryImpl( store: TaskStore, id: string, repoRelPath: string, - patch: Partial, - options: { requireExistingEntry?: boolean; clearSingularWorktree?: boolean } = {}, + patch: Partial | ((current: Task) => Promise>), + options: { + requireExistingEntry?: boolean; + clearSingularWorktree?: boolean; + validateBeforePersist?: (current: Task) => Promise; + } = {}, ): Promise { return store.withTaskLock(id, async () => { + let resolvedPatch: Partial; + 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, diff --git a/packages/engine/src/__tests__/workspace-add-repo-midflight.test.ts b/packages/engine/src/__tests__/workspace-add-repo-midflight.test.ts index 65404ce52b..02d6d78d2f 100644 --- a/packages/engine/src/__tests__/workspace-add-repo-midflight.test.ts +++ b/packages/engine/src/__tests__/workspace-add-repo-midflight.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts index 7dc04127d5..509610288e 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts @@ -46,12 +46,16 @@ function makeFakeStore( failWhen?: (patch: Partial) => boolean; beforeWorkspaceMerge?: (repoRelPath: string) => Promise; } = {}, -): { store: TaskStore; current: () => Task; logs: string[]; patches: Partial[] } { +): { store: TaskStore; current: () => Task; logs: string[]; patches: Partial[]; mutationsDuringMerge: () => number } { let current = task; + let mergeTail = Promise.resolve(); + let mergeCallbackActive = false; + let nestedMutationCount = 0; const logs: string[] = []; const patches: Partial[] = []; const store = { async updateTask(id: string, patch: Partial): Promise { + 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[string]>, - mergeOptions?: { requireExistingEntry?: boolean; clearSingularWorktree?: boolean }, + patch: + | Partial[string]> + | ((freshTask: Task) => Promise[string]>>), + mergeOptions?: { + requireExistingEntry?: boolean; + clearSingularWorktree?: boolean; + validateBeforePersist?: (freshTask: Task) => Promise; + }, ): Promise { 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 = { - 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((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[string]>; + try { + resolvedPatch = typeof patch === "function" ? await patch(current) : patch; + } finally { + mergeCallbackActive = false; + } + await mergeOptions?.validateBeforePersist?.(current); + const mergedPatch: Partial = { + 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 { + if (mergeCallbackActive) nestedMutationCount += 1; logs.push(message); }, async getTask(id: string): Promise { 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 { auditEvents.push(event); }, + async filesystem(): Promise {}, + }; + + 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 { auditEvents.push(event); }, + async filesystem(): Promise {}, + }, + 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")); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index eb8a6ef9ec..caa3b26d91 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -6447,6 +6447,33 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri }; } +export function isLateAcquireColumnBlocked(workflowIr: fusionCore.WorkflowIr, column: string): boolean { + const blockedColumns = new Set([ + "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 { + 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.` }], diff --git a/packages/engine/src/worktree/worktree-acquisition.ts b/packages/engine/src/worktree/worktree-acquisition.ts index 856a10dc53..68a9166789 100644 --- a/packages/engine/src/worktree/worktree-acquisition.ts +++ b/packages/engine/src/worktree/worktree-acquisition.ts @@ -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; } /* @@ -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 { + 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> | undefined; + const deferredTaskMutations: Array<() => Promise> = []; + 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): Promise => { + deferredTaskMutations.push(() => store.logEntry(...args)); + }; + } + if (property === "updateTask") { + return async (...args: Parameters): Promise => { + 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): Promise => { + 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 => { + 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: diff --git a/packages/engine/src/worktree/worktree-backend.ts b/packages/engine/src/worktree/worktree-backend.ts index 8ec413618a..4ab6abee70 100644 --- a/packages/engine/src/worktree/worktree-backend.ts +++ b/packages/engine/src/worktree/worktree-backend.ts @@ -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.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; diff --git a/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx b/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx index 67b316c70c..9b449cc32a 100644 --- a/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx @@ -166,7 +166,7 @@ export function GraphTaskNode({ {getStatusLabel(task.status)} ) : null} - {}} disableDrag={true} /> + {}} /> ); } diff --git a/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx b/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx index 1775a72ba5..28e75b52ee 100644 --- a/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx @@ -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 }) => ( - + TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: (task: Task) => void }) => ( + ), })); diff --git a/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx b/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx index e1e1ac7780..4b96b2a9cc 100644 --- a/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx @@ -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(
- +
, ); diff --git a/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts b/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts index 499100d807..45fd2d59ab 100644 --- a/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +++ b/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts @@ -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; - 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; }