feat(FN-5195): merge fusion/fn-5195

This commit is contained in:
gsxdsm
2026-05-20 02:47:34 -07:00
parent c9fd41ef26
commit b1c0a91752
4 changed files with 136 additions and 6 deletions

View File

@@ -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));

View File

@@ -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();
}
});
});

View File

@@ -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[]));

View File

@@ -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",
});