FN-9055: guard archive disposal of live workspace worktrees

Prevent archive cleanup from removing worktrees that still belong to live tasks.

- Add shared archive liveness evaluation and transactional refusal guards.
- Require CLI and extension archive operations to refuse live tasks unless a human uses --force.
- Protect baseline archive worktree disposal and cover CLI, core, and engine paths with tests.

Files changed:
 .changeset/fn-9055-archive-live-worktree-guard.md  |  7 ++
 docs/cli-reference.md                              |  4 +-
 docs/task-management.md                            |  4 +-
 .../extension-task-archive-live-guard.test.ts      | 82 +++++++++++++++++++
 packages/cli/src/bin.ts                            |  6 +-
 packages/cli/src/commands/__tests__/task.test.ts   | 66 ++++++++++++++-
 packages/cli/src/commands/task.ts                  | 49 +++++++++---
 packages/cli/src/extension.ts                      | 25 ++++--
 .../src/__tests__/archive-live-task-guard.test.ts  | 70 ++++++++++++++++
 .../postgres/archive-live-task-fence.pg.test.ts    | 69 ++++++++++++++++
 .../src/__tests__/task-archive-liveness.test.ts    | 19 +++++
 packages/core/src/index.ts                         | 10 +++
 packages/core/src/store.ts                         |  4 +-
 .../core/src/task-store/archive-lifecycle-2.ts     | 27 +++++--
 packages/core/src/task-store/archive-lifecycle.ts  | 20 ++---
 .../src/task-store/async/async-archive-lineage.ts  | 17 +++-
 packages/core/src/tasks/task-archive-liveness.ts   | 56 +++++++++++++
 ...chive-baseline-disposer-live-task-guard.test.ts | 93 ++++++++++++++++++++++
 .../healing/archive-worktree-disposer-install.ts   | 15 +++-
 19 files changed, 592 insertions(+), 51 deletions(-)

Fusion-Task-Id: FN-9055

Fusion-Task-Lineage: 08644626-cc61-4854-abb9-5b415ddc9491

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 00:35:12 -07:00
parent 6adcab3b0e
commit c7779e44ac
19 changed files with 592 additions and 51 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Protect live task worktrees from CLI and agent archive cleanup.
category: fix
dev: Adds core archive liveness and advisory-lock fencing, baseline disposer refusal and cleanup suppression, CLI --force, and structured fn_task_archive refusal.

View File

