feat(FN-5175): thread soft-delete audit context and add reconciliation scri

Adds a reconcile script to recover leaked soft-deleted tasks, threads delete audit context through all callers, and records soft-delete audit events with archive column tracking across core/engine/cli/dashboard, with reliability backstop tests covering caller alignment.

Fusion-Task-Id: FN-5175
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 02:38:58 -07:00
committed by gsxdsm
parent 6ecef44ab3
commit c9fd41ef26
17 changed files with 656 additions and 21 deletions

View File

@@ -206,6 +206,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
- **Landed-files attribution (FN-5103)**: Rebase-strategy `mergeDetails.landedFiles` / `filesChanged` / `insertions` / `deletions` are captured from task-attributable commits only via `filterFilesToOwnTaskCommits` (subject-prefix + trailer + bracket-prefix evidence), tagged `landedFilesAttributionRestricted: true`. Zero own commits → `landedFiles: []` and `noOpVerifiedShortCircuit: true`. Attribution-helper failures fall back to the unrestricted `rebaseBaseSha..sha` walk and set `landedFilesCaptureFallback: 'attribution-failed'`. Self-healing `recoverDoneTaskMergeMetadata` skips reconcile when `landedFilesAttributionRestricted` or `noOpVerifiedShortCircuit` is set so the narrower set is not overwritten with the full range. Squash-strategy capture is unchanged.
- **Soft-delete scheduler invalidation (FN-5137)**: `task:deleted` events must invalidate `AutoClaimSnapshotManager` and clear scheduler bookkeeping (`pausedTaskIds`, `failedTaskIds`, `wasNodeDispatchValidationBlocked`, `wasNodeBlocked`); `executor.execute()` / `resumeOrphaned()` / `resumeTaskForAgent()` refuse any task with `deletedAt` set.
- **Soft-delete in-flight abort (FN-5142)**: `task:deleted` must immediately abort/dispose active executor work (`activeSessions`, `activeStepExecutors`, `activeWorkflowStepSessions`, reviewer subagents), interrupt active merge state (`mergeAbortController`, `activeMergeSession`, `activeMergeTaskId`, `mergeActive`, `mergeQueue`, `pausedReviewTaskIds`), and abort triage specify/subagent sessions for that id. Handlers are per-task and idempotent.
- **Soft-delete audit + column reconcile (FN-5175)**: `TaskStore.deleteTask` records a `runAuditEvents` row (`mutationType: "task:deleted"`, `domain: "database"`) inside the same transaction that sets `deletedAt`, and sets `"column" = 'archived'` on the row. Callers without a heartbeat run context (`fn task delete`, pi extension, dashboard delete route) pass an `auditContext` with `agentId: "system"` and a synthetic `runId`. The watcher cross-instance emit path does NOT re-record the audit event. The row stays in `tasks` (not `archivedTasks`); `archiveTask` is unchanged.
- **Soft-delete resurrection guard (FN-5208)**: `TaskStore.readTaskJson()` must never fall back to `.fusion/tasks/<id>/task.json` when the DB row exists with `deletedAt` set — it throws `TaskDeletedError`. `atomicCreateTaskJson` / `atomicWriteTaskJson` / `atomicWriteTaskJsonWithAudit` refuse to upsert a task whose row is currently soft-deleted (unless the in-memory task carries `deletedAt` itself, for soft-delete maintenance paths), emit a `[soft-delete-resurrection-blocked]` log line, and record a `task:resurrection-blocked` run-audit event. Stale in-flight planner/triage writes for a soft-deleted ID surface `TaskDeletedError` and abort cleanly without emitting `task:created`.
- **Soft-delete stream verification gate (FN-5153)**: `docs/soft-delete-verification-matrix.md` is the authoritative checklist for the FN-5105 → FN-5143 soft-delete stream. Every scenario × layer cell must be GREEN (or have a linked follow-up FN) before the stream is closed; `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` is the cross-layer regression backstop.

View File

