FN-7410: allow archived task deletion

Allow archived task snapshots to be deleted while preserving soft-delete tombstones.

- Restore cold archived snapshots into the tasks table before applying delete tombstones.
- Remove archive snapshots after deletion and preserve allowResurrection semantics.
- Cover archived deletion through store, API, UI menu, docs, and changeset updates.

Files changed:
 .changeset/fn-7410-delete-archived-tasks.md        |   7 ++
 docs/task-management.md                            |   3 +-
 .../core/src/__tests__/soft-delete-tasks.test.ts   | 129 +++++++++++++++++++++
 packages/core/src/store.ts                         |  25 +++-
 .../components/__tests__/TaskContextMenu.test.tsx  |  19 +++
 .../src/__tests__/routes-tasks-ops.test.ts         |  24 ++++
 6 files changed, 204 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7410
Fusion-Task-Lineage: fa7d9983-e62c-4849-89d8-1ae5f6134815
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 00:36:23 -07:00
parent 2d8f087a9d
commit 377eee64af
6 changed files with 204 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Allow operators to delete archived tasks.
category: fix
dev: Extends task deletion to archive-db snapshots while preserving soft-delete tombstones and ID reservation.

View File

@@ -617,6 +617,7 @@ Behavior:
- `fn task archive <id>` moves any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) to `archived`; tasks already in `archived` are rejected.
- Archive records the task's `preArchiveColumn` so restore can return to the original live column instead of always assuming `done`.
- Dashboard delete confirmations for live tasks include an **Archive Instead** action so users can preserve history without soft-deleting the task.
- Archived tasks can also be deleted from the dashboard/API/CLI. Deleting an archived task removes the archived snapshot from lists and search, but first materializes the normal soft-delete tombstone so the task ID remains reserved unless the operator explicitly chooses allow-resurrection behavior.
- Cleanup mode can persist compact metadata and remove the task directory
- Archived tasks are read-only for task log/document writes:
- `logEntry()` throws `Task <id> is archived — logging is read-only`
@@ -625,7 +626,7 @@ Behavior:
### Cleanup behavior
- Archived entries are persisted as compact archive snapshots (current runtime stores these in SQLite `archivedTasks`; legacy docs may refer to `.fusion/archive.jsonl`)
- Archived entries are persisted as compact archive snapshots in `archive.db`; legacy in-main-DB `archivedTasks` rows and older `.fusion/archive.jsonl` references may still appear in historical data/docs.
- Task directory (`task.json`, `PROMPT.md`, `agent.log`, attachments) can be removed
### Compact archive entry format

View File

