feat(FN-5233): add tombstone recreate guard and allow-resurrection delete f

Implements the FN-5233 tombstone system for soft-delete resurrection: a configurable `tombstoneWindowSeconds` deduplicates recreation of recently deleted tasks, with an `allowResurrection` flag that permits explicit resurrect-on-recreate, tombstone recreate guards in the store layer, and cleanup of

Fusion-Task-Id: FN-5233
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 21:35:11 -07:00
committed by gsxdsm
parent 916047c2ae
commit 2d2e5b809f
18 changed files with 603 additions and 29 deletions

View File

@@ -650,6 +650,16 @@ describe("bin command routing and fallbacks", () => {
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Usage: fn pr create <task-id>"));
});
it("routes task delete with allow-resurrection flag", async () => {
await runBin(["task", "delete", "FN-1", "--force", "--allow-resurrection"]);
expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, true, undefined);
});
it("routes task delete default allow-resurrection=false", async () => {
await runBin(["task", "delete", "FN-1", "--force"]);
expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, false, undefined);
});
it("routes desktop flags to runDesktop", async () => {
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
expect(commandMocks.runDesktop).toHaveBeenCalledWith({

View File

@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "@fusion/core";
import kbExtension from "../extension.js";
type RegisteredTool = {
name: string;
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string }) => Promise<any>;
};
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
return {
tools,
registerTool(tool: RegisteredTool) {
tools.set(tool.name, tool);
},
registerCommand() {
// no-op for tests
},
on() {
// no-op for tests
},
} as any;
}
describe("task delete allowResurrection plumbing", () => {
let rootDir: string;
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "fn-task-delete-allow-"));
await mkdir(join(rootDir, ".fusion"), { recursive: true });
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
it("fn_task_delete forwards allowResurrection=true", async () => {
const store = new TaskStore(rootDir);
await store.init();
const task = await store.createTask({ title: "x", description: "y", column: "todo" });
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
await tool.execute("call-1", { id: task.id, allowResurrection: true }, undefined, undefined, { cwd: rootDir });
const deleted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { allowResurrection?: boolean; deletedAt?: string };
expect(deleted.deletedAt).toBeTruthy();
expect(deleted.allowResurrection).toBe(true);
});
it("fn_task_delete defaults allowResurrection=false", async () => {
const store = new TaskStore(rootDir);
await store.init();
const task = await store.createTask({ title: "x", description: "y", column: "todo" });
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
await tool.execute("call-2", { id: task.id }, undefined, undefined, { cwd: rootDir });
const deleted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { allowResurrection?: boolean; deletedAt?: string };
expect(deleted.deletedAt).toBeTruthy();
expect(deleted.allowResurrection).toBeUndefined();
});
});

View File

@@ -275,7 +275,8 @@ Usage:
fn task refine <id> [opts] Create a refinement task from done/in-review
fn task archive <id> Archive a done task
fn task unarchive <id> Unarchive an archived task
fn task delete <id> [--force] Delete a task (use --force to skip confirmation)
fn task delete <id> [--force] [--allow-resurrection]
Delete a task (use --force to skip confirmation; --allow-resurrection permits intentional ID recreation)
fn task attach <id> <file> Attach a file to a task
fn task pause <id> Pause a task (stops all automation)
fn task unpause <id> Unpause a task (resumes automation)
@@ -1138,9 +1139,10 @@ async function main() {
}
case "delete": {
const id = args[2];
if (!id) { console.error("Usage: fn task delete <id> [--force]"); process.exit(1); }
if (!id) { console.error("Usage: fn task delete <id> [--force] [--allow-resurrection]"); process.exit(1); }
const force = args.includes("--force");
await runTaskDelete(id, force, projectName);
const allowResurrection = args.includes("--allow-resurrection");
await runTaskDelete(id, force, allowResurrection, projectName);
break;
}
case "attach": {

View File

@@ -1023,7 +1023,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
console.log();
}
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
export async function runTaskDelete(id: string, force?: boolean, allowResurrection?: boolean, projectName?: string) {
const store = await getStore(projectName);
// Check if task exists first
@@ -1051,6 +1051,7 @@ export async function runTaskDelete(id: string, force?: boolean, projectName?: s
try {
await store.deleteTask(id, {
allowResurrection: allowResurrection === true,
auditContext: {
agentId: "cli",
runId: `synthetic-cli-delete-${id}-${Date.now()}`,

View File

@@ -1177,22 +1177,24 @@ export default function kbExtension(pi: ExtensionAPI) {
label: "fn: Delete Task",
description:
"Soft-delete a task from active Fusion board views. " +
"The task row and artifacts are preserved, and the task ID remains reserved for potential operator recovery.",
"The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation.",
promptSnippet: "Soft-delete a Fusion task",
promptGuidelines: [
"Use for cleaning up test tasks or tasks created in error when you want the task hidden from active board views",
"This tool performs a soft delete: task data is preserved and the ID stays reserved",
"Tasks cannot be undeleted through the current pi/CLI tool surface",
"Use allowResurrection:true when operators want the deleted task ID to be intentionally reusable on future createTask calls",
"Use fn_task_archive for completed work you want to keep referenceable in the board",
"True hard removal is handled by archive cleanup paths (archiveTaskAndCleanup / cleanupArchivedTasks), not fn_task_delete",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
allowResurrection: Type.Optional(Type.Boolean({ description: "When true, mark this tombstone as explicitly reusable for future recreation." })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.deleteTask(params.id, {
allowResurrection: params.allowResurrection === true,
auditContext: {
agentId: "pi-extension",
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,