feat(FN-5143): clear agent logs when task is soft-deleted

Clears agent logs when a task is soft-deleted, with new regression tests covering the end-to-end behavior and documentation updated in the storage guide. The core logic lives in `store.ts` while `store-upsert.test.ts` is updated to reflect the new expectations.

Fusion-Task-Id: FN-5143
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 09:27:35 -07:00
committed by gsxdsm
parent 5696823eaa
commit 321c170b90
4 changed files with 173 additions and 4 deletions

View File

@@ -15,6 +15,11 @@
- Archived-task flows (`archiveTask`, archived cleanup/migration) still hard-delete from the active `tasks` table after copying to cold storage (`archive.db`).
- ID reservation is unchanged: soft-deleted IDs remain reserved. `distributed-task-id` and `task-id-integrity` intentionally scan all task rows (including soft-deleted rows), and must not filter on `deletedAt`.
### Agent log clearing (FN-5143)
- `TaskStore.deleteTask` now clears `agentLogEntries` rows for the soft-deleted task in the same transaction that writes `deletedAt`, so downstream `getAgentLogs*` / `getAgentLogCount` calls observe zero logs immediately.
- This is soft-delete-specific cleanup; archived-task agent log snapshot behavior (`taskToArchiveEntry` / `archiveTask`) is unchanged.
### Dashboard delete-event handling (FN-5135)
- Dashboard clients treat any SSE payload with `deletedAt != null` (`task:created`, `task:updated`, `task:moved`, `task:merged`) as a delete-equivalent and remove/suppress that task locally.

View File

@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore soft-delete agent log clearing (FN-5143)", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
await harness.afterEach();
});
it("deletes pre-existing persisted agent logs on soft-delete", async () => {
const store = harness.store();
const task = await harness.createTestTask();
await store.appendAgentLog(task.id, "entry-1", "text");
await store.appendAgentLog(task.id, "entry-2", "text");
await store.appendAgentLog(task.id, "entry-3", "text");
await store.getAgentLogs(task.id);
const before = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(task.id) as { count: number };
expect(before.count).toBe(3);
await store.deleteTask(task.id);
const after = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(task.id) as { count: number };
expect(after.count).toBe(0);
await expect(store.getAgentLogs(task.id)).resolves.toEqual([]);
await expect(store.getAgentLogCount(task.id)).resolves.toBe(0);
});
it("discards buffered unflushed entries when task is soft-deleted", async () => {
const store = harness.store();
const task = await harness.createTestTask();
await store.appendAgentLog(task.id, "buffered-only", "text");
await store.deleteTask(task.id);
const rows = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(task.id) as { count: number };
expect(rows.count).toBe(0);
await expect(store.getAgentLogs(task.id)).resolves.toEqual([]);
});
it("keeps idempotent re-delete as a no-op for agent logs", async () => {
const store = harness.store();
const task = await harness.createTestTask();
await store.appendAgentLog(task.id, "first", "text");
await store.getAgentLogs(task.id);
await store.deleteTask(task.id);
const firstDeleteCount = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(task.id) as { count: number };
expect(firstDeleteCount.count).toBe(0);
const rowBefore = (store as any).db
.prepare('SELECT deletedAt, updatedAt, "column" FROM tasks WHERE id = ?')
.get(task.id) as { deletedAt: string | null; updatedAt: string | null; column: string | null };
await expect(store.deleteTask(task.id)).resolves.toMatchObject({ id: task.id });
const rowAfter = (store as any).db
.prepare('SELECT deletedAt, updatedAt, "column" FROM tasks WHERE id = ?')
.get(task.id) as { deletedAt: string | null; updatedAt: string | null; column: string | null };
expect(rowAfter.deletedAt).toBe(rowBefore.deletedAt);
expect(rowAfter.updatedAt).toBe(rowBefore.updatedAt);
expect(rowAfter.column).toBe("archived");
const secondDeleteCount = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(task.id) as { count: number };
expect(secondDeleteCount.count).toBe(0);
});
it("clears only the soft-deleted parent logs when removing lineage references", async () => {
const store = harness.store();
const parent = await store.createTask({ description: "parent" });
const child = await store.createTask({ description: "child", sourceTaskId: parent.id, sourceParentTaskId: parent.id });
await store.appendAgentLog(parent.id, "parent-log", "text");
await store.appendAgentLog(child.id, "child-log", "text");
await store.getAgentLogs(parent.id);
await store.getAgentLogs(child.id);
const childBefore = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(child.id) as { count: number };
expect(childBefore.count).toBe(1);
await store.deleteTask(parent.id, { removeLineageReferences: true });
const parentAfter = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(parent.id) as { count: number };
const childAfter = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(child.id) as { count: number };
expect(parentAfter.count).toBe(0);
expect(childAfter.count).toBe(1);
});
it("does not affect other tasks' agent logs", async () => {
const store = harness.store();
const first = await harness.createTestTask();
const second = await harness.createTestTask();
await store.appendAgentLog(first.id, "first-log", "text");
await store.appendAgentLog(second.id, "second-log", "text");
await store.getAgentLogs(first.id);
await store.getAgentLogs(second.id);
await store.deleteTask(first.id);
const firstAfter = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(first.id) as { count: number };
const secondAfter = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(second.id) as { count: number };
expect(firstAfter.count).toBe(0);
expect(secondAfter.count).toBe(1);
});
it("emits task:deleted only after agent logs are cleared", async () => {
const store = harness.store();
const task = await harness.createTestTask();
await store.appendAgentLog(task.id, "event-order", "text");
await store.getAgentLogs(task.id);
const seenCounts: number[] = [];
store.once("task:deleted", async (deletedTask) => {
seenCounts.push(await store.getAgentLogCount(deletedTask.id));
});
await store.deleteTask(task.id);
expect(seenCounts).toEqual([0]);
});
});

