feat(FN-5195): merge fusion/fn-5195
This commit is contained in:
@@ -341,8 +341,8 @@ async function runCliNearDuplicateCheck(args: {
|
||||
title: task.title ?? "",
|
||||
description: task.description,
|
||||
column: task.column,
|
||||
fileScope: Array.isArray(task.source?.sourceMetadata?.fileScope)
|
||||
? task.source.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string")
|
||||
fileScope: Array.isArray(task.sourceMetadata?.fileScope)
|
||||
? task.sourceMetadata.fileScope.filter((entry: unknown): entry is string => typeof entry === "string")
|
||||
: undefined,
|
||||
createdAt: Date.parse(task.createdAt),
|
||||
} satisfies NearDuplicateCandidate));
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentStore } from "../agent-store.js";
|
||||
import { TaskStore } from "../store.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("TaskStore soft delete of checked-out tasks", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.afterEach();
|
||||
});
|
||||
|
||||
it("clears agents.taskId when deleting a checked-out task", async () => {
|
||||
harness.store().close();
|
||||
const store = new TaskStore(harness.rootDir(), harness.globalDir());
|
||||
await store.init();
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir(), taskStore: store });
|
||||
await agentStore.init();
|
||||
|
||||
try {
|
||||
const task = await store.createTask({ description: "checked-out delete target" });
|
||||
const agent = await agentStore.createAgent({ name: "Lease Holder", role: "executor" });
|
||||
|
||||
await store.updateTask(task.id, { assignedAgentId: agent.id });
|
||||
expect((await agentStore.getAgent(agent.id))?.taskId).toBe(task.id);
|
||||
|
||||
await agentStore.checkoutTask(agent.id, task.id);
|
||||
expect((await store.getTask(task.id)).checkedOutBy).toBe(agent.id);
|
||||
|
||||
const deletedEvents: string[] = [];
|
||||
store.on("task:deleted", (event) => deletedEvents.push(event.id));
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
expect((await agentStore.getAgent(agent.id))?.taskId).toBeUndefined();
|
||||
|
||||
const row = (store as any).db.prepare(
|
||||
"SELECT taskId, json_extract(data, '$.taskId') AS jsonTaskId FROM agents WHERE id = ?",
|
||||
).get(agent.id) as { taskId: string | null; jsonTaskId: string | null };
|
||||
expect(row.taskId).toBeNull();
|
||||
expect(row.jsonTaskId).toBeNull();
|
||||
expect(deletedEvents).toEqual([task.id]);
|
||||
} finally {
|
||||
agentStore.close();
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps checked-out soft-deleted rows invisible to live readers", async () => {
|
||||
harness.store().close();
|
||||
const store = new TaskStore(harness.rootDir(), harness.globalDir());
|
||||
await store.init();
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir(), taskStore: store });
|
||||
await agentStore.init();
|
||||
|
||||
try {
|
||||
const task = await store.createTask({ description: "checked-out invisible row" });
|
||||
const agent = await agentStore.createAgent({ name: "Deleted Lease Holder", role: "executor" });
|
||||
|
||||
await store.updateTask(task.id, { assignedAgentId: agent.id });
|
||||
await agentStore.checkoutTask(agent.id, 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);
|
||||
|
||||
const row = (store as any).db.prepare(
|
||||
"SELECT checkedOutBy, deletedAt FROM tasks WHERE id = ?",
|
||||
).get(task.id) as { checkedOutBy: string | null; deletedAt: string | null };
|
||||
expect(row.checkedOutBy).toBe(agent.id);
|
||||
expect(typeof row.deletedAt).toBe("string");
|
||||
} finally {
|
||||
agentStore.close();
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,24 @@ describe("AutoClaimSnapshotManager soft-delete guards", () => {
|
||||
expect(snapshot.tasks.map((task) => task.id)).toEqual([live.id]);
|
||||
});
|
||||
|
||||
it("omits a checked-out task once it is soft-deleted", async () => {
|
||||
const live = await store.createTask({ title: "Live", description: "live" });
|
||||
const checkedOutDeleted = await store.createTask({ title: "Checked out", description: "checked out" });
|
||||
await store.moveTask(live.id, "todo");
|
||||
await store.moveTask(checkedOutDeleted.id, "todo");
|
||||
await store.updateTask(checkedOutDeleted.id, {
|
||||
checkedOutBy: "agent-1",
|
||||
checkoutLeaseEpoch: 1,
|
||||
checkoutNodeId: "node-1",
|
||||
});
|
||||
await store.deleteTask(checkedOutDeleted.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 () => ([
|
||||
@@ -85,7 +103,7 @@ describe("AutoClaimSnapshotManager soft-delete guards", () => {
|
||||
expect(afterDelete.tasks.map((task) => task.id)).toEqual(["FN-001"]);
|
||||
});
|
||||
|
||||
it("defense-in-depth filters deleted candidates from synthetic listTasks results", async () => {
|
||||
it("defense-in-depth filters checked-out soft-deleted candidates from synthetic listTasks results", async () => {
|
||||
const listTasks = vi.fn(async () => ([
|
||||
{
|
||||
id: "FN-live",
|
||||
@@ -100,6 +118,7 @@ describe("AutoClaimSnapshotManager soft-delete guards", () => {
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
checkedOutBy: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
@@ -115,6 +134,7 @@ describe("AutoClaimSnapshotManager soft-delete guards", () => {
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
checkedOutBy: "agent-1",
|
||||
deletedAt: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
] as unknown as Task[]));
|
||||
|
||||
@@ -22,6 +22,8 @@ function makeTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
||||
currentStep: overrides.currentStep ?? 0,
|
||||
log: overrides.log ?? [],
|
||||
assignedAgentId: overrides.assignedAgentId,
|
||||
checkedOutBy: overrides.checkedOutBy,
|
||||
checkoutLeaseEpoch: overrides.checkoutLeaseEpoch,
|
||||
paused: overrides.paused,
|
||||
deletedAt: overrides.deletedAt,
|
||||
} as unknown as Task;
|
||||
@@ -47,13 +49,18 @@ describe("TaskExecutor soft-delete guards", () => {
|
||||
resetExecutorMocks();
|
||||
});
|
||||
|
||||
it("refuses to execute soft-deleted tasks and releases execution lock", async () => {
|
||||
it("refuses to execute soft-deleted tasks even when checked out 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" });
|
||||
const task = makeTask({
|
||||
id: "FN-5137",
|
||||
checkedOutBy: "agent-1",
|
||||
checkoutLeaseEpoch: 2,
|
||||
deletedAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
await executor.execute(task);
|
||||
|
||||
expect(execSyncSpy).not.toHaveBeenCalled();
|
||||
@@ -62,10 +69,31 @@ describe("TaskExecutor soft-delete guards", () => {
|
||||
executingTaskLock.release(task.id);
|
||||
});
|
||||
|
||||
it("resumeOrphaned skips in-progress tasks that are soft-deleted", async () => {
|
||||
it("resumeTaskForAgent skips checked-out soft-deleted tasks", async () => {
|
||||
const deletedTask = makeTask({
|
||||
id: "FN-assigned-deleted",
|
||||
column: "in-progress",
|
||||
assignedAgentId: "agent-1",
|
||||
checkedOutBy: "agent-1",
|
||||
checkoutLeaseEpoch: 3,
|
||||
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.resumeTaskForAgent("agent-1");
|
||||
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumeOrphaned skips checked-out soft-deleted in-progress tasks", async () => {
|
||||
const deletedTask = makeTask({
|
||||
id: "FN-deleted",
|
||||
column: "in-progress",
|
||||
checkedOutBy: "agent-1",
|
||||
checkoutLeaseEpoch: 4,
|
||||
paused: false,
|
||||
deletedAt: "2026-01-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user