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

@@ -118,11 +118,12 @@ Unarchive an archived task (move from archived → done). Restores the task to t
### fn_task_delete
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.
Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID to delete (e.g. FN-001) |
| `allowResurrection` | boolean | — | When true, mark this tombstone as explicitly reusable for future recreation. |
### fn_task_plan

View File

@@ -24,7 +24,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
| `fn_task_archive` | Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. |
| `fn_task_unarchive` | Unarchive an archived task (move from archived → done). Restores the task to the done column. |
| `fn_task_delete` | 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. |
| `fn_task_delete` | Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. |
| `fn_task_import_github` | Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the planning column. Each task includes the issue title and body with a link to the source issue. |
| `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. |
| `fn_task_browse_github_issues` | List open GitHub issues from a repository to browse before importing. Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue to import specific issues by number. |

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()}`,

View File

@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TombstonedTaskResurrectionError } from "../store.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("FN-5233 tombstone sticky-window duplicate intake", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
vi.useRealTimers();
await harness.afterEach();
});
it("refuses near-duplicate intake against recent tombstone and records intake:resurrection-blocked", async () => {
const store = harness.store();
await store.updateSettings({ tombstoneStickyWindowDays: 7 });
const original = await store.createTask({
title: "Memory leak in merge worker",
description: "Fix memory leak in merge worker when queue is drained",
source: { sourceType: "unknown", sourceAgentId: "agent-1" },
});
await store.deleteTask(original.id);
await expect(store.createTask({
title: "Memory leak in merge worker",
description: "Fix memory leak in merge worker when queue is drained",
source: { sourceType: "unknown", sourceAgentId: "agent-1" },
})).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
const events = (store as any).db.prepare(
"SELECT mutationType FROM runAuditEvents WHERE mutationType = 'intake:resurrection-blocked'"
).all() as Array<{ mutationType: string }>;
expect(events).toHaveLength(1);
});
it("allows intake when sticky window is disabled", async () => {
const store = harness.store();
await store.updateSettings({ tombstoneStickyWindowDays: 0 });
const original = await store.createTask({
title: "A",
description: "same text",
source: { sourceType: "unknown", sourceAgentId: "agent-2" },
});
await store.deleteTask(original.id);
await expect(store.createTask({
title: "A",
description: "same text",
source: { sourceType: "unknown", sourceAgentId: "agent-2" },
})).resolves.toMatchObject({ id: expect.any(String) });
});
it("ignores tombstones outside sticky window", async () => {
vi.useFakeTimers();
const oldNow = new Date("2026-01-01T00:00:00.000Z");
vi.setSystemTime(oldNow);
const store = harness.store();
await store.updateSettings({ tombstoneStickyWindowDays: 7 });
const original = await store.createTask({
title: "Old tombstone",
description: "same text",
source: { sourceType: "unknown", sourceAgentId: "agent-2b" },
});
await store.deleteTask(original.id);
vi.setSystemTime(new Date("2026-01-12T00:00:00.000Z"));
await expect(store.createTask({
title: "Old tombstone",
description: "same text",
source: { sourceType: "unknown", sourceAgentId: "agent-2b" },
})).resolves.toMatchObject({ id: expect.any(String) });
});
it("allows intake when tombstoned match has allowResurrection unlock", async () => {
const store = harness.store();
await store.updateSettings({ tombstoneStickyWindowDays: 7 });
const original = await store.createTask({
title: "Refactor parser",
description: "Refactor parser for streaming input",
source: { sourceType: "unknown", sourceAgentId: "agent-3" },
});
await store.deleteTask(original.id, { allowResurrection: true });
await expect(store.createTask({
title: "Refactor parser",
description: "Refactor parser for streaming input",
source: { sourceType: "unknown", sourceAgentId: "agent-3" },
})).resolves.toMatchObject({ id: expect.any(String) });
});
it("keeps live-task duplicate behavior (auto-archive) unchanged", async () => {
const store = harness.store();
const live = await store.createTask({
title: "Live dup",
description: "duplicate text",
source: { sourceType: "unknown", sourceAgentId: "agent-4" },
});
const dup = await store.createTask({
title: "Live dup",
description: "duplicate text",
source: { sourceType: "unknown", sourceAgentId: "agent-4" },
});
expect(dup.column).toBe("archived");
const events = (store as any).db.prepare("SELECT mutationType FROM runAuditEvents WHERE mutationType = 'intake:resurrection-blocked'").all() as Array<{ mutationType: string }>;
expect(events).toHaveLength(0);
expect(live.id).not.toBe(dup.id);
});
it("fails open when tombstone widening query errors", async () => {
const store = harness.store();
const db = (store as any).db;
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql: string) => {
if (sql.includes("deletedAt IS NOT NULL") && sql.includes("sourceAgentId")) {
throw new Error("synthetic tombstone query failure");
}
return originalPrepare(sql);
};
await expect(store.createTask({
title: "Fallback path",
description: "create despite widening failure",
source: { sourceType: "unknown", sourceAgentId: "agent-5" },
})).resolves.toMatchObject({ id: expect.any(String) });
db.prepare = originalPrepare;
});
});

