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:
committed by
gsxdsm
parent
916047c2ae
commit
2d2e5b809f
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -134,6 +134,7 @@ export {
|
||||
SelfDefeatingDependencyError,
|
||||
DependencyCycleError,
|
||||
TaskDeletedError,
|
||||
TombstonedTaskResurrectionError,
|
||||
MergeQueueTaskNotFoundError,
|
||||
MergeQueueInvalidColumnError,
|
||||
MergeQueueLeaseOwnershipError,
|
||||
|
||||
@@ -188,6 +188,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
pollIntervalMs: 15000,
|
||||
heartbeatMultiplier: 1,
|
||||
autoClaimCandidatesInPrompt: 5,
|
||||
tombstoneStickyWindowDays: 7,
|
||||
heartbeatScopeDiscipline: "strict",
|
||||
heartbeatPromptTemplate: "default",
|
||||
groupOverlappingFiles: true,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user