diff --git a/.changeset/fn-9055-archive-live-worktree-guard.md b/.changeset/fn-9055-archive-live-worktree-guard.md new file mode 100644 index 0000000000..03bae32f0f --- /dev/null +++ b/.changeset/fn-9055-archive-live-worktree-guard.md @@ -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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5f1ea03d3a..186795d086 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 diff --git a/docs/task-management.md b/docs/task-management.md index 41618b5679..c7e7f041fd 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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 ` moves any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) to `archived`; tasks already in `archived` are rejected. +- `fn task archive ` 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. diff --git a/packages/cli/src/__tests__/extension-task-archive-live-guard.test.ts b/packages/cli/src/__tests__/extension-task-archive-live-guard.test.ts new file mode 100644 index 0000000000..944f4dc289 --- /dev/null +++ b/packages/cli/src/__tests__/extension-task-archive-live-guard.test.ts @@ -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 --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}}}; + } + + 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"); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index f155b1692d..7e955e0a79 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -342,7 +342,7 @@ Usage: fn task merge Merge an in-review task and close it fn task duplicate Duplicate a task (creates copy in triage) fn task refine [opts] Create a refinement task from done/in-review - fn task archive Archive a task (from any column) + fn task archive [--force] Archive a task; --force permits live-worktree removal fn task unarchive Unarchive an archived task fn task delete [--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 "); process.exit(1); } - await runTaskArchive(id, projectName); + if (!id) { console.error("Usage: fn task archive [--force]"); process.exit(1); } + await runTaskArchive(id, projectName, {force: args.includes("--force")}); break; } case "unarchive": { diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index a67fdd53ae..60344d5527 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -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 })); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 51d0c4e836..0374285c9c 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -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 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 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 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[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) { diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 41094c876f..d7d8ccc953 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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, 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; + } }, }); diff --git a/packages/core/src/__tests__/archive-live-task-guard.test.ts b/packages/core/src/__tests__/archive-live-task-guard.test.ts new file mode 100644 index 0000000000..f9e06cd35c --- /dev/null +++ b/packages/core/src/__tests__/archive-live-task-guard.test.ts @@ -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"}); + }); +}); diff --git a/packages/core/src/__tests__/postgres/archive-live-task-fence.pg.test.ts b/packages/core/src/__tests__/postgres/archive-live-task-fence.pg.test.ts new file mode 100644 index 0000000000..45bd31dc97 --- /dev/null +++ b/packages/core/src/__tests__/postgres/archive-live-task-fence.pg.test.ts @@ -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; + + 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(); + }); +}); diff --git a/packages/core/src/__tests__/task-archive-liveness.test.ts b/packages/core/src/__tests__/task-archive-liveness.test.ts new file mode 100644 index 0000000000..1e6b91cf3b --- /dev/null +++ b/packages/core/src/__tests__/task-archive-liveness.test.ts @@ -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}); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6696e495ba..a0237a7f3f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 524cbb788e..ba557b6a26 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3056,14 +3056,14 @@ export class TaskStore extends EventEmitter { async archiveAllDone(options?: { removeLineageReferences?: boolean }): Promise { return archiveAllDoneImpl(this, options); } - async archiveTask( id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean } = true, ): Promise { + async archiveTask( id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean; liveExecutionGuard?: "refuse" | "off" } = true, ): Promise { return archiveTaskImpl(this, id, optionsOrCleanup); } /** * FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-14:55: */ - public async archiveTaskBackend( id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean }, ): Promise { + public async archiveTaskBackend( id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean; liveExecutionGuard?: "refuse" | "off" }, ): Promise { return archiveTaskBackendImpl(this, id, optionsOrCleanup); } diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index a41af8e913..2cc3add3eb 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -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 { const settings = await store.getSettingsFast(); @@ -414,10 +415,11 @@ async function archivedLanesForTask(store: TaskStore, taskId: string): Promise { +export async function archiveTaskBackendImpl(store: TaskStore, id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean; liveExecutionGuard?: "refuse" | "off" },): Promise { 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 | 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; 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); } } diff --git a/packages/core/src/task-store/archive-lifecycle.ts b/packages/core/src/task-store/archive-lifecycle.ts index 6e1322f598..760b862b68 100644 --- a/packages/core/src/task-store/archive-lifecycle.ts +++ b/packages/core/src/task-store/archive-lifecycle.ts @@ -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 { - 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 { +export async function archiveTaskImpl(store: TaskStore, id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean; liveExecutionGuard?: "refuse" | "off" } = true,): Promise { /* FNXC:SqliteDualPathCleanup 2026-07-26-14:08: archiveTask is PostgreSQL-only via archiveTaskBackend (async archive-lineage helper). diff --git a/packages/core/src/task-store/async/async-archive-lineage.ts b/packages/core/src/task-store/async/async-archive-lineage.ts index 4a5383f76f..54065ed9fd 100644 --- a/packages/core/src/task-store/async/async-archive-lineage.ts +++ b/packages/core/src/task-store/async/async-archive-lineage.ts @@ -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; beforeLineageGate?: () => void | Promise; archivedColumns?: ReadonlySet; revalidateAgainst?: readonly string[]; promptByChildId?: ReadonlyMap; evidenceTargetVersionForTest?: (childId: string, computed: number, attempt: number) => number } = {}, + options: { removeLineageReferences?: boolean; now?: string; beforeArchive?: (tx: DbTransaction) => Promise; beforeLineageGate?: () => void | Promise; archivedColumns?: ReadonlySet; revalidateAgainst?: readonly string[]; promptByChildId?: ReadonlyMap; evidenceTargetVersionForTest?: (childId: string, computed: number, attempt: number) => number; livenessWipLanes?: ReadonlySet } = {}, -): 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. diff --git a/packages/core/src/tasks/task-archive-liveness.ts b/packages/core/src/tasks/task-archive-liveness.ts new file mode 100644 index 0000000000..dedb6995c1 --- /dev/null +++ b/packages/core/src/tasks/task-archive-liveness.ts @@ -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}): 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> { + 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): Promise { + 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"; + } +} diff --git a/packages/engine/src/__tests__/archive-baseline-disposer-live-task-guard.test.ts b/packages/engine/src/__tests__/archive-baseline-disposer-live-task-guard.test.ts new file mode 100644 index 0000000000..f4247f955e --- /dev/null +++ b/packages/engine/src/__tests__/archive-baseline-disposer-live-task-guard.test.ts @@ -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()), + removeWorktree, +})); +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + 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); + }); +}); diff --git a/packages/engine/src/healing/archive-worktree-disposer-install.ts b/packages/engine/src/healing/archive-worktree-disposer-install.ts index af7130cbc1..a46b44cf1b 100644 --- a/packages/engine/src/healing/archive-worktree-disposer-install.ts +++ b/packages/engine/src/healing/archive-worktree-disposer-install.ts @@ -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>}): () => void { +export function installBaselineArchiveWorktreeDisposer(store: TaskStore, input: {rootDir: string; getSettings: () => Promise>; 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");