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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-14 23:22:05 -07:00
parent db8e715452
commit 892afaaf03
6 changed files with 236 additions and 5 deletions

View File

@@ -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.

View File

@@ -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/<task-id>` 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

View File

@@ -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<string, unknown> | 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);
}
});
});

View File

@@ -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

View File

@@ -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<boolean> {
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/<id>` 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<string, unknown> | null; worktree: string | null }> {
const entries = workspaceWorktrees && typeof workspaceWorktrees === "object" && !Array.isArray(workspaceWorktrees)
? Object.entries(workspaceWorktrees as Record<string, unknown>)
: [];
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.

View File

@@ -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<Task["workspaceWorktrees"]> = {};
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 [