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:
7
.changeset/fn-9055-archive-live-worktree-guard.md
Normal file
7
.changeset/fn-9055-archive-live-worktree-guard.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
70
packages/core/src/__tests__/archive-live-task-guard.test.ts
Normal file
70
packages/core/src/__tests__/archive-live-task-guard.test.ts
Normal 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"});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
19
packages/core/src/__tests__/task-archive-liveness.test.ts
Normal file
19
packages/core/src/__tests__/task-archive-liveness.test.ts
Normal 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});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
56
packages/core/src/tasks/task-archive-liveness.ts
Normal file
56
packages/core/src/tasks/task-archive-liveness.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user