feat(FN-5153): deliver soft-delete verification gate
Completes FN-5153 by delivering the soft-delete verification gate with a new end-to-end test suite covering the full soft-delete lifecycle and a documentation matrix summarizing the gate behavior. Updates the dashboard test file and adds a brief entry to AGENTS.md to surface the new feature. Fusion-Task-Id: FN-5153
This commit is contained in:
committed by
gsxdsm
parent
b7e57f84a9
commit
b39c2dd071
@@ -204,6 +204,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
- **Landed-files attribution (FN-5103)**: Rebase-strategy `mergeDetails.landedFiles` / `filesChanged` / `insertions` / `deletions` are captured from task-attributable commits only via `filterFilesToOwnTaskCommits` (subject-prefix + trailer + bracket-prefix evidence), tagged `landedFilesAttributionRestricted: true`. Zero own commits → `landedFiles: []` and `noOpVerifiedShortCircuit: true`. Attribution-helper failures fall back to the unrestricted `rebaseBaseSha..sha` walk and set `landedFilesCaptureFallback: 'attribution-failed'`. Self-healing `recoverDoneTaskMergeMetadata` skips reconcile when `landedFilesAttributionRestricted` or `noOpVerifiedShortCircuit` is set so the narrower set is not overwritten with the full range. Squash-strategy capture is unchanged.
|
||||
- **Soft-delete scheduler invalidation (FN-5137)**: `task:deleted` events must invalidate `AutoClaimSnapshotManager` and clear scheduler bookkeeping (`pausedTaskIds`, `failedTaskIds`, `wasNodeDispatchValidationBlocked`, `wasNodeBlocked`); `executor.execute()` / `resumeOrphaned()` / `resumeTaskForAgent()` refuse any task with `deletedAt` set.
|
||||
- **Soft-delete stream verification gate (FN-5153)**: `docs/soft-delete-verification-matrix.md` is the authoritative checklist for the FN-5105 → FN-5143 soft-delete stream. Every scenario × layer cell must be GREEN (or have a linked follow-up FN) before the stream is closed; `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` is the cross-layer regression backstop.
|
||||
|
||||
## Engine Process Rules
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow
|
||||
| [Multi-Node Coordination Validation Findings (FN-4820)](./findings/fn-4820-multi-node-coordination-validation.md) | Validation findings from multi-node coordination testing and edge-case analysis |
|
||||
| [Secrets Sync Auth Parity Review (FN-4886)](./reviews/fn-4886-secrets-sync-auth-parity.md) | Review of node secrets sync API authentication parity and security boundaries |
|
||||
| [Test Speed Audit (FN-5048)](./test-speed-audit-FN-5048.md) | Measured baseline test performance, offender list, and optimization priorities |
|
||||
| [Soft-Delete Verification Matrix](./soft-delete-verification-matrix.md) | Authoritative checklist for the FN-5105 → FN-5143 soft-delete stream: scenario × layer coverage |
|
||||
|
||||
## External Resources
|
||||
|
||||
|
||||
79
docs/soft-delete-verification-matrix.md
Normal file
79
docs/soft-delete-verification-matrix.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Soft-delete verification matrix
|
||||
|
||||
**Purpose:** Single source of truth for closing the FN-5105 / FN-5135 / FN-5137 / FN-5139 / FN-5140 / FN-5141 / FN-5142 / FN-5143 soft-delete stream.
|
||||
|
||||
**Success metric:** 0 reproducible cases where a soft-deleted task is runnable in scheduler / executor / merger / triage or visible in active dashboard queues after refresh or engine tick.
|
||||
|
||||
**Gate rule:** Every matrix row is GREEN or has a linked follow-up FN task before the stream is closed.
|
||||
|
||||
## Scenario matrix
|
||||
|
||||
| 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 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 | 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
|
||||
|
||||
| Scenario set | Invariant | Evidence owner |
|
||||
| --- | --- | --- |
|
||||
| 1-10 | Soft-delete never frees the task ID. The `tasks` row persists with `deletedAt` set and allocators continue scanning all rows, so the ID is not reusable. | FN-5105 / FN-5128 |
|
||||
| Re-delete of an already soft-deleted task | Second delete is a no-op: same `deletedAt`, no fresh event, ID still reserved. | FN-5127 / FN-5128 |
|
||||
|
||||
## Coverage gaps
|
||||
|
||||
| Scenario # | Layer | Existing coverage (file:line) | Gap | Proposed test file target | Owning FN |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 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` | 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 | 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
|
||||
|
||||
- `pnpm lint`
|
||||
- `pnpm test`
|
||||
- `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
|
||||
- `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
|
||||
|
||||
## Forensic access escape hatch
|
||||
|
||||
Supported forensic access is internal only:
|
||||
|
||||
- `readTaskFromDb(id, { includeDeleted: true })` in `packages/core/src/store.ts`
|
||||
- direct SQL against `tasks`, `task_documents`, and `agentLogEntries`
|
||||
|
||||
No public API flag exposes deleted-task forensics today. Adding one requires a new FN with its own review.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Undelete UX / restore-from-trash flow — file a new FN if requested
|
||||
- Hard-delete UI — file a new FN if requested
|
||||
- Mission / feature / slice soft-delete — those entities do not use `deletedAt` today; file a new FN if requested
|
||||
- Agent / node / secret soft-delete — file a new FN if requested
|
||||
|
||||
## Notes
|
||||
|
||||
- Store-level soft-delete invariants live in `packages/core/src/store.ts` (`ACTIVE_TASKS_WHERE`, `deleteTask`, `readTaskFromDb(..., { includeDeleted: true })`) and `packages/core/src/__tests__/soft-delete-tasks.test.ts`.
|
||||
- Cross-reference `docs/storage.md` for the persisted-row model; FN-5140 and FN-5143 own the storage-doc deltas for document and agent-log visibility.
|
||||
@@ -339,6 +339,8 @@ const { WorktreePool } = await import("@fusion/engine");
|
||||
vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@fusion/engine")>();
|
||||
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
|
||||
const coreModule = await import("@fusion/core");
|
||||
const taskStoreMock = (coreModule.TaskStore as any);
|
||||
const TriageProcessor = vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
@@ -652,51 +654,49 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
ProjectEngine,
|
||||
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
|
||||
const engines = new Map<string, any>();
|
||||
const starting = new Map<string, Promise<any>>();
|
||||
const startEngine = async (id: string, pathHint?: string) => {
|
||||
const existing = engines.get(id);
|
||||
if (existing) return existing;
|
||||
const pending = starting.get(id);
|
||||
if (pending) return pending;
|
||||
const startPromises = new Map<string, Promise<void>>();
|
||||
|
||||
const promise = (async () => {
|
||||
const { TaskStore: TSMock } = await import("@fusion/core");
|
||||
const lastStore = (TSMock as any).mock?.results?.at(-1)?.value;
|
||||
const project = pathHint ? { path: pathHint } : await centralCore.getProject(id);
|
||||
const engine = new ProjectEngine(
|
||||
{ workingDirectory: project?.path ?? process.cwd() },
|
||||
const materializeEngine = (id: string, path: string = process.cwd()) => {
|
||||
let engine = engines.get(id);
|
||||
if (!engine) {
|
||||
const lastStore = taskStoreMock.mock?.results?.at(-1)?.value;
|
||||
engine = new ProjectEngine(
|
||||
{ workingDirectory: path },
|
||||
centralCore,
|
||||
{ ...options, externalTaskStore: lastStore, projectId: id },
|
||||
);
|
||||
await engine.start();
|
||||
engines.set(id, engine);
|
||||
starting.delete(id);
|
||||
return engine;
|
||||
})();
|
||||
|
||||
starting.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
if (!startPromises.has(id)) {
|
||||
startPromises.set(id, Promise.resolve(engine.start()).then(() => undefined));
|
||||
}
|
||||
return engine;
|
||||
};
|
||||
|
||||
return {
|
||||
startAll: vi.fn(async () => {
|
||||
const projects = await centralCore.listProjects();
|
||||
for (const project of projects) {
|
||||
await startEngine(project.id, project.path);
|
||||
materializeEngine(project.id, project.path);
|
||||
}
|
||||
await Promise.all([...startPromises.values()]);
|
||||
}),
|
||||
getEngine: vi.fn((id: string) => engines.get(id)),
|
||||
getEngine: vi.fn((id: string) => materializeEngine(id)),
|
||||
getAllEngines: vi.fn(() => engines),
|
||||
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
||||
has: vi.fn((id: string) => engines.has(id) || starting.has(id)),
|
||||
ensureEngine: vi.fn(async (id: string) => startEngine(id)),
|
||||
has: vi.fn((id: string) => engines.has(id)),
|
||||
ensureEngine: vi.fn(async (id: string) => {
|
||||
const project = await centralCore.getProject?.(id).catch(() => null);
|
||||
return materializeEngine(id, project?.path ?? process.cwd());
|
||||
}),
|
||||
stopAll: vi.fn(async () => {
|
||||
await Promise.all([...startPromises.values()]);
|
||||
for (const engine of engines.values()) await engine.stop();
|
||||
engines.clear();
|
||||
starting.clear();
|
||||
startPromises.clear();
|
||||
}),
|
||||
onProjectAccessed: vi.fn((id: string) => {
|
||||
void startEngine(id);
|
||||
materializeEngine(id);
|
||||
}),
|
||||
startReconciliation: vi.fn(),
|
||||
};
|
||||
@@ -1542,14 +1542,14 @@ describe("runDashboard — immediate resume on unpause", () => {
|
||||
});
|
||||
|
||||
it("sweeps merge queue on unpause when autoMerge is enabled", async () => {
|
||||
// Set up settings to return autoMerge: true for the drain queue check
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
const currentSettings = {
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoMerge: false,
|
||||
pollIntervalMs: 60_000,
|
||||
globalPause: false,
|
||||
});
|
||||
};
|
||||
mockStore.getSettings.mockImplementation(async () => ({ ...currentSettings }));
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "FN-MQ1", column: "in-review", paused: false },
|
||||
{ id: "FN-MQ2", column: "in-review", paused: false },
|
||||
@@ -1568,10 +1568,9 @@ describe("runDashboard — immediate resume on unpause", () => {
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// Clear any calls from startup sweep
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
currentSettings.autoMerge = true;
|
||||
|
||||
// Trigger unpause event with autoMerge enabled
|
||||
mockStore.emit("settings:updated", {
|
||||
settings: { globalPause: false, maxConcurrent: 1, autoMerge: true },
|
||||
previous: { globalPause: true },
|
||||
@@ -1876,14 +1875,15 @@ describe("runDashboard — enginePaused (soft pause)", () => {
|
||||
});
|
||||
|
||||
it("sweeps merge queue on engine unpause when autoMerge is enabled", async () => {
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
const currentSettings = {
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoMerge: false,
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
};
|
||||
mockStore.getSettings.mockImplementation(async () => ({ ...currentSettings }));
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "FN-EP2", column: "in-review", paused: false },
|
||||
]);
|
||||
@@ -1901,6 +1901,7 @@ describe("runDashboard — enginePaused (soft pause)", () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
currentSettings.autoMerge = true;
|
||||
|
||||
mockStore.emit("settings:updated", {
|
||||
settings: { enginePaused: false, maxConcurrent: 1, autoMerge: true },
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import "../executor-test-helpers.js";
|
||||
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 { executorLog } from "../../logger.js";
|
||||
import { createMockStore, resetExecutorMocks } from "../executor-test-helpers.js";
|
||||
|
||||
type TestTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
column: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
dependencies: string[];
|
||||
comments: unknown[];
|
||||
steps: unknown[];
|
||||
currentStep: number;
|
||||
log: unknown[];
|
||||
deletedAt?: string | null;
|
||||
paused?: boolean;
|
||||
checkedOutBy?: string | null;
|
||||
};
|
||||
|
||||
function createEventedSoftDeleteStore(initialTasks: TestTask[] = []) {
|
||||
const listeners = new Map<string, ((payload: any) => void)[]>();
|
||||
let sequence = 1;
|
||||
const tasks = initialTasks.map((task) => ({ ...task }));
|
||||
|
||||
const emit = (event: string, payload: any) => {
|
||||
for (const listener of listeners.get(event) ?? []) {
|
||||
listener(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const nextTimestamp = () => new Date(1_716_000_000_000 + sequence++).toISOString();
|
||||
|
||||
return {
|
||||
on: vi.fn((event: string, listener: (payload: any) => void) => {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(listener);
|
||||
listeners.set(event, existing);
|
||||
}),
|
||||
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"),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
updateTask: vi.fn().mockImplementation(async (id: string, patch: Partial<TestTask>) => {
|
||||
const task = tasks.find((entry) => entry.id === id);
|
||||
if (!task) return undefined;
|
||||
Object.assign(task, patch, { updatedAt: nextTimestamp() });
|
||||
emit("task:updated", { ...task });
|
||||
return { ...task };
|
||||
}),
|
||||
async createTask(input: Partial<TestTask> = {}) {
|
||||
const id = `FN-${String(sequence).padStart(4, "0")}`;
|
||||
const task: TestTask = {
|
||||
id,
|
||||
title: input.title ?? `Task ${id}`,
|
||||
description: input.description ?? id,
|
||||
status: input.status ?? "open",
|
||||
column: input.column ?? "triage",
|
||||
createdAt: nextTimestamp(),
|
||||
updatedAt: nextTimestamp(),
|
||||
dependencies: input.dependencies ?? [],
|
||||
comments: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
deletedAt: input.deletedAt ?? null,
|
||||
paused: input.paused,
|
||||
checkedOutBy: input.checkedOutBy ?? null,
|
||||
};
|
||||
tasks.push(task);
|
||||
emit("task:created", { ...task });
|
||||
return { ...task };
|
||||
},
|
||||
async getTask(id: string, options?: { includeDeleted?: boolean }) {
|
||||
const task = tasks.find((entry) => entry.id === id);
|
||||
if (!task || (!options?.includeDeleted && task.deletedAt)) {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
return { ...task };
|
||||
},
|
||||
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 listTasks(options?: { column?: string; slim?: boolean }) {
|
||||
return tasks
|
||||
.filter((task) => !task.deletedAt)
|
||||
.filter((task) => (options?.column ? task.column === options.column : true))
|
||||
.map((task) => ({ ...task }));
|
||||
},
|
||||
async moveTask(id: string, column: string) {
|
||||
const task = tasks.find((entry) => entry.id === id);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
const from = task.column;
|
||||
task.column = column;
|
||||
task.updatedAt = nextTimestamp();
|
||||
emit("task:moved", { task: { ...task }, from, to: column });
|
||||
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();
|
||||
task.deletedAt = deletedAt;
|
||||
task.updatedAt = deletedAt;
|
||||
emit("task:deleted", { ...task });
|
||||
return { ...task };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("reliability interactions: FN-5153 soft-delete end-to-end", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
});
|
||||
|
||||
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" });
|
||||
const snapshotManager = new AutoClaimSnapshotManager({ taskStore: store as any });
|
||||
const invalidateSpy = vi.spyOn(snapshotManager, "invalidate");
|
||||
new Scheduler(store as any, { snapshotManager } as any);
|
||||
|
||||
expect((await snapshotManager.getSnapshot()).tasks.map((entry) => entry.id)).toContain(task.id);
|
||||
expect((await store.listTasks()).map((entry) => entry.id)).toContain(task.id);
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
await expect(store.getTask(task.id)).rejects.toThrow(`Task ${task.id} not found`);
|
||||
expect((await store.listTasks()).map((entry) => entry.id)).not.toContain(task.id);
|
||||
expect(store.readTaskFromDb(task.id, { includeDeleted: true })?.deletedAt).toBeTruthy();
|
||||
expect(invalidateSpy).toHaveBeenCalledWith("task:deleted");
|
||||
expect((await snapshotManager.getSnapshot()).tasks.map((entry) => entry.id)).not.toContain(task.id);
|
||||
});
|
||||
|
||||
it("keeps executor entry points from running soft-deleted tasks", async () => {
|
||||
const deletedTask = {
|
||||
id: "FN-5153-DELETED",
|
||||
title: "Deleted",
|
||||
description: "deleted",
|
||||
status: "open",
|
||||
column: "in-progress",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
dependencies: [],
|
||||
comments: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
deletedAt: "2026-01-02T00:00:00.000Z",
|
||||
} as any;
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([deletedTask]);
|
||||
|
||||
const executor = new TaskExecutor(store as any, "/tmp/test");
|
||||
const warnSpy = vi.spyOn(executorLog, "warn");
|
||||
const executeSpy = vi.spyOn(executor, "execute");
|
||||
|
||||
await executor.execute(deletedTask);
|
||||
await executor.resumeOrphaned();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith("FN-5153-DELETED: refusing execute — task is soft-deleted");
|
||||
expect(executeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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.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.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.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.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.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" });
|
||||
const deletedEvents: string[] = [];
|
||||
store.on("task:deleted", (event) => deletedEvents.push(event.id));
|
||||
|
||||
const firstDelete = await store.deleteTask(task.id);
|
||||
const secondDelete = await store.deleteTask(task.id);
|
||||
const replacement = await store.createTask({ column: "todo", title: "Replacement" });
|
||||
|
||||
expect(firstDelete.deletedAt).toBeTruthy();
|
||||
expect(secondDelete.deletedAt).toBe(firstDelete.deletedAt);
|
||||
expect(deletedEvents).toEqual([task.id]);
|
||||
expect(replacement.id).not.toBe(task.id);
|
||||
});
|
||||
|
||||
it("converges cleanly when in-progress → todo is immediately followed by task:deleted", async () => {
|
||||
const store = createEventedSoftDeleteStore();
|
||||
const task = await store.createTask({ column: "in-progress", title: "Race 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.moveTask(task.id, "todo");
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith("task:deleted");
|
||||
expect((await snapshotManager.getSnapshot()).tasks.map((entry) => entry.id)).not.toContain(task.id);
|
||||
await expect(store.getTask(task.id)).rejects.toThrow(`Task ${task.id} not found`);
|
||||
expect((await store.listTasks()).map((entry) => entry.id)).not.toContain(task.id);
|
||||
|
||||
const mockStore = createMockStore();
|
||||
mockStore.listTasks.mockResolvedValue([]);
|
||||
const executor = new TaskExecutor(mockStore as any, "/tmp/test");
|
||||
const warnSpy = vi.spyOn(executorLog, "warn");
|
||||
const executeSpy = vi.spyOn(executor, "execute");
|
||||
|
||||
await executor.resumeOrphaned();
|
||||
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user