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

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