This commit is contained in:
gsxdsm
2026-05-22 04:09:22 -07:00
parent 5a9095e547
commit 3a0d179ba2
9 changed files with 57 additions and 451 deletions

View File

@@ -1,5 +0,0 @@
---
"@runfusion/fusion": patch
---
FN-5196: pin the archived-task delete contract. `deleteTask` on a hard-archived task now throws the typed `TaskAlreadyHardArchivedError`; the dashboard delete route maps this to HTTP 410 Gone with `code: "TASK_ALREADY_HARD_ARCHIVED"`. No silent no-op.

View File

@@ -146,10 +146,6 @@ Per-task opt-out exists: `task.scopeOverride = true` (log the reason).
When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a human. Lifecycle-mutating self-healing must not move these tasks backward, pause/fail them, or re-enqueue them for execution.
### Archived-task delete contract (FN-5196)
`TaskStore.deleteTask(id)` on a hard-archived task (row moved to `archive.db` via `archiveTask`) throws `TaskAlreadyHardArchivedError`. The dashboard `DELETE /api/tasks/:id` route maps this to HTTP 410 Gone with `{ code: "TASK_ALREADY_HARD_ARCHIVED", taskId, archivedAt }`. No silent no-op; callers must `unarchiveTask` first or use archive-cleanup APIs for forensic removal.
### Mock provider (test mode)
`testMode?: boolean` is now available in both project and global settings. If project `testMode === true` (or the resolved default provider is `"mock"` at any tier), every AI lane is forced to `mock/scripted`, overriding per-task and per-lane model selections. The dashboard exposes this via the Settings Modal "Enable test mode" toggle and a persistent "Test mode — no real AI calls" banner.

View File

