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:
7
.changeset/fn-9048-workspace-unarchive.md
Normal file
7
.changeset/fn-9048-workspace-unarchive.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user