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
|
### 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 |
|
| Parameter | Type | Required | Description |
|
||||||
|-----------|------|----------|-------------|
|
|-----------|------|----------|-------------|
|
||||||
| `id` | string | ✓ | Task ID to delete (e.g. FN-001) |
|
| `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
|
### 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_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_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_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` | 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_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. |
|
| `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>"));
|
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 () => {
|
it("routes desktop flags to runDesktop", async () => {
|
||||||
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
||||||
expect(commandMocks.runDesktop).toHaveBeenCalledWith({
|
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 refine <id> [opts] Create a refinement task from done/in-review
|
||||||
fn task archive <id> Archive a done task
|
fn task archive <id> Archive a done task
|
||||||
fn task unarchive <id> Unarchive an archived 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 attach <id> <file> Attach a file to a task
|
||||||
fn task pause <id> Pause a task (stops all automation)
|
fn task pause <id> Pause a task (stops all automation)
|
||||||
fn task unpause <id> Unpause a task (resumes automation)
|
fn task unpause <id> Unpause a task (resumes automation)
|
||||||
@@ -1138,9 +1139,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
case "delete": {
|
case "delete": {
|
||||||
const id = args[2];
|
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");
|
const force = args.includes("--force");
|
||||||
await runTaskDelete(id, force, projectName);
|
const allowResurrection = args.includes("--allow-resurrection");
|
||||||
|
await runTaskDelete(id, force, allowResurrection, projectName);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "attach": {
|
case "attach": {
|
||||||
|
|||||||
@@ -1023,7 +1023,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
|||||||
console.log();
|
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);
|
const store = await getStore(projectName);
|
||||||
|
|
||||||
// Check if task exists first
|
// Check if task exists first
|
||||||
@@ -1051,6 +1051,7 @@ export async function runTaskDelete(id: string, force?: boolean, projectName?: s
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await store.deleteTask(id, {
|
await store.deleteTask(id, {
|
||||||
|
allowResurrection: allowResurrection === true,
|
||||||
auditContext: {
|
auditContext: {
|
||||||
agentId: "cli",
|
agentId: "cli",
|
||||||
runId: `synthetic-cli-delete-${id}-${Date.now()}`,
|
runId: `synthetic-cli-delete-${id}-${Date.now()}`,
|
||||||
|
|||||||
@@ -1177,22 +1177,24 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
label: "fn: Delete Task",
|
label: "fn: Delete Task",
|
||||||
description:
|
description:
|
||||||
"Soft-delete a task from active Fusion board views. " +
|
"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",
|
promptSnippet: "Soft-delete a Fusion task",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
"Use for cleaning up test tasks or tasks created in error when you want the task hidden from active board views",
|
"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",
|
"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",
|
"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",
|
"True hard removal is handled by archive cleanup paths (archiveTaskAndCleanup / cleanupArchivedTasks), not fn_task_delete",
|
||||||
],
|
],
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
|
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) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
const store = await getStore(ctx.cwd);
|
const store = await getStore(ctx.cwd);
|
||||||
const task = await store.deleteTask(params.id, {
|
const task = await store.deleteTask(params.id, {
|
||||||
|
allowResurrection: params.allowResurrection === true,
|
||||||
auditContext: {
|
auditContext: {
|
||||||
agentId: "pi-extension",
|
agentId: "pi-extension",
|
||||||
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
|
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,
|
checkoutRunId TEXT,
|
||||||
checkoutLeaseRenewedAt TEXT,
|
checkoutLeaseRenewedAt TEXT,
|
||||||
checkoutLeaseEpoch INTEGER DEFAULT 0,
|
checkoutLeaseEpoch INTEGER DEFAULT 0,
|
||||||
deletedAt TEXT
|
deletedAt TEXT,
|
||||||
|
allowResurrection INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Config table (single row with project settings)
|
-- Config table (single row with project settings)
|
||||||
@@ -3505,6 +3506,7 @@ export class Database {
|
|||||||
|
|
||||||
if (version < 88) {
|
if (version < 88) {
|
||||||
this.applyMigration(88, () => {
|
this.applyMigration(88, () => {
|
||||||
|
this.addColumnIfMissing("tasks", "allowResurrection", "INTEGER DEFAULT 0");
|
||||||
try {
|
try {
|
||||||
const taskColumns = this.getTableColumns("tasks");
|
const taskColumns = this.getTableColumns("tasks");
|
||||||
const requiredColumns = ["paused", "userPaused", "pausedByAgentId", "pausedReason"];
|
const requiredColumns = ["paused", "userPaused", "pausedByAgentId", "pausedReason"];
|
||||||
|
|||||||
@@ -21,11 +21,17 @@ export interface SameAgentDuplicateCandidate {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
sourceAgentId: string | null;
|
sourceAgentId: string | null;
|
||||||
sourceParentTaskId?: string | null;
|
sourceParentTaskId?: string | null;
|
||||||
|
tombstoned?: boolean;
|
||||||
|
deletedAt?: string;
|
||||||
|
allowResurrection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SameAgentDuplicateMatch {
|
export interface SameAgentDuplicateMatch {
|
||||||
id: string;
|
id: string;
|
||||||
score: number;
|
score: number;
|
||||||
|
tombstoned?: boolean;
|
||||||
|
deletedAt?: string;
|
||||||
|
allowResurrection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,10 +57,11 @@ export function findSameAgentDuplicates(
|
|||||||
const inputParentId = input.sourceParentTaskId ?? null;
|
const inputParentId = input.sourceParentTaskId ?? null;
|
||||||
|
|
||||||
const recent = candidates.filter((candidate) => {
|
const recent = candidates.filter((candidate) => {
|
||||||
if (candidate.createdAt < cutoff) return false;
|
|
||||||
const agentMatch = inputAgentId != null && candidate.sourceAgentId === inputAgentId;
|
const agentMatch = inputAgentId != null && candidate.sourceAgentId === inputAgentId;
|
||||||
const parentMatch = inputParentId != null && candidate.sourceParentTaskId === inputParentId;
|
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(
|
const matches = findDuplicateMatches(
|
||||||
@@ -68,7 +75,17 @@ export function findSameAgentDuplicates(
|
|||||||
{ threshold },
|
{ 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(
|
export async function archiveAsSameAgentDuplicate(
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export {
|
|||||||
SelfDefeatingDependencyError,
|
SelfDefeatingDependencyError,
|
||||||
DependencyCycleError,
|
DependencyCycleError,
|
||||||
TaskDeletedError,
|
TaskDeletedError,
|
||||||
|
TombstonedTaskResurrectionError,
|
||||||
MergeQueueTaskNotFoundError,
|
MergeQueueTaskNotFoundError,
|
||||||
MergeQueueInvalidColumnError,
|
MergeQueueInvalidColumnError,
|
||||||
MergeQueueLeaseOwnershipError,
|
MergeQueueLeaseOwnershipError,
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
heartbeatMultiplier: 1,
|
heartbeatMultiplier: 1,
|
||||||
autoClaimCandidatesInPrompt: 5,
|
autoClaimCandidatesInPrompt: 5,
|
||||||
|
tombstoneStickyWindowDays: 7,
|
||||||
heartbeatScopeDiscipline: "strict",
|
heartbeatScopeDiscipline: "strict",
|
||||||
heartbeatPromptTemplate: "default",
|
heartbeatPromptTemplate: "default",
|
||||||
groupOverlappingFiles: true,
|
groupOverlappingFiles: true,
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ interface TaskRow {
|
|||||||
checkoutLeaseRenewedAt: string | null;
|
checkoutLeaseRenewedAt: string | null;
|
||||||
checkoutLeaseEpoch: number | null;
|
checkoutLeaseEpoch: number | null;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
|
allowResurrection: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Database row shape for the task_documents table. */
|
/** 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 {
|
export class TaskHasLineageChildrenError extends Error {
|
||||||
readonly taskId: string;
|
readonly taskId: string;
|
||||||
readonly childIds: string[];
|
readonly childIds: string[];
|
||||||
@@ -1499,6 +1514,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
checkoutLeaseRenewedAt: row.checkoutLeaseRenewedAt || undefined,
|
checkoutLeaseRenewedAt: row.checkoutLeaseRenewedAt || undefined,
|
||||||
checkoutLeaseEpoch: row.checkoutLeaseEpoch ?? undefined,
|
checkoutLeaseEpoch: row.checkoutLeaseEpoch ?? undefined,
|
||||||
deletedAt: row.deletedAt ?? undefined,
|
deletedAt: row.deletedAt ?? undefined,
|
||||||
|
allowResurrection: row.allowResurrection ? true : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1740,7 +1756,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
"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
|
// `log` is fetched in slim mode so the server can aggregate
|
||||||
// `timedExecutionMs` from `[timing] … in <N>ms` entries before
|
// `timedExecutionMs` from `[timing] … in <N>ms` entries before
|
||||||
// returning. The log itself is stripped from the response —
|
// returning. The log itself is stripped from the response —
|
||||||
@@ -1789,7 +1805,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||||
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt",
|
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection",
|
||||||
];
|
];
|
||||||
|
|
||||||
const limitedLog = `
|
const limitedLog = `
|
||||||
@@ -1926,6 +1942,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
task.checkoutLeaseRenewedAt ?? null,
|
task.checkoutLeaseRenewedAt ?? null,
|
||||||
task.checkoutLeaseEpoch ?? 0,
|
task.checkoutLeaseEpoch ?? 0,
|
||||||
task.deletedAt ?? null,
|
task.deletedAt ?? null,
|
||||||
|
task.allowResurrection ? 1 : 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1948,7 +1965,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
dependencies, steps, log, attachments, steeringComments,
|
dependencies, steps, log, attachments, steeringComments,
|
||||||
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
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})
|
) VALUES (${placeholders})
|
||||||
`).run(...values);
|
`).run(...values);
|
||||||
this.db.bumpLastModified();
|
this.db.bumpLastModified();
|
||||||
@@ -1975,7 +1992,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
dependencies, steps, log, attachments, steeringComments,
|
dependencies, steps, log, attachments, steeringComments,
|
||||||
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
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})
|
) VALUES (${placeholders})
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
lineageId = excluded.lineageId,
|
lineageId = excluded.lineageId,
|
||||||
@@ -2086,7 +2103,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
checkoutRunId = excluded.checkoutRunId,
|
checkoutRunId = excluded.checkoutRunId,
|
||||||
checkoutLeaseRenewedAt = excluded.checkoutLeaseRenewedAt,
|
checkoutLeaseRenewedAt = excluded.checkoutLeaseRenewedAt,
|
||||||
checkoutLeaseEpoch = excluded.checkoutLeaseEpoch,
|
checkoutLeaseEpoch = excluded.checkoutLeaseEpoch,
|
||||||
deletedAt = excluded.deletedAt
|
deletedAt = excluded.deletedAt,
|
||||||
|
allowResurrection = excluded.allowResurrection
|
||||||
`).run(...this.getTaskPersistValues(task));
|
`).run(...this.getTaskPersistValues(task));
|
||||||
this.db.bumpLastModified();
|
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 {
|
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;
|
const row = this.db.prepare(`SELECT "column" FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(id) as { column: Column } | undefined;
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -3524,6 +3574,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
await this.assertNoDependencyCycle(id, input.dependencies ?? [], "createTaskWithReservedId");
|
await this.assertNoDependencyCycle(id, input.dependencies ?? [], "createTaskWithReservedId");
|
||||||
|
|
||||||
|
this.maybeResolveTombstonedTaskId(id, input, "createTask");
|
||||||
this.assertTaskIdAvailable(id);
|
this.assertTaskIdAvailable(id);
|
||||||
|
|
||||||
const title = input.title?.trim() || undefined;
|
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(",")}]`);
|
storeLog.log(`[title-id-drift] normalized title for ${id}: removed=[${removed.join(",")}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.maybeResolveTombstonedTaskId(id, input, "createTask");
|
||||||
this.assertTaskIdAvailable(id);
|
this.assertTaskIdAvailable(id);
|
||||||
|
|
||||||
const dir = this.taskDir(id);
|
const dir = this.taskDir(id);
|
||||||
@@ -3730,31 +3782,107 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return agentMatch || parentMatch;
|
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(
|
const matches = findSameAgentDuplicates(
|
||||||
{
|
{
|
||||||
title: input.title ?? task.title,
|
title: input.title ?? task.title,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
sourceParentTaskId,
|
sourceParentTaskId,
|
||||||
},
|
},
|
||||||
recent.map((candidate) => ({
|
[
|
||||||
id: candidate.id,
|
...recent.map((candidate) => ({
|
||||||
title: candidate.title ?? "",
|
id: candidate.id,
|
||||||
description: candidate.description,
|
title: candidate.title ?? "",
|
||||||
column: candidate.column,
|
description: candidate.description,
|
||||||
createdAt: Date.parse(candidate.createdAt),
|
column: candidate.column,
|
||||||
sourceAgentId: candidate.sourceAgentId ?? null,
|
createdAt: Date.parse(candidate.createdAt),
|
||||||
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
|
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 },
|
{ nowMs, sourceAgentId },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matches.length === 0) return;
|
if (matches.length === 0) return;
|
||||||
|
|
||||||
const siblingTaskIds = matches.map((match) => match.id);
|
const tombstonedMatch = matches.find((match) => match.tombstoned && match.allowResurrection !== true);
|
||||||
const scores = Object.fromEntries(matches.map((match) => [match.id, match.score]));
|
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);
|
await archiveAsSameAgentDuplicate(this, task.id, siblingTaskIds, scores);
|
||||||
task.column = "archived";
|
task.column = "archived";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TombstonedTaskResurrectionError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(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,
|
baseBranch: sourceTask.baseBranch,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.maybeResolveTombstonedTaskId(newId, {}, "duplicateTask");
|
||||||
this.assertTaskIdAvailable(newId);
|
this.assertTaskIdAvailable(newId);
|
||||||
|
|
||||||
const newDir = this.taskDir(newId);
|
const newDir = this.taskDir(newId);
|
||||||
@@ -3882,6 +4011,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
|
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.maybeResolveTombstonedTaskId(newId, {}, "refineTask");
|
||||||
this.assertTaskIdAvailable(newId);
|
this.assertTaskIdAvailable(newId);
|
||||||
|
|
||||||
const newDir = this.taskDir(newId);
|
const newDir = this.taskDir(newId);
|
||||||
@@ -6620,6 +6750,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
options?: {
|
options?: {
|
||||||
removeDependencyReferences?: boolean;
|
removeDependencyReferences?: boolean;
|
||||||
removeLineageReferences?: boolean;
|
removeLineageReferences?: boolean;
|
||||||
|
allowResurrection?: boolean;
|
||||||
githubIssueAction?: GithubIssueAction;
|
githubIssueAction?: GithubIssueAction;
|
||||||
auditContext?: { agentId: string; runId: string; sessionId?: string };
|
auditContext?: { agentId: string; runId: string; sessionId?: string };
|
||||||
},
|
},
|
||||||
@@ -6667,7 +6798,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
|
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
|
||||||
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
|
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
|
||||||
const deletedAt = new Date().toISOString();
|
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({
|
this.recordRunAuditEvent({
|
||||||
domain: "database",
|
domain: "database",
|
||||||
mutationType: "task:deleted",
|
mutationType: "task:deleted",
|
||||||
@@ -6681,6 +6813,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
githubIssueAction: options?.githubIssueAction ?? "auto",
|
githubIssueAction: options?.githubIssueAction ?? "auto",
|
||||||
removeDependencyReferences: !!options?.removeDependencyReferences,
|
removeDependencyReferences: !!options?.removeDependencyReferences,
|
||||||
removeLineageReferences: !!options?.removeLineageReferences,
|
removeLineageReferences: !!options?.removeLineageReferences,
|
||||||
|
allowResurrection: options?.allowResurrection === true,
|
||||||
sessionId: options?.auditContext?.sessionId,
|
sessionId: options?.auditContext?.sessionId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1879,6 +1879,7 @@ export interface Task {
|
|||||||
* todo/triage when resume state is not preserved. */
|
* todo/triage when resume state is not preserved. */
|
||||||
executionCompletedAt?: string;
|
executionCompletedAt?: string;
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
|
allowResurrection?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -1915,6 +1916,11 @@ export interface TaskCreateInput {
|
|||||||
title?: string;
|
title?: string;
|
||||||
/** Optional lineage override for trusted replication/import paths only. */
|
/** Optional lineage override for trusted replication/import paths only. */
|
||||||
lineageId?: string;
|
lineageId?: string;
|
||||||
|
/**
|
||||||
|
* Opt-in createTask override for soft-deleted ID reuse.
|
||||||
|
* Not persisted to storage.
|
||||||
|
*/
|
||||||
|
forceResurrect?: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
/** Configured merge target/base branch for this task (task intent).
|
/** Configured merge target/base branch for this task (task intent).
|
||||||
* Defaults to the project default branch when omitted. */
|
* Defaults to the project default branch when omitted. */
|
||||||
@@ -2701,6 +2707,9 @@ export interface ProjectSettings {
|
|||||||
heartbeatMultiplier?: number;
|
heartbeatMultiplier?: number;
|
||||||
/** Number of auto-claim candidates rendered in no-task heartbeat prompts. Range: 0-10. Default: 5. */
|
/** Number of auto-claim candidates rendered in no-task heartbeat prompts. Range: 0-10. Default: 5. */
|
||||||
autoClaimCandidatesInPrompt?: number;
|
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.
|
/** Heartbeat scope-discipline procedure mode.
|
||||||
* - "strict": coordination-focused scope discipline (default)
|
* - "strict": coordination-focused scope discipline (default)
|
||||||
* - "lite": pre-FN-3884 behavior
|
* - "lite": pre-FN-3884 behavior
|
||||||
|
|||||||
@@ -1158,6 +1158,7 @@ describe("DELETE /tasks/:id", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.id).toBe("KB-001");
|
expect(res.body.id).toBe("KB-001");
|
||||||
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", expect.objectContaining({
|
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", expect.objectContaining({
|
||||||
|
allowResurrection: false,
|
||||||
removeDependencyReferences: false,
|
removeDependencyReferences: false,
|
||||||
removeLineageReferences: false,
|
removeLineageReferences: false,
|
||||||
githubIssueAction: undefined,
|
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) => {
|
it.each(["close", "delete", "leave", "auto"] as const)("forwards githubIssueAction=%s", async (githubIssueAction) => {
|
||||||
const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-001" };
|
const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-001" };
|
||||||
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(deletedTask);
|
(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"
|
const removeLineageReferences = req.query.removeLineageReferences === "1"
|
||||||
|| req.query.removeLineageReferences === "true";
|
|| req.query.removeLineageReferences === "true";
|
||||||
const githubIssueActionRaw = req.query.githubIssueAction;
|
const githubIssueActionRaw = req.query.githubIssueAction;
|
||||||
|
const allowResurrection = req.query.allowResurrection === "1"
|
||||||
|
|| req.query.allowResurrection === "true";
|
||||||
const githubIssueActionValues: readonly GithubIssueAction[] = ["close", "delete", "leave", "auto"];
|
const githubIssueActionValues: readonly GithubIssueAction[] = ["close", "delete", "leave", "auto"];
|
||||||
let githubIssueAction: GithubIssueAction | undefined;
|
let githubIssueAction: GithubIssueAction | undefined;
|
||||||
if (typeof githubIssueActionRaw === "string") {
|
if (typeof githubIssueActionRaw === "string") {
|
||||||
@@ -3426,6 +3428,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
const task = await scopedStore.deleteTask(req.params.id, {
|
const task = await scopedStore.deleteTask(req.params.id, {
|
||||||
removeDependencyReferences,
|
removeDependencyReferences,
|
||||||
removeLineageReferences,
|
removeLineageReferences,
|
||||||
|
allowResurrection,
|
||||||
githubIssueAction,
|
githubIssueAction,
|
||||||
auditContext: {
|
auditContext: {
|
||||||
agentId: "system",
|
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