From 892afaaf0362acb662a24e3c5d421282ccae6bb2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 14 Aug 2026 23:22:05 -0700 Subject: [PATCH] FN-9048: clear disposed workspace state on unarchive Prevent disposed workspace metadata from being revived when archived tasks return. - Reconcile missing workspace and singular worktree paths during archive restore. - Preserve surviving workspace entries and cover archive-to-restore recovery behavior. - Document the restore lifecycle and add a patch changeset. Files changed: .changeset/fn-9048-workspace-unarchive.md | 7 ++ docs/task-management.md | 2 +- .../archive-restore-workspace-worktrees.pg.test.ts | 70 +++++++++++++ .../core/src/task-store/archive-lifecycle-2.ts | 8 +- .../src/task-store/async/async-archive-lineage.ts | 43 ++++++++ .../src/__tests__/self-healing-workspace.test.ts | 111 ++++++++++++++++++++- 6 files changed, 236 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-9048 Fusion-Task-Lineage: fa9b9359-5fab-497f-87ce-0174d8069d99 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9048-workspace-unarchive.md | 7 ++ docs/task-management.md | 2 +- ...ive-restore-workspace-worktrees.pg.test.ts | 70 +++++++++++ .../src/task-store/archive-lifecycle-2.ts | 8 +- .../task-store/async/async-archive-lineage.ts | 43 +++++++ .../__tests__/self-healing-workspace.test.ts | 111 +++++++++++++++++- 6 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-9048-workspace-unarchive.md create mode 100644 packages/core/src/__tests__/postgres/archive-restore-workspace-worktrees.pg.test.ts diff --git a/.changeset/fn-9048-workspace-unarchive.md b/.changeset/fn-9048-workspace-unarchive.md new file mode 100644 index 0000000000..6f5e0cb074 --- /dev/null +++ b/.changeset/fn-9048-workspace-unarchive.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent unarchived workspace tasks from retaining disposed worktree state. +category: fix +dev: restoreTaskFromArchive reconciles disposed workspace entries before reconcileWorkspacePartialLands runs. diff --git a/docs/task-management.md b/docs/task-management.md index 36dfeafff5..41618b5679 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -108,7 +108,7 @@ This layer complements, rather than replaces, FN-4829 similarity detection, FN-4 ### Workspace worktree cleanup on archive -Archiving a workspace (multi-repository) task now synchronously removes every recorded per-sub-repository worktree, including archives initiated by `fn_task_archive` and CLI paths that do not construct an executor. Each path is protected by a per-repository cross-process reservation until backend removal and branch cleanup finish. If one removal fails, its reservation is quarantined and the next acquisition reconciles that orphan; successful sibling repositories are still released. `archiveTask(..., { cleanup: false })` intentionally retains worktrees, and the self-healing workspace sweep remains an idempotent backstop. See [Workspaces](./workspaces.md#archiving-and-cleanup) for the workspace operator lifecycle. +Archiving a workspace (multi-repository) task now synchronously removes every recorded per-sub-repository worktree, including archives initiated by `fn_task_archive` and CLI paths that do not construct an executor. Each path is protected by a per-repository cross-process reservation until backend removal and its `fusion/` branch cleanup finish. If one removal fails, its reservation is quarantined and the next acquisition reconciles that orphan; successful sibling repositories are still released. On unarchive, Fusion reconciles the retained live-row metadata: it drops each per-repository entry whose exact worktree path is gone (including its `landedSha`) and clears a stale singular worktree path. A fully disposed workspace task consequently returns as a non-workspace card that must be re-executed rather than re-landed. `archiveTask(..., { cleanup: false })` intentionally retains worktrees, and the self-healing workspace sweep remains an idempotent backstop. See [Workspaces](./workspaces.md#archiving-and-cleanup) for the workspace operator lifecycle. ### Task-pinned orphan recovery diff --git a/packages/core/src/__tests__/postgres/archive-restore-workspace-worktrees.pg.test.ts b/packages/core/src/__tests__/postgres/archive-restore-workspace-worktrees.pg.test.ts new file mode 100644 index 0000000000..2c2ec49a45 --- /dev/null +++ b/packages/core/src/__tests__/postgres/archive-restore-workspace-worktrees.pg.test.ts @@ -0,0 +1,70 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createSharedPgTaskStoreTestHarness, pgDescribe, type SharedPgTaskStoreHarness } from "../../__test-utils__/pg-test-harness.js"; +import { isWorkspaceTask } from "../../types.js"; + +pgDescribe("archive restore workspace worktrees", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_archive_restore_workspace" }); + let root = ""; + + beforeAll(h.beforeAll); + afterAll(h.afterAll); + beforeEach(async () => { await h.beforeEach(); root = await mkdtemp(join(tmpdir(), "fusion-archive-restore-")); }); + afterEach(async () => { await rm(root, { recursive: true, force: true }); await h.afterEach(); }); + + async function archivedTask(workspaceWorktrees: Record | undefined, worktree?: string) { + const store = h.store(); + const task = await store.createTaskWithReservedId( + { description: "workspace archive restore", column: "in-review" }, + { taskId: `FN-RESTORE-${Date.now()}-${Math.random()}`, applyDefaultWorkflowSteps: false }, + ); + await store.updateTask(task.id, { workspaceWorktrees, worktree } as never); + await store.archiveTask(task.id, { cleanup: false }); + return { store, id: task.id }; + } + + it("drops removed repository entries and stale singular worktree on restore", async () => { + /* + FNXC:ArchiveRestore 2026-08-15-05:39: + Archive cleanup can remove every workspace path after the live row was soft-deleted. Unarchive + must not resurrect those entries or their landedSha values into a false partial-land candidate. + */ + const removedA = join(root, "repo-a"); + const removedB = join(root, "repo-b"); + const { store, id } = await archivedTask({ + "repo-a": { worktreePath: removedA, branch: "fusion/a", landedSha: "landed-a" }, + "repo-b": { worktreePath: removedB, branch: "fusion/b" }, + }, removedA); + + const restored = await store.unarchiveTask(id); + expect(restored.workspaceWorktrees).toBeUndefined(); + expect(restored.worktree).toBeUndefined(); + expect(isWorkspaceTask(restored)).toBe(false); + }); + + it("preserves existing repository entries and landed state while removing disposed siblings", async () => { + const surviving = join(root, "repo-a"); + await mkdir(surviving); + const { store, id } = await archivedTask({ + "repo-a": { worktreePath: surviving, branch: "fusion/a", landedSha: "landed-a" }, + "repo-b": { worktreePath: join(root, "repo-b"), branch: "fusion/b", landedSha: "landed-b" }, + }); + + const restored = await store.unarchiveTask(id); + expect(restored.workspaceWorktrees).toEqual({ + "repo-a": { worktreePath: surviving, branch: "fusion/a", landedSha: "landed-a" }, + }); + expect(isWorkspaceTask(restored)).toBe(true); + }); + + it("is a no-op for absent and empty workspace maps", async () => { + for (const workspaceWorktrees of [undefined, {}]) { + const { store, id } = await archivedTask(workspaceWorktrees); + const restored = await store.unarchiveTask(id); + expect(restored.workspaceWorktrees).toBeUndefined(); + expect(isWorkspaceTask(restored)).toBe(false); + } + }); +}); diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index de37615cd6..a41af8e913 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -741,7 +741,13 @@ export async function restoreFromArchiveImpl(store: TaskStore, entry: import(".. noCommitsExpected: entry.noCommitsExpected, modifiedFiles: entry.modifiedFiles, declaredSymbols: entry.declaredSymbols, - // Intentionally NOT restoring: worktree, status, blockedBy, paused, executionStartBranch, baseCommitSha, error + /* + FNXC:ArchiveRestore 2026-08-15-05:39: + Cold archive entries intentionally omit per-repository worktree and landing state. Reconstructing + either `workspaceWorktrees` or `branch` would revive disposed paths and let the workspace + partial-land reconciler mistake an unarchived card for a recoverable landing. + */ + // Intentionally NOT restoring: worktree, workspaceWorktrees, branch, status, blockedBy, paused, executionStartBranch, baseCommitSha, error }; // Write task.json diff --git a/packages/core/src/task-store/async/async-archive-lineage.ts b/packages/core/src/task-store/async/async-archive-lineage.ts index f71754e5d8..4a5383f76f 100644 --- a/packages/core/src/task-store/async/async-archive-lineage.ts +++ b/packages/core/src/task-store/async/async-archive-lineage.ts @@ -29,6 +29,7 @@ * interface (U4), not the underlying driver. */ import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { access } from "node:fs/promises"; import * as schema from "../../postgres/schema/index.js"; import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js"; import { ACTIVE_TASK_FILTER } from "./async-persistence.js"; @@ -304,6 +305,42 @@ export async function archiveParentTaskWithLineageGate( * @param taskRecord The task fields to re-insert (caller builds from the entry). * @param context Serialization context for the task insert. */ +async function pathExists(path: unknown): Promise { + if (typeof path !== "string" || !path) return false; + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** + * FNXC:TaskStoreArchiveLineage 2026-08-15-05:39: + * Archive disposal removes each workspace worktree and its `fusion/` branch but does not + * persist its in-memory map mutation. Restore therefore drops only entries whose exact recorded + * paths are gone, preventing reconcileWorkspacePartialLands FORK-A from parking the card failed. + */ +async function reconcileRestoredWorktreeState( + workspaceWorktrees: unknown, + worktree: unknown, +): Promise<{ workspaceWorktrees: Record | null; worktree: string | null }> { + const entries = workspaceWorktrees && typeof workspaceWorktrees === "object" && !Array.isArray(workspaceWorktrees) + ? Object.entries(workspaceWorktrees as Record) + : []; + const surviving = await Promise.all(entries.map(async ([repoRel, value]) => { + const worktreePath = value && typeof value === "object" && !Array.isArray(value) + ? (value as { worktreePath?: unknown }).worktreePath + : undefined; + return typeof worktreePath === "string" && await pathExists(worktreePath) ? [repoRel, value] as const : undefined; + })); + const retained = surviving.filter((entry): entry is readonly [string, unknown] => entry !== undefined); + return { + workspaceWorktrees: retained.length > 0 ? Object.fromEntries(retained) : null, + worktree: typeof worktree === "string" && await pathExists(worktree) ? worktree : null, + }; +} + export async function restoreTaskFromArchive( layer: AsyncDataLayer, entry: ArchivedTaskEntry, @@ -322,6 +359,10 @@ export async function restoreTaskFromArchive( // OUTSIDE the txn. const existing = await readTaskRowInTransaction(tx, entry.id, { includeDeleted: true }, layer.projectId); if (existing) { + const reconciledWorktreeState = await reconcileRestoredWorktreeState( + existing.workspaceWorktrees, + existing.worktree, + ); // Row exists (was soft-deleted). Restore it: clear deleted_at, keep // column as "archived" so the caller (unarchiveTaskImpl) can verify the // task is in the archived column and then moveTask it to the target @@ -331,6 +372,8 @@ export async function restoreTaskFromArchive( .update(schema.project.tasks) .set({ deletedAt: null, + workspaceWorktrees: reconciledWorktreeState.workspaceWorktrees, + worktree: reconciledWorktreeState.worktree, /* FNXC:TaskStoreArchiveLineage 2026-08-01-23:23 DELIBERATE-LITERAL — STATE MARKER: Restore exposes the durable row before the caller's validated move out of the archive state. diff --git a/packages/engine/src/__tests__/self-healing-workspace.test.ts b/packages/engine/src/__tests__/self-healing-workspace.test.ts index 578b345eb2..830baf3c84 100644 --- a/packages/engine/src/__tests__/self-healing-workspace.test.ts +++ b/packages/engine/src/__tests__/self-healing-workspace.test.ts @@ -17,12 +17,13 @@ Surfaces (FN-5893): - FORK-A: branch-gone + landedSha-unset → parked failed; branch-gone + landedSha-set → skipped as landed. - regression: a single-repo (non-workspace) task → reconcilers behave identically. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; -import { existsSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; -import type { Settings, Task, TaskStore } from "@fusion/core"; +import { registerArchiveWorkspaceWorktreeDisposer, type Settings, type Task, type TaskStore } from "@fusion/core"; +import { createSharedPgTaskStoreTestHarness, pgDescribe, type SharedPgTaskStoreHarness } from "../../../core/src/__test-utils__/pg-test-harness.js"; import { SelfHealingManager } from "../self-healing.js"; import { classifyBranchProbeError } from "../self-healing-git-evidence.js"; import { activeSessionRegistry, executingTaskLock } from "../agents/active-session-registry.js"; @@ -173,6 +174,91 @@ function workspaceTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Pa } as unknown as Task; } +/* +FNXC:WorkspaceArchiveRestore 2026-08-15-05:55: +The archive-to-unarchive regression below uses the real PostgreSQL restore transaction and the +store-scoped archive disposal seam. Booting Executor would add unrelated session lifecycle work; +the seam is the production boundary that owns removing each sub-repo worktree and branch. +*/ +const pgDescribeIfGit = hasGit ? pgDescribe : describe.skip; + +pgDescribeIfGit("FN-9048 workspace archive restore reaches self-healing cleanly", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_workspace_archive_restore_e2e", + }); + let fx: WorkspaceFixture; + + beforeAll(h.beforeAll); + beforeEach(async () => { + await h.beforeEach(); + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + }); + afterEach(async () => { + fx?.cleanup(); + await h.afterEach(); + }); + afterAll(h.afterAll); + + it("archives, disposes, restores, then skips FORK-A after its stale map is reconciled", async () => { + const store = h.store(); + const id = "FN-9048-RESTORE-E2E"; + const branch = `fusion/${id.toLowerCase()}`; + const workspaceWorktrees: NonNullable = {}; + for (const repoRel of fx.repos) { + const worktreePath = path.join(fx.rootDir, ".worktrees", repoRel); + mkdirSync(path.dirname(worktreePath), { recursive: true }); + fx.git(repoRel, `git worktree add -b ${branch} ${worktreePath} HEAD`); + workspaceWorktrees[repoRel] = { worktreePath, branch }; + } + const task = await store.createTaskWithReservedId( + { description: "archive workspace restore regression", column: "in-review" }, + { taskId: id, applyDefaultWorkflowSteps: false }, + ); + await store.updateTask(id, { workspaceWorktrees, branch: undefined } as never); + const stalePreArchive = (await store.getTask(id))!; + const unregister = registerArchiveWorkspaceWorktreeDisposer(store, async (_task, plan) => { + for (const entry of plan) { + fx.git(entry.repoRel, `git worktree remove --force ${entry.worktreePath}`); + fx.git(entry.repoRel, `git branch -D ${entry.branch}`); + } + return { removed: plan.map((entry) => entry.repoRel), failed: [] }; + }); + + try { + await store.archiveTask(id); + for (const repoRel of fx.repos) { + expect(existsSync(workspaceWorktrees[repoRel]!.worktreePath)).toBe(false); + expect(fx.git(repoRel, `git show-ref --verify --quiet refs/heads/${branch}; echo $?`)).toBe("1"); + } + + /* + FNXC:WorkspaceArchiveRestore 2026-08-15-05:55: + This captures the exact pre-fix resurrection shape: archive removed both branches, but the + soft-deleted row still has the old map and FORK-A proves it is unrecoverable. + */ + const staleStore = createStore([stalePreArchive]); + const staleManager = makeManager(staleStore, fx.rootDir); + await staleManager.reconcileWorkspacePartialLands(); + expect(staleStore.updateTask).toHaveBeenCalledWith(id, expect.objectContaining({ status: "failed" })); + + const restored = await store.unarchiveTask(id); + expect(restored.workspaceWorktrees).toBeUndefined(); + const updateTask = vi.spyOn(store, "updateTask"); + const recordRunAuditEvent = vi.spyOn(store, "recordRunAuditEvent"); + const manager = makeManager(store, fx.rootDir); + + expect(await manager.reconcileWorkspacePartialLands()).toBe(0); + expect(updateTask).not.toHaveBeenCalledWith(id, expect.objectContaining({ status: "failed" })); + expect(recordRunAuditEvent).not.toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "task:reconcile-workspace-partial-land", + metadata: expect.objectContaining({ action: "park-failed" }), + })); + } finally { + unregister(); + } + }); +}); + describeIfGit("workspace-aware self-healing (Phase D U1)", () => { let fx: WorkspaceFixture; beforeEach(() => { @@ -354,6 +440,25 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => { expect(store.enqueued).not.toContain(TASK_ID); }); + it("skips a restored fully-disposed workspace task after restore clears its map", async () => { + /* + FNXC:WorkspaceArchiveRestore 2026-08-15-05:39: + The preceding FORK-A case proves stale entries with deleted branches must fail loudly. Restore + clears that disposed map, so this same two-repository shape is no longer a workspace candidate. + */ + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const restored = workspaceTask(undefined, { worktree: undefined }); + const store = createStore([restored]); + const manager = makeManager(store, fx.rootDir); + + expect(await manager.reconcileWorkspacePartialLands()).toBe(0); + expect(store.updateTask).not.toHaveBeenCalledWith( + TASK_ID, + expect.objectContaining({ status: "failed" }), + ); + expect(store.emitted.some((event) => event.event === "task:reconcile-workspace-partial-land")).toBe(false); + }); + it("classifies only a clean exit-1 branch probe error as absent", () => { expect(classifyBranchProbeError({ code: 1 })).toBe("absent"); for (const error of [