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 ### 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 ### 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, noCommitsExpected: entry.noCommitsExpected,
modifiedFiles: entry.modifiedFiles, modifiedFiles: entry.modifiedFiles,
declaredSymbols: entry.declaredSymbols, 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 // Write task.json

View File

@@ -29,6 +29,7 @@
* interface (U4), not the underlying driver. * interface (U4), not the underlying driver.
*/ */
import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { access } from "node:fs/promises";
import * as schema from "../../postgres/schema/index.js"; import * as schema from "../../postgres/schema/index.js";
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js"; import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js";
import { ACTIVE_TASK_FILTER } from "./async-persistence.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 taskRecord The task fields to re-insert (caller builds from the entry).
* @param context Serialization context for the task insert. * @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( export async function restoreTaskFromArchive(
layer: AsyncDataLayer, layer: AsyncDataLayer,
entry: ArchivedTaskEntry, entry: ArchivedTaskEntry,
@@ -322,6 +359,10 @@ export async function restoreTaskFromArchive(
// OUTSIDE the txn. // OUTSIDE the txn.
const existing = await readTaskRowInTransaction(tx, entry.id, { includeDeleted: true }, layer.projectId); const existing = await readTaskRowInTransaction(tx, entry.id, { includeDeleted: true }, layer.projectId);
if (existing) { if (existing) {
const reconciledWorktreeState = await reconcileRestoredWorktreeState(
existing.workspaceWorktrees,
existing.worktree,
);
// Row exists (was soft-deleted). Restore it: clear deleted_at, keep // Row exists (was soft-deleted). Restore it: clear deleted_at, keep
// column as "archived" so the caller (unarchiveTaskImpl) can verify the // column as "archived" so the caller (unarchiveTaskImpl) can verify the
// task is in the archived column and then moveTask it to the target // 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) .update(schema.project.tasks)
.set({ .set({
deletedAt: null, deletedAt: null,
workspaceWorktrees: reconciledWorktreeState.workspaceWorktrees,
worktree: reconciledWorktreeState.worktree,
/* /*
FNXC:TaskStoreArchiveLineage 2026-08-01-23:23 DELIBERATE-LITERAL — STATE MARKER: 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. 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. - 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. - 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 { EventEmitter } from "node:events";
import { execSync } from "node:child_process"; 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 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 { SelfHealingManager } from "../self-healing.js";
import { classifyBranchProbeError } from "../self-healing-git-evidence.js"; import { classifyBranchProbeError } from "../self-healing-git-evidence.js";
import { activeSessionRegistry, executingTaskLock } from "../agents/active-session-registry.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; } 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)", () => { describeIfGit("workspace-aware self-healing (Phase D U1)", () => {
let fx: WorkspaceFixture; let fx: WorkspaceFixture;
beforeEach(() => { beforeEach(() => {
@@ -354,6 +440,25 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => {
expect(store.enqueued).not.toContain(TASK_ID); 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", () => { it("classifies only a clean exit-1 branch probe error as absent", () => {
expect(classifyBranchProbeError({ code: 1 })).toBe("absent"); expect(classifyBranchProbeError({ code: 1 })).toBe("absent");
for (const error of [ for (const error of [