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:
committed by
gsxdsm
parent
6ecef44ab3
commit
c9fd41ef26
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}` }],
|
||||
|
||||
106
packages/core/src/__tests__/soft-delete-audit-and-column.test.ts
Normal file
106
packages/core/src/__tests__/soft-delete-audit-and-column.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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-/),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user