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

@@ -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");
});
});