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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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