@@ -118,6 +118,135 @@ describe("TaskStore soft delete", () => {
expect((store as any).archiveDb.get(doneTask.id)?.id).toBe(doneTask.id);
});
it("deletes cold archive snapshots through soft-delete tombstones", async () => {
const store = harness.store();
const task = await store.createTask({
column: "todo",
title: "Cold Archived Delete",
description: "cold-archive-delete-needle description",
});
await store.addComment(task.id, "cold-archive-comment-needle", "operator");
const taskDir = join(harness.rootDir(), ".fusion", "tasks", task.id);
await store.archiveTask(task.id, true);
expect(existsSync(taskDir)).toBe(false);
expect((store as any).archiveDb.get(task.id)?.id).toBe(task.id);
expect((await store.searchTasks("cold-archive-delete-needle", { includeArchived: true })).map((entry) => entry.id)).toContain(task.id);
expect((await store.searchTasks("cold-archive-comment-needle", { includeArchived: true })).map((entry) => entry.id)).toContain(task.id);
const deletedEvents: string[] = [];
store.on("task:deleted", (event) => deletedEvents.push(event.id));
const deleted = await store.deleteTask(task.id);
expect(deleted).toMatchObject({ id: task.id, column: "archived", title: "Cold Archived Delete" });
expect((store as any).archiveDb.get(task.id)).toBeUndefined();
expect((await store.listTasks({ column: "archived", includeArchived: true })).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.searchTasks("cold-archive-delete-needle", { includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id);
expect((await store.searchTasks("cold-archive-comment-needle", { includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id);
expect((await store.searchTasks(task.id, { includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id);
await expect(store.getTask(task.id)).rejects.toThrow(`Task ${task.id} not found`);
const row = (store as any).db
.prepare("SELECT id, deletedAt, allowResurrection, \"column\" FROM tasks WHERE id = ?")
.get(task.id) as { id: string; deletedAt: string | null; allowResurrection: number; column: string };
expect(row).toMatchObject({ id: task.id, allowResurrection: 0, column: "archived" });
expect(row.deletedAt).toBeTruthy();
expect(() => (store as any).assertTaskIdAvailable(task.id)).toThrow();
expect((store as any).taskIdExistsAnywhere(task.id)).toBe(true);
expect((store as any).isTaskArchived(task.id)).toBe(false);
expect(deletedEvents).toEqual([task.id]);
});
it("honors allowResurrection when deleting cold archived snapshots", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", description: "resurrectable cold archive" });
await store.archiveTask(task.id, true);
await store.deleteTask(task.id, { allowResurrection: true });
const row = (store as any).db
.prepare("SELECT deletedAt, allowResurrection FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null; allowResurrection: number };
expect(row.deletedAt).toBeTruthy();
expect(row.allowResurrection).toBe(1);
expect((store as any).taskIdExistsAnywhere(task.id)).toBe(true);
expect(() => (store as any).assertTaskIdAvailable(task.id)).toThrow();
expect(() => (store as any).maybeResolveTombstonedTaskId(task.id, { forceResurrect: false }, "createTask")).not.toThrow();
});
it("applies dependency and lineage guards when deleting cold archives", async () => {
const store = harness.store();
const parent = await store.createTask({ column: "todo", description: "cold parent" });
const dependent = await store.createTask({ column: "todo", description: "live dependent" });
await store.updateTask(dependent.id, { dependencies: [parent.id] });
await store.archiveTask(parent.id, true);
await expect(store.deleteTask(parent.id)).rejects.toThrow("still referenced as a dependency");
await expect(store.deleteTask(parent.id, { removeDependencyReferences: true })).resolves.toMatchObject({ id: parent.id });
expect((await store.getTask(dependent.id)).dependencies).toEqual([]);
const lineageParent = await store.createTask({ column: "todo", description: "cold lineage parent" });
const lineageChild = await store.createTask({ column: "todo", description: "live lineage child" });
await store.archiveTask(lineageParent.id, true);
(store as any).db.prepare("UPDATE tasks SET sourceParentTaskId = ?, sourceType = ?, updatedAt = ? WHERE id = ?").run(
lineageParent.id,
"duplicate",
new Date().toISOString(),
lineageChild.id,
);
await expect(store.deleteTask(lineageParent.id)).rejects.toThrow("still referenced as a lineage parent");
await expect(store.deleteTask(lineageParent.id, { removeLineageReferences: true })).resolves.toMatchObject({ id: lineageParent.id });
expect((await store.getTask(lineageChild.id)).sourceParentTaskId).toBeUndefined();
});
it("removes duplicate archive snapshots when deleting the authoritative active row", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", title: "Authoritative Active", description: "stale-archive-duplicate-needle" });
const staleEntry = await (store as any).taskToArchiveEntry({ ...task, column: "archived" }, new Date().toISOString());
(store as any).archiveDb.upsert(staleEntry);
expect((store as any).archiveDb.get(task.id)?.id).toBe(task.id);
await store.deleteTask(task.id);
expect((store as any).archiveDb.get(task.id)).toBeUndefined();
expect((await store.listTasks({ includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id);
expect((await store.searchTasks("stale-archive-duplicate-needle", { includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id);
});
it("deletes hot archived rows idempotently without duplicate delete events", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", description: "hot archive delete target" });
await store.archiveTask(task.id, false);
const deletedEvents: string[] = [];
store.on("task:deleted", (event) => deletedEvents.push(event.id));
const firstResult = await store.deleteTask(task.id);
const firstRow = (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 };
const secondResult = await store.deleteTask(task.id);
const secondRow = (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(firstResult).toMatchObject({ id: task.id, column: "archived" });
expect(firstRow.deletedAt).toBeTruthy();
expect(firstRow.column).toBe("archived");
expect(secondResult.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");
});
it("is idempotent on re-delete and does not re-emit task:deleted", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", description: "idempotent re-delete target" });

View File

@@ -11391,9 +11391,22 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// Flush buffered agent logs inside the lock so no new appends for this
// task can sneak in between flush and soft-delete mutation.
this.flushAgentLogBuffer();
const task = this.readTaskFromDb(id, { includeDeleted: true });
let task = this.readTaskFromDb(id, { includeDeleted: true });
let restoredColdArchive = false;
if (!task) {
throw new Error(`Task ${id} not found`);
const archivedEntry = await this.findInArchive(id);
if (!archivedEntry) {
throw new Error(`Task ${id} not found`);
}
/*
FNXC:TaskDeletion 2026-07-01-23:58:
Cold archived tasks exist only as archive.db snapshots, but deleting them must still leave a tasks-table soft-delete tombstone. Restore the snapshot into the normal delete path so task IDs stay reserved and allowResurrection keeps using the existing tombstone contract instead of hard-dropping history.
FNXC:TaskDeletion 2026-07-02-00:00:
The restore helper only materializes task files; cold archived deletes must also insert the restored task row inside the delete transaction before applying the tombstone update, otherwise the archive snapshot can be removed with no DB row preserving the ID reservation.
*/
task = await this.restoreFromArchive(archivedEntry);
restoredColdArchive = true;
}
if (task.deletedAt) {
@@ -11431,9 +11444,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
rewrittenBlockedByResidueDependents = this.rewriteBlockedByResidueDependentsForRemoval(id, new Set(dependentIds));
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
if (restoredColdArchive) {
this.upsertTaskWithFtsRecovery(task);
}
const deletedAt = new Date().toISOString();
const allowResurrection = options?.allowResurrection === true ? 1 : 0;
this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, allowResurrection = ?, updatedAt = ? WHERE id = ?").run(deletedAt, allowResurrection, deletedAt, id);
task.deletedAt = deletedAt;
task.allowResurrection = options?.allowResurrection === true ? true : undefined;
task.updatedAt = deletedAt;
this.recordRunAuditEvent({
domain: "database",
mutationType: "task:deleted",
@@ -11469,6 +11488,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
this.agentLogBuffer = this.agentLogBuffer.filter((entry) => entry.taskId !== id);
}
this.archiveDb.delete(id);
// Remove from cache if watcher is active
if (this.isWatching) this.taskCache.delete(id);

View File

@@ -210,6 +210,25 @@ describe("TaskContextMenu shared task action model", () => {
}).reviewAction).toMatchObject({ id: "pr-automation", label: "Merging PR…", disabled: true });
});
it("keeps archived delete available without live-only destructive shells", () => {
const onDelete = vi.fn();
const archivedModel = buildTaskActionMenuModel({
task: makeTask({ column: "archived" }),
t,
columnLabel: columnLabel as any,
hasResetHandler: true,
onReset: vi.fn(),
onTogglePause: vi.fn(),
onDelete,
});
expect(archivedModel.actions.map((action) => action.id)).toEqual(["respecify", "delete"]);
expect(archivedModel.actions.map((action) => action.id)).not.toContain("pause");
expect(archivedModel.actions.map((action) => action.id)).not.toContain("reset");
archivedModel.actions.find((action) => action.id === "delete")?.onSelect?.();
expect(onDelete).toHaveBeenCalledTimes(1);
});
it("renders descriptors and delegates selection to injected host handlers", () => {
const onDelete = vi.fn();
const onActionSelect = vi.fn();

View File

@@ -1898,6 +1898,30 @@ describe("DELETE /tasks/:id", () => {
}));
});
it("returns 200 for archived tasks and forwards every delete option unchanged", async () => {
const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-ARCHIVED", column: "archived" };
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(deletedTask);
const res = await REQUEST(
buildApp(),
"DELETE",
"/api/tasks/KB-ARCHIVED?allowResurrection=1&removeDependencyReferences=1&removeLineageReferences=true&githubIssueAction=leave",
);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: "KB-ARCHIVED", column: "archived" });
expect(store.deleteTask).toHaveBeenCalledWith("KB-ARCHIVED", expect.objectContaining({
allowResurrection: true,
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: "leave",
auditContext: expect.objectContaining({
agentId: "system",
runId: expect.stringMatching(/^synthetic-dashboard-delete-KB-ARCHIVED-/),
}),
}));
});
it.each(["close", "delete", "leave", "auto"] as const)("forwards githubIssueAction=%s", async (githubIssueAction) => {
const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-001" };
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(deletedTask);