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:
committed by
gsxdsm
parent
5696823eaa
commit
321c170b90
@@ -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.
|
||||
|
||||
149
packages/core/src/__tests__/soft-delete-agent-logs.test.ts
Normal file
149
packages/core/src/__tests__/soft-delete-agent-logs.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user