@@ -1154,7 +1154,12 @@ describe("project-aware task command behavior", () => {
await runTaskDelete("FN-123", true, "demo-project");
expect(getTask).toHaveBeenCalledWith("FN-123");
expect(deleteTask).toHaveBeenCalledWith("FN-123");
expect(deleteTask).toHaveBeenCalledWith("FN-123", expect.objectContaining({
auditContext: expect.objectContaining({
agentId: "cli",
runId: expect.stringMatching(/^synthetic-cli-delete-FN-123-/),
}),
}));
});
it("runTaskComment, runTaskComments, and runTaskSteer use resolved project store", async () => {
@@ -2224,7 +2229,12 @@ describe("runTaskDelete", () => {
expect(mockGetTask).toHaveBeenCalledWith("FN-001");
expect(mockRlQuestion).not.toHaveBeenCalled();
expect(mockDeleteTask).toHaveBeenCalledOnce();
expect(mockDeleteTask).toHaveBeenCalledWith("FN-001");
expect(mockDeleteTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
auditContext: expect.objectContaining({
agentId: "cli",
runId: expect.stringMatching(/^synthetic-cli-delete-FN-001-/),
}),
}));
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("✓ Deleted"),
@@ -2242,7 +2252,12 @@ describe("runTaskDelete", () => {
expect(mockRlQuestion).toHaveBeenCalledWith("Are you sure you want to delete FN-001? [y/N] ");
expect(mockRlClose).toHaveBeenCalled();
expect(mockDeleteTask).toHaveBeenCalledOnce();
expect(mockDeleteTask).toHaveBeenCalledWith("FN-001");
expect(mockDeleteTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
auditContext: expect.objectContaining({
agentId: "cli",
runId: expect.stringMatching(/^synthetic-cli-delete-FN-001-/),
}),
}));
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("✓ Deleted"),

View File

@@ -1207,7 +1207,12 @@ export async function runTaskDelete(id: string, force?: boolean, projectName?: s
}
try {
await store.deleteTask(id);
await store.deleteTask(id, {
auditContext: {
agentId: "cli",
runId: `synthetic-cli-delete-${id}-${Date.now()}`,
},
});
console.log();
console.log(` ✓ Deleted ${id}`);
console.log();

View File

@@ -1179,7 +1179,12 @@ export default function kbExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.deleteTask(params.id);
const task = await store.deleteTask(params.id, {
auditContext: {
agentId: "pi-extension",
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
},
});
return {
content: [{ type: "text", text: `Deleted ${task.id}` }],

View File

@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("soft-delete audit + archived column (FN-5175)", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
await harness.afterEach();
});
it("writes exactly one task:deleted run-audit row with explicit auditContext", async () => {
const store = harness.store();
const task = await store.createTask({ column: "in-review", description: "audit me" });
await store.deleteTask(task.id, {
auditContext: {
agentId: "agent-explicit",
runId: "run-explicit",
sessionId: "session-explicit",
},
});
const events = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:deleted" });
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
domain: "database",
mutationType: "task:deleted",
target: task.id,
taskId: task.id,
agentId: "agent-explicit",
runId: "run-explicit",
metadata: {
previousColumn: "in-review",
previousStatus: null,
githubIssueAction: "auto",
removeDependencyReferences: false,
removeLineageReferences: false,
sessionId: "session-explicit",
},
});
});
it("falls back to a synthetic delete runId and remains idempotent on re-delete", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", description: "synthetic delete" });
await store.deleteTask(task.id);
await store.deleteTask(task.id);
const events = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:deleted" });
expect(events).toHaveLength(1);
expect(events[0]?.agentId).toBe("system");
expect(events[0]?.runId).toMatch(/^synthetic-task-delete-/);
});
it("marks the tasks row archived without moving it into archivedTasks", async () => {
const store = harness.store();
const task = await store.createTask({ column: "in-progress", description: "archive the soft-deleted row" });
await store.deleteTask(task.id);
const row = (store as any).db.prepare('SELECT "column", deletedAt FROM tasks WHERE id = ?').get(task.id) as {
column: string;
deletedAt: string | null;
};
expect(row.column).toBe("archived");
expect(typeof row.deletedAt).toBe("string");
expect((store as any).archiveDb.get(task.id)).toBeUndefined();
});
it("keeps soft-deleted rows out of listTasks even when includeArchived is true", async () => {
const store = harness.store();
const task = await store.createTask({ column: "done", description: "hidden from listTasks" });
await store.deleteTask(task.id);
expect((await store.listTasks({ includeArchived: false })).map((entry) => entry.id)).not.toContain(task.id);
expect((await store.listTasks({ includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id);
expect((await store.listTasks({ column: "archived", includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id);
});
it("records githubIssueAction and option flags in audit metadata", async () => {
const store = harness.store();
const task = await store.createTask({ column: "triage", description: "metadata flags" });
await store.deleteTask(task.id, {
githubIssueAction: "delete",
removeDependencyReferences: true,
removeLineageReferences: true,
});
const [event] = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:deleted" });
expect(event?.metadata).toMatchObject({
previousColumn: "triage",
githubIssueAction: "delete",
removeDependencyReferences: true,
removeLineageReferences: true,
});
});
});

View File

@@ -84,7 +84,7 @@ describe("TaskStore soft delete", () => {
expect(deletedEvents.length).toBe(afterFirstPoll);
});
it("keeps soft-deleted ids reserved and non-archived", async () => {
it("keeps soft-deleted ids reserved while leaving them out of archivedTasks", async () => {
const store = harness.store();
const task = await store.createTask({ description: "reserved id task" });
@@ -94,6 +94,9 @@ describe("TaskStore soft delete", () => {
expect((store as any).taskIdExistsAnywhere(task.id)).toBe(true);
expect((store as any).isTaskArchived(task.id)).toBe(false);
const row = (store as any).db.prepare('SELECT "column" FROM tasks WHERE id = ?').get(task.id) as { column: string };
expect(row.column).toBe("archived");
const prefix = task.id.split("-")[0];
reconcileTaskIdState((store as any).db);
const allocator = createDistributedTaskIdAllocator((store as any).db);
@@ -124,22 +127,24 @@ describe("TaskStore soft delete", () => {
const firstResult = await store.deleteTask(task.id);
const firstRow = (store as any).db
.prepare("SELECT deletedAt, updatedAt FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null; updatedAt: string | null };
.prepare("SELECT deletedAt, updatedAt, \"column\" FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null; updatedAt: string | null; column: string | null };
const secondResult = await store.deleteTask(task.id);
const secondRow = (store as any).db
.prepare("SELECT deletedAt, updatedAt FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null; updatedAt: string | null };
.prepare("SELECT deletedAt, updatedAt, \"column\" FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null; updatedAt: string | null; column: string | null };
const thirdResult = await store.deleteTask(task.id);
expect(firstRow.deletedAt).toBeTruthy();
expect(firstRow.column).toBe("archived");
expect(secondResult.deletedAt).toBe(firstRow.deletedAt);
expect(thirdResult.deletedAt).toBe(firstRow.deletedAt);
expect(deletedEvents).toEqual([task.id]);
expect(secondRow.deletedAt).toBe(firstRow.deletedAt);
expect(secondRow.updatedAt).toBe(firstRow.updatedAt);
expect(secondRow.column).toBe("archived");
await expect(store.deleteTask("FN-DOES-NOT-EXIST")).rejects.toThrow("Task FN-DOES-NOT-EXIST not found");
});

View File

@@ -6202,6 +6202,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return paths.filter((path) => isValidFileScopeEntry(path));
}
private makeSyntheticDeleteRunId(taskId: string): string {
return `synthetic-task-delete-${taskId}-${Date.now()}-${randomUUID().slice(0, 8)}`;
}
/**
* Soft-delete a live task by setting tasks.deletedAt/updatedAt while leaving
* the row and on-disk task artifacts in place for potential recovery.
@@ -6215,6 +6219,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
auditContext?: { agentId: string; runId: string; sessionId?: string };
},
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -6260,7 +6265,23 @@ 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 deletedAt = ?, updatedAt = ? WHERE id = ?").run(deletedAt, deletedAt, id);
this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, updatedAt = ? WHERE id = ?").run(deletedAt, deletedAt, id);
this.recordRunAuditEvent({
domain: "database",
mutationType: "task:deleted",
target: task.id,
taskId: task.id,
agentId: options?.auditContext?.agentId ?? "system",
runId: options?.auditContext?.runId ?? this.makeSyntheticDeleteRunId(task.id),
metadata: {
previousColumn: task.column,
previousStatus: task.status ?? null,
githubIssueAction: options?.githubIssueAction ?? "auto",
removeDependencyReferences: !!options?.removeDependencyReferences,
removeLineageReferences: !!options?.removeLineageReferences,
sessionId: options?.auditContext?.sessionId,
},
});
this.clearLinkedAgentTaskIds(id, deletedAt);
this.db.bumpLastModified();
});
@@ -7116,6 +7137,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// TaskStore instance wrote the row in-process.
this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" });
} else {
// Polling replicas only mirror the originating delete signal.
// Do not record run-audit here; the writer already owns that row.
this.emit("task:deleted", cached);
}
} finally {
@@ -7145,6 +7168,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (task.deletedAt) {
if (cached) {
this.taskCache.delete(task.id);
// Polling replicas only re-emit task:deleted for subscribers.
// They must not insert duplicate run-audit rows cross-instance.
this.emit("task:deleted", cached);
}
continue;

View File

@@ -1378,7 +1378,13 @@ describe("projectId store scoping regressions", () => {
expect(defaultStore.getTask).not.toHaveBeenCalled();
expect(scopedStore.createTask).toHaveBeenCalledTimes(2);
expect(defaultStore.createTask).not.toHaveBeenCalled();
expect(scopedStore.deleteTask).toHaveBeenCalledWith("FN-PARENT");
expect(scopedStore.deleteTask).toHaveBeenCalledWith("FN-PARENT", expect.objectContaining({
auditContext: expect.objectContaining({
agentId: "system",
runId: expect.stringMatching(/^synthetic-planning-delete-FN-PARENT-/),
sessionId: "subtask-session-1",
}),
}));
expect(defaultStore.deleteTask).not.toHaveBeenCalled();
});
});

View File

@@ -309,7 +309,13 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
let parentTaskCloseError: string | undefined;
if (normalizedParentId) {
try {
await scopedStore.deleteTask(normalizedParentId);
await scopedStore.deleteTask(normalizedParentId, {
auditContext: {
agentId: "system",
runId: `synthetic-planning-delete-${normalizedParentId}-${Date.now()}`,
sessionId,
},
});
parentTaskClosed = true;
} catch (err: unknown) {
// deleteTask refuses when live tasks still reference the parent id.

View File

@@ -2778,6 +2778,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
removeDependencyReferences,
removeLineageReferences,
githubIssueAction,
auditContext: {
agentId: "system",
runId: `synthetic-dashboard-delete-${req.params.id}-${Date.now()}`,
},
});
res.json(task);
} catch (err: unknown) {

View File

@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import { AutoClaimSnapshotManager } from "../../auto-claim-snapshot.js";
import { Scheduler } from "../../scheduler.js";
type TestTask = {
id: string;
title: string;
description: string;
status: string | null;
column: string;
createdAt: string;
updatedAt: string;
dependencies: string[];
comments: unknown[];
steps: unknown[];
currentStep: number;
log: unknown[];
deletedAt?: string | null;
};
function createEventedSoftDeleteStore(initialTasks: TestTask[] = []) {
const listeners = new Map<string, ((payload: any) => void)[]>();
const tasks = initialTasks.map((task) => ({ ...task }));
const archivedTasks = new Map<string, TestTask>();
const auditEvents: Array<Record<string, unknown>> = [];
let sequence = 1;
const nextTimestamp = () => new Date(1_716_000_000_000 + sequence++).toISOString();
const emit = (event: string, payload: any) => {
for (const listener of listeners.get(event) ?? []) {
listener(payload);
}
};
return {
auditEvents,
archivedTasks,
emit,
on: vi.fn((event: string, listener: (payload: any) => void) => {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
}),
off: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2, maxWorktrees: 4 }),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
readTaskFromDb(id: string, options?: { includeDeleted?: boolean }) {
const task = tasks.find((entry) => entry.id === id);
if (!task || (!options?.includeDeleted && task.deletedAt)) return undefined;
return { ...task };
},
async getTask(id: string, options?: { includeDeleted?: boolean }) {
const task = this.readTaskFromDb(id, options);
if (!task) throw new Error(`Task ${id} not found`);
return task;
},
async listTasks(options?: { column?: string }) {
return tasks
.filter((task) => !task.deletedAt)
.filter((task) => (options?.column ? task.column === options.column : true))
.map((task) => ({ ...task }));
},
async createTask(input: Partial<TestTask> = {}) {
const id = input.id ?? `FN-${String(sequence).padStart(4, "0")}`;
const task: TestTask = {
id,
title: input.title ?? id,
description: input.description ?? id,
status: input.status ?? null,
column: input.column ?? "todo",
createdAt: nextTimestamp(),
updatedAt: nextTimestamp(),
dependencies: input.dependencies ?? [],
comments: [],
steps: [],
currentStep: 0,
log: [],
deletedAt: input.deletedAt ?? null,
};
tasks.push(task);
emit("task:created", { ...task });
return { ...task };
},
async deleteTask(id: string) {
const task = tasks.find((entry) => entry.id === id);
if (!task) throw new Error(`Task ${id} not found`);
if (task.deletedAt) return { ...task };
const deletedAt = nextTimestamp();
auditEvents.push({
domain: "database",
mutationType: "task:deleted",
target: id,
taskId: id,
});
task.column = "archived";
task.deletedAt = deletedAt;
task.updatedAt = deletedAt;
emit("task:deleted", { ...task });
return { ...task };
},
async archiveTask(id: string) {
const index = tasks.findIndex((entry) => entry.id === id);
if (index < 0) throw new Error(`Task ${id} not found`);
const [task] = tasks.splice(index, 1);
archivedTasks.set(task.id, { ...task });
emit("task:moved", { task: { ...task }, from: task.column, to: "archived" });
return { ...task, column: "archived" };
},
};
}
describe("reliability interactions: FN-5175 soft-delete audit + archived column", () => {
it("invalidates scheduler snapshots while keeping archived-column soft-deletes undispatchable", async () => {
const store = createEventedSoftDeleteStore();
const task = await store.createTask({ column: "todo", title: "Soft delete target" });
const snapshotManager = new AutoClaimSnapshotManager({ taskStore: store as any });
const invalidateSpy = vi.spyOn(snapshotManager, "invalidate");
new Scheduler(store as any, { snapshotManager } as any);
await store.deleteTask(task.id);
expect(invalidateSpy).toHaveBeenCalledWith("task:deleted");
expect(store.readTaskFromDb(task.id, { includeDeleted: true })).toMatchObject({
id: task.id,
column: "archived",
});
expect((await snapshotManager.getSnapshot()).tasks.map((entry) => entry.id)).not.toContain(task.id);
expect((await store.listTasks({ column: "archived" })).map((entry) => entry.id)).not.toContain(task.id);
expect(store.auditEvents).toHaveLength(1);
});
it("fans out a single task:deleted event to listeners while recording one audit event", async () => {
const store = createEventedSoftDeleteStore();
const task = await store.createTask({ column: "in-progress", title: "Listener target" });
const abortSpy = vi.fn();
store.on("task:deleted", abortSpy);
await store.deleteTask(task.id);
expect(abortSpy).toHaveBeenCalledTimes(1);
expect(abortSpy).toHaveBeenCalledWith(expect.objectContaining({ id: task.id, column: "archived" }));
expect(store.auditEvents).toHaveLength(1);
});
it("does not duplicate audit bookkeeping when a watcher re-emits task:deleted", async () => {
const store = createEventedSoftDeleteStore();
const task = await store.createTask({ column: "todo", title: "Watcher target" });
const deleted = await store.deleteTask(task.id);
store.emit("task:deleted", { ...deleted });
expect(store.auditEvents).toHaveLength(1);
expect(store.auditEvents[0]).toMatchObject({ mutationType: "task:deleted", taskId: task.id });
});
it("keeps archiveTask semantics unchanged for live done rows while soft-delete audit rows persist", async () => {
const store = createEventedSoftDeleteStore();
const softDeleted = await store.createTask({ column: "todo", title: "soft delete first" });
const doneTask = await store.createTask({ column: "done", title: "archive me" });
await store.deleteTask(softDeleted.id);
await store.archiveTask(doneTask.id);
expect(store.readTaskFromDb(doneTask.id, { includeDeleted: true })).toBeUndefined();
expect(store.archivedTasks.get(doneTask.id)).toMatchObject({ id: doneTask.id, column: "done" });
expect(store.auditEvents).toHaveLength(1);
expect(store.auditEvents[0]).toMatchObject({ mutationType: "task:deleted", taskId: softDeleted.id });
});
});

View File

@@ -106,7 +106,13 @@ describe("triage finalize duplicate lineage", () => {
const store = createMockStore();
await runRecovery(createTask(), "DUPLICATE: FN-4894\n", store);
expect(store.deleteTask).toHaveBeenCalledWith("FN-001", { removeLineageReferences: true });
expect(store.deleteTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
removeLineageReferences: true,
auditContext: expect.objectContaining({
agentId: "triage",
runId: expect.stringMatching(/^triage-delete-FN-001-/),
}),
}));
expect(store.updateTask).not.toHaveBeenCalled();
});
});

View File

@@ -104,7 +104,13 @@ describe("triage split/delete lineage forwarding", () => {
const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 });
await processor.specifyTask(createTask());
expect(store.deleteTask).toHaveBeenCalledWith("FN-500", { removeLineageReferences: true });
expect(store.deleteTask).toHaveBeenCalledWith("FN-500", expect.objectContaining({
removeLineageReferences: true,
auditContext: expect.objectContaining({
agentId: "triage",
runId: expect.stringMatching(/^triage-delete-FN-500-/),
}),
}));
});
it("passes removeLineageReferences when split-close happens on the fallback planning path", async () => {
@@ -124,7 +130,13 @@ describe("triage split/delete lineage forwarding", () => {
const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 });
await processor.specifyTask(createTask({ id: "FN-600" }));
expect(store.deleteTask).toHaveBeenCalledWith("FN-600", { removeLineageReferences: true });
expect(store.deleteTask).toHaveBeenCalledWith("FN-600", expect.objectContaining({
removeLineageReferences: true,
auditContext: expect.objectContaining({
agentId: "triage",
runId: expect.stringMatching(/^triage-delete-FN-600-/),
}),
}));
});
it("passes removeLineageReferences on DUPLICATE close", async () => {
@@ -143,7 +155,13 @@ describe("triage split/delete lineage forwarding", () => {
createTask({ id: "FN-001", log: [{ timestamp: new Date().toISOString(), action: "Spec review: APPROVE" }] as any }),
);
expect(store.deleteTask).toHaveBeenCalledWith("FN-001", { removeLineageReferences: true });
expect(store.deleteTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
removeLineageReferences: true,
auditContext: expect.objectContaining({
agentId: "triage",
runId: expect.stringMatching(/^triage-delete-FN-001-/),
}),
}));
} finally {
await rm(rootDir, { recursive: true, force: true });
}

View File

@@ -2216,7 +2216,13 @@ describe("taskCreate tool model inheritance", () => {
"FN-500",
expect.stringContaining("Converted into subtasks: FN-501, FN-502"),
);
expect(store.deleteTask).toHaveBeenCalledWith("FN-500", { removeLineageReferences: true });
expect(store.deleteTask).toHaveBeenCalledWith("FN-500", expect.objectContaining({
removeLineageReferences: true,
auditContext: expect.objectContaining({
agentId: "triage",
runId: expect.stringMatching(/^triage-delete-FN-500-/),
}),
}));
});
});

View File

@@ -1326,7 +1326,13 @@ export class TriageProcessor {
);
try {
// FN-5129 / FN-5131: split-close must unlink lineage children when deleting the parent.
await this.store.deleteTask(task.id, { removeLineageReferences: true });
await this.store.deleteTask(task.id, {
removeLineageReferences: true,
auditContext: {
agentId: task.assignedAgentId ?? "triage",
runId: generateSyntheticRunId("triage-delete", task.id),
},
});
planLog.log(`${task.id} split into subtasks (${childTaskIds}) and closed`);
} catch (err: unknown) {
// deleteTask refuses when live tasks still depend on this id.
@@ -1471,7 +1477,13 @@ export class TriageProcessor {
`Converted into subtasks: ${childTaskIds}`,
);
// FN-5129 / FN-5131: split-close must unlink lineage children when deleting the parent.
await this.store.deleteTask(task.id, { removeLineageReferences: true });
await this.store.deleteTask(task.id, {
removeLineageReferences: true,
auditContext: {
agentId: task.assignedAgentId ?? "triage",
runId: generateSyntheticRunId("triage-delete", task.id),
},
});
planLog.log(`${task.id} split into subtasks (${childTaskIds}) and closed`);
return;
}
@@ -2232,7 +2244,13 @@ export class TriageProcessor {
`Duplicate of ${dupId} — closed`,
);
// Pass removeLineageReferences so a duplicate-close cannot be blocked by lineage children (FN-5129 / FN-5131).
await this.store.deleteTask(task.id, { removeLineageReferences: true });
await this.store.deleteTask(task.id, {
removeLineageReferences: true,
auditContext: {
agentId: task.assignedAgentId ?? "triage",
runId: generateSyntheticRunId("triage-delete", task.id),
},
});
return;
}

View File

@@ -0,0 +1,119 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { reconcileLeakedSoftDeletes } from "../reconcile-leaked-soft-deletes.mjs";
function setupFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fn-5175-"));
const dbPath = path.join(dir, "fusion.db");
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
"column" TEXT NOT NULL,
status TEXT,
deletedAt TEXT,
updatedAt TEXT
);
CREATE TABLE runAuditEvents (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
taskId TEXT,
agentId TEXT NOT NULL,
runId TEXT NOT NULL,
domain TEXT NOT NULL,
mutationType TEXT NOT NULL,
target TEXT NOT NULL,
metadata TEXT
);
`);
return { dir, db };
}
function insertTask(db, row) {
db.prepare(`INSERT INTO tasks (id, "column", status, deletedAt, updatedAt) VALUES (?, ?, ?, ?, ?)`)
.run(row.id, row.column, row.status ?? null, row.deletedAt ?? null, row.updatedAt ?? null);
}
test("reconciles leaked soft-deletes and is idempotent on re-run", () => {
const { dir, db } = setupFixture();
try {
insertTask(db, {
id: "FN-5130",
column: "in-review",
status: "failed",
deletedAt: "2026-05-19T00:00:00.000Z",
updatedAt: "2026-05-19T00:00:00.000Z",
});
insertTask(db, {
id: "FN-5133",
column: "todo",
status: null,
deletedAt: "2026-05-19T01:00:00.000Z",
updatedAt: "2026-05-19T01:00:00.000Z",
});
insertTask(db, {
id: "FN-5167",
column: "archived",
status: null,
deletedAt: "2026-05-19T02:00:00.000Z",
updatedAt: "2026-05-19T02:00:00.000Z",
});
const result = reconcileLeakedSoftDeletes({
db,
dryRun: false,
runId: "synthetic-reconcile-fn-5175-test",
});
assert.equal(result.rowsScanned, 2);
assert.equal(result.rowsUpdated, 2);
assert.equal(result.auditRowsInserted, 2);
const columns = db.prepare(`SELECT id, "column" FROM tasks ORDER BY id`).all().map((row) => ({ ...row }));
assert.deepEqual(columns, [
{ id: "FN-5130", column: "archived" },
{ id: "FN-5133", column: "archived" },
{ id: "FN-5167", column: "archived" },
]);
const audits = db.prepare(`SELECT taskId, agentId, runId, mutationType, target, metadata FROM runAuditEvents ORDER BY taskId`).all().map((row) => ({ ...row }));
assert.equal(audits.length, 2);
assert.deepEqual(audits.map((row) => row.taskId), ["FN-5130", "FN-5133"]);
assert.ok(audits.every((row) => row.agentId === "system"));
assert.ok(audits.every((row) => row.runId === "synthetic-reconcile-fn-5175-test"));
assert.ok(audits.every((row) => row.mutationType === "task:soft-delete-column-reconcile"));
assert.deepEqual(audits.map((row) => JSON.parse(row.metadata)), [
{
previousColumn: "in-review",
previousStatus: "failed",
source: "FN-5175 reconcile",
},
{
previousColumn: "todo",
previousStatus: null,
source: "FN-5175 reconcile",
},
]);
const second = reconcileLeakedSoftDeletes({
db,
dryRun: false,
runId: "synthetic-reconcile-fn-5175-test-2",
});
assert.equal(second.rowsScanned, 0);
assert.equal(second.rowsUpdated, 0);
assert.equal(second.auditRowsInserted, 0);
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM runAuditEvents").get().count, 2);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env node
import path from "node:path";
import process from "node:process";
import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
export function parseArgs(argv = process.argv.slice(2)) {
const args = [...argv];
let dbPath = ".fusion/fusion.db";
for (let i = 0; i < args.length; i += 1) {
if (args[i] === "--db" && args[i + 1]) {
dbPath = args[i + 1];
i += 1;
}
}
return {
apply: args.includes("--apply"),
dryRun: !args.includes("--apply"),
dbPath,
};
}
export function findLeakedSoftDeletes(db) {
return db.prepare(`
SELECT id, "column", status, deletedAt
FROM tasks
WHERE deletedAt IS NOT NULL AND "column" != 'archived'
ORDER BY id
`).all();
}
export function reconcileLeakedSoftDeletes({ db, dryRun = true, runId = `synthetic-reconcile-fn-5175-${Date.now()}` }) {
const rows = findLeakedSoftDeletes(db);
const summary = {
rowsScanned: rows.length,
rowsUpdated: 0,
auditRowsInserted: 0,
runId,
findings: rows.map((row) => ({ ...row, status: row.status ?? null })),
};
if (dryRun || rows.length === 0) {
return summary;
}
const now = new Date().toISOString();
const updateTask = db.prepare(`UPDATE tasks SET "column" = 'archived' WHERE id = ?`);
const insertAudit = db.prepare(`
INSERT INTO runAuditEvents (
id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
db.exec("BEGIN IMMEDIATE");
try {
for (const row of rows) {
updateTask.run(row.id);
insertAudit.run(
randomUUID(),
now,
row.id,
"system",
runId,
"database",
"task:soft-delete-column-reconcile",
row.id,
JSON.stringify({
previousColumn: row.column,
previousStatus: row.status ?? null,
source: "FN-5175 reconcile",
}),
);
summary.rowsUpdated += 1;
summary.auditRowsInserted += 1;
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
return summary;
}
export function formatSummary(summary, dryRun) {
const lines = [
dryRun ? "Mode: DRY RUN" : "Mode: APPLY",
"id\tcolumn\tstatus\tdeletedAt",
...summary.findings.map((row) => `${row.id}\t${row.column}\t${row.status ?? "NULL"}\t${row.deletedAt}`),
`Rows scanned: ${summary.rowsScanned}`,
`Rows updated: ${summary.rowsUpdated}`,
`Audit rows inserted: ${summary.auditRowsInserted}`,
];
return lines.join("\n");
}
export async function main(argv = process.argv.slice(2)) {
const { dryRun, dbPath } = parseArgs(argv);
const resolvedDbPath = path.resolve(dbPath);
const db = new DatabaseSync(resolvedDbPath);
try {
const summary = reconcileLeakedSoftDeletes({ db, dryRun });
console.log(formatSummary(summary, dryRun));
return summary;
} finally {
db.close();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}