test(FN-5137): complete Step 4-6 — soft-delete regression coverage

Fusion-Task-Id: FN-5137
Fusion-Task-Lineage: bb2e2e56-d64c-46ca-a6c8-abda13029fc2
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 07:21:50 -07:00
committed by gsxdsm
parent 9b6513af71
commit f74c0d5105
3 changed files with 230 additions and 1 deletions

View File

@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore, type Task } from "@fusion/core";
import { AutoClaimSnapshotManager } from "../auto-claim-snapshot.js";
describe("AutoClaimSnapshotManager soft-delete guards", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fn-5137-engine-root-"));
globalDir = mkdtempSync(join(tmpdir(), "fn-5137-engine-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(() => {
store.stopWatching();
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
it("returns only live todo tasks after soft delete", async () => {
const live = await store.createTask({ title: "Live", description: "live" });
const deleted = await store.createTask({ title: "Deleted", description: "deleted" });
await store.moveTask(live.id, "todo");
await store.moveTask(deleted.id, "todo");
await store.deleteTask(deleted.id);
const manager = new AutoClaimSnapshotManager({ taskStore: store });
const snapshot = await manager.getSnapshot();
expect(snapshot.tasks.map((task) => task.id)).toEqual([live.id]);
});
it("drops deleted ids after cache invalidation and rebuild", async () => {
let includeDeletedCandidate = true;
const listTasks = vi.fn(async () => ([
{
id: "FN-001",
title: "First",
description: "first",
status: "open",
column: "todo",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
dependencies: [],
comments: [],
steps: [],
currentStep: 0,
log: [],
deletedAt: null,
},
...(includeDeletedCandidate
? [{
id: "FN-002",
title: "Second",
description: "second",
status: "open",
column: "todo",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
dependencies: [],
comments: [],
steps: [],
currentStep: 0,
log: [],
deletedAt: null,
}]
: []),
] as unknown as Task[]));
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks } });
const beforeDelete = await manager.getSnapshot();
expect(beforeDelete.tasks.map((task) => task.id)).toEqual(["FN-001", "FN-002"]);
includeDeletedCandidate = false;
manager.invalidate("task:deleted");
const afterDelete = await manager.getSnapshot();
expect(afterDelete.tasks.map((task) => task.id)).toEqual(["FN-001"]);
});
it("defense-in-depth filters deleted candidates from synthetic listTasks results", async () => {
const listTasks = vi.fn(async () => ([
{
id: "FN-live",
title: "Live",
description: "live",
status: "open",
column: "todo",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
dependencies: [],
comments: [],
steps: [],
currentStep: 0,
log: [],
deletedAt: null,
},
{
id: "FN-deleted",
title: "Deleted",
description: "deleted",
status: "open",
column: "todo",
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 unknown as Task[]));
const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks } });
const snapshot = await manager.getSnapshot();
expect(snapshot.tasks.map((task) => task.id)).toEqual(["FN-live"]);
});
});

View File

@@ -0,0 +1,75 @@
import "./executor-test-helpers.js";
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { executingTaskLock } from "../active-session-registry.js";
import { executorLog } from "../logger.js";
import * as childProcess from "node:child_process";
function makeTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
return {
id: overrides.id,
title: overrides.title ?? null,
description: overrides.description ?? "desc",
status: overrides.status ?? "open",
column: overrides.column ?? "in-progress",
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
dependencies: overrides.dependencies ?? [],
comments: overrides.comments ?? [],
steps: overrides.steps ?? [],
currentStep: overrides.currentStep ?? 0,
log: overrides.log ?? [],
assignedAgentId: overrides.assignedAgentId,
paused: overrides.paused,
deletedAt: overrides.deletedAt,
} as unknown as Task;
}
function createStore(overrides?: { tasks?: Task[] }) {
const listeners = new Map<string, ((payload: unknown) => void)[]>();
const store = {
on: vi.fn((event: string, listener: (payload: unknown) => void) => {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
}),
off: vi.fn(),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
listTasks: vi.fn().mockResolvedValue(overrides?.tasks ?? []),
} as any;
return store;
}
describe("TaskExecutor soft-delete guards", () => {
it("refuses to execute soft-deleted tasks and releases execution lock", async () => {
const store = createStore();
const executor = new TaskExecutor(store, "/tmp/test");
const warnSpy = vi.spyOn(executorLog, "warn");
const execSyncSpy = vi.spyOn(childProcess, "execSync");
const task = makeTask({ id: "FN-5137", deletedAt: "2026-01-02T00:00:00.000Z" });
await executor.execute(task);
expect(execSyncSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith("FN-5137: refusing execute — task is soft-deleted");
expect(executingTaskLock.tryClaim(task.id)).toBe(true);
executingTaskLock.release(task.id);
});
it("resumeOrphaned skips in-progress tasks that are soft-deleted", async () => {
const deletedTask = makeTask({
id: "FN-deleted",
column: "in-progress",
paused: false,
deletedAt: "2026-01-03T00:00:00.000Z",
});
const store = createStore({ tasks: [deletedTask] });
const executor = new TaskExecutor(store, "/tmp/test");
const executeSpy = vi.spyOn(executor, "execute");
await executor.resumeOrphaned();
expect(executeSpy).not.toHaveBeenCalled();
});
});

View File

@@ -14,6 +14,8 @@ function createStore() {
on,
off: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
listTasks: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
const emit = (event: string, payload: unknown) => {
@@ -26,16 +28,41 @@ function createStore() {
}
describe("Scheduler auto-claim snapshot invalidation", () => {
it("invalidates on task:created and task:updated", () => {
it("invalidates on task:created, task:updated, and task:deleted", () => {
const invalidate = vi.fn();
const { store, emit } = createStore();
new Scheduler(store, { snapshotManager: { invalidate } as any });
emit("task:created", { task: { id: "FN-1" } });
emit("task:updated", { id: "FN-1" });
emit("task:deleted", { id: "FN-1" });
expect(invalidate).toHaveBeenCalledWith("task:created");
expect(invalidate).toHaveBeenCalledWith("task:updated");
expect(invalidate).toHaveBeenCalledWith("task:deleted");
});
it("clears scheduler bookkeeping for deleted tasks", () => {
const { store, emit } = createStore();
const scheduler = new Scheduler(store, {});
const internals = scheduler as unknown as {
pausedTaskIds: Set<string>;
failedTaskIds: Set<string>;
wasNodeDispatchValidationBlocked: Set<string>;
wasNodeBlocked: Set<string>;
};
internals.pausedTaskIds.add("FN-1");
internals.failedTaskIds.add("FN-1");
internals.wasNodeDispatchValidationBlocked.add("FN-1");
internals.wasNodeBlocked.add("FN-1");
emit("task:deleted", { id: "FN-1" });
expect(internals.pausedTaskIds.has("FN-1")).toBe(false);
expect(internals.failedTaskIds.has("FN-1")).toBe(false);
expect(internals.wasNodeDispatchValidationBlocked.has("FN-1")).toBe(false);
expect(internals.wasNodeBlocked.has("FN-1")).toBe(false);
});
it("invalidates task:moved only when todo is source or destination", () => {