@@ -10,15 +10,15 @@
| Scenario | Pre-state | API (REST) | Scheduler / Executor | Merger | Triage | Dashboard (SSE + initial load) | Agent logs | Documents | Owning FN |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1. Soft-delete a `triage` task with no `PROMPT.md` yet | Live `triage` row; task dir may exist without spec | `DELETE /api/tasks/:id` soft-deletes; later live readers 404 / omit; forensic store access still sees `deletedAt` row | Not dispatchable; auto-claim snapshots exclude it; executor entry points refuse if manually targeted | N/A | Active/queued triage abort + cleanup now asserted in the end-to-end composition backstop | SSE `task:deleted` removes it; reload keeps it absent from board/ListView/TodoView | Preserved until FN-5143 lands | No documents expected; document APIs still treat parent as absent once FN-5140 lands | FN-5105 / FN-5135 / FN-5137 / FN-5142 |
| 1. Soft-delete a `triage` task with no `PROMPT.md` yet | Live `triage` row; task dir may exist without spec | `DELETE /api/tasks/:id` soft-deletes; later live readers 404 / omit; forensic store access still sees `deletedAt` row | Not dispatchable; auto-claim snapshots exclude it; executor entry points refuse if manually targeted | N/A | Active/queued triage must not re-open it after refresh; active abort is **blocked by FN-5142** | SSE `task:deleted` removes it; reload keeps it absent from board/ListView/TodoView | Preserved until FN-5143 lands | No documents expected; document APIs still treat parent as absent once FN-5140 lands | FN-5105 / FN-5135 / FN-5137 / FN-5142 |
| 2. Soft-delete a `todo` task with dependencies pointing AT it | Live `todo`; other live tasks depend on it | Delete is 409 `TASK_HAS_DEPENDENTS` unless caller opts into `removeDependencyReferences`; retry succeeds and returns soft-deleted task | Deleted task is never redispatched; rewritten dependents keep running as live tasks | N/A | N/A | SSE removes deleted card; dependents reflect rewritten dependency state on refresh | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5105 / FN-5137 |
| 3. Soft-delete a `todo` task with lineage children | Live `todo`; `sourceParentTaskId` children exist | 409 lineage-child guard + removeLineageReferences retry path are asserted in composition backstop | Deleted parent never dispatches; rewritten children remain live | N/A | N/A | Delete/archive UI must surface confirm-retry flow once FN-5139 lands | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5139 |
| 4. Soft-delete an `in-progress` task with an active executor session | Live `in-progress`; executor owns active session | Delete succeeds; task vanishes from live readers immediately | Entry guards refuse reruns; active-session abort / dispose now asserted in composition backstop | N/A | N/A | SSE removes card; reload does not re-seed it into active queues | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5142 |
| 5. Soft-delete an `in-progress` task with an active workflow-step session and reviewer subagent | Live `in-progress`; workflow step child session exists | Delete succeeds; no public recovery/undelete flag | New execution attempts refuse; workflow-step + reviewer cleanup covered in FN-5142 owning suite and composition backstop | N/A | N/A | SSE removes card; reload stays clean | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 |
| 6. Soft-delete an `in-review` task that is currently being merged | Live `in-review`; active merge session in flight | Delete succeeds; live readers omit afterward | Scheduler must not requeue it | Active merge abort, queue removal, and controller cleanup now asserted in composition backstop | N/A | SSE removes card; reload keeps it absent | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 |
| 7. Soft-delete an `in-review` task queued for merge but not yet active | Live `in-review`; merge queued only | Delete succeeds; row stays for forensics only | Scheduler/executor must not pick it up again | Merge queue filter assertion is now covered in composition backstop | N/A | SSE removes card; reload keeps it absent | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5142 |
| 8. Soft-delete a `done` task with archived/visible agent logs and saved task documents | Live `done`; has task docs + agent logs | Delete succeeds; live task readers omit afterward; forensic reads still allowed internally | Not runnable after any engine tick or restart | N/A | N/A | SSE removes card; refresh does not show it in board/ListView/TodoView | `agentLogEntries` clear + empty readers asserted in composition backstop | `/api/documents` and per-task docs are hidden while DB rows remain, asserted in composition backstop | FN-5140 / FN-5143 |
| 9. Soft-delete an archived task | Task already archived / moved out of live `tasks` table | `DELETE /api/tasks/:id` returns **410 Gone** with `{ code: "TASK_ALREADY_HARD_ARCHIVED", taskId, archivedAt, message }`. Caller must `POST /api/tasks/:id/unarchive` first, then re-delete; archive-cleanup APIs remain the forensic-removal path. | Never affected; archived rows are absent from live scheduler/executor readers | N/A | N/A | No `task:deleted` SSE re-emit for this failure path; dashboard surfaces the 410 and points to unarchive | N/A | N/A | FN-5196 |
| 3. Soft-delete a `todo` task with lineage children | Live `todo`; `sourceParentTaskId` children exist | Current contract is 409 with lineage child IDs, then retry with `removeLineageReferences=true`; route/UI wiring is **pending FN-5139** | Deleted parent never dispatches; rewritten children remain live | N/A | N/A | Delete/archive UI must surface confirm-retry flow once FN-5139 lands | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5139 |
| 4. Soft-delete an `in-progress` task with an active executor session | Live `in-progress`; executor owns active session | Delete succeeds; task vanishes from live readers immediately | Entry guards already refuse reruns; active-session abort / dispose is **pending FN-5142** | N/A | N/A | SSE removes card; reload does not re-seed it into active queues | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5142 |
| 5. Soft-delete an `in-progress` task with an active workflow-step session and reviewer subagent | Live `in-progress`; workflow step child session exists | Delete succeeds; no public recovery/undelete flag | New execution attempts refuse; workflow-step + reviewer abort/cleanup is **pending FN-5142** | N/A | N/A | SSE removes card; reload stays clean | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 |
| 6. Soft-delete an `in-review` task that is currently being merged | Live `in-review`; active merge session in flight | Delete succeeds; live readers omit afterward | Scheduler must not requeue it | Active merge abort, queue removal, and controller cleanup are **pending FN-5142** | N/A | SSE removes card; reload keeps it absent | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 |
| 7. Soft-delete an `in-review` task queued for merge but not yet active | Live `in-review`; merge queued only | Delete succeeds; row stays for forensics only | Scheduler/executor must not pick it up again | Merge queue must filter it out; pending FN-5142 covers deterministic abort/filter assertions | N/A | SSE removes card; reload keeps it absent | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5142 |
| 8. Soft-delete a `done` task with archived/visible agent logs and saved task documents | Live `done`; has task docs + agent logs | Delete succeeds; live task readers omit afterward; forensic reads still allowed internally | Not runnable after any engine tick or restart | N/A | N/A | SSE removes card; refresh does not show it in board/ListView/TodoView | `agentLogEntries` must clear atomically; **pending FN-5143** | `/api/documents` and per-task docs must disappear while DB rows remain; **pending FN-5140** | FN-5140 / FN-5143 |
| 9. Soft-delete an archived task | Task already archived / moved out of live `tasks` table | Current contract is not pinned; matrix gate requires deterministic error-or-no-op behavior. Follow-up filed as **FN-5196**. | Must never affect active queues either way | N/A | N/A | No dashboard resurrection; exact UX blocked by FN-5196 | N/A | N/A | FN-5196 |
| 10. Soft-delete a task that is checked out by an agent (`checkedOutBy` set) | Live task with lease / checkout metadata | Delete succeeds; linked agent task references clear with delete | Soft-deleted checked-out task must not be auto-claimed or executed after refresh/tick; extra deterministic coverage filed as **FN-5195** | If merge-owned, FN-5142 owns active merge abort details | If triage-owned, FN-5142 owns active triage abort details | SSE removes card; refresh must not show stale checked-out task | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5195 |
## ID reservation invariant
@@ -34,16 +34,16 @@
| --- | --- | --- | --- | --- | --- |
| 1,4,7,10 | Scheduler invalidation on `task:deleted` | `packages/engine/src/__tests__/scheduler-auto-claim-invalidation.test.ts:31` | GREEN | — | FN-5137 |
| 1,4,7,10 | Executor `execute()` refusal of soft-deleted task | `packages/engine/src/__tests__/executor-soft-delete-guard.test.ts:50` | GREEN | — | FN-5137 |
| 4,5 | In-flight executor / workflow-step / reviewer abort on `task:deleted` | `packages/engine/src/__tests__/executor-soft-delete-abort.test.ts` + composition assertions in `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` | GREEN | — | FN-5142 |
| 6,7 | In-flight merge abort / merge queue filtering | `packages/engine/src/__tests__/project-engine-soft-delete-merge-abort.test.ts` + composition assertions in `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` | GREEN | — | FN-5142 |
| 1,10 | Triage abort on `task:deleted` | `packages/engine/src/__tests__/triage-soft-delete-abort.test.ts` + composition assertions in `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` | GREEN | — | FN-5142 |
| 8 | `agentLogEntries` cleared on soft-delete | `packages/core/src/__tests__/soft-delete-agent-logs.test.ts` + composition assertion in `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` | GREEN | — | FN-5143 |
| 8 | `/api/documents` and per-task docs exclude soft-deleted parents | `packages/core/src/__tests__/task-documents.test.ts` + composition assertion in `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` | GREEN | — | FN-5140 |
| 3 | Lineage-unlink 409 flow through API + UI | `packages/dashboard/src/__tests__/routes-tasks-ops.test.ts` (route/UI path) + composition store-level lineage guard assertion in `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` | GREEN for stream-close gating | — | FN-5139 |
| 4,5 | In-flight executor / workflow-step / reviewer abort on `task:deleted` | Entry-guard coverage only; no active-session abort assertion | Missing deterministic abort + dispose assertions | `packages/engine/src/__tests__/executor-soft-delete-abort.test.ts` and `packages/engine/src/__tests__/reliability-interactions/soft-delete-in-flight-abort.test.ts` | FN-5142 |
| 6,7 | In-flight merge abort / merge queue filtering | No deterministic merge-abort assertion in current corpus | Missing active merge abort + queued merge filtering coverage | `packages/engine/src/__tests__/project-engine-soft-delete-merge-abort.test.ts` | FN-5142 |
| 1,10 | Triage abort on `task:deleted` | No deterministic triage abort assertion in current corpus | Missing active triage session + subagent abort coverage | `packages/engine/src/__tests__/triage-soft-delete-abort.test.ts` | FN-5142 |
| 8 | `agentLogEntries` cleared on soft-delete | No dedicated coverage today | Missing atomic clear + post-delete empty-reader assertion | `packages/core/src/__tests__/soft-delete-agent-logs.test.ts` | FN-5143 |
| 8 | `/api/documents` and per-task docs exclude soft-deleted parents | No dedicated soft-delete document visibility assertion today | Missing store + route coverage | `packages/core/src/__tests__/task-documents.test.ts` and `packages/dashboard/src/__tests__/routes-tasks.test.ts` | FN-5140 |
| 3 | Lineage-unlink 409 flow through API + UI | Store lineage guards are covered; route/UI flow is not | Missing 409 payload + confirm-retry UX coverage | `packages/dashboard/src/__tests__/routes-tasks-ops.test.ts`, `packages/dashboard/app/utils/__tests__/taskDelete.test.ts`, `packages/dashboard/app/components/__tests__/TaskCard.test.tsx`, `packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx`, `packages/dashboard/app/components/__tests__/ListView.test.tsx` | FN-5139 |
| Stream-wide | `fn_task_delete` tool / skill terminology | No regression asserting soft-delete wording | Missing user-facing copy coverage | `packages/cli/src/__tests__/extension.test.ts` | FN-5141 |
| 1,4,6,7,8 | Dashboard SSE + reload deleted-task filtering | `packages/dashboard/src/__tests__/sse-task-deleted-payload.test.ts:5`; `packages/dashboard/app/hooks/__tests__/useTasks.test.ts:1124`; `packages/dashboard/app/hooks/__tests__/useTasks.test.ts:1147`; `packages/dashboard/app/hooks/__tests__/useTasks.test.ts:1255` | GREEN | — | FN-5135 |
| 4,10 | Cross-layer delete-during-execution / refresh convergence | No single composition backstop before this task | Added in this task | `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` | FN-5153 |
| 9 | Archived-task delete contract | `packages/core/src/__tests__/soft-delete-tasks.test.ts` (`throws TaskAlreadyHardArchivedError when deleting a hard-archived task`, `throws TaskAlreadyHardArchivedError for a legacy in-db archivedTasks row`) and `packages/dashboard/src/__tests__/routes-tasks-ops.test.ts` (`returns 410 TASK_ALREADY_HARD_ARCHIVED when deleteTask throws TaskAlreadyHardArchivedError`) | GREEN — pinned to typed `TaskAlreadyHardArchivedError` + HTTP 410 | — | FN-5196 |
| 9 | Archived-task delete contract | No deterministic store/route assertion; behavior not pinned | New follow-up filed | `packages/core/src/__tests__/soft-delete-tasks.test.ts` and `packages/dashboard/src/__tests__/routes-tasks.test.ts` | FN-5196 |
| 10 | Checked-out-task soft-delete composition | Entry guards exist, but no coverage focused on checked-out deleted tasks | New follow-up filed | `packages/core/src/__tests__/soft-delete-tasks.test.ts` and `packages/engine/src/__tests__/auto-claim-snapshot-soft-delete.test.ts` | FN-5195 |
## Release-blocking checks
@@ -53,8 +53,7 @@
- `pnpm build`
- `pnpm --filter @fusion/dashboard test:browser-smoke`
- Every scenario-matrix row is GREEN or has a linked follow-up FN task before the FN closing that row merges
- Soft-delete against a hard-archived task returns HTTP 410 with `code: TASK_ALREADY_HARD_ARCHIVED` and never silently succeeds (FN-5196)
- `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` is now fully un-skipped (closed by FN-5481) and remains a stream-close gate
- `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` is fully un-skipped before the stream is declared closed
- Manual smoke once on the live dashboard at port 4040: delete one `todo`, one `in-progress`, and one `in-review` task; refresh; confirm none reappear in any board column, ListView, or TodoView; confirm no SSE flicker re-adds the card
- `node scripts/audit-squash-merge.mjs <sha>` remains unaffected by any task in this stream