View File

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TombstonedTaskResurrectionError } from "../store.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("FN-5233 tombstoned createTask behavior", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
await harness.afterEach();
});
it("throws TombstonedTaskResurrectionError when recreating a tombstoned id", async () => {
const store = harness.store();
const task = await store.createTask({ title: "a", description: "alpha", column: "todo" });
await store.deleteTask(task.id);
const created: string[] = [];
store.on("task:created", (event) => created.push(event.id));
await expect(
store.createTaskWithReservedId({ title: "b", description: "beta", column: "todo" }, { taskId: task.id }),
).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
const row = (store as any).db.prepare("SELECT deletedAt, allowResurrection FROM tasks WHERE id = ?").get(task.id) as {
deletedAt: string | null;
allowResurrection: number;
};
expect(row.deletedAt).toBeTruthy();
expect(row.allowResurrection).toBe(0);
expect(created).toEqual([]);
});
it("allows forceResurrect recreation and clears allowResurrection", async () => {
const store = harness.store();
const task = await store.createTask({ title: "a", description: "alpha", column: "todo" });
await store.deleteTask(task.id, { allowResurrection: true });
const created: string[] = [];
store.on("task:created", (event) => created.push(event.id));
const recreated = await store.createTaskWithReservedId(
{ title: "c", description: "charlie", forceResurrect: true, column: "todo" },
{ taskId: task.id },
);
expect(recreated.id).toBe(task.id);
expect(created).toEqual([task.id]);
const row = (store as any).db.prepare("SELECT deletedAt, allowResurrection FROM tasks WHERE id = ?").get(task.id) as {
deletedAt: string | null;
allowResurrection: number;
};
expect(row.deletedAt).toBeNull();
expect(row.allowResurrection).toBe(0);
});
it("allows recreation when tombstone row has allowResurrection=1", async () => {
const store = harness.store();
const task = await store.createTask({ title: "a", description: "alpha", column: "todo" });
await store.deleteTask(task.id, { allowResurrection: true });
const recreated = await store.createTaskWithReservedId({ title: "d", description: "delta", column: "todo" }, { taskId: task.id });
expect(recreated.id).toBe(task.id);
const row = (store as any).db.prepare("SELECT deletedAt, allowResurrection FROM tasks WHERE id = ?").get(task.id) as {
deletedAt: string | null;
allowResurrection: number;
};
expect(row.deletedAt).toBeNull();
expect(row.allowResurrection).toBe(0);
});
it("records task:resurrection-blocked audit for createTask refusal", async () => {
const store = harness.store();
const task = await store.createTask({ title: "a", description: "alpha", column: "todo" });
await store.deleteTask(task.id);
await expect(
store.createTaskWithReservedId({ title: "b", description: "beta", column: "todo" }, { taskId: task.id }),
).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
const events = (store as any).db.prepare(
"SELECT mutationType, metadata FROM runAuditEvents WHERE taskId = ? AND mutationType = ?"
).all(task.id, "task:resurrection-blocked") as Array<{ mutationType: string; metadata: string | null }>;
expect(events.length).toBeGreaterThan(0);
expect(events.at(-1)?.metadata ?? "").toContain("createTask");
});
});

View File

