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
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TombstonedTaskResurrectionError } from "../store.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("FN-5233 tombstone sticky-window duplicate intake", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await harness.afterEach();
|
||||
});
|
||||
|
||||
it("refuses near-duplicate intake against recent tombstone and records intake:resurrection-blocked", async () => {
|
||||
const store = harness.store();
|
||||
await store.updateSettings({ tombstoneStickyWindowDays: 7 });
|
||||
|
||||
const original = await store.createTask({
|
||||
title: "Memory leak in merge worker",
|
||||
description: "Fix memory leak in merge worker when queue is drained",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-1" },
|
||||
});
|
||||
await store.deleteTask(original.id);
|
||||
|
||||
await expect(store.createTask({
|
||||
title: "Memory leak in merge worker",
|
||||
description: "Fix memory leak in merge worker when queue is drained",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-1" },
|
||||
})).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
|
||||
|
||||
const events = (store as any).db.prepare(
|
||||
"SELECT mutationType FROM runAuditEvents WHERE mutationType = 'intake:resurrection-blocked'"
|
||||
).all() as Array<{ mutationType: string }>;
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows intake when sticky window is disabled", async () => {
|
||||
const store = harness.store();
|
||||
await store.updateSettings({ tombstoneStickyWindowDays: 0 });
|
||||
|
||||
const original = await store.createTask({
|
||||
title: "A",
|
||||
description: "same text",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-2" },
|
||||
});
|
||||
await store.deleteTask(original.id);
|
||||
|
||||
await expect(store.createTask({
|
||||
title: "A",
|
||||
description: "same text",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-2" },
|
||||
})).resolves.toMatchObject({ id: expect.any(String) });
|
||||
});
|
||||
|
||||
it("ignores tombstones outside sticky window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const oldNow = new Date("2026-01-01T00:00:00.000Z");
|
||||
vi.setSystemTime(oldNow);
|
||||
const store = harness.store();
|
||||
await store.updateSettings({ tombstoneStickyWindowDays: 7 });
|
||||
const original = await store.createTask({
|
||||
title: "Old tombstone",
|
||||
description: "same text",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-2b" },
|
||||
});
|
||||
await store.deleteTask(original.id);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-12T00:00:00.000Z"));
|
||||
await expect(store.createTask({
|
||||
title: "Old tombstone",
|
||||
description: "same text",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-2b" },
|
||||
})).resolves.toMatchObject({ id: expect.any(String) });
|
||||
});
|
||||
|
||||
it("allows intake when tombstoned match has allowResurrection unlock", async () => {
|
||||
const store = harness.store();
|
||||
await store.updateSettings({ tombstoneStickyWindowDays: 7 });
|
||||
|
||||
const original = await store.createTask({
|
||||
title: "Refactor parser",
|
||||
description: "Refactor parser for streaming input",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-3" },
|
||||
});
|
||||
await store.deleteTask(original.id, { allowResurrection: true });
|
||||
|
||||
await expect(store.createTask({
|
||||
title: "Refactor parser",
|
||||
description: "Refactor parser for streaming input",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-3" },
|
||||
})).resolves.toMatchObject({ id: expect.any(String) });
|
||||
});
|
||||
|
||||
it("keeps live-task duplicate behavior (auto-archive) unchanged", async () => {
|
||||
const store = harness.store();
|
||||
const live = await store.createTask({
|
||||
title: "Live dup",
|
||||
description: "duplicate text",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-4" },
|
||||
});
|
||||
const dup = await store.createTask({
|
||||
title: "Live dup",
|
||||
description: "duplicate text",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-4" },
|
||||
});
|
||||
expect(dup.column).toBe("archived");
|
||||
const events = (store as any).db.prepare("SELECT mutationType FROM runAuditEvents WHERE mutationType = 'intake:resurrection-blocked'").all() as Array<{ mutationType: string }>;
|
||||
expect(events).toHaveLength(0);
|
||||
expect(live.id).not.toBe(dup.id);
|
||||
});
|
||||
|
||||
it("fails open when tombstone widening query errors", async () => {
|
||||
const store = harness.store();
|
||||
const db = (store as any).db;
|
||||
const originalPrepare = db.prepare.bind(db);
|
||||
db.prepare = (sql: string) => {
|
||||
if (sql.includes("deletedAt IS NOT NULL") && sql.includes("sourceAgentId")) {
|
||||
throw new Error("synthetic tombstone query failure");
|
||||
}
|
||||
return originalPrepare(sql);
|
||||
};
|
||||
|
||||
await expect(store.createTask({
|
||||
title: "Fallback path",
|
||||
description: "create despite widening failure",
|
||||
source: { sourceType: "unknown", sourceAgentId: "agent-5" },
|
||||
})).resolves.toMatchObject({ id: expect.any(String) });
|
||||
|
||||
db.prepare = originalPrepare;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { TombstonedTaskResurrectionError } from "../store.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("FN-5233 tombstoned createTask behavior", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.afterEach();
|
||||
});
|
||||
|
||||
it("throws TombstonedTaskResurrectionError when recreating a tombstoned id", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ title: "a", description: "alpha", column: "todo" });
|
||||
await store.deleteTask(task.id);
|
||||
const created: string[] = [];
|
||||
store.on("task:created", (event) => created.push(event.id));
|
||||
|
||||
await expect(
|
||||
store.createTaskWithReservedId({ title: "b", description: "beta", column: "todo" }, { taskId: task.id }),
|
||||
).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
|
||||
|
||||
const row = (store as any).db.prepare("SELECT deletedAt, allowResurrection FROM tasks WHERE id = ?").get(task.id) as {
|
||||
deletedAt: string | null;
|
||||
allowResurrection: number;
|
||||
};
|
||||
expect(row.deletedAt).toBeTruthy();
|
||||
expect(row.allowResurrection).toBe(0);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows forceResurrect recreation and clears allowResurrection", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ title: "a", description: "alpha", column: "todo" });
|
||||
await store.deleteTask(task.id, { allowResurrection: true });
|
||||
|
||||
const created: string[] = [];
|
||||
store.on("task:created", (event) => created.push(event.id));
|
||||
const recreated = await store.createTaskWithReservedId(
|
||||
{ title: "c", description: "charlie", forceResurrect: true, column: "todo" },
|
||||
{ taskId: task.id },
|
||||
);
|
||||
expect(recreated.id).toBe(task.id);
|
||||
expect(created).toEqual([task.id]);
|
||||
|
||||
const row = (store as any).db.prepare("SELECT deletedAt, allowResurrection FROM tasks WHERE id = ?").get(task.id) as {
|
||||
deletedAt: string | null;
|
||||
allowResurrection: number;
|
||||
};
|
||||
expect(row.deletedAt).toBeNull();
|
||||
expect(row.allowResurrection).toBe(0);
|
||||
});
|
||||
|
||||
it("allows recreation when tombstone row has allowResurrection=1", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ title: "a", description: "alpha", column: "todo" });
|
||||
await store.deleteTask(task.id, { allowResurrection: true });
|
||||
|
||||
const recreated = await store.createTaskWithReservedId({ title: "d", description: "delta", column: "todo" }, { taskId: task.id });
|
||||
expect(recreated.id).toBe(task.id);
|
||||
const row = (store as any).db.prepare("SELECT deletedAt, allowResurrection FROM tasks WHERE id = ?").get(task.id) as {
|
||||
deletedAt: string | null;
|
||||
allowResurrection: number;
|
||||
};
|
||||
expect(row.deletedAt).toBeNull();
|
||||
expect(row.allowResurrection).toBe(0);
|
||||
});
|
||||
|
||||
it("records task:resurrection-blocked audit for createTask refusal", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ title: "a", description: "alpha", column: "todo" });
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
await expect(
|
||||
store.createTaskWithReservedId({ title: "b", description: "beta", column: "todo" }, { taskId: task.id }),
|
||||
).rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
|
||||
|
||||
const events = (store as any).db.prepare(
|
||||
"SELECT mutationType, metadata FROM runAuditEvents WHERE taskId = ? AND mutationType = ?"
|
||||
).all(task.id, "task:resurrection-blocked") as Array<{ mutationType: string; metadata: string | null }>;
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events.at(-1)?.metadata ?? "").toContain("createTask");
|
||||
});
|
||||
});
|
||||
@@ -314,7 +314,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
checkoutRunId TEXT,
|
||||
checkoutLeaseRenewedAt TEXT,
|
||||
checkoutLeaseEpoch INTEGER DEFAULT 0,
|
||||
deletedAt TEXT
|
||||
deletedAt TEXT,
|
||||
allowResurrection INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Config table (single row with project settings)
|
||||
@@ -3505,6 +3506,7 @@ export class Database {
|
||||
|
||||
if (version < 88) {
|
||||
this.applyMigration(88, () => {
|
||||
this.addColumnIfMissing("tasks", "allowResurrection", "INTEGER DEFAULT 0");
|
||||
try {
|
||||
const taskColumns = this.getTableColumns("tasks");
|
||||
const requiredColumns = ["paused", "userPaused", "pausedByAgentId", "pausedReason"];
|
||||
|
||||
@@ -21,11 +21,17 @@ export interface SameAgentDuplicateCandidate {
|
||||
createdAt: number;
|
||||
sourceAgentId: string | null;
|
||||
sourceParentTaskId?: string | null;
|
||||
tombstoned?: boolean;
|
||||
deletedAt?: string;
|
||||
allowResurrection?: boolean;
|
||||
}
|
||||
|
||||
export interface SameAgentDuplicateMatch {
|
||||
id: string;
|
||||
score: number;
|
||||
tombstoned?: boolean;
|
||||
deletedAt?: string;
|
||||
allowResurrection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,10 +57,11 @@ export function findSameAgentDuplicates(
|
||||
const inputParentId = input.sourceParentTaskId ?? null;
|
||||
|
||||
const recent = candidates.filter((candidate) => {
|
||||
if (candidate.createdAt < cutoff) return false;
|
||||
const agentMatch = inputAgentId != null && candidate.sourceAgentId === inputAgentId;
|
||||
const parentMatch = inputParentId != null && candidate.sourceParentTaskId === inputParentId;
|
||||
return agentMatch || parentMatch;
|
||||
if (!agentMatch && !parentMatch) return false;
|
||||
if (candidate.tombstoned) return true;
|
||||
return candidate.createdAt >= cutoff;
|
||||
});
|
||||
|
||||
const matches = findDuplicateMatches(
|
||||
@@ -68,7 +75,17 @@ export function findSameAgentDuplicates(
|
||||
{ threshold },
|
||||
);
|
||||
|
||||
return matches.map((match) => ({ id: match.id, score: match.score }));
|
||||
const metadataById = new Map(recent.map((candidate) => [candidate.id, candidate]));
|
||||
return matches.map((match) => {
|
||||
const candidate = metadataById.get(match.id);
|
||||
return {
|
||||
id: match.id,
|
||||
score: match.score,
|
||||
tombstoned: candidate?.tombstoned,
|
||||
deletedAt: candidate?.deletedAt,
|
||||
allowResurrection: candidate?.allowResurrection,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveAsSameAgentDuplicate(
|
||||
|
||||
@@ -134,6 +134,7 @@ export {
|
||||
SelfDefeatingDependencyError,
|
||||
DependencyCycleError,
|
||||
TaskDeletedError,
|
||||
TombstonedTaskResurrectionError,
|
||||
MergeQueueTaskNotFoundError,
|
||||
MergeQueueInvalidColumnError,
|
||||
MergeQueueLeaseOwnershipError,
|
||||
|
||||
@@ -188,6 +188,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
pollIntervalMs: 15000,
|
||||
heartbeatMultiplier: 1,
|
||||
autoClaimCandidatesInPrompt: 5,
|
||||
tombstoneStickyWindowDays: 7,
|
||||
heartbeatScopeDiscipline: "strict",
|
||||
heartbeatPromptTemplate: "default",
|
||||
groupOverlappingFiles: true,
|
||||
|
||||
@@ -173,6 +173,7 @@ interface TaskRow {
|
||||
checkoutLeaseRenewedAt: string | null;
|
||||
checkoutLeaseEpoch: number | null;
|
||||
deletedAt: string | null;
|
||||
allowResurrection: number | null;
|
||||
}
|
||||
|
||||
/** Database row shape for the task_documents table. */
|
||||
@@ -621,6 +622,20 @@ export class TaskDeletedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class TombstonedTaskResurrectionError extends Error {
|
||||
constructor(
|
||||
public readonly taskId: string,
|
||||
public readonly deletedAt: string,
|
||||
public readonly allowResurrection: boolean,
|
||||
) {
|
||||
super(
|
||||
`Task ${taskId} is soft-deleted (deletedAt=${deletedAt}) and cannot be recreated without forceResurrect: true. `
|
||||
+ `Operator unlock: allowResurrection=${allowResurrection}`,
|
||||
);
|
||||
this.name = "TombstonedTaskResurrectionError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskHasLineageChildrenError extends Error {
|
||||
readonly taskId: string;
|
||||
readonly childIds: string[];
|
||||
@@ -1499,6 +1514,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
checkoutLeaseRenewedAt: row.checkoutLeaseRenewedAt || undefined,
|
||||
checkoutLeaseEpoch: row.checkoutLeaseEpoch ?? undefined,
|
||||
deletedAt: row.deletedAt ?? undefined,
|
||||
allowResurrection: row.allowResurrection ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1740,7 +1756,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt",
|
||||
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection",
|
||||
// `log` is fetched in slim mode so the server can aggregate
|
||||
// `timedExecutionMs` from `[timing] … in <N>ms` entries before
|
||||
// returning. The log itself is stripped from the response —
|
||||
@@ -1789,7 +1805,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt",
|
||||
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection",
|
||||
];
|
||||
|
||||
const limitedLog = `
|
||||
@@ -1926,6 +1942,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.checkoutLeaseRenewedAt ?? null,
|
||||
task.checkoutLeaseEpoch ?? 0,
|
||||
task.deletedAt ?? null,
|
||||
task.allowResurrection ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1948,7 +1965,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
|
||||
) VALUES (${placeholders})
|
||||
`).run(...values);
|
||||
this.db.bumpLastModified();
|
||||
@@ -1975,7 +1992,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
|
||||
) VALUES (${placeholders})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
lineageId = excluded.lineageId,
|
||||
@@ -2086,7 +2103,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
checkoutRunId = excluded.checkoutRunId,
|
||||
checkoutLeaseRenewedAt = excluded.checkoutLeaseRenewedAt,
|
||||
checkoutLeaseEpoch = excluded.checkoutLeaseEpoch,
|
||||
deletedAt = excluded.deletedAt
|
||||
deletedAt = excluded.deletedAt,
|
||||
allowResurrection = excluded.allowResurrection
|
||||
`).run(...this.getTaskPersistValues(task));
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
@@ -2211,6 +2229,38 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private maybeResolveTombstonedTaskId(
|
||||
id: string,
|
||||
input: Pick<TaskCreateInput, "forceResurrect">,
|
||||
operation: "createTask" | "duplicateTask" | "refineTask",
|
||||
): void {
|
||||
const existing = this.readTaskFromDb(id, { includeDeleted: true });
|
||||
if (!existing?.deletedAt) return;
|
||||
|
||||
const allowResurrection = existing.allowResurrection === true;
|
||||
if (input.forceResurrect === true || allowResurrection) {
|
||||
this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
return;
|
||||
}
|
||||
|
||||
storeLog.warn(`[tombstone-resurrection-blocked] ${id} deletedAt=${existing.deletedAt}`);
|
||||
this.insertRunAuditEventRow({
|
||||
taskId: id,
|
||||
domain: "database",
|
||||
mutationType: "task:resurrection-blocked",
|
||||
target: id,
|
||||
metadata: {
|
||||
id,
|
||||
deletedAt: existing.deletedAt,
|
||||
allowResurrection,
|
||||
operation,
|
||||
},
|
||||
});
|
||||
|
||||
throw new TombstonedTaskResurrectionError(id, existing.deletedAt, allowResurrection);
|
||||
}
|
||||
|
||||
private isTaskArchived(id: string): boolean {
|
||||
const row = this.db.prepare(`SELECT "column" FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(id) as { column: Column } | undefined;
|
||||
if (row) {
|
||||
@@ -3524,6 +3574,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
await this.assertNoDependencyCycle(id, input.dependencies ?? [], "createTaskWithReservedId");
|
||||
|
||||
this.maybeResolveTombstonedTaskId(id, input, "createTask");
|
||||
this.assertTaskIdAvailable(id);
|
||||
|
||||
const title = input.title?.trim() || undefined;
|
||||
@@ -3678,6 +3729,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
storeLog.log(`[title-id-drift] normalized title for ${id}: removed=[${removed.join(",")}]`);
|
||||
}
|
||||
|
||||
this.maybeResolveTombstonedTaskId(id, input, "createTask");
|
||||
this.assertTaskIdAvailable(id);
|
||||
|
||||
const dir = this.taskDir(id);
|
||||
@@ -3730,31 +3782,107 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return agentMatch || parentMatch;
|
||||
});
|
||||
|
||||
const settings = await this.getSettings();
|
||||
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
|
||||
let tombstonedCandidates: Array<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
description: string;
|
||||
column: Column;
|
||||
createdAt: string;
|
||||
sourceAgentId: string | null;
|
||||
deletedAt: string;
|
||||
allowResurrection: number | null;
|
||||
}> = [];
|
||||
|
||||
if (stickyWindowDays > 0) {
|
||||
try {
|
||||
const cutoffIso = new Date(nowMs - stickyWindowDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
tombstonedCandidates = this.db.prepare(`
|
||||
SELECT id, title, description, "column", createdAt, sourceAgentId, deletedAt, allowResurrection
|
||||
FROM tasks
|
||||
WHERE deletedAt IS NOT NULL
|
||||
AND deletedAt >= ?
|
||||
AND sourceAgentId = ?
|
||||
AND id != ?
|
||||
`).all(cutoffIso, sourceAgentId, task.id) as typeof tombstonedCandidates;
|
||||
} catch (error) {
|
||||
storeLog.warn(`FN-5233 tombstone candidate widening failed open for ${task.id}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const matches = findSameAgentDuplicates(
|
||||
{
|
||||
title: input.title ?? task.title,
|
||||
description: input.description,
|
||||
sourceParentTaskId,
|
||||
},
|
||||
recent.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
title: candidate.title ?? "",
|
||||
description: candidate.description,
|
||||
column: candidate.column,
|
||||
createdAt: Date.parse(candidate.createdAt),
|
||||
sourceAgentId: candidate.sourceAgentId ?? null,
|
||||
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
|
||||
})),
|
||||
[
|
||||
...recent.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
title: candidate.title ?? "",
|
||||
description: candidate.description,
|
||||
column: candidate.column,
|
||||
createdAt: Date.parse(candidate.createdAt),
|
||||
sourceAgentId: candidate.sourceAgentId ?? null,
|
||||
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
|
||||
tombstoned: false,
|
||||
})),
|
||||
...tombstonedCandidates.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
title: candidate.title ?? "",
|
||||
description: candidate.description,
|
||||
column: "todo" as Column,
|
||||
createdAt: Date.parse(candidate.createdAt),
|
||||
sourceAgentId: candidate.sourceAgentId,
|
||||
sourceParentTaskId: null,
|
||||
tombstoned: true,
|
||||
deletedAt: candidate.deletedAt,
|
||||
allowResurrection: candidate.allowResurrection === 1,
|
||||
})),
|
||||
],
|
||||
{ nowMs, sourceAgentId },
|
||||
);
|
||||
|
||||
if (matches.length === 0) return;
|
||||
|
||||
const siblingTaskIds = matches.map((match) => match.id);
|
||||
const scores = Object.fromEntries(matches.map((match) => [match.id, match.score]));
|
||||
const tombstonedMatch = matches.find((match) => match.tombstoned && match.allowResurrection !== true);
|
||||
if (tombstonedMatch?.deletedAt) {
|
||||
this.insertRunAuditEventRow({
|
||||
taskId: task.id,
|
||||
domain: "database",
|
||||
mutationType: "intake:resurrection-blocked",
|
||||
target: task.id,
|
||||
metadata: {
|
||||
matchedTaskId: tombstonedMatch.id,
|
||||
score: tombstonedMatch.score,
|
||||
tombstoneDeletedAt: tombstonedMatch.deletedAt,
|
||||
stickyWindowDays,
|
||||
},
|
||||
});
|
||||
if (this.isWatching) this.taskCache.delete(task.id);
|
||||
this.deleteTaskById(task.id);
|
||||
const { rm } = await import("node:fs/promises");
|
||||
const taskDir = this.taskDir(task.id);
|
||||
if (existsSync(taskDir)) {
|
||||
await rm(taskDir, { recursive: true, force: true });
|
||||
}
|
||||
throw new TombstonedTaskResurrectionError(
|
||||
tombstonedMatch.id,
|
||||
tombstonedMatch.deletedAt,
|
||||
tombstonedMatch.allowResurrection === true,
|
||||
);
|
||||
}
|
||||
|
||||
const siblingTaskIds = matches.filter((match) => !match.tombstoned).map((match) => match.id);
|
||||
if (siblingTaskIds.length === 0) return;
|
||||
const scores = Object.fromEntries(matches.filter((match) => !match.tombstoned).map((match) => [match.id, match.score]));
|
||||
await archiveAsSameAgentDuplicate(this, task.id, siblingTaskIds, scores);
|
||||
task.column = "archived";
|
||||
} catch (error) {
|
||||
if (error instanceof TombstonedTaskResurrectionError) {
|
||||
throw error;
|
||||
}
|
||||
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -3806,6 +3934,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
baseBranch: sourceTask.baseBranch,
|
||||
};
|
||||
|
||||
this.maybeResolveTombstonedTaskId(newId, {}, "duplicateTask");
|
||||
this.assertTaskIdAvailable(newId);
|
||||
|
||||
const newDir = this.taskDir(newId);
|
||||
@@ -3882,6 +4011,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
|
||||
};
|
||||
|
||||
this.maybeResolveTombstonedTaskId(newId, {}, "refineTask");
|
||||
this.assertTaskIdAvailable(newId);
|
||||
|
||||
const newDir = this.taskDir(newId);
|
||||
@@ -6620,6 +6750,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
allowResurrection?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
auditContext?: { agentId: string; runId: string; sessionId?: string };
|
||||
},
|
||||
@@ -6667,7 +6798,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
|
||||
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
|
||||
const deletedAt = new Date().toISOString();
|
||||
this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, updatedAt = ? WHERE id = ?").run(deletedAt, deletedAt, id);
|
||||
const allowResurrection = options?.allowResurrection === true ? 1 : 0;
|
||||
this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, allowResurrection = ?, updatedAt = ? WHERE id = ?").run(deletedAt, allowResurrection, deletedAt, id);
|
||||
this.recordRunAuditEvent({
|
||||
domain: "database",
|
||||
mutationType: "task:deleted",
|
||||
@@ -6681,6 +6813,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
githubIssueAction: options?.githubIssueAction ?? "auto",
|
||||
removeDependencyReferences: !!options?.removeDependencyReferences,
|
||||
removeLineageReferences: !!options?.removeLineageReferences,
|
||||
allowResurrection: options?.allowResurrection === true,
|
||||
sessionId: options?.auditContext?.sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1879,6 +1879,7 @@ export interface Task {
|
||||
* todo/triage when resume state is not preserved. */
|
||||
executionCompletedAt?: string;
|
||||
deletedAt?: string;
|
||||
allowResurrection?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -1915,6 +1916,11 @@ export interface TaskCreateInput {
|
||||
title?: string;
|
||||
/** Optional lineage override for trusted replication/import paths only. */
|
||||
lineageId?: string;
|
||||
/**
|
||||
* Opt-in createTask override for soft-deleted ID reuse.
|
||||
* Not persisted to storage.
|
||||
*/
|
||||
forceResurrect?: boolean;
|
||||
description: string;
|
||||
/** Configured merge target/base branch for this task (task intent).
|
||||
* Defaults to the project default branch when omitted. */
|
||||
@@ -2701,6 +2707,9 @@ export interface ProjectSettings {
|
||||
heartbeatMultiplier?: number;
|
||||
/** Number of auto-claim candidates rendered in no-task heartbeat prompts. Range: 0-10. Default: 5. */
|
||||
autoClaimCandidatesInPrompt?: number;
|
||||
/** Sticky window for intake duplicate checks against soft-deleted tasks.
|
||||
* Unit: days. Default: 7. Set to 0 to disable tombstone-window widening. */
|
||||
tombstoneStickyWindowDays?: number;
|
||||
/** Heartbeat scope-discipline procedure mode.
|
||||
* - "strict": coordination-focused scope discipline (default)
|
||||
* - "lite": pre-FN-3884 behavior
|
||||
|
||||
Reference in New Issue
Block a user