View File

@@ -602,7 +602,7 @@ describe("TaskStore", () => {
await expect(store.deleteTask(targetTask.id)).resolves.toMatchObject({ id: targetTask.id });
});
it("deleting a task preserves agent log entries for soft-deleted rows", async () => {
it("deleting a task clears persisted agent log entries for soft-deleted rows", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "cascade me", "text");
(store as any).flushAgentLogBuffer();
@@ -617,7 +617,7 @@ describe("TaskStore", () => {
const after = (store as any).db.prepare(
"SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?",
).get(task.id) as { count: number };
expect(after.count).toBe(1);
expect(after.count).toBe(0);
});
it("deleteTask clears linked agent task assignments", async () => {
@@ -744,7 +744,7 @@ describe("TaskStore", () => {
expect(count).toBe(1);
});
it("auto-flushes before deleteTask", async () => {
it("auto-flushes before deleteTask and soft-delete clears resulting rows", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "to be cascaded", "text");
@@ -757,7 +757,7 @@ describe("TaskStore", () => {
const after = (store as any).db.prepare(
"SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?",
).get(task.id) as { count: number };
expect(after.count).toBe(1);
expect(after.count).toBe(0);
});
it("flushes remaining entries on close without throwing", async () => {

View File

@@ -6301,9 +6301,24 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
},
});
this.clearLinkedAgentTaskIds(id, deletedAt);
// FN-5143: clear historical agent logs for the soft-deleted task so
// downstream readers (evaluator evidence, self-healing diagnostics,
// dashboard log views, register-task-workflow-routes) observe zero logs
// immediately after deletedAt is set. Atomic with the deletedAt write.
this.db.prepare("DELETE FROM agentLogEntries WHERE taskId = ?").run(id);
this.db.bumpLastModified();
});
// FN-5143 defense-in-depth: drop any in-memory buffer entries for this
// task. flushAgentLogBuffer() above already ran inside the lock, but a
// concurrent appendAgentLog from another async path could re-buffer
// before this lock releases; the next flush would still drop them via
// ACTIVE_TASKS_WHERE, but filtering here avoids the warn log and keeps
// memory bounded.
if (this.agentLogBuffer.length > 0) {
this.agentLogBuffer = this.agentLogBuffer.filter((entry) => entry.taskId !== id);
}
// Remove from cache if watcher is active
if (this.isWatching) this.taskCache.delete(id);