@@ -314,7 +314,8 @@ CREATE TABLE IF NOT EXISTS tasks (
checkoutRunId TEXT,
checkoutLeaseRenewedAt TEXT,
checkoutLeaseEpoch INTEGER DEFAULT 0,
deletedAt TEXT
deletedAt TEXT,
allowResurrection INTEGER DEFAULT 0
);
-- Config table (single row with project settings)
@@ -3505,6 +3506,7 @@ export class Database {
if (version < 88) {
this.applyMigration(88, () => {
this.addColumnIfMissing("tasks", "allowResurrection", "INTEGER DEFAULT 0");
try {
const taskColumns = this.getTableColumns("tasks");
const requiredColumns = ["paused", "userPaused", "pausedByAgentId", "pausedReason"];

View File

@@ -21,11 +21,17 @@ export interface SameAgentDuplicateCandidate {
createdAt: number;
sourceAgentId: string | null;
sourceParentTaskId?: string | null;
tombstoned?: boolean;
deletedAt?: string;
allowResurrection?: boolean;
}
export interface SameAgentDuplicateMatch {
id: string;
score: number;
tombstoned?: boolean;
deletedAt?: string;
allowResurrection?: boolean;
}
/**
@@ -51,10 +57,11 @@ export function findSameAgentDuplicates(
const inputParentId = input.sourceParentTaskId ?? null;
const recent = candidates.filter((candidate) => {
if (candidate.createdAt < cutoff) return false;
const agentMatch = inputAgentId != null && candidate.sourceAgentId === inputAgentId;
const parentMatch = inputParentId != null && candidate.sourceParentTaskId === inputParentId;
return agentMatch || parentMatch;
if (!agentMatch && !parentMatch) return false;
if (candidate.tombstoned) return true;
return candidate.createdAt >= cutoff;
});
const matches = findDuplicateMatches(
@@ -68,7 +75,17 @@ export function findSameAgentDuplicates(
{ threshold },
);
return matches.map((match) => ({ id: match.id, score: match.score }));
const metadataById = new Map(recent.map((candidate) => [candidate.id, candidate]));
return matches.map((match) => {
const candidate = metadataById.get(match.id);
return {
id: match.id,
score: match.score,
tombstoned: candidate?.tombstoned,
deletedAt: candidate?.deletedAt,
allowResurrection: candidate?.allowResurrection,
};
});
}
export async function archiveAsSameAgentDuplicate(

View File

@@ -134,6 +134,7 @@ export {
SelfDefeatingDependencyError,
DependencyCycleError,
TaskDeletedError,
TombstonedTaskResurrectionError,
MergeQueueTaskNotFoundError,
MergeQueueInvalidColumnError,
MergeQueueLeaseOwnershipError,

View File

@@ -188,6 +188,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
pollIntervalMs: 15000,
heartbeatMultiplier: 1,
autoClaimCandidatesInPrompt: 5,
tombstoneStickyWindowDays: 7,
heartbeatScopeDiscipline: "strict",
heartbeatPromptTemplate: "default",
groupOverlappingFiles: true,

View File

@@ -173,6 +173,7 @@ interface TaskRow {
checkoutLeaseRenewedAt: string | null;
checkoutLeaseEpoch: number | null;
deletedAt: string | null;
allowResurrection: number | null;
}
/** Database row shape for the task_documents table. */
@@ -621,6 +622,20 @@ export class TaskDeletedError extends Error {
}
}
export class TombstonedTaskResurrectionError extends Error {
constructor(
public readonly taskId: string,
public readonly deletedAt: string,
public readonly allowResurrection: boolean,
) {
super(
`Task ${taskId} is soft-deleted (deletedAt=${deletedAt}) and cannot be recreated without forceResurrect: true. `
+ `Operator unlock: allowResurrection=${allowResurrection}`,
);
this.name = "TombstonedTaskResurrectionError";
}
}
export class TaskHasLineageChildrenError extends Error {
readonly taskId: string;
readonly childIds: string[];
@@ -1499,6 +1514,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
checkoutLeaseRenewedAt: row.checkoutLeaseRenewedAt || undefined,
checkoutLeaseEpoch: row.checkoutLeaseEpoch ?? undefined,
deletedAt: row.deletedAt ?? undefined,
allowResurrection: row.allowResurrection ? true : undefined,
};
}
@@ -1740,7 +1756,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt",
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection",
// `log` is fetched in slim mode so the server can aggregate
// `timedExecutionMs` from `[timing] … in <N>ms` entries before
// returning. The log itself is stripped from the response —
@@ -1789,7 +1805,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt",
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection",
];
const limitedLog = `
@@ -1926,6 +1942,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.checkoutLeaseRenewedAt ?? null,
task.checkoutLeaseEpoch ?? 0,
task.deletedAt ?? null,
task.allowResurrection ? 1 : 0,
];
}
@@ -1948,7 +1965,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies, steps, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
) VALUES (${placeholders})
`).run(...values);
this.db.bumpLastModified();
@@ -1975,7 +1992,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies, steps, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
) VALUES (${placeholders})
ON CONFLICT(id) DO UPDATE SET
lineageId = excluded.lineageId,
@@ -2086,7 +2103,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
checkoutRunId = excluded.checkoutRunId,
checkoutLeaseRenewedAt = excluded.checkoutLeaseRenewedAt,
checkoutLeaseEpoch = excluded.checkoutLeaseEpoch,
deletedAt = excluded.deletedAt
deletedAt = excluded.deletedAt,
allowResurrection = excluded.allowResurrection
`).run(...this.getTaskPersistValues(task));
this.db.bumpLastModified();
}
@@ -2211,6 +2229,38 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
private maybeResolveTombstonedTaskId(
id: string,
input: Pick<TaskCreateInput, "forceResurrect">,
operation: "createTask" | "duplicateTask" | "refineTask",
): void {
const existing = this.readTaskFromDb(id, { includeDeleted: true });
if (!existing?.deletedAt) return;
const allowResurrection = existing.allowResurrection === true;
if (input.forceResurrect === true || allowResurrection) {
this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
this.db.bumpLastModified();
return;
}
storeLog.warn(`[tombstone-resurrection-blocked] ${id} deletedAt=${existing.deletedAt}`);
this.insertRunAuditEventRow({
taskId: id,
domain: "database",
mutationType: "task:resurrection-blocked",
target: id,
metadata: {
id,
deletedAt: existing.deletedAt,
allowResurrection,
operation,
},
});
throw new TombstonedTaskResurrectionError(id, existing.deletedAt, allowResurrection);
}
private isTaskArchived(id: string): boolean {
const row = this.db.prepare(`SELECT "column" FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(id) as { column: Column } | undefined;
if (row) {
@@ -3524,6 +3574,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await this.assertNoDependencyCycle(id, input.dependencies ?? [], "createTaskWithReservedId");
this.maybeResolveTombstonedTaskId(id, input, "createTask");
this.assertTaskIdAvailable(id);
const title = input.title?.trim() || undefined;
@@ -3678,6 +3729,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
storeLog.log(`[title-id-drift] normalized title for ${id}: removed=[${removed.join(",")}]`);
}
this.maybeResolveTombstonedTaskId(id, input, "createTask");
this.assertTaskIdAvailable(id);
const dir = this.taskDir(id);
@@ -3730,31 +3782,107 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return agentMatch || parentMatch;
});
const settings = await this.getSettings();
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
let tombstonedCandidates: Array<{
id: string;
title: string | null;
description: string;
column: Column;
createdAt: string;
sourceAgentId: string | null;
deletedAt: string;
allowResurrection: number | null;
}> = [];
if (stickyWindowDays > 0) {
try {
const cutoffIso = new Date(nowMs - stickyWindowDays * 24 * 60 * 60 * 1000).toISOString();
tombstonedCandidates = this.db.prepare(`
SELECT id, title, description, "column", createdAt, sourceAgentId, deletedAt, allowResurrection
FROM tasks
WHERE deletedAt IS NOT NULL
AND deletedAt >= ?
AND sourceAgentId = ?
AND id != ?
`).all(cutoffIso, sourceAgentId, task.id) as typeof tombstonedCandidates;
} catch (error) {
storeLog.warn(`FN-5233 tombstone candidate widening failed open for ${task.id}: ${getErrorMessage(error)}`);
}
}
const matches = findSameAgentDuplicates(
{
title: input.title ?? task.title,
description: input.description,
sourceParentTaskId,
},
recent.map((candidate) => ({
id: candidate.id,
title: candidate.title ?? "",
description: candidate.description,
column: candidate.column,
createdAt: Date.parse(candidate.createdAt),
sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
})),
[
...recent.map((candidate) => ({
id: candidate.id,
title: candidate.title ?? "",
description: candidate.description,
column: candidate.column,
createdAt: Date.parse(candidate.createdAt),
sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
tombstoned: false,
})),
...tombstonedCandidates.map((candidate) => ({
id: candidate.id,
title: candidate.title ?? "",
description: candidate.description,
column: "todo" as Column,
createdAt: Date.parse(candidate.createdAt),
sourceAgentId: candidate.sourceAgentId,
sourceParentTaskId: null,
tombstoned: true,
deletedAt: candidate.deletedAt,
allowResurrection: candidate.allowResurrection === 1,
})),
],
{ nowMs, sourceAgentId },
);
if (matches.length === 0) return;
const siblingTaskIds = matches.map((match) => match.id);
const scores = Object.fromEntries(matches.map((match) => [match.id, match.score]));
const tombstonedMatch = matches.find((match) => match.tombstoned && match.allowResurrection !== true);
if (tombstonedMatch?.deletedAt) {
this.insertRunAuditEventRow({
taskId: task.id,
domain: "database",
mutationType: "intake:resurrection-blocked",
target: task.id,
metadata: {
matchedTaskId: tombstonedMatch.id,
score: tombstonedMatch.score,
tombstoneDeletedAt: tombstonedMatch.deletedAt,
stickyWindowDays,
},
});
if (this.isWatching) this.taskCache.delete(task.id);
this.deleteTaskById(task.id);
const { rm } = await import("node:fs/promises");
const taskDir = this.taskDir(task.id);
if (existsSync(taskDir)) {
await rm(taskDir, { recursive: true, force: true });
}
throw new TombstonedTaskResurrectionError(
tombstonedMatch.id,
tombstonedMatch.deletedAt,
tombstonedMatch.allowResurrection === true,
);
}
const siblingTaskIds = matches.filter((match) => !match.tombstoned).map((match) => match.id);
if (siblingTaskIds.length === 0) return;
const scores = Object.fromEntries(matches.filter((match) => !match.tombstoned).map((match) => [match.id, match.score]));
await archiveAsSameAgentDuplicate(this, task.id, siblingTaskIds, scores);
task.column = "archived";
} catch (error) {
if (error instanceof TombstonedTaskResurrectionError) {
throw error;
}
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(error)}`);
}
}
@@ -3806,6 +3934,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
baseBranch: sourceTask.baseBranch,
};
this.maybeResolveTombstonedTaskId(newId, {}, "duplicateTask");
this.assertTaskIdAvailable(newId);
const newDir = this.taskDir(newId);
@@ -3882,6 +4011,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
};
this.maybeResolveTombstonedTaskId(newId, {}, "refineTask");
this.assertTaskIdAvailable(newId);
const newDir = this.taskDir(newId);
@@ -6620,6 +6750,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
allowResurrection?: boolean;
githubIssueAction?: GithubIssueAction;
auditContext?: { agentId: string; runId: string; sessionId?: string };
},
@@ -6667,7 +6798,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
const deletedAt = new Date().toISOString();
this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, updatedAt = ? WHERE id = ?").run(deletedAt, deletedAt, id);
const allowResurrection = options?.allowResurrection === true ? 1 : 0;
this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, allowResurrection = ?, updatedAt = ? WHERE id = ?").run(deletedAt, allowResurrection, deletedAt, id);
this.recordRunAuditEvent({
domain: "database",
mutationType: "task:deleted",
@@ -6681,6 +6813,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
githubIssueAction: options?.githubIssueAction ?? "auto",
removeDependencyReferences: !!options?.removeDependencyReferences,
removeLineageReferences: !!options?.removeLineageReferences,
allowResurrection: options?.allowResurrection === true,
sessionId: options?.auditContext?.sessionId,
},
});

View File

@@ -1879,6 +1879,7 @@ export interface Task {
* todo/triage when resume state is not preserved. */
executionCompletedAt?: string;
deletedAt?: string;
allowResurrection?: boolean;
createdAt: string;
updatedAt: string;
}
@@ -1915,6 +1916,11 @@ export interface TaskCreateInput {
title?: string;
/** Optional lineage override for trusted replication/import paths only. */
lineageId?: string;
/**
* Opt-in createTask override for soft-deleted ID reuse.
* Not persisted to storage.
*/
forceResurrect?: boolean;
description: string;
/** Configured merge target/base branch for this task (task intent).
* Defaults to the project default branch when omitted. */
@@ -2701,6 +2707,9 @@ export interface ProjectSettings {
heartbeatMultiplier?: number;
/** Number of auto-claim candidates rendered in no-task heartbeat prompts. Range: 0-10. Default: 5. */
autoClaimCandidatesInPrompt?: number;
/** Sticky window for intake duplicate checks against soft-deleted tasks.
* Unit: days. Default: 7. Set to 0 to disable tombstone-window widening. */
tombstoneStickyWindowDays?: number;
/** Heartbeat scope-discipline procedure mode.
* - "strict": coordination-focused scope discipline (default)
* - "lite": pre-FN-3884 behavior

View File

@@ -1158,6 +1158,7 @@ describe("DELETE /tasks/:id", () => {
expect(res.status).toBe(200);
expect(res.body.id).toBe("KB-001");
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", expect.objectContaining({
allowResurrection: false,
removeDependencyReferences: false,
removeLineageReferences: false,
githubIssueAction: undefined,
@@ -1223,6 +1224,20 @@ describe("DELETE /tasks/:id", () => {
}));
});
it("passes allowResurrection when explicitly requested", async () => {
const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-001" };
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(deletedTask);
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001?allowResurrection=true");
expect(res.status).toBe(200);
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", expect.objectContaining({
allowResurrection: true,
removeDependencyReferences: false,
removeLineageReferences: false,
}));
});
it.each(["close", "delete", "leave", "auto"] as const)("forwards githubIssueAction=%s", async (githubIssueAction) => {
const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-001" };
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(deletedTask);

View File

@@ -3415,6 +3415,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const removeLineageReferences = req.query.removeLineageReferences === "1"
|| req.query.removeLineageReferences === "true";
const githubIssueActionRaw = req.query.githubIssueAction;
const allowResurrection = req.query.allowResurrection === "1"
|| req.query.allowResurrection === "true";
const githubIssueActionValues: readonly GithubIssueAction[] = ["close", "delete", "leave", "auto"];
let githubIssueAction: GithubIssueAction | undefined;
if (typeof githubIssueActionRaw === "string") {
@@ -3426,6 +3428,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const task = await scopedStore.deleteTask(req.params.id, {
removeDependencyReferences,
removeLineageReferences,
allowResurrection,
githubIssueAction,
auditContext: {
agentId: "system",

View File

@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskDeletedError, TaskStore, TombstonedTaskResurrectionError } from "@fusion/core";
describe("reliability interactions: FN-5233 soft-delete stickiness", () => {
let rootDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "fn-5233-reliability-"));
await mkdir(join(rootDir, ".fusion"), { recursive: true });
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
});
afterEach(async () => {
vi.useRealTimers();
await rm(rootDir, { recursive: true, force: true });
});
it("composes Layer1 delete signal + Layer2 write guard + Layer3 recreate refusal", async () => {
const task = await store.createTask({ title: "target", description: "target", column: "todo" });
const deletedEvents: string[] = [];
store.on("task:deleted", (event) => deletedEvents.push(event.id));
await store.deleteTask(task.id);
expect(deletedEvents).toEqual([task.id]);
await expect(store.updateTask(task.id, { title: "stale write" })).rejects.toBeInstanceOf(TaskDeletedError);
await expect(
store.createTaskWithReservedId({ title: "recreate", description: "same", column: "todo" }, { taskId: task.id }),
).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
});
it("refuses near-duplicate intake against recent tombstone and supports explicit unlock", async () => {
await store.updateSettings({ tombstoneStickyWindowDays: 7 });
const original = await store.createTask({
title: "duplicate me",
description: "duplicate me now",
source: { sourceType: "unknown", sourceAgentId: "agent-r1" },
});
await store.deleteTask(original.id);
await expect(store.createTask({
title: "duplicate me",
description: "duplicate me now",
source: { sourceType: "unknown", sourceAgentId: "agent-r1" },
})).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
const blocked = (store as any).db.prepare("SELECT mutationType FROM runAuditEvents WHERE mutationType = 'intake:resurrection-blocked'").all() as Array<{ mutationType: string }>;
expect(blocked.length).toBeGreaterThan(0);
const unlocked = await store.createTask({
title: "unlock target",
description: "unlock target",
source: { sourceType: "unknown", sourceAgentId: "agent-r2" },
});
await store.deleteTask(unlocked.id, { allowResurrection: true });
await expect(store.createTaskWithReservedId({ title: "allowed", description: "allowed", column: "todo" }, { taskId: unlocked.id })).resolves.toMatchObject({ id: unlocked.id });
});
it("does not emit recreated task ids across repeated stale attempts over simulated 6 minutes", async () => {
vi.useFakeTimers();
const task = await store.createTask({ title: "clock", description: "clock", column: "todo" });
await store.deleteTask(task.id);
const createdEvents: string[] = [];
store.on("task:created", (event) => createdEvents.push(event.id));
for (let i = 0; i < 6; i += 1) {
vi.advanceTimersByTime(60_000);
await expect(
store.createTaskWithReservedId({ title: `retry-${i}`, description: "retry", column: "todo" }, { taskId: task.id }),
).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
}
expect(createdEvents).toEqual([]);
});
});