View File

@@ -4,7 +4,6 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import { createDistributedTaskIdAllocator, reconcileTaskIdState } from "../distributed-task-id.js";
import { TaskAlreadyHardArchivedError } from "../store.js";
describe("TaskStore soft delete", () => {
const harness = createTaskStoreTestHarness();
@@ -150,99 +149,6 @@ describe("TaskStore soft delete", () => {
await expect(store.deleteTask("FN-DOES-NOT-EXIST")).rejects.toThrow("Task FN-DOES-NOT-EXIST not found");
});
it("throws TaskAlreadyHardArchivedError when deleting a hard-archived task", async () => {
const store = harness.store();
const doneTask = await store.createTask({ column: "done", description: "hard archive delete target" });
await store.archiveTask(doneTask.id);
const row = (store as any).db
.prepare('SELECT id FROM tasks WHERE id = ?')
.get(doneTask.id) as { id: string } | undefined;
expect(row).toBeUndefined();
const archived = (store as any).archiveDb.get(doneTask.id);
expect(archived?.id).toBe(doneTask.id);
let thrown: unknown;
try {
await store.deleteTask(doneTask.id);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(TaskAlreadyHardArchivedError);
expect((thrown as TaskAlreadyHardArchivedError).taskId).toBe(doneTask.id);
expect((thrown as TaskAlreadyHardArchivedError).archivedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("preserves task id reservation after archive+delete attempt", async () => {
const store = harness.store();
const doneTask = await store.createTask({ column: "done", description: "hard archive reservation target" });
await store.archiveTask(doneTask.id);
await expect(store.deleteTask(doneTask.id)).rejects.toBeInstanceOf(TaskAlreadyHardArchivedError);
expect((store as any).taskIdExistsAnywhere(doneTask.id)).toBe(true);
expect(() => (store as any).assertTaskIdAvailable(doneTask.id)).toThrow();
});
it("does not re-emit task:deleted when delete fails against a hard-archived task", async () => {
const store = harness.store();
const doneTask = await store.createTask({ column: "done", description: "hard archive event target" });
const deletedEvents: string[] = [];
store.on("task:deleted", (event) => deletedEvents.push(event.id));
await store.archiveTask(doneTask.id);
await expect(store.deleteTask(doneTask.id)).rejects.toBeInstanceOf(TaskAlreadyHardArchivedError);
expect(deletedEvents).toEqual([]);
});
it("still throws generic Error when target id has never existed", async () => {
const store = harness.store();
await expect(store.deleteTask("FN-999999")).rejects.toMatchObject({
name: "Error",
message: expect.stringContaining("not found"),
});
});
it("throws TaskAlreadyHardArchivedError for a legacy in-db archivedTasks row", async () => {
const store = harness.store();
const archivedAt = new Date().toISOString();
(store as any).db.prepare("INSERT INTO archivedTasks (id, data, archivedAt) VALUES (?, ?, ?)").run(
"FN-888888",
JSON.stringify({ id: "FN-888888", description: "legacy row" }),
archivedAt,
);
await expect(store.deleteTask("FN-888888")).rejects.toMatchObject({
name: "TaskAlreadyHardArchivedError",
taskId: "FN-888888",
archivedAt: null,
});
});
it("keeps FN-5127 idempotent re-delete short-circuit ahead of any archive probe", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", description: "idempotent archive probe guard" });
const firstResult = await store.deleteTask(task.id);
const firstRow = (store as any).db
.prepare("SELECT deletedAt FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null };
expect(firstRow.deletedAt).toBeTruthy();
await store.findInArchive(task.id);
const getSpy = vi.spyOn((store as any).archiveDb, "get");
const legacySpy = vi.spyOn(store as any, "isTaskIdPresentInArchivedTasksTable");
const secondResult = await store.deleteTask(task.id);
expect(secondResult.deletedAt).toBe(firstRow.deletedAt); expect(getSpy).not.toHaveBeenCalled();
expect(legacySpy).not.toHaveBeenCalled();
});
it("unlinks mission feature task references when task is soft-deleted", async () => {
const store = harness.store();
const unlinkFeatureFromTask = vi.fn();

View File

@@ -134,7 +134,6 @@ export {
SelfDefeatingDependencyError,
DependencyCycleError,
TaskDeletedError,
TaskAlreadyHardArchivedError,
MergeQueueTaskNotFoundError,
MergeQueueInvalidColumnError,
MergeQueueLeaseOwnershipError,

View File

@@ -621,21 +621,6 @@ export class TaskDeletedError extends Error {
}
}
export class TaskAlreadyHardArchivedError extends Error {
readonly taskId: string;
readonly archivedAt: string | null;
constructor(taskId: string, archivedAt: string | null) {
super(
`Task ${taskId} is already hard-archived (moved to archive.db) and cannot be soft-deleted. `
+ "Use unarchiveTask first, or call the archive-cleanup APIs for forensic removal.",
);
this.name = "TaskAlreadyHardArchivedError";
this.taskId = taskId;
this.archivedAt = archivedAt;
}
}
export class TaskHasLineageChildrenError extends Error {
readonly taskId: string;
readonly childIds: string[];
@@ -6647,15 +6632,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.flushAgentLogBuffer();
const task = this.readTaskFromDb(id, { includeDeleted: true });
if (!task) {
// FN-5196: keep legacy archivedTasks probe first because archiveDb.get()
// only sees modern archive.db rows and does not include legacy entries.
if (this.isTaskIdPresentInArchivedTasksTable(id)) {
throw new TaskAlreadyHardArchivedError(id, null);
}
const archived = this.archiveDb.get(id);
if (archived) {
throw new TaskAlreadyHardArchivedError(id, archived.archivedAt);
}
throw new Error(`Task ${id} not found`);
}

View File

@@ -1258,49 +1258,6 @@ describe("DELETE /tasks/:id", () => {
});
});
it("returns 410 TASK_ALREADY_HARD_ARCHIVED when deleteTask throws TaskAlreadyHardArchivedError", async () => {
const err = new Error("Task KB-001 is already hard-archived (moved to archive.db) and cannot be soft-deleted.");
err.name = "TaskAlreadyHardArchivedError";
(err as Error & { taskId: string; archivedAt: string | null }).taskId = "KB-001";
(err as Error & { taskId: string; archivedAt: string | null }).archivedAt = "2026-05-20T10:00:00.000Z";
(store.deleteTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001");
expect(res.status).toBe(410);
expect(res.body).toEqual({
code: "TASK_ALREADY_HARD_ARCHIVED",
taskId: "KB-001",
archivedAt: "2026-05-20T10:00:00.000Z",
message: err.message,
});
});
it("does not call releaseExecutionAgentBindings when delete fails against a hard-archived task", async () => {
const agentStore = {
listAgents: vi.fn().mockResolvedValue([{ id: "agent-1", taskId: "KB-001" }]),
syncExecutionTaskLink: vi.fn().mockResolvedValue(undefined),
deleteAgent: vi.fn().mockResolvedValue(undefined),
};
const engine = {
getTaskStore: () => store,
getAgentStore: () => agentStore,
};
const err = new Error("Task KB-001 is already hard-archived");
err.name = "TaskAlreadyHardArchivedError";
(err as Error & { taskId: string; archivedAt: string | null }).taskId = "KB-001";
(err as Error & { taskId: string; archivedAt: string | null }).archivedAt = "2026-05-20T10:00:00.000Z";
(store.deleteTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const res = await REQUEST(buildApp(engine), "DELETE", "/api/tasks/KB-001");
expect(res.status).toBe(410);
expect(agentStore.listAgents).not.toHaveBeenCalled();
expect(agentStore.syncExecutionTaskLink).not.toHaveBeenCalled();
expect(agentStore.deleteAgent).not.toHaveBeenCalled();
});
it("rejects invalid githubIssueAction values", async () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001?githubIssueAction=bad-value");

View File

@@ -3144,20 +3144,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
});
}
const isTaskAlreadyHardArchivedError =
err instanceof Error
&& err.name === "TaskAlreadyHardArchivedError";
if (isTaskAlreadyHardArchivedError) {
const archivedAt = (err as { archivedAt?: string | null }).archivedAt ?? null;
return res.status(410).json({
code: "TASK_ALREADY_HARD_ARCHIVED",
taskId: req.params.id,
archivedAt,
message: err.message,
});
}
rethrowAsApiError(err);
}
});

View File

@@ -1,13 +1,8 @@
import "../executor-test-helpers.js";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TaskStore } from "@fusion/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AutoClaimSnapshotManager } from "../../auto-claim-snapshot.js";
import { Scheduler } from "../../scheduler.js";
import { TaskExecutor } from "../../executor.js";
import { TriageProcessor } from "../../triage.js";
import { executorLog } from "../../logger.js";
import { createMockStore, resetExecutorMocks } from "../executor-test-helpers.js";
@@ -43,19 +38,12 @@ function createEventedSoftDeleteStore(initialTasks: TestTask[] = []) {
const nextTimestamp = () => new Date(1_716_000_000_000 + sequence++).toISOString();
return {
emitEvent: 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((event: string, listener: (payload: any) => void) => {
const existing = listeners.get(event) ?? [];
listeners.set(
event,
existing.filter((entry) => entry !== listener),
);
}),
off: vi.fn(),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2, maxWorktrees: 4 }),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
@@ -139,106 +127,11 @@ function createEventedSoftDeleteStore(initialTasks: TestTask[] = []) {
};
}
async function createProjectEngineHarness() {
vi.resetModules();
const mocks = {
runtimeStart: vi.fn(async () => undefined),
runtimeStop: vi.fn(async () => undefined),
runtimeResumeAfterUnpause: vi.fn(async () => undefined),
runtimeConfigurePrMonitoring: vi.fn(),
aiMergeTask: vi.fn(),
execFile: vi.fn(),
currentStore: null as Record<string, unknown> | null,
};
vi.doMock("@fusion/core", async (importOriginal) => {
const { createEngineCoreMock } = await import("../../test/mockCore.js");
return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {});
});
vi.doMock("../../merger.js", () => ({ aiMergeTask: mocks.aiMergeTask, sweepStaleAutostashes: vi.fn(async () => undefined) }));
vi.doMock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, execFile: mocks.execFile };
});
vi.doMock("../../pr-monitor.js", () => ({ PrMonitor: vi.fn().mockImplementation(() => ({ onNewComments: vi.fn() })) }));
vi.doMock("../../pr-comment-handler.js", () => ({ PrCommentHandler: vi.fn().mockImplementation(() => ({ handleNewComments: vi.fn() })) }));
vi.doMock("../../auth-storage.js", () => ({
createFusionAuthStorage: vi.fn(() => ({ reload: vi.fn(), getOAuthProviders: vi.fn(() => []), get: vi.fn(() => undefined) })),
}));
vi.doMock("../../notifier.js", () => ({ NtfyNotifier: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })) }));
vi.doMock("../../notification/index.js", () => ({
NotificationService: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
OAuthExpiryMonitor: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
}));
vi.doMock("../../cron-runner.js", () => ({
CronRunner: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
createAiPromptExecutor: vi.fn(async () => vi.fn()),
}));
vi.doMock("../../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: mocks.runtimeStart,
stop: mocks.runtimeStop,
resumeAfterUnpause: mocks.runtimeResumeAfterUnpause,
getTaskStore: () => mocks.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
configurePrMonitoring: mocks.runtimeConfigurePrMonitoring,
setActiveMergeTaskIdProvider: vi.fn(),
setMergeEnqueuer: vi.fn(),
setMergeActiveClearer: vi.fn(),
})),
}));
const { ProjectEngine } = await import("../../project-engine.js");
const mockStore = createEventedSoftDeleteStore() as any;
mockStore.getActiveMergingTask = vi.fn(() => null);
mockStore.addTaskComment = vi.fn(async () => undefined);
mocks.currentStore = mockStore;
const engine = new ProjectEngine(
{
projectId: "proj_test",
workingDirectory: "/tmp/proj_test",
isolationMode: "in-process",
maxConcurrent: 2,
maxWorktrees: 2,
},
{} as never,
{ skipNotifier: true },
);
return { engine, mockStore };
}
async function createCoreStoreForTest() {
const rootDir = await mkdtemp(join(tmpdir(), "kb-engine-soft-delete-"));
const globalDir = await mkdtemp(join(tmpdir(), "kb-engine-soft-delete-global-"));
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
return {
store,
async cleanup() {
store.stopWatching();
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
},
};
}
describe("reliability interactions: FN-5153 soft-delete end-to-end", () => {
beforeEach(async () => {
beforeEach(() => {
resetExecutorMocks();
});
afterEach(async () => {
vi.doUnmock("@fusion/core");
});
it("keeps live readers and scheduler snapshots converged after task:deleted", async () => {
const store = createEventedSoftDeleteStore();
const task = await store.createTask({ column: "todo", title: "Soft delete target" });
@@ -288,156 +181,55 @@ describe("reliability interactions: FN-5153 soft-delete end-to-end", () => {
expect(executeSpy).toHaveBeenCalledTimes(1);
});
it("FN-5142 — abort in-flight executor session on task:deleted", async () => {
const store = createEventedSoftDeleteStore();
const stuckTaskDetector = { untrackTask: vi.fn() };
const executor = new TaskExecutor(store as any, "/tmp/test", { stuckTaskDetector } as any);
const abort = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
const task = await store.createTask({ column: "in-progress" });
const taskId = task.id;
(executor as any).activeSessions.set(taskId, { session: { abort, dispose }, seenSteeringIds: new Set<string>() });
await store.deleteTask(taskId);
await (executor as any).pendingTaskDisposals.get(taskId);
expect(abort).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect((executor as any).activeSessions.has(taskId)).toBe(false);
expect((executor as any).pausedAborted.has(taskId)).toBe(true);
expect((executor as any).userCanceledTaskIds.has(taskId)).toBe(true);
expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith(taskId);
it.skip("FN-5142 — abort in-flight executor session on task:deleted", async () => {
// Owning FN: FN-5142.
// Assertion blueprint: register a fake active executor session for the task,
// emit/store-delete `task:deleted`, then assert `session.abort()` and
// `session.dispose()` were called and the executor's active-session map entry
// was cleared exactly once.
// Narrow owning test file: packages/engine/src/__tests__/executor-soft-delete-abort.test.ts
});
it("FN-5142 — abort active merge on task:deleted", async () => {
const { engine, mockStore } = await createProjectEngineHarness();
const privateEngine = engine as any;
const abort = vi.fn();
const dispose = vi.fn();
await engine.start();
const task = await mockStore.createTask({ column: "in-review" });
const taskId = task.id;
privateEngine.activeMergeTaskId = taskId;
privateEngine.activeMergeSession = { dispose };
privateEngine.mergeAbortController = { abort };
privateEngine.mergeActive.add(taskId);
privateEngine.mergeQueue = [taskId, "FN-OTHER"];
privateEngine.pausedReviewTaskIds.add(taskId);
await mockStore.deleteTask(taskId);
expect(abort).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect(privateEngine.activeMergeTaskId).toBeNull();
expect(privateEngine.activeMergeSession).toBeNull();
expect(privateEngine.mergeAbortController).toBeNull();
expect(privateEngine.mergeActive.has(taskId)).toBe(false);
expect(privateEngine.mergeQueue).toEqual(["FN-OTHER"]);
expect(privateEngine.pausedReviewTaskIds.has(taskId)).toBe(false);
await engine.stop();
it.skip("FN-5142 — abort active merge on task:deleted", async () => {
// Owning FN: FN-5142.
// Assertion blueprint: seed active merge state plus a mergeAbortController,
// emit/store-delete `task:deleted`, then assert controller.abort(), queue
// removal, and active merge state cleanup for the deleted task.
// Narrow owning test file: packages/engine/src/__tests__/project-engine-soft-delete-merge-abort.test.ts
});
it("FN-5142 — abort triage session on task:deleted", async () => {
const store = createEventedSoftDeleteStore();
const abort = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
const stuckTaskDetector = { untrackTask: vi.fn() };
const processor = new TriageProcessor(store as any, "/tmp/root", { stuckTaskDetector } as any);
const taskId = "FN-5142-TRIAGE";
processor.start();
(processor as any).activeSessions.set(taskId, { abort, dispose });
store.emitEvent("task:deleted", { id: taskId });
await Promise.resolve();
expect(abort).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect((processor as any).activeSessions.has(taskId)).toBe(false);
expect((processor as any).pauseAborted.has(taskId)).toBe(true);
expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith(taskId);
processor.stop();
it.skip("FN-5142 — abort triage session on task:deleted", async () => {
// Owning FN: FN-5142.
// Assertion blueprint: register an active triage session/subagent for the
// task, emit/store-delete `task:deleted`, then assert the triage session and
// subagent are aborted/disposed and the task is not re-polled.
// Narrow owning test file: packages/engine/src/__tests__/triage-soft-delete-abort.test.ts
});
it("FN-5143 — agent log rows cleared on task:deleted", async () => {
const core = await createCoreStoreForTest();
const store = core.store;
const task = await store.createTask({ description: "Test task" });
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);
await core.cleanup();
it.skip("FN-5143 — agent log rows cleared on task:deleted", async () => {
// Owning FN: FN-5143.
// Assertion blueprint: create agent log rows for the target task, soft-delete
// it, then assert persisted `agentLogEntries` count is 0 for that task and
// `store.getAgentLogs(taskId)` returns an empty array.
// Narrow owning test file: packages/core/src/__tests__/soft-delete-agent-logs.test.ts
});
it("FN-5140 — documents excluded from live readers", async () => {
const core = await createCoreStoreForTest();
const store = core.store;
const liveTask = await store.createTask({ description: "live" });
const deletedTask = await store.createTask({ description: "deleted" });
await store.upsertTaskDocument(liveTask.id, { key: "plan", content: "shared visibility token" });
await store.upsertTaskDocument(deletedTask.id, { key: "notes", content: "shared visibility token" });
await store.deleteTask(deletedTask.id);
const all = await store.getAllDocuments();
expect(all).toHaveLength(1);
expect(all[0]?.taskId).toBe(liveTask.id);
const searched = await store.getAllDocuments({ searchQuery: "shared visibility token" });
expect(searched).toHaveLength(1);
expect(searched[0]?.taskId).toBe(liveTask.id);
await expect(store.getTaskDocuments(deletedTask.id)).resolves.toEqual([]);
await expect(store.getTaskDocument(deletedTask.id, "notes")).resolves.toBeNull();
await expect(store.getTaskDocumentRevisions(deletedTask.id, "notes")).resolves.toEqual([]);
const row = (store as any).db
.prepare("SELECT COUNT(*) as count FROM task_documents WHERE taskId = ?")
.get(deletedTask.id) as { count: number };
expect(row.count).toBeGreaterThan(0);
await core.cleanup();
it.skip("FN-5140 — documents excluded from live readers", async () => {
// Owning FN: FN-5140.
// Assertion blueprint: create task documents, soft-delete the parent task,
// then assert `getAllDocuments()` excludes it, `getTaskDocuments(taskId)` is
// `[]`, and `getTaskDocument(taskId, key)` / revisions return null-or-empty.
// Narrow owning test files: packages/core/src/__tests__/task-documents.test.ts and packages/dashboard/src/__tests__/routes-tasks.test.ts
});
it("FN-5139 — TaskHasLineageChildrenError surfaces as 409 with lineageChildIds", async () => {
const core = await createCoreStoreForTest();
const store = core.store;
const parent = await store.createTask({ description: "parent" });
const child = await store.createTask({
description: "child",
source: { sourceType: "task_refine", sourceParentTaskId: parent.id },
});
await expect(store.deleteTask(parent.id)).rejects.toMatchObject({
name: "TaskHasLineageChildrenError",
taskId: parent.id,
childIds: [child.id],
});
await expect(store.deleteTask(parent.id, { removeLineageReferences: true })).resolves.toMatchObject({ id: parent.id });
await core.cleanup();
it.skip("FN-5139 — TaskHasLineageChildrenError surfaces as 409 with lineageChildIds", async () => {
// Owning FN: FN-5139.
// Assertion blueprint: delete a task with live lineage children through the
// route layer, assert HTTP 409 plus `{ code: "TASK_HAS_LINEAGE_CHILDREN",
// taskId, lineageChildIds }`, then cover the confirm-retry UI path.
// Narrow owning test files: packages/dashboard/src/__tests__/routes-tasks-ops.test.ts and dashboard delete-flow UI tests listed in FN-5139.
});
it("keeps re-delete idempotent and deleted IDs reserved", async () => {
const store = createEventedSoftDeleteStore();
const task = await store.createTask({ column: "todo", title: "Original" });