test(FN-4296): complete Step 4 — add agent link drift regression coverage
Fusion-Task-Id: FN-4296 Fusion-Task-Lineage: 9d061861-547a-4905-8fdd-4b66dd7bff97
This commit is contained in:
@@ -1,42 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Agent, Task } from "@fusion/core";
|
||||
import type { AgentStore } from "@fusion/core";
|
||||
import type { Agent, AgentStore, Task } from "@fusion/core";
|
||||
|
||||
import { SelfHealingManager } from "../self-healing";
|
||||
|
||||
describe("FN-4296: self-healing agent link drift", () => {
|
||||
it("FN-4296: durable running agent linked to done task is cleared by drift recovery", async () => {
|
||||
const agents: Agent[] = [
|
||||
{
|
||||
id: "agent-Y",
|
||||
state: "running",
|
||||
taskId: "FN-X",
|
||||
updatedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
} as Agent,
|
||||
];
|
||||
function makeAgent(id: string, taskId: string, state: Agent["state"] = "active"): Agent {
|
||||
return { id, state, taskId, updatedAt: new Date(Date.now() - 120_000).toISOString() } as Agent;
|
||||
}
|
||||
|
||||
describe("FN-4296: self-healing agent link drift", () => {
|
||||
function buildManager(agents: Agent[], tasks: Record<string, Task | null>, hasActiveAgentExecution?: (agentId: string) => boolean) {
|
||||
const store = {
|
||||
getTask: vi.fn(async (taskId: string) => (taskId === "FN-X" ? ({ id: "FN-X", column: "done" } as Task) : null)),
|
||||
getTask: vi.fn(async (taskId: string) => tasks[taskId] ?? null),
|
||||
} as any;
|
||||
|
||||
const agentStore = {
|
||||
listAgents: vi.fn(async () => agents),
|
||||
getActiveHeartbeatRun: vi.fn(async () => null),
|
||||
updateAgentState: vi.fn(async () => undefined),
|
||||
syncExecutionTaskLink: vi.fn(async (agentId: string, taskId?: string) => {
|
||||
const agent = agents.find((candidate) => candidate.id === agentId);
|
||||
if (agent) agent.taskId = taskId;
|
||||
}),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project", agentStore });
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project", agentStore, hasActiveAgentExecution });
|
||||
return { manager, agentStore };
|
||||
}
|
||||
|
||||
const recovered = await manager.recoverDriftedAgentTaskLinks();
|
||||
|
||||
expect(recovered).toBe(1);
|
||||
it("FN-4296: durable agent linked to done task is cleared by sweep", async () => {
|
||||
const agents = [makeAgent("agent-1", "FN-1")];
|
||||
const { manager } = buildManager(agents, { "FN-1": { id: "FN-1", column: "done" } as Task });
|
||||
await manager.recoverDriftedAgentTaskLinks();
|
||||
expect(agents[0].taskId).toBeUndefined();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4296: durable agent linked to archived task is cleared by sweep", async () => {
|
||||
const agents = [makeAgent("agent-1", "FN-1")];
|
||||
const { manager } = buildManager(agents, { "FN-1": { id: "FN-1", column: "archived" } as Task });
|
||||
await manager.recoverDriftedAgentTaskLinks();
|
||||
expect(agents[0].taskId).toBeUndefined();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4296: durable agent linked to queued todo task with no live run is cleared", async () => {
|
||||
const agents = [makeAgent("agent-1", "FN-1")];
|
||||
const { manager } = buildManager(agents, { "FN-1": { id: "FN-1", column: "todo" } as Task }, () => false);
|
||||
await manager.recoverDriftedAgentTaskLinks();
|
||||
expect(agents[0].taskId).toBeUndefined();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4296: durable agent linked to todo task with fresh active run is NOT cleared", async () => {
|
||||
const agents = [makeAgent("agent-1", "FN-1")];
|
||||
const { manager, agentStore } = buildManager(agents, { "FN-1": { id: "FN-1", column: "todo" } as Task }, () => true);
|
||||
await manager.recoverDriftedAgentTaskLinks();
|
||||
expect(agents[0].taskId).toBe("FN-1");
|
||||
expect((agentStore as any).syncExecutionTaskLink).not.toHaveBeenCalled();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4296: durable agent linked to in-progress task with matching assignedAgentId is NOT cleared", async () => {
|
||||
const agents = [makeAgent("agent-1", "FN-1")];
|
||||
const { manager } = buildManager(agents, { "FN-1": { id: "FN-1", column: "in-progress", assignedAgentId: "agent-1" } as Task });
|
||||
await manager.recoverDriftedAgentTaskLinks();
|
||||
expect(agents[0].taskId).toBe("FN-1");
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4296: durable agent linked to task assigned to different agent is cleared", async () => {
|
||||
const agents = [makeAgent("agent-1", "FN-1")];
|
||||
const { manager } = buildManager(agents, { "FN-1": { id: "FN-1", column: "in-progress", assignedAgentId: "agent-2" } as Task });
|
||||
await manager.recoverDriftedAgentTaskLinks();
|
||||
expect(agents[0].taskId).toBeUndefined();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4296: durable agent linked to nonexistent task id is cleared", async () => {
|
||||
const agents = [makeAgent("agent-1", "FN-1")];
|
||||
const { manager } = buildManager(agents, { "FN-1": null });
|
||||
await manager.recoverDriftedAgentTaskLinks();
|
||||
expect(agents[0].taskId).toBeUndefined();
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4296: ephemeral agents are not touched", async () => {
|
||||
const durable = makeAgent("agent-1", "FN-1");
|
||||
const ephemeral = makeAgent("temp-worker", "FN-2");
|
||||
const agents = [durable];
|
||||
const { manager } = buildManager(agents, {
|
||||
"FN-1": { id: "FN-1", column: "done" } as Task,
|
||||
"FN-2": { id: "FN-2", column: "done" } as Task,
|
||||
});
|
||||
await manager.recoverDriftedAgentTaskLinks();
|
||||
expect(durable.taskId).toBeUndefined();
|
||||
expect(ephemeral.taskId).toBe("FN-2");
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
|
||||
131
packages/engine/src/__tests__/task-agent-sync.test.ts
Normal file
131
packages/engine/src/__tests__/task-agent-sync.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { AgentStore, type AgentCreateInput, type Task } from "@fusion/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { attachAgentLinkSync } from "../task-agent-sync";
|
||||
|
||||
class EventedStore extends EventEmitter {
|
||||
on(event: "task:moved", listener: (data: { task: Task; from: string; to: string }) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
off(event: "task:moved", listener: (data: { task: Task; from: string; to: string }) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
const createInput: AgentCreateInput = { name: "durable-agent", role: "executor" };
|
||||
|
||||
describe("FN-4296: task agent sync", () => {
|
||||
const runCase = async (to: string, hasActiveAgentExecution = false) => {
|
||||
const store = new EventedStore();
|
||||
const agentStore = {
|
||||
listAgents: vi.fn(async () => [{ id: "agent-1", taskId: "FN-1" }]),
|
||||
syncExecutionTaskLink: vi.fn(async () => undefined),
|
||||
assignTask: vi.fn(async () => undefined),
|
||||
} as any;
|
||||
|
||||
const detach = attachAgentLinkSync({
|
||||
store: store as any,
|
||||
agentStore,
|
||||
hasActiveAgentExecution: () => hasActiveAgentExecution,
|
||||
logger: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
store.emit("task:moved", { task: { id: "FN-1" }, from: "in-progress", to });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
return { detach, agentStore };
|
||||
};
|
||||
|
||||
it("FN-4296: task:moved → done clears linked durable agent's taskId", async () => {
|
||||
const { agentStore } = await runCase("done");
|
||||
expect(agentStore.syncExecutionTaskLink).toHaveBeenCalledWith("agent-1", undefined);
|
||||
});
|
||||
|
||||
it("FN-4296: task:moved → archived clears link", async () => {
|
||||
const { agentStore } = await runCase("archived");
|
||||
expect(agentStore.syncExecutionTaskLink).toHaveBeenCalledWith("agent-1", undefined);
|
||||
});
|
||||
|
||||
it("FN-4296: task:moved → todo clears link when no in-flight execution", async () => {
|
||||
const { agentStore } = await runCase("todo", false);
|
||||
expect(agentStore.syncExecutionTaskLink).toHaveBeenCalledWith("agent-1", undefined);
|
||||
});
|
||||
|
||||
it("FN-4296: task:moved → todo does NOT clear link when hasActiveAgentExecution=true", async () => {
|
||||
const { agentStore } = await runCase("todo", true);
|
||||
expect(agentStore.syncExecutionTaskLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FN-4296: task:moved → triage clears link", async () => {
|
||||
const { agentStore } = await runCase("triage", false);
|
||||
expect(agentStore.syncExecutionTaskLink).toHaveBeenCalledWith("agent-1", undefined);
|
||||
});
|
||||
|
||||
it("FN-4296: task:moved → in-review does NOT clear link", async () => {
|
||||
const { agentStore } = await runCase("in-review", false);
|
||||
expect(agentStore.syncExecutionTaskLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FN-4296: task:moved → in-progress does NOT clear link", async () => {
|
||||
const { agentStore } = await runCase("in-progress", false);
|
||||
expect(agentStore.syncExecutionTaskLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FN-4296: returned detach function unsubscribes the listener", async () => {
|
||||
const store = new EventedStore();
|
||||
const agentStore = {
|
||||
listAgents: vi.fn(async () => [{ id: "agent-1", taskId: "FN-1" }]),
|
||||
syncExecutionTaskLink: vi.fn(async () => undefined),
|
||||
assignTask: vi.fn(async () => undefined),
|
||||
} as any;
|
||||
const detach = attachAgentLinkSync({ store: store as any, agentStore, logger: { log: vi.fn(), warn: vi.fn() } });
|
||||
detach();
|
||||
store.emit("task:moved", { task: { id: "FN-1" }, from: "in-progress", to: "done" });
|
||||
await Promise.resolve();
|
||||
expect(agentStore.syncExecutionTaskLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FN-4296: clear uses syncExecutionTaskLink not assignTask", async () => {
|
||||
const { agentStore } = await runCase("done", false);
|
||||
expect(agentStore.syncExecutionTaskLink).toHaveBeenCalled();
|
||||
expect(agentStore.assignTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FN-4296: integration-flavored clear persists on real AgentStore", async () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-4296-agent-store-"));
|
||||
try {
|
||||
const store = new EventedStore();
|
||||
const agentStore = new AgentStore({ rootDir, inMemoryDb: true });
|
||||
const created = await agentStore.createAgent(createInput);
|
||||
await agentStore.syncExecutionTaskLink(created.id, "FN-REAL");
|
||||
|
||||
const logger = { log: vi.fn(), warn: vi.fn() };
|
||||
const detach = attachAgentLinkSync({
|
||||
store: store as any,
|
||||
agentStore,
|
||||
logger,
|
||||
});
|
||||
|
||||
store.emit("task:moved", { task: { id: "FN-REAL" } as Task, from: "in-progress", to: "done" });
|
||||
|
||||
let hydrated = await agentStore.getAgent(created.id);
|
||||
for (let attempt = 0; attempt < 10 && hydrated?.taskId; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
hydrated = await agentStore.getAgent(created.id);
|
||||
}
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(hydrated?.taskId).toBeUndefined();
|
||||
detach();
|
||||
await agentStore.close();
|
||||
} finally {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user