@@ -814,12 +814,14 @@ fn task merge FN-001
fn task duplicate FN-001
fn task refine FN-001 --feedback "Add rollback handling"
fn task archive FN-001
fn task archive FN-001 --force
fn task unarchive FN-001
fn task delete FN-001 --force
```
Notes:
- `fn task archive` accepts any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) and preserves the original column for restore.
- `fn task archive` accepts live-board tasks and preserves the original column for restore. It refuses tasks in a WIP lane or active merge pipeline to protect another process's worktrees; a human operator may use `--force` to override this destructive guard.
- The agent-facing `fn_task_archive` tool returns a structured error for the same live-task refusal and deliberately has no force parameter.
- `fn task unarchive` restores to the saved pre-archive column when available, with legacy archives falling back to `done`.
### Branch conflict handling

View File

@@ -369,7 +369,7 @@ Auto-completion/finalization remains owned by existing recovery passes:
### Archive worktree cleanup
Archiving a single-repository task synchronously removes its git worktree before branch and task-metadata cleanup. This applies to random and pinned (`task-id`/`task-title`) names, including `fn_task_archive` and direct CLI archive commands that run without an executor. A host-scoped filesystem reservation serializes a successor's deterministic-path acquisition with archival disposal; if removal fails, the reservation is quarantined so the next acquisition can reconcile the orphan instead of colliding with it. `archive({ cleanup: false })` intentionally retains the worktree. Workspace tasks' per-repository `workspaceWorktrees` are not removed by this lifecycle yet.
Archiving a single-repository task synchronously removes its git worktree before branch and task-metadata cleanup. CLI and extension archive requests fence live execution under the per-task advisory transaction lock: a WIP-lane or active-merge refusal performs no archive write and suppresses all cleanup (worktrees, branches, and task directory). This applies to random and pinned (`task-id`/`task-title`) names, including `fn_task_archive` and direct CLI archive commands that run without an executor. A host-scoped filesystem reservation serializes a successor's deterministic-path acquisition with archival disposal; if removal fails, the reservation is quarantined so the next acquisition can reconcile the orphan instead of colliding with it. `archive({ cleanup: false })` intentionally retains the worktree. Workspace tasks' per-repository `workspaceWorktrees` are not removed by this lifecycle yet.
Board ordering behavior:
- `todo` mirrors scheduler dispatch order: priority first (`urgent` → `low`), then oldest `createdAt` within a priority tier, then task ID as deterministic tie-break.
@@ -649,7 +649,7 @@ Behavior:
### Archive behavior
- `fn task archive <id>` moves any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) to `archived`; tasks already in `archived` are rejected.
- `fn task archive <id>` moves eligible live-board tasks to `archived`; tasks already in `archived` are rejected. It refuses WIP-lane or active-merge tasks unless a human operator explicitly supplies `--force`.
- Archive records the task's `preArchiveColumn` so restore can return to the original live column instead of always assuming `done`.
- Dashboard delete confirmations for live tasks include an **Archive Instead** action so users can preserve history without soft-deleting the task.
- Archived tasks can also be deleted from the dashboard/API/CLI. Deleting an archived task removes the archived snapshot from lists and search, but first materializes the normal soft-delete tombstone so the task ID remains reserved unless the operator explicitly chooses allow-resurrection behavior.

View File

@@ -0,0 +1,82 @@
import {afterAll, afterEach, beforeAll, beforeEach, expect, it} from "vitest";
import {access} from "node:fs/promises";
import {join} from "node:path";
import {
createMockApi,
createPgExtensionHarness,
pgDescribe,
registerExtension,
requireTool,
} from "./pg-extension-harness.js";
/*
FNXC:TaskLifecycleTools 2026-08-15-06:35:
The pi archive tool is an agent-facing destructive surface, not a CLI alias. It must report the
core transactional live-task fence as a structured MCP error and deliberately has no agent force
escape hatch; only a human can use `fn task archive <id> --force`.
*/
const h = createPgExtensionHarness("fn-archive-live-guard");
pgDescribe("fn_task_archive live-task guard", () => {
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
function context() {
return {cwd: h.rootDir()};
}
function archiveTool() {
const api = createMockApi();
registerExtension(api);
return {tool: requireTool(api, "fn_task_archive"), registered: api.tools.get("fn_task_archive") as unknown as {parameters?: {properties?: Record<string, unknown>}}};
}
it("returns a structured refusal for a WIP task without archiving its task directory", async () => {
const store = h.store();
const task = await store.createTask({column: "in-progress", title: "live", description: "live"});
const {tool} = archiveTool();
const result = await tool.execute("live-wip", {id: task.id}, undefined, undefined, context());
expect(result.isError).toBe(true);
expect(result.content[0]?.text).toMatch(/WIP lane/i);
expect(result.content[0]?.text).toContain(`fn task archive ${task.id} --force`);
const persisted = await store.getTask(task.id, {includeDeleted: true});
expect(persisted.column).toBe("in-progress");
expect(persisted.deletedAt).toBeUndefined();
await expect(access(join(h.rootDir(), ".fusion", "tasks", task.id))).resolves.toBeUndefined();
});
it("returns a structured refusal for an active merge status", async () => {
const store = h.store();
const task = await store.createTask({column: "in-review", title: "landing", description: "landing"});
await store.updateTask(task.id, {status: "merging"});
const {tool} = archiveTool();
const result = await tool.execute("live-merge", {id: task.id}, undefined, undefined, context());
expect(result.isError).toBe(true);
expect(result.content[0]?.text).toMatch(/active merge pipeline/i);
expect((await store.getTask(task.id, {includeDeleted: true})).column).toBe("in-review");
});
it("archives a dead task with its existing cleanup default", async () => {
const store = h.store();
const task = await store.createTask({column: "done", title: "dead", description: "dead"});
const {tool} = archiveTool();
const result = await tool.execute("dead", {id: task.id}, undefined, undefined, context());
expect(result.isError).not.toBe(true);
expect(result.details?.column).toBe("archived");
expect((await store.getTask(task.id, {includeDeleted: true})).deletedAt).toBeTruthy();
});
it("does not expose a force override in the tool schema", () => {
const {registered} = archiveTool();
expect(registered.parameters?.properties).not.toHaveProperty("force");
expect(registered.parameters?.properties).not.toHaveProperty("allowLive");
});
});

View File

@@ -342,7 +342,7 @@ Usage:
fn task merge <id> Merge an in-review task and close it
fn task duplicate <id> Duplicate a task (creates copy in triage)
fn task refine <id> [opts] Create a refinement task from done/in-review
fn task archive <id> Archive a task (from any column)
fn task archive <id> [--force] Archive a task; --force permits live-worktree removal
fn task unarchive <id> Unarchive an archived task
fn task delete <id> [--force] [--allow-resurrection]
Delete a task (use --force to skip confirmation; --allow-resurrection permits intentional ID recreation)
@@ -1423,8 +1423,8 @@ async function main() {
}
case "archive": {
const id = args[2];
if (!id) { console.error("Usage: fn task archive <id>"); process.exit(1); }
await runTaskArchive(id, projectName);
if (!id) { console.error("Usage: fn task archive <id> [--force]"); process.exit(1); }
await runTaskArchive(id, projectName, {force: args.includes("--force")});
break;
}
case "unarchive": {

View File

@@ -225,7 +225,7 @@ vi.mock("../../project-context.js", () => ({
}));
import { createInterface } from "node:readline/promises";
import { TaskStore, CentralCore, extractIntentSignature, findNearDuplicates, runDeterministicDuplicateGuard, reconcileDeterministicDuplicate } from "@fusion/core";
import { TaskStore, CentralCore, extractIntentSignature, findNearDuplicates, runDeterministicDuplicateGuard, reconcileDeterministicDuplicate, TaskIsLiveError } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { exec } from "node:child_process";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
@@ -1282,10 +1282,72 @@ describe("project-aware task command behavior", () => {
await runTaskArchive("FN-123", "demo-project");
await runTaskUnarchive("FN-123", "demo-project");
expect(archiveTask).toHaveBeenCalledWith("FN-123");
expect(archiveTask).toHaveBeenCalledWith("FN-123", {liveExecutionGuard: "refuse"});
expect(unarchiveTask).toHaveBeenCalledWith("FN-123");
});
it("refuses a live archive before calling the store and exits non-zero", async () => {
const getTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "in-progress" }));
const archiveTask = vi.fn();
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code}`);
}) as (code?: number) => never);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test", projectPath: "/test", projectName: "demo-project", isRegistered: true,
store: { getTask, archiveTask } as unknown as TaskStore,
});
try {
await expect(runTaskArchive("FN-123", "demo-project")).rejects.toThrow("process.exit:1");
expect(archiveTask).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Refusing to archive live task FN-123"));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("--force"));
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
errorSpy.mockRestore();
exitSpy.mockRestore();
}
});
it("allows the human --force archive escape hatch for a live task", async () => {
const getTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "in-progress" }));
const archiveTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "archived" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test", projectPath: "/test", projectName: "demo-project", isRegistered: true,
store: { getTask, archiveTask } as unknown as TaskStore,
});
await runTaskArchive("FN-123", "demo-project", { force: true });
expect(archiveTask).toHaveBeenCalledWith("FN-123", { liveExecutionGuard: "off" });
});
it("formats a raced transactional live refusal without a raw error", async () => {
const getTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "todo" }));
const archiveTask = vi.fn().mockRejectedValue(new TaskIsLiveError("FN-123", ["wip-lane"]));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code}`);
}) as (code?: number) => never);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test", projectPath: "/test", projectName: "demo-project", isRegistered: true,
store: { getTask, archiveTask } as unknown as TaskStore,
});
try {
await expect(runTaskArchive("FN-123", "demo-project")).rejects.toThrow("process.exit:1");
expect(archiveTask).toHaveBeenCalledWith("FN-123", { liveExecutionGuard: "refuse" });
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Refusing to archive live task FN-123"));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("--force"));
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("Task FN-123 is live"));
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
errorSpy.mockRestore();
exitSpy.mockRestore();
}
});
it("runTaskRetry uses resolved project store", async () => {
const getTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", status: "failed", column: "in-progress" }));
const updateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", status: undefined }));

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, resolveProjectColumnsForRoles, TERMINAL_ROLES, resolveReviewColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isValidRepoSlug, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, resolveProjectColumnsForRoles, TERMINAL_ROLES, resolveReviewColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isValidRepoSlug, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, evaluateArchiveTaskLiveness, describeArchiveLiveness, TaskIsLiveError, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -18,6 +18,7 @@ import { findNodeByNameOrId } from "./node.js";
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
let archiveForceOverride = false;
/** #1403: display a column's label, falling back to the raw id for
* workflow-defined custom columns that have no legacy label. */
@@ -185,7 +186,7 @@ async function getBoardCommandContext(projectName?: string): Promise<ProjectCont
if (!context) {
throw new Error(`Project ${projectName} not found`);
}
installBaselineArchiveWorktreeDisposer(context.store, {rootDir: context.projectPath, getSettings: () => context.store.getSettings()});
installBaselineArchiveWorktreeDisposer(context.store, {rootDir: context.projectPath, getSettings: () => context.store.getSettings(), allowLiveRemoval: () => archiveForceOverride});
return context;
}
@@ -194,7 +195,7 @@ async function getBoardCommandContext(projectName?: string): Promise<ProjectCont
if (!context) {
throw new Error("No project context");
}
installBaselineArchiveWorktreeDisposer(context.store, {rootDir: context.projectPath, getSettings: () => context.store.getSettings()});
installBaselineArchiveWorktreeDisposer(context.store, {rootDir: context.projectPath, getSettings: () => context.store.getSettings(), allowLiveRemoval: () => archiveForceOverride});
return context;
} catch {
// FNXC:PostgresCutover 2026-07-05-12:00: the cwd fallback must boot through
@@ -202,7 +203,7 @@ async function getBoardCommandContext(projectName?: string): Promise<ProjectCont
// resolves to the removed SQLite runtime, which throws on first DB access.
const store = await createLocalStore(process.cwd());
const context = asLocalProjectContext(store);
installBaselineArchiveWorktreeDisposer(store, {rootDir: context.projectPath, getSettings: () => store.getSettings()});
installBaselineArchiveWorktreeDisposer(store, {rootDir: context.projectPath, getSettings: () => store.getSettings(), allowLiveRemoval: () => archiveForceOverride});
return context;
}
}
@@ -1485,15 +1486,37 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam
});
}
export async function runTaskArchive(id: string, projectName?: string) {
// FNXC:CliBoardMutation 2026-07-09-00:00 (FN-7734): single board write.
await withBoardWrite(projectName, { id, action: "archive task" }, async (context) => {
const task = await context.store.archiveTask(id);
console.log();
console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`);
console.log();
});
export async function runTaskArchive(id: string, projectName?: string, options: {force?: boolean} = {}) {
/* FNXC:CliBoardMutation 2026-08-15-06:35: force is scoped to this command's disposer lifetime; every other CLI archive stays protective by default. */
archiveForceOverride = options.force === true;
try {
await withBoardWrite(projectName, { id, action: "archive task" }, async (context) => {
// Compatibility test/store doubles may expose archiveTask without the advisory reader.
const current = typeof (context.store as unknown as {getTask?: unknown}).getTask === "function" ? await context.store.getTask(id) : undefined;
const refuseLiveArchive = async (verdict: Parameters<typeof describeArchiveLiveness>[1]) => {
/*
FNXC:CliBoardMutation 2026-08-15-07:07:
A CLI liveness refusal is an operator-facing safety result, not an uncaught stack trace.
Exit through the established board-context path so the command is non-zero while its store closes.
*/
console.error(`\n ✗ ${describeArchiveLiveness(id, verdict, {workspaceWorktreeCount: Object.keys(current?.workspaceWorktrees ?? {}).length})}\n`);
await closeBoardContextAndExit(context, 1);
};
if (current && !options.force) {
const verdict = await evaluateArchiveTaskLiveness(context.store, current);
if (verdict.live) await refuseLiveArchive(verdict);
}
try {
const task = await context.store.archiveTask(id, {liveExecutionGuard: options.force ? "off" : "refuse"});
console.log();
console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`);
console.log();
} catch (error) {
if (error instanceof TaskIsLiveError) await refuseLiveArchive({live: true, reasons: error.reasons});
throw error;
}
});
} finally { archiveForceOverride = false; }
}
export async function runTaskUnarchive(id: string, projectName?: string) {

View File

@@ -524,6 +524,7 @@ async function getStore(
await boot.shutdown().catch(() => undefined);
return raced.store;
}
/* FNXC:TaskLifecycleTools 2026-08-15-06:35: Agent tools intentionally install the protective baseline with no force path; only a human CLI invocation can override live removal. */
installBaselineArchiveWorktreeDisposer(boot.taskStore, {rootDir: projectRoot, getSettings: () => boot.taskStore.getSettings()});
storeCache.set(projectRoot, { store: boot.taskStore, shutdown: boot.shutdown });
return boot.taskStore;
@@ -2848,14 +2849,22 @@ export default function kbExtension(pi: ExtensionAPI) {
const gated = await applyAgentPolicyGateForExtensionTool("fn_task_archive", params as Record<string, unknown>, ctx as ExtensionCallerContext);
if (gated) return gated;
const store = await getStore(ctx.cwd);
const task = await store.archiveTask(params.id, {
removeLineageReferences: params.removeLineageReferences === true,
});
return {
content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }],
details: { taskId: task.id, column: task.column },
};
try {
const task = await store.archiveTask(params.id, {
removeLineageReferences: params.removeLineageReferences === true,
liveExecutionGuard: "refuse",
});
return {
content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }],
details: { taskId: task.id, column: task.column },
};
} catch (error) {
if (error instanceof fusionCore.TaskIsLiveError) {
const task = await store.getTask(params.id);
return {isError: true, content: [{type: "text", text: fusionCore.describeArchiveLiveness(params.id, {live: true, reasons: error.reasons}, {workspaceWorktreeCount: Object.keys(task?.workspaceWorktrees ?? {}).length})}], details: {taskId: params.id}};
}
throw error;
}
},
});

View File

@@ -0,0 +1,70 @@
import {access} from "node:fs/promises";
import {join} from "node:path";
import {afterAll, afterEach, beforeAll, beforeEach, expect, it, vi} from "vitest";
import {
LiveTaskWorktreeRemovalRefusedError,
registerArchiveWorkspaceWorktreeDisposer,
} from "../index.js";
import {
createSharedPgTaskStoreTestHarness,
pgDescribe,
type SharedPgTaskStoreHarness,
} from "../__test-utils__/pg-test-harness.js";
/*
FNXC:WorkflowLifecycle 2026-08-15-06:35:
A reported live-removal refusal means none of the archive cleanup chain is safe. This production
archive test protects against a future change that continues with branch or task-directory deletion
after preserving the workspace worktree reservation.
*/
pgDescribe("archive cleanup live-refusal suppression", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({prefix: "archive_live_cleanup"});
let unregister: (() => void) | undefined;
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(async () => {
unregister?.();
unregister = undefined;
await h.afterEach();
});
afterAll(h.afterAll);
async function workspaceTask(column = "done") {
const store = h.store();
const task = await store.createTask({column: column as never, title: "workspace", description: "workspace"});
return store.updateTask(task.id, {workspaceWorktrees: {
"repo-a": {worktreePath: join(h.rootDir(), ".worktrees", task.id, "repo-a"), branch: `fusion/${task.id}-a`},
}});
}
it("preserves branches and task files when disposal refuses a live task", async () => {
const store = h.store();
const task = await workspaceTask();
unregister = registerArchiveWorkspaceWorktreeDisposer(store, async (snapshot, plan) => ({
removed: [],
failed: plan.map((entry) => ({repoRel: entry.repoRel, error: new LiveTaskWorktreeRemovalRefusedError(snapshot.id, entry.repoRel, entry.worktreePath, ["wip-lane"])})),
}));
const cleanup = vi.spyOn(store, "cleanupBranchForTask");
const taskDir = store.taskDir(task.id);
await store.archiveTask(task.id);
expect(cleanup).not.toHaveBeenCalled();
await expect(access(taskDir)).resolves.toBeUndefined();
expect((await store.getTask(task.id, {includeDeleted: true})).workspaceWorktrees).toEqual(task.workspaceWorktrees);
});
it("keeps normal cleanup behavior after successful disposal", async () => {
const store = h.store();
const task = await workspaceTask();
unregister = registerArchiveWorkspaceWorktreeDisposer(store, async (_snapshot, plan) => ({removed: plan.map((entry) => entry.repoRel), failed: []}));
const cleanup = vi.spyOn(store, "cleanupBranchForTask").mockResolvedValue(undefined);
const taskDir = store.taskDir(task.id);
await store.archiveTask(task.id);
expect(cleanup).toHaveBeenCalledTimes(1);
await expect(access(taskDir)).rejects.toMatchObject({code: "ENOENT"});
});
});

View File

@@ -0,0 +1,69 @@
import {afterAll, afterEach, beforeAll, beforeEach, expect, it} from "vitest";
import {findArchivedTaskEntry} from "../../task-store/async/async-archive-lineage.js";
import {acquireTaskAdvisoryXactLock, taskAdvisoryLockKey} from "../../task-store/task-advisory-lock.js";
import {TaskIsLiveError} from "../../tasks/task-archive-liveness.js";
import {createSharedPgTaskStoreTestHarness, pgDescribe, type SharedPgTaskStoreHarness} from "../../__test-utils__/pg-test-harness.js";
/*
FNXC:WorkflowLifecycle 2026-08-15-06:35:
The archive verdict must serialize with task admission. These integration cases use the real PG
archive transaction to prove a WIP row writes neither cold storage nor soft-delete state, while
preserving default-off compatibility for other archive owners.
*/
pgDescribe("archive live-task advisory-lock fence", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({prefix: "archive_live_fence"});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
it("refuses a live row without writing cold storage or changing its lane", async () => {
const store = h.store();
const task = await store.createTask({column: "in-progress", title: "live", description: "live"});
await expect(store.archiveTask(task.id, {cleanup: false, liveExecutionGuard: "refuse"})).rejects.toBeInstanceOf(TaskIsLiveError);
const live = await store.getTask(task.id, {includeDeleted: true});
expect(live.column).toBe("in-progress");
expect(live.deletedAt).toBeUndefined();
expect(await findArchivedTaskEntry(h.layer().db, task.id, h.layer().projectId)).toBeUndefined();
});
it("rejects a task admitted from todo before its archive transaction", async () => {
const store = h.store();
const task = await store.createTask({column: "todo", title: "raced", description: "raced"});
await store.moveTask(task.id, "in-progress");
await expect(store.archiveTask(task.id, {cleanup: false, liveExecutionGuard: "refuse"})).rejects.toBeInstanceOf(TaskIsLiveError);
const live = await store.getTask(task.id, {includeDeleted: true});
expect(live.column).toBe("in-progress");
expect(live.deletedAt).toBeUndefined();
expect(await findArchivedTaskEntry(h.layer().db, task.id, h.layer().projectId)).toBeUndefined();
});
it("waits on the same exported advisory key that admission writers hold", async () => {
const store = h.store();
const task = await store.createTask({column: "in-progress", title: "serialized", description: "serialized"});
let settled = false;
let archive!: Promise<unknown>;
await h.layer().transactionImmediate(async (tx) => {
await acquireTaskAdvisoryXactLock(tx, h.layer().projectId, task.id);
archive = store.archiveTask(task.id, {cleanup: false, liveExecutionGuard: "refuse"}).finally(() => { settled = true; });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled, `archive bypassed ${taskAdvisoryLockKey(h.layer().projectId, task.id)}`).toBe(false);
});
await expect(archive).rejects.toBeInstanceOf(TaskIsLiveError);
});
it("preserves default-off behavior for existing archive callers", async () => {
const store = h.store();
const task = await store.createTask({column: "in-progress", title: "legacy", description: "legacy"});
await store.archiveTask(task.id, {cleanup: false});
expect((await store.getTask(task.id, {includeDeleted: true})).deletedAt).toBeTruthy();
expect(await findArchivedTaskEntry(h.layer().db, task.id, h.layer().projectId)).toBeDefined();
});
});

View File

@@ -0,0 +1,19 @@
import {describe, expect, it} from "vitest";
import {decideArchiveLiveness, resolveArchiveLivenessWipLanes} from "../tasks/task-archive-liveness.js";
describe("task archive liveness", () => {
it("protects default and renamed WIP lanes, active merges, and paused work", async () => {
expect(decideArchiveLiveness({column: "in-progress", wipLanes: new Set(["in-progress"])})).toMatchObject({live: true, reasons: ["wip-lane"]});
const ir = {version: "v2", id: "renamed", name: "renamed", nodes: [], edges: [], columns: [{id: "todo", name: "Todo", traits: [{trait: "intake"}]}, {id: "building", name: "Building", traits: [{trait: "wip"}]}]};
const store = {getTaskWorkflowSelection: () => ({workflowId: "renamed", stepIds: []}), getTaskWorkflowSelectionAsync: async () => ({workflowId: "renamed", stepIds: []}), getWorkflowDefinition: async () => ({id: "renamed", ir})} as never;
const lanes = await resolveArchiveLivenessWipLanes(store, "FN-1");
expect(decideArchiveLiveness({column: "building", wipLanes: lanes})).toMatchObject({live: true});
expect(decideArchiveLiveness({column: "todo", wipLanes: lanes})).toMatchObject({live: false});
expect(decideArchiveLiveness({column: "in-review", status: "merging", wipLanes: lanes})).toMatchObject({live: true, reasons: ["active-merge-status"]});
});
it("falls back to legacy WIP when workflow resolution fails", async () => {
const lanes = await resolveArchiveLivenessWipLanes({getTaskWorkflowSelectionAsync: async () => { throw new Error("unavailable"); }} as never, "FN-1");
expect(decideArchiveLiveness({column: "in-progress", wipLanes: lanes})).toMatchObject({live: true});
});
});

View File

@@ -774,6 +774,16 @@ export {
ACTIVE_MERGE_PIPELINE_STATUSES,
isActiveMergeStatus,
} from "./merge/active-merge-status.js";
export {
decideArchiveLiveness,
resolveArchiveLivenessWipLanes,
evaluateArchiveTaskLiveness,
describeArchiveLiveness,
TaskIsLiveError,
LiveTaskWorktreeRemovalRefusedError,
type ArchiveLivenessReason,
type ArchiveLivenessVerdict,
} from "./tasks/task-archive-liveness.js";
export {
setTaskCreatedHook,
getTaskCreatedHook,

View File

@@ -3056,14 +3056,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async archiveAllDone(options?: { removeLineageReferences?: boolean }): Promise<Task[]> {
return archiveAllDoneImpl(this, options);
}
async archiveTask( id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean } = true, ): Promise<Task> {
async archiveTask( id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean; liveExecutionGuard?: "refuse" | "off" } = true, ): Promise<Task> {
return archiveTaskImpl(this, id, optionsOrCleanup);
}
/**
* FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-14:55:
*/
public async archiveTaskBackend( id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean }, ): Promise<Task> {
public async archiveTaskBackend( id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean; liveExecutionGuard?: "refuse" | "off" }, ): Promise<Task> {
return archiveTaskBackendImpl(this, id, optionsOrCleanup);
}

View File

@@ -33,6 +33,7 @@ import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js";
import {archiveParentTaskWithLineageGate, findArchivedTaskEntry, deleteArchivedTaskEntry, restoreTaskFromArchive} from "../task-store/async/async-archive-lineage.js";
import {getArchivedRowCount, listArchivedTaskEntriesPage} from "../async-stores/async-archive-db.js";
import {disposeArchivedWorkspaceWorktrees, disposeArchivedWorktree, prepareArchivedWorkspaceWorktrees, releasePreparedWorkspaceArchiveDisposal} from "./archive-lifecycle.js";
import {resolveArchiveLivenessWipLanes, TaskIsLiveError} from "../tasks/task-archive-liveness.js";
export async function taskToArchiveEntryImpl(store: TaskStore, task: Task, archivedAt: string): Promise<ArchivedTaskEntry> {
const settings = await store.getSettingsFast();
@@ -414,10 +415,11 @@ async function archivedLanesForTask(store: TaskStore, taskId: string): Promise<R
return lanes;
}
export async function archiveTaskBackendImpl(store: TaskStore, id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean },): Promise<Task> {
export async function archiveTaskBackendImpl(store: TaskStore, id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean; liveExecutionGuard?: "refuse" | "off" },): Promise<Task> {
const layer = store.asyncLayer!;
const cleanup = typeof optionsOrCleanup === "boolean" ? optionsOrCleanup : optionsOrCleanup.cleanup !== false;
const removeLineageRefs = typeof optionsOrCleanup === "object" && optionsOrCleanup.removeLineageReferences === true;
const liveExecutionGuard = typeof optionsOrCleanup === "object" ? optionsOrCleanup.liveExecutionGuard ?? "off" : "off";
// Read the task (forensic: include deleted for idempotency check).
const task = await store.getTask(id);
@@ -445,6 +447,8 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio
one value through pre-read and gate. The workspace reservation is created inside the locked body.
*/
const archiveLineageArchivedLanes: ReadonlySet<string> | undefined = undefined;
// Resolve configuration before the transaction; only its durable row verdict is authoritative.
const livenessWipLanes = liveExecutionGuard === "refuse" ? await resolveArchiveLivenessWipLanes(store, id) : undefined;
const archiveRun = async (context?: { candidateIds: string[]; promptByChildId: ReadonlyMap<string, string>; locksHeld: boolean; attempt: number }) => {
const preparedWorkspace = cleanup ? await prepareArchivedWorkspaceWorktrees(store, task) : undefined;
try {
@@ -452,6 +456,7 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio
removeLineageReferences: removeLineageRefs,
now: archivedAt,
archivedColumns: archiveLineageArchivedLanes,
...(livenessWipLanes ? {livenessWipLanes} : {}),
...(context ? {
revalidateAgainst: context.candidateIds,
promptByChildId: context.promptByChildId,
@@ -497,6 +502,7 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio
const preparedWorkspace = archiveExecution.preparedWorkspace;
if (!result.archived) {
if (preparedWorkspace) await releasePreparedWorkspaceArchiveDisposal(preparedWorkspace);
if ("liveVerdict" in result) throw new TaskIsLiveError(id, result.liveVerdict.reasons);
throw new TaskHasLineageChildrenError(id, result.liveChildIds);
}
@@ -510,12 +516,19 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio
successful archives still await disposal before publishing the move event.
*/
const workspace = await disposeArchivedWorkspaceWorktrees(store, task, preparedWorkspace);
if (!workspace.singularDeduplicated) await disposeArchivedWorktree(store, task);
await store.cleanupBranchForTask(task);
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true, force: true });
if (store.isWatching) {
store.taskCache.delete(id);
const singular = workspace.singularDeduplicated ? {refusedLive: false} : await disposeArchivedWorktree(store, task);
/*
FNXC:WorkflowLifecycle 2026-08-15-06:35:
A disposer live-refusal preserves data rather than merely reporting a failed worktree action.
Branch cleanup and task-directory removal are the same destructive operation and must stop together.
*/
if (workspace.refusedLive || singular.refusedLive) {
storeLog.warn("archive-cleanup-suppressed-live-task", {taskId: id, refusedBy: workspace.refusedLive ? "workspace" : "singular"});
} else {
await store.cleanupBranchForTask(task);
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true, force: true });
if (store.isWatching) store.taskCache.delete(id);
}
}

View File

@@ -15,6 +15,7 @@ import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {getErrorMessage} from "../process/error-message.js";
import {ArchiveWorkspaceDisposalError, ArchiveWorkspaceDisposalIncompleteError, ArchiveWorkspaceWorktreeDisposerMissingError, getArchiveWorkspaceWorktreeDisposer, getArchiveWorktreeDisposer, type ArchiveWorkspaceDisposalResult, type WorkspaceDisposalPlanEntry} from "../db/archive-worktree-disposer.js";
import {acquireWorktreePathReservation, canonicalizeWorktreePath} from "../tasks/worktree-path-reservation.js";
import {LiveTaskWorktreeRemovalRefusedError} from "../tasks/task-archive-liveness.js";
import {basename, join, resolve} from "node:path";
import {homedir} from "node:os";
@@ -104,10 +105,10 @@ export async function releasePreparedWorkspaceArchiveDisposal(prepared: Prepared
}
}
export async function disposeArchivedWorkspaceWorktrees(store: TaskStore, task: Task, prepared = undefined as PreparedWorkspaceArchiveDisposal | undefined): Promise<{singularDeduplicated: boolean}> {
export async function disposeArchivedWorkspaceWorktrees(store: TaskStore, task: Task, prepared = undefined as PreparedWorkspaceArchiveDisposal | undefined): Promise<{singularDeduplicated: boolean; refusedLive: boolean}> {
const disposal = prepared ?? await prepareArchivedWorkspaceWorktrees(store, task);
const {plan, reservations, singularDeduplicated} = disposal;
if (plan.length === 0) return {singularDeduplicated};
if (plan.length === 0) return {singularDeduplicated, refusedLive: false};
try {
const disposer = getArchiveWorkspaceWorktreeDisposer(store);
let result: ArchiveWorkspaceDisposalResult;
@@ -124,29 +125,30 @@ export async function disposeArchivedWorkspaceWorktrees(store: TaskStore, task:
}
const normalized = normalizeWorkspaceDisposalResult(plan, result);
for (const [repoRel, error] of normalized.failures) await reservations[repoRel].quarantine(getErrorMessage(error));
return {singularDeduplicated, refusedLive: [...normalized.failures.values()].some((error) => error instanceof LiveTaskWorktreeRemovalRefusedError)};
} finally {
await releasePreparedWorkspaceArchiveDisposal(disposal);
}
return {singularDeduplicated};
}
export async function disposeArchivedWorktree(store: TaskStore, task: Task): Promise<void> {
if (!task.worktree) return;
export async function disposeArchivedWorktree(store: TaskStore, task: Task): Promise<{refusedLive: boolean}> {
if (!task.worktree) return {refusedLive: false};
const settings = await store.getSettings();
const canonical = await canonicalizeWorktreePath(task.worktree);
if (canonical === await canonicalizeWorktreePath(store.rootDir)) return;
if (canonical === await canonicalizeWorktreePath(store.rootDir)) return {refusedLive: false};
const reservation = await acquireWorktreePathReservation({canonicalPath: canonical, worktreesDir: resolveArchiveWorktreesDir(store, settings.worktreesDir), rootDir: store.rootDir});
try {
const disposer = getArchiveWorktreeDisposer(store);
if (!disposer) {
/* FNXC:WorkflowLifecycle 2026-07-16-10:00: A non-root archived worktree without a store-scoped engine disposer must be loud rather than silently leaked by an executor-less archive surface. */
storeLog.warn("archive-worktree-disposer-missing", {taskId: task.id, worktreePath: canonical});
return;
return {refusedLive: false};
}
try { await disposer(task, reservation); }
try { await disposer(task, reservation); return {refusedLive: false}; }
catch (error) {
await reservation.quarantine(getErrorMessage(error));
storeLog.warn("Archive worktree disposal failed; reservation quarantined", {taskId: task.id, worktreePath: canonical, error: getErrorMessage(error)});
return {refusedLive: error instanceof LiveTaskWorktreeRemovalRefusedError};
}
} finally { if (reservation.state === "held") await reservation.release(); }
}
@@ -206,7 +208,7 @@ export async function deleteTaskIfImpl(
return store.deleteTaskIf(id, predicate, options);
}
export async function archiveTaskImpl(store: TaskStore, id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean } = true,): Promise<Task> {
export async function archiveTaskImpl(store: TaskStore, id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean; liveExecutionGuard?: "refuse" | "off" } = true,): Promise<Task> {
/*
FNXC:SqliteDualPathCleanup 2026-07-26-14:08:
archiveTask is PostgreSQL-only via archiveTaskBackend (async archive-lineage helper).

View File

@@ -40,6 +40,8 @@ import {
readTaskRowInTransaction,
} from "./async-persistence.js";
import type { ArchivedTaskEntry } from "../../types.js";
import {acquireTaskAdvisoryXactLock} from "../task-advisory-lock.js";
import {decideArchiveLiveness, type ArchiveLivenessVerdict} from "../../tasks/task-archive-liveness.js";
/**
* FNXC:TaskStoreArchiveLineage 2026-06-24-07:10:
@@ -238,12 +240,23 @@ export async function archiveParentTaskWithLineageGate(
layer: AsyncDataLayer,
taskId: string,
entry: ArchivedTaskEntry,
options: { removeLineageReferences?: boolean; now?: string; beforeArchive?: (tx: DbTransaction) => Promise<void>; beforeLineageGate?: () => void | Promise<void>; archivedColumns?: ReadonlySet<string>; revalidateAgainst?: readonly string[]; promptByChildId?: ReadonlyMap<string, string>; evidenceTargetVersionForTest?: (childId: string, computed: number, attempt: number) => number } = {},
options: { removeLineageReferences?: boolean; now?: string; beforeArchive?: (tx: DbTransaction) => Promise<void>; beforeLineageGate?: () => void | Promise<void>; archivedColumns?: ReadonlySet<string>; revalidateAgainst?: readonly string[]; promptByChildId?: ReadonlyMap<string, string>; evidenceTargetVersionForTest?: (childId: string, computed: number, attempt: number) => number; livenessWipLanes?: ReadonlySet<string> } = {},
): Promise<{ archived: true; lineageOutcome?: LineageRemovalOutcome } | { archived: false; liveChildIds: string[] }> {
): Promise<{ archived: true; lineageOutcome?: LineageRemovalOutcome } | { archived: false; liveChildIds: string[] } | { archived: false; liveVerdict: ArchiveLivenessVerdict }> {
const now = options.now ?? new Date().toISOString();
return layer.transactionImmediate(async (tx) => {
/*
FNXC:WorkflowLifecycle 2026-08-15-06:35:
Admission writers take this same advisory key before changing a task's lane. Re-read and decide
under it so a CLI archive cannot win a todo-to-WIP race and destroy an executor's live worktree.
*/
if (options.livenessWipLanes) {
await acquireTaskAdvisoryXactLock(tx, layer.projectId, taskId);
const live = await readTaskRowInTransaction(tx, taskId, undefined, layer.projectId);
const verdict = decideArchiveLiveness({column: String(live?.column ?? ""), status: live?.status as string | null | undefined, wipLanes: options.livenessWipLanes});
if (verdict.live) return {archived: false as const, liveVerdict: verdict};
}
// Test-only barrier is before this operation's single in-transaction lineage read.
await options.beforeLineageGate?.();
// 1. Lineage gate — check for live children inside the transaction.

View File

@@ -0,0 +1,56 @@
import type {TaskStore} from "../store.js";
import type {Task} from "../types.js";
import {isActiveMergeStatus} from "../merge/active-merge-status.js";
import {columnsWithFlag, declaresAnyLifecycleTrait} from "../workflows/workflow-lifecycle-traits.js";
import {resolveWorkflowIrForTask} from "../workflows/workflow-ir-resolver.js";
export type ArchiveLivenessReason = "wip-lane" | "active-merge-status";
export type ArchiveLivenessVerdict = {live: boolean; reasons: ArchiveLivenessReason[]};
/*
FNXC:WorkflowLifecycle 2026-08-15-06:35:
Archive from an executor-less CLI or extension process must fail closed against durable task-row
signals: WIP lanes and active merge statuses. A pause does not prove another process has stopped.
The in-process activeSessionRegistry/executingTaskLock and AgentStore heartbeat evidence are deliberately
excluded: neither is available to this core archive path. The transaction fence is authoritative; these
helpers provide its pure decision and advisory caller messaging.
*/
export function decideArchiveLiveness(input: {column: string; status?: string | null; wipLanes: ReadonlySet<string>}): ArchiveLivenessVerdict {
const reasons: ArchiveLivenessReason[] = [];
if (input.wipLanes.has(input.column)) reasons.push("wip-lane");
if (isActiveMergeStatus(input.status)) reasons.push("active-merge-status");
return {live: reasons.length > 0, reasons};
}
export async function resolveArchiveLivenessWipLanes(store: TaskStore, taskId: string): Promise<ReadonlySet<string>> {
try {
const ir = await resolveWorkflowIrForTask(store, taskId);
if (ir && declaresAnyLifecycleTrait(ir)) return new Set(columnsWithFlag(ir, "countsTowardWip"));
} catch { /* degraded-but-protective legacy fallback */ }
return new Set(["in-progress"]);
}
/** Advisory only: callers must rely on the archive transaction's re-read for authority. */
export async function evaluateArchiveTaskLiveness(store: TaskStore, task: Pick<Task, "id" | "column" | "status">): Promise<ArchiveLivenessVerdict> {
return decideArchiveLiveness({column: task.column, status: task.status, wipLanes: await resolveArchiveLivenessWipLanes(store, task.id)});
}
export function describeArchiveLiveness(taskId: string, verdict: ArchiveLivenessVerdict, extra?: {workspaceWorktreeCount?: number}): string {
const reasons = verdict.reasons.map((reason) => reason === "wip-lane" ? "in a WIP lane" : "has an active merge pipeline").join(" and ");
const workspace = extra?.workspaceWorktreeCount ? `; ${extra.workspaceWorktreeCount} workspace worktree${extra.workspaceWorktreeCount === 1 ? "" : "s"} may be destroyed` : "";
return `Refusing to archive live task ${taskId}: it is ${reasons}${workspace}. Use \`fn task archive ${taskId} --force\` to override.`;
}
export class TaskIsLiveError extends Error {
constructor(readonly taskId: string, readonly reasons: ArchiveLivenessReason[]) {
super(`Task ${taskId} is live: ${reasons.join(", ")}`);
this.name = "TaskIsLiveError";
}
}
export class LiveTaskWorktreeRemovalRefusedError extends Error {
constructor(readonly taskId: string, readonly repoRel: string, readonly worktreePath: string, readonly reasons: ArchiveLivenessReason[]) {
super(`Refusing to remove live task ${taskId} worktree ${worktreePath}: ${reasons.join(", ")}`);
this.name = "LiveTaskWorktreeRemovalRefusedError";
}
}

View File

@@ -0,0 +1,93 @@
import {afterEach, describe, expect, it, vi} from "vitest";
const {removeWorktree, execFile} = vi.hoisted(() => ({
removeWorktree: vi.fn().mockResolvedValue(undefined),
execFile: vi.fn((...args: unknown[]) => (args.at(-1) as (error: Error | null) => void)(null)),
}));
vi.mock("../worktree/worktree-backend.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../worktree/worktree-backend.js")>()),
removeWorktree,
}));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
execFile,
}));
import {
getArchiveWorkspaceWorktreeDisposer,
getArchiveWorktreeDisposer,
LiveTaskWorktreeRemovalRefusedError,
registerArchiveWorktreeDisposer,
type TaskStore,
} from "@fusion/core";
import {installBaselineArchiveWorktreeDisposer} from "../healing/archive-worktree-disposer-install.js";
/*
FNXC:WorkflowLifecycle 2026-08-15-06:35:
The executor-less baseline disposer is a defensive final fence. Test both workspace and singular
paths because a force removal in either path irreversibly loses work that another process owns.
*/
describe("baseline archive disposer live-task guard", () => {
const unregister: (() => void)[] = [];
afterEach(() => {
while (unregister.length) unregister.pop()!();
vi.clearAllMocks();
});
function store(): TaskStore {
return {rootDir: "/repo", getTaskWorkflowSelectionAsync: async () => undefined} as unknown as TaskStore;
}
const live = {id: "FN-live", column: "in-progress", workspaceWorktrees: {
"repo-a": {worktreePath: "/repo/repo-a/.worktrees/a", branch: "fusion/a"},
"repo-b": {worktreePath: "/repo/repo-b/.worktrees/b", branch: "fusion/b"},
}} as never;
const plan = [
{repoRel: "repo-a", worktreePath: "/repo/repo-a/.worktrees/a", branch: "fusion/a", repoRootDir: "/repo/repo-a", aliasRepoRels: []},
{repoRel: "repo-b", worktreePath: "/repo/repo-b/.worktrees/b", branch: "fusion/b", repoRootDir: "/repo/repo-b", aliasRepoRels: []},
];
it("does not force-remove any live workspace worktree", async () => {
const taskStore = store();
unregister.push(installBaselineArchiveWorktreeDisposer(taskStore, {rootDir: "/repo", getSettings: async () => ({})}));
const disposer = getArchiveWorkspaceWorktreeDisposer(taskStore)!;
const result = await disposer(live, plan, {} as never);
expect(removeWorktree).not.toHaveBeenCalled();
expect(execFile).not.toHaveBeenCalled();
expect(result.removed).toEqual([]);
expect(result.failed).toHaveLength(2);
expect(result.failed.every(({error}) => error instanceof LiveTaskWorktreeRemovalRefusedError)).toBe(true);
expect(live.workspaceWorktrees).toHaveProperty("repo-a");
expect(live.workspaceWorktrees).toHaveProperty("repo-b");
});
it("permits explicit human force removal and dead task cleanup", async () => {
const taskStore = store();
unregister.push(installBaselineArchiveWorktreeDisposer(taskStore, {rootDir: "/repo", getSettings: async () => ({}), allowLiveRemoval: () => true}));
const forced = await getArchiveWorkspaceWorktreeDisposer(taskStore)!(structuredClone(live), plan, {} as never);
expect(forced.removed).toEqual(["repo-a", "repo-b"]);
expect(removeWorktree).toHaveBeenCalledTimes(2);
expect(execFile).toHaveBeenCalledTimes(2);
const deadStore = store();
unregister.push(installBaselineArchiveWorktreeDisposer(deadStore, {rootDir: "/repo", getSettings: async () => ({})}));
const dead = {...structuredClone(live), id: "FN-dead", column: "done"};
await getArchiveWorkspaceWorktreeDisposer(deadStore)!(dead, plan, {} as never);
expect(removeWorktree).toHaveBeenCalledTimes(4);
});
it("throws for a live singular worktree and retains an executor disposer", async () => {
const taskStore = store();
unregister.push(installBaselineArchiveWorktreeDisposer(taskStore, {rootDir: "/repo", getSettings: async () => ({})}));
await expect(getArchiveWorktreeDisposer(taskStore)!({id: "FN-single", column: "in-progress", worktree: "/repo/.worktrees/live"} as never, {} as never)).rejects.toBeInstanceOf(LiveTaskWorktreeRemovalRefusedError);
expect(removeWorktree).not.toHaveBeenCalled();
const executorStore = store();
const executor = vi.fn();
const removeExecutor = registerArchiveWorktreeDisposer(executorStore, executor);
unregister.push(removeExecutor, installBaselineArchiveWorktreeDisposer(executorStore, {rootDir: "/repo", getSettings: async () => ({})}));
expect(getArchiveWorktreeDisposer(executorStore)).toBe(executor);
});
});

View File

@@ -1,6 +1,6 @@
import {execFile} from "node:child_process";
import {promisify} from "node:util";
import {canonicalizeWorktreePath, getArchiveWorkspaceWorktreeDisposer, getArchiveWorktreeDisposer, registerArchiveWorkspaceWorktreeDisposer, registerArchiveWorktreeDisposer, type Settings, type TaskStore} from "@fusion/core";
import {canonicalizeWorktreePath, evaluateArchiveTaskLiveness, LiveTaskWorktreeRemovalRefusedError, getArchiveWorkspaceWorktreeDisposer, getArchiveWorktreeDisposer, registerArchiveWorkspaceWorktreeDisposer, registerArchiveWorktreeDisposer, type Settings, type TaskStore} from "@fusion/core";
import {removeWorktree, RemovalReason} from "../worktree/worktree-backend.js";
const execFileAsync = promisify(execFile);
@@ -11,16 +11,27 @@ const execFileAsync = promisify(execFile);
* presence-guarded baseline uses the configured backend, while an executor may
* replace it with its session-aware disposer for the same store.
*/
export function installBaselineArchiveWorktreeDisposer(store: TaskStore, input: {rootDir: string; getSettings: () => Promise<Partial<Settings>>}): () => void {
export function installBaselineArchiveWorktreeDisposer(store: TaskStore, input: {rootDir: string; getSettings: () => Promise<Partial<Settings>>; allowLiveRemoval?: () => boolean}): () => void {
const allowLiveRemoval = input.allowLiveRemoval ?? (() => false);
const unregisterSingle = getArchiveWorktreeDisposer(store) ? () => {} : registerArchiveWorktreeDisposer(store, async (task) => {
if (!task.worktree) return;
if (await canonicalizeWorktreePath(task.worktree) === await canonicalizeWorktreePath(input.rootDir)) return;
/*
FNXC:WorkflowLifecycle 2026-08-15-06:35:
This executor-less baseline cannot await in-process abort/session signals. Its durable row check is
defense in depth behind the archive fence: leaking a worktree is recoverable; force-removing live work is not.
*/
const verdict = await evaluateArchiveTaskLiveness(store, task);
if (verdict.live && !allowLiveRemoval()) throw new LiveTaskWorktreeRemovalRefusedError(task.id, "__singular_worktree__", task.worktree, verdict.reasons);
await removeWorktree({worktreePath: task.worktree, rootDir: input.rootDir, settings: await input.getSettings(), taskId: task.id, reason: RemovalReason.ExecutorDispose, force: true});
task.worktree = undefined;
});
const unregisterWorkspace = getArchiveWorkspaceWorktreeDisposer(store) ? () => {} : registerArchiveWorkspaceWorktreeDisposer(store, async (task, plan) => {
const removed: string[] = [];
const failed: {repoRel: string; error: unknown}[] = [];
/* FNXC:WorkflowLifecycle 2026-08-15-06:35: Evaluate once so an executor-less archive never partially destroys a live workspace task. */
const verdict = await evaluateArchiveTaskLiveness(store, task);
if (verdict.live && !allowLiveRemoval()) return {removed, failed: plan.map((entry) => ({repoRel: entry.repoRel, error: new LiveTaskWorktreeRemovalRefusedError(task.id, entry.repoRel, entry.worktreePath, verdict.reasons)}))};
for (const entry of plan) {
try {
if (await canonicalizeWorktreePath(entry.worktreePath) === await canonicalizeWorktreePath(entry.repoRootDir)) throw new Error("Refusing to remove workspace repository root");