test(FN-704): add comprehensive AgentStore test suite
- Add 62 tests covering all AgentStore public methods (init, CRUD, state transitions, heartbeats, assignments) - Test event emissions (agent:created, agent:updated, agent:deleted, agent:heartbeat, agent:stateChanged) - Test error paths, state transition validation, and concurrency locking - Verify filesystem persistence across store re-initialization - Update coverage gaps doc to mark agent-store.ts as covered
This commit is contained in:
817
packages/core/src/agent-store.test.ts
Normal file
817
packages/core/src/agent-store.test.ts
Normal file
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* Tests for AgentStore — filesystem-based agent lifecycle management.
|
||||
*
|
||||
* Covers every public method: init, createAgent, getAgent, getAgentDetail,
|
||||
* updateAgent, updateAgentState, assignTask, listAgents, deleteAgent,
|
||||
* recordHeartbeat, getHeartbeatHistory, startHeartbeatRun, endHeartbeatRun,
|
||||
* getActiveHeartbeatRun, getCompletedHeartbeatRuns.
|
||||
*
|
||||
* Also tests event emissions (agent:created, agent:updated, agent:deleted,
|
||||
* agent:heartbeat, agent:stateChanged), error paths, state transition
|
||||
* validation, concurrency locking, and filesystem persistence.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentStore } from "./agent-store.js";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { AgentCapability, AgentState } from "./types.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-agent-store-test-"));
|
||||
}
|
||||
|
||||
describe("AgentStore", () => {
|
||||
let rootDir: string;
|
||||
let store: AgentStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = makeTmpDir();
|
||||
store = new AgentStore({ rootDir });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("init", () => {
|
||||
it("creates the agents/ directory inside rootDir", async () => {
|
||||
const agentsDir = join(rootDir, "agents");
|
||||
expect(existsSync(agentsDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent (calling twice doesn't error)", async () => {
|
||||
await store.init();
|
||||
await store.init();
|
||||
const agentsDir = join(rootDir, "agents");
|
||||
expect(existsSync(agentsDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createAgent ───────────────────────────────────────────────────
|
||||
|
||||
describe("createAgent", () => {
|
||||
it("returns an agent with correct fields", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: " Test Agent ",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
expect(agent.id).toMatch(/^agent-/);
|
||||
expect(agent.name).toBe("Test Agent"); // trimmed
|
||||
expect(agent.role).toBe("executor");
|
||||
expect(agent.state).toBe("idle");
|
||||
expect(agent.metadata).toEqual({});
|
||||
expect(new Date(agent.createdAt).getTime()).not.toBeNaN();
|
||||
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("preserves custom metadata", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "With Meta",
|
||||
role: "reviewer",
|
||||
metadata: { version: 2, tags: ["test"] },
|
||||
});
|
||||
|
||||
expect(agent.metadata).toEqual({ version: 2, tags: ["test"] });
|
||||
});
|
||||
|
||||
it("throws when name is empty", async () => {
|
||||
await expect(
|
||||
store.createAgent({ name: "", role: "executor" })
|
||||
).rejects.toThrow("Agent name is required");
|
||||
});
|
||||
|
||||
it("throws when name is whitespace-only", async () => {
|
||||
await expect(
|
||||
store.createAgent({ name: " ", role: "executor" })
|
||||
).rejects.toThrow("Agent name is required");
|
||||
});
|
||||
|
||||
it("throws when role is missing", async () => {
|
||||
await expect(
|
||||
store.createAgent({ name: "No Role", role: "" as AgentCapability })
|
||||
).rejects.toThrow("Agent role is required");
|
||||
});
|
||||
|
||||
it("emits 'agent:created' event with the created agent", async () => {
|
||||
const handler = vi.fn();
|
||||
store.on("agent:created", handler);
|
||||
|
||||
const agent = await store.createAgent({
|
||||
name: "Event Agent",
|
||||
role: "triage",
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(agent);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAgent ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getAgent", () => {
|
||||
it("returns the agent after creation", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Lookup Agent",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const found = await store.getAgent(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.name).toBe("Lookup Agent");
|
||||
expect(found!.role).toBe("executor");
|
||||
expect(found!.state).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns null for a non-existent ID", async () => {
|
||||
const result = await store.getAgent("agent-nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateAgent ───────────────────────────────────────────────────
|
||||
|
||||
describe("updateAgent", () => {
|
||||
it("updates name, role, and metadata fields", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Before",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const updated = await store.updateAgent(created.id, {
|
||||
name: "After",
|
||||
role: "reviewer",
|
||||
metadata: { key: "value" },
|
||||
});
|
||||
|
||||
expect(updated.name).toBe("After");
|
||||
expect(updated.role).toBe("reviewer");
|
||||
expect(updated.metadata).toEqual({ key: "value" });
|
||||
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(created.updatedAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves fields not included in the update input", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Original",
|
||||
role: "executor",
|
||||
metadata: { preserved: true },
|
||||
});
|
||||
|
||||
const updated = await store.updateAgent(created.id, {
|
||||
name: "Changed Name",
|
||||
});
|
||||
|
||||
expect(updated.name).toBe("Changed Name");
|
||||
expect(updated.role).toBe("executor"); // preserved
|
||||
expect(updated.metadata).toEqual({ preserved: true }); // preserved
|
||||
});
|
||||
|
||||
it("throws for non-existent agent ID", async () => {
|
||||
await expect(
|
||||
store.updateAgent("agent-missing", { name: "Nope" })
|
||||
).rejects.toThrow("Agent agent-missing not found");
|
||||
});
|
||||
|
||||
it("emits 'agent:updated' event", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Update Event",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
store.on("agent:updated", handler);
|
||||
|
||||
const updated = await store.updateAgent(created.id, { name: "New Name" });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteAgent ───────────────────────────────────────────────────
|
||||
|
||||
describe("deleteAgent", () => {
|
||||
it("removes the agent so getAgent returns null", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "To Delete",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
await store.deleteAgent(created.id);
|
||||
const found = await store.getAgent(created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("also removes heartbeat file if present", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "With HB",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
// Record a heartbeat to create the file
|
||||
await store.recordHeartbeat(created.id, "ok");
|
||||
|
||||
const hbPath = join(rootDir, "agents", `${created.id}-heartbeats.jsonl`);
|
||||
expect(existsSync(hbPath)).toBe(true);
|
||||
|
||||
await store.deleteAgent(created.id);
|
||||
expect(existsSync(hbPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("throws for non-existent agent ID", async () => {
|
||||
await expect(store.deleteAgent("agent-missing")).rejects.toThrow(
|
||||
"Agent agent-missing not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("emits 'agent:deleted' event with the agent ID", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Delete Event",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
store.on("agent:deleted", handler);
|
||||
|
||||
await store.deleteAgent(created.id);
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(created.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listAgents ────────────────────────────────────────────────────
|
||||
|
||||
describe("listAgents", () => {
|
||||
it("returns empty array when no agents exist", async () => {
|
||||
const agents = await store.listAgents();
|
||||
expect(agents).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all created agents sorted by createdAt descending", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const a1 = await store.createAgent({ name: "First", role: "executor" });
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
|
||||
const a2 = await store.createAgent({ name: "Second", role: "reviewer" });
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-03T00:00:00Z"));
|
||||
const a3 = await store.createAgent({ name: "Third", role: "triage" });
|
||||
|
||||
const agents = await store.listAgents();
|
||||
expect(agents).toHaveLength(3);
|
||||
// Newest first
|
||||
expect(agents[0].id).toBe(a3.id);
|
||||
expect(agents[1].id).toBe(a2.id);
|
||||
expect(agents[2].id).toBe(a1.id);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("filters by state", async () => {
|
||||
const a1 = await store.createAgent({ name: "Idle", role: "executor" });
|
||||
const a2 = await store.createAgent({ name: "Active", role: "executor" });
|
||||
// Record a heartbeat first so that updateAgentState(→active) doesn't
|
||||
// trigger startHeartbeatRun internally (which would re-enter withLock).
|
||||
await store.recordHeartbeat(a2.id, "ok");
|
||||
await store.updateAgentState(a2.id, "active");
|
||||
|
||||
const idle = await store.listAgents({ state: "idle" });
|
||||
expect(idle).toHaveLength(1);
|
||||
expect(idle[0].id).toBe(a1.id);
|
||||
|
||||
const active = await store.listAgents({ state: "active" });
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].id).toBe(a2.id);
|
||||
});
|
||||
|
||||
it("filters by role", async () => {
|
||||
await store.createAgent({ name: "Exec", role: "executor" });
|
||||
await store.createAgent({ name: "Review", role: "reviewer" });
|
||||
|
||||
const executors = await store.listAgents({ role: "executor" });
|
||||
expect(executors).toHaveLength(1);
|
||||
expect(executors[0].name).toBe("Exec");
|
||||
});
|
||||
|
||||
it("filters by both state and role", async () => {
|
||||
const a1 = await store.createAgent({ name: "ActiveExec", role: "executor" });
|
||||
await store.recordHeartbeat(a1.id, "ok");
|
||||
await store.updateAgentState(a1.id, "active");
|
||||
await store.createAgent({ name: "IdleExec", role: "executor" });
|
||||
const a3 = await store.createAgent({ name: "ActiveReview", role: "reviewer" });
|
||||
await store.recordHeartbeat(a3.id, "ok");
|
||||
await store.updateAgentState(a3.id, "active");
|
||||
|
||||
const result = await store.listAgents({ state: "active", role: "executor" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("ActiveExec");
|
||||
});
|
||||
|
||||
it("skips corrupted JSON files without throwing", async () => {
|
||||
await store.createAgent({ name: "Valid", role: "executor" });
|
||||
|
||||
// Write a corrupted file
|
||||
const corruptPath = join(rootDir, "agents", "agent-corrupt.json");
|
||||
writeFileSync(corruptPath, "not-valid-json{{{");
|
||||
|
||||
const agents = await store.listAgents();
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0].name).toBe("Valid");
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateAgentState ──────────────────────────────────────────────
|
||||
|
||||
describe("updateAgentState", () => {
|
||||
// Helper: create an agent and set lastHeartbeatAt so that
|
||||
// idle→active transitions don't trigger the re-entrant
|
||||
// startHeartbeatRun path (see FN-711 for the deadlock bug).
|
||||
// Also records a "missed" heartbeat to close any active run,
|
||||
// preventing the terminated-transition deadlock path too.
|
||||
async function createReadyAgent(s: AgentStore, name: string) {
|
||||
const agent = await s.createAgent({ name, role: "executor" });
|
||||
await s.recordHeartbeat(agent.id, "ok");
|
||||
// Close the active run so transitioning to terminated
|
||||
// won't trigger endHeartbeatRun inside withLock.
|
||||
await s.recordHeartbeat(agent.id, "missed");
|
||||
return agent;
|
||||
}
|
||||
|
||||
it("idle → active transition succeeds", async () => {
|
||||
const agent = await createReadyAgent(store, "IdleToActive");
|
||||
const updated = await store.updateAgentState(agent.id, "active");
|
||||
expect(updated.state).toBe("active");
|
||||
});
|
||||
|
||||
it("active → paused transition succeeds", async () => {
|
||||
const agent = await createReadyAgent(store, "ActiveToPaused");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
const updated = await store.updateAgentState(agent.id, "paused");
|
||||
expect(updated.state).toBe("paused");
|
||||
});
|
||||
|
||||
it("active → terminated transition succeeds", async () => {
|
||||
const agent = await createReadyAgent(store, "ActiveToTerminated");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
const updated = await store.updateAgentState(agent.id, "terminated");
|
||||
expect(updated.state).toBe("terminated");
|
||||
});
|
||||
|
||||
it("paused → active transition succeeds", async () => {
|
||||
const agent = await createReadyAgent(store, "PausedToActive");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
await store.updateAgentState(agent.id, "paused");
|
||||
const updated = await store.updateAgentState(agent.id, "active");
|
||||
expect(updated.state).toBe("active");
|
||||
});
|
||||
|
||||
it("paused → terminated transition succeeds", async () => {
|
||||
const agent = await createReadyAgent(store, "PausedToTerminated");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
await store.updateAgentState(agent.id, "paused");
|
||||
const updated = await store.updateAgentState(agent.id, "terminated");
|
||||
expect(updated.state).toBe("terminated");
|
||||
});
|
||||
|
||||
it("same-state transition returns agent unchanged (no-op)", async () => {
|
||||
const agent = await store.createAgent({ name: "SameState", role: "executor" });
|
||||
const unchanged = await store.updateAgentState(agent.id, "idle");
|
||||
expect(unchanged.state).toBe("idle");
|
||||
expect(unchanged.updatedAt).toBe(agent.updatedAt);
|
||||
});
|
||||
|
||||
it("idle → paused throws with descriptive error message", async () => {
|
||||
const agent = await store.createAgent({ name: "BadTransition", role: "executor" });
|
||||
await expect(
|
||||
store.updateAgentState(agent.id, "paused")
|
||||
).rejects.toThrow("Invalid state transition: idle -> paused");
|
||||
});
|
||||
|
||||
it("idle → terminated throws", async () => {
|
||||
const agent = await store.createAgent({ name: "BadTerminate", role: "executor" });
|
||||
await expect(
|
||||
store.updateAgentState(agent.id, "terminated")
|
||||
).rejects.toThrow("Invalid state transition: idle -> terminated");
|
||||
});
|
||||
|
||||
it("transition from terminated to any state throws", async () => {
|
||||
const agent = await createReadyAgent(store, "Terminated");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
await store.updateAgentState(agent.id, "terminated");
|
||||
|
||||
await expect(
|
||||
store.updateAgentState(agent.id, "active")
|
||||
).rejects.toThrow("Cannot transition from terminated");
|
||||
await expect(
|
||||
store.updateAgentState(agent.id, "idle")
|
||||
).rejects.toThrow("Cannot transition from terminated");
|
||||
});
|
||||
|
||||
it("emits both 'agent:stateChanged' and 'agent:updated' events", async () => {
|
||||
const agent = await createReadyAgent(store, "StateEvents");
|
||||
|
||||
const stateHandler = vi.fn();
|
||||
const updateHandler = vi.fn();
|
||||
store.on("agent:stateChanged", stateHandler);
|
||||
store.on("agent:updated", updateHandler);
|
||||
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
|
||||
expect(stateHandler).toHaveBeenCalledOnce();
|
||||
expect(stateHandler).toHaveBeenCalledWith(agent.id, "idle", "active");
|
||||
|
||||
// agent:updated is called with updated agent and previousState
|
||||
expect(updateHandler).toHaveBeenCalled();
|
||||
const [updatedAgent, previousState] = updateHandler.mock.calls[0];
|
||||
expect(updatedAgent.state).toBe("active");
|
||||
expect(previousState).toBe("idle");
|
||||
});
|
||||
|
||||
it("throws for non-existent agent", async () => {
|
||||
await expect(
|
||||
store.updateAgentState("agent-nope", "active")
|
||||
).rejects.toThrow("Agent agent-nope not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ── assignTask ────────────────────────────────────────────────────
|
||||
|
||||
describe("assignTask", () => {
|
||||
it("sets taskId on the agent", async () => {
|
||||
const agent = await store.createAgent({ name: "Assignee", role: "executor" });
|
||||
const updated = await store.assignTask(agent.id, "KB-001");
|
||||
expect(updated.taskId).toBe("KB-001");
|
||||
|
||||
const fetched = await store.getAgent(agent.id);
|
||||
expect(fetched!.taskId).toBe("KB-001");
|
||||
});
|
||||
|
||||
it("clears taskId with undefined", async () => {
|
||||
const agent = await store.createAgent({ name: "Unassign", role: "executor" });
|
||||
await store.assignTask(agent.id, "KB-001");
|
||||
const updated = await store.assignTask(agent.id, undefined);
|
||||
expect(updated.taskId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits 'agent:updated' event", async () => {
|
||||
const agent = await store.createAgent({ name: "AssignEvent", role: "executor" });
|
||||
const handler = vi.fn();
|
||||
store.on("agent:updated", handler);
|
||||
|
||||
await store.assignTask(agent.id, "KB-002");
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
const [updatedAgent] = handler.mock.calls[0];
|
||||
expect(updatedAgent.taskId).toBe("KB-002");
|
||||
});
|
||||
|
||||
it("throws for non-existent agent", async () => {
|
||||
await expect(
|
||||
store.assignTask("agent-missing", "KB-001")
|
||||
).rejects.toThrow("Agent agent-missing not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ── recordHeartbeat ───────────────────────────────────────────────
|
||||
|
||||
describe("recordHeartbeat", () => {
|
||||
it("appends to the heartbeats JSONL file", async () => {
|
||||
const agent = await store.createAgent({ name: "HB Agent", role: "executor" });
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
|
||||
const history = await store.getHeartbeatHistory(agent.id);
|
||||
expect(history).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("with status 'ok' updates agent's lastHeartbeatAt", async () => {
|
||||
const agent = await store.createAgent({ name: "OK HB", role: "executor" });
|
||||
expect(agent.lastHeartbeatAt).toBeUndefined();
|
||||
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
const updated = await store.getAgent(agent.id);
|
||||
expect(updated!.lastHeartbeatAt).toBeDefined();
|
||||
expect(new Date(updated!.lastHeartbeatAt!).getTime()).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("with status 'missed' does NOT update lastHeartbeatAt", async () => {
|
||||
const agent = await store.createAgent({ name: "Missed HB", role: "executor" });
|
||||
|
||||
// Record an OK heartbeat first to set lastHeartbeatAt
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
const afterOk = await store.getAgent(agent.id);
|
||||
const okTimestamp = afterOk!.lastHeartbeatAt;
|
||||
|
||||
// Record a missed heartbeat — lastHeartbeatAt should stay the same
|
||||
await store.recordHeartbeat(agent.id, "missed");
|
||||
const afterMissed = await store.getAgent(agent.id);
|
||||
expect(afterMissed!.lastHeartbeatAt).toBe(okTimestamp);
|
||||
});
|
||||
|
||||
it("emits 'agent:heartbeat' event", async () => {
|
||||
const agent = await store.createAgent({ name: "HB Event", role: "executor" });
|
||||
const handler = vi.fn();
|
||||
store.on("agent:heartbeat", handler);
|
||||
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
const [id, event] = handler.mock.calls[0];
|
||||
expect(id).toBe(agent.id);
|
||||
expect(event.status).toBe("ok");
|
||||
expect(event.runId).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws for non-existent agent", async () => {
|
||||
await expect(
|
||||
store.recordHeartbeat("agent-ghost", "ok")
|
||||
).rejects.toThrow("Agent agent-ghost not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getHeartbeatHistory ───────────────────────────────────────────
|
||||
|
||||
describe("getHeartbeatHistory", () => {
|
||||
it("returns events newest-first", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agent = await store.createAgent({ name: "History", role: "executor" });
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-03T00:00:00Z"));
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
|
||||
const history = await store.getHeartbeatHistory(agent.id);
|
||||
expect(history).toHaveLength(3);
|
||||
// Newest first
|
||||
expect(history[0].timestamp).toBe("2026-01-03T00:00:00.000Z");
|
||||
expect(history[1].timestamp).toBe("2026-01-02T00:00:00.000Z");
|
||||
expect(history[2].timestamp).toBe("2026-01-01T00:00:00.000Z");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("respects limit parameter", async () => {
|
||||
const agent = await store.createAgent({ name: "Limited", role: "executor" });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
}
|
||||
|
||||
const limited = await store.getHeartbeatHistory(agent.id, 3);
|
||||
expect(limited).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns empty array when no heartbeats exist", async () => {
|
||||
const agent = await store.createAgent({ name: "NoHB", role: "executor" });
|
||||
const history = await store.getHeartbeatHistory(agent.id);
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── heartbeat runs ────────────────────────────────────────────────
|
||||
|
||||
describe("heartbeat runs", () => {
|
||||
it("startHeartbeatRun returns a run with status 'active' and valid fields", async () => {
|
||||
const agent = await store.createAgent({ name: "RunAgent", role: "executor" });
|
||||
const run = await store.startHeartbeatRun(agent.id);
|
||||
|
||||
expect(run.id).toMatch(/^run-/);
|
||||
expect(run.agentId).toBe(agent.id);
|
||||
expect(run.status).toBe("active");
|
||||
expect(run.endedAt).toBeNull();
|
||||
expect(new Date(run.startedAt).getTime()).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("getActiveHeartbeatRun returns the active run after starting one", async () => {
|
||||
const agent = await store.createAgent({ name: "ActiveRunAgent", role: "executor" });
|
||||
const run = await store.startHeartbeatRun(agent.id);
|
||||
|
||||
const active = await store.getActiveHeartbeatRun(agent.id);
|
||||
expect(active).not.toBeNull();
|
||||
expect(active!.id).toBe(run.id);
|
||||
expect(active!.status).toBe("active");
|
||||
});
|
||||
|
||||
it("getActiveHeartbeatRun returns null when no runs exist", async () => {
|
||||
const agent = await store.createAgent({ name: "NoRuns", role: "executor" });
|
||||
const active = await store.getActiveHeartbeatRun(agent.id);
|
||||
expect(active).toBeNull();
|
||||
});
|
||||
|
||||
it("endHeartbeatRun with 'terminated' marks the run as ended", async () => {
|
||||
const agent = await store.createAgent({ name: "TermRun", role: "executor" });
|
||||
const run = await store.startHeartbeatRun(agent.id);
|
||||
|
||||
await store.endHeartbeatRun(run.id, "terminated");
|
||||
|
||||
const completed = await store.getCompletedHeartbeatRuns(agent.id);
|
||||
expect(completed).toHaveLength(1);
|
||||
expect(completed[0].id).toBe(run.id);
|
||||
expect(completed[0].status).toBe("terminated");
|
||||
expect(completed[0].endedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("endHeartbeatRun with 'completed' records an ok heartbeat", async () => {
|
||||
const agent = await store.createAgent({ name: "CompleteRun", role: "executor" });
|
||||
const run = await store.startHeartbeatRun(agent.id);
|
||||
|
||||
await store.endHeartbeatRun(run.id, "completed");
|
||||
|
||||
// A completed run records status "ok" (not "missed"), so the run
|
||||
// stays active in the reconstructed view (implementation detail).
|
||||
// The getActiveHeartbeatRun still sees it as active since only
|
||||
// "missed" status marks a run as terminated.
|
||||
const active = await store.getActiveHeartbeatRun(agent.id);
|
||||
expect(active).not.toBeNull();
|
||||
});
|
||||
|
||||
it("getCompletedHeartbeatRuns returns only non-active runs", async () => {
|
||||
const agent = await store.createAgent({ name: "MultiRun", role: "executor" });
|
||||
|
||||
const run1 = await store.startHeartbeatRun(agent.id);
|
||||
await store.endHeartbeatRun(run1.id, "terminated");
|
||||
|
||||
const run2 = await store.startHeartbeatRun(agent.id);
|
||||
// run2 is still active
|
||||
|
||||
const completed = await store.getCompletedHeartbeatRuns(agent.id);
|
||||
expect(completed).toHaveLength(1);
|
||||
expect(completed[0].id).toBe(run1.id);
|
||||
|
||||
// Active run should not appear in completed
|
||||
const active = await store.getActiveHeartbeatRun(agent.id);
|
||||
expect(active).not.toBeNull();
|
||||
expect(active!.id).toBe(run2.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAgentDetail ────────────────────────────────────────────────
|
||||
|
||||
describe("getAgentDetail", () => {
|
||||
it("returns agent data plus heartbeat info", async () => {
|
||||
const agent = await store.createAgent({ name: "DetailAgent", role: "executor" });
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
|
||||
const detail = await store.getAgentDetail(agent.id);
|
||||
expect(detail).not.toBeNull();
|
||||
expect(detail!.id).toBe(agent.id);
|
||||
expect(detail!.name).toBe("DetailAgent");
|
||||
expect(detail!.heartbeatHistory).toHaveLength(1);
|
||||
expect(detail!.completedRuns).toBeDefined();
|
||||
expect(Array.isArray(detail!.completedRuns)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for non-existent agent", async () => {
|
||||
const detail = await store.getAgentDetail("agent-nope");
|
||||
expect(detail).toBeNull();
|
||||
});
|
||||
|
||||
it("respects heartbeatLimit parameter", async () => {
|
||||
const agent = await store.createAgent({ name: "LimitDetail", role: "executor" });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
}
|
||||
|
||||
const detail = await store.getAgentDetail(agent.id, 3);
|
||||
expect(detail!.heartbeatHistory).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("includes active and completed runs", async () => {
|
||||
const agent = await store.createAgent({ name: "RunsDetail", role: "executor" });
|
||||
const run1 = await store.startHeartbeatRun(agent.id);
|
||||
await store.endHeartbeatRun(run1.id, "terminated");
|
||||
const run2 = await store.startHeartbeatRun(agent.id);
|
||||
|
||||
const detail = await store.getAgentDetail(agent.id);
|
||||
expect(detail!.activeRun).toBeDefined();
|
||||
expect(detail!.activeRun!.id).toBe(run2.id);
|
||||
expect(detail!.completedRuns).toHaveLength(1);
|
||||
expect(detail!.completedRuns[0].id).toBe(run1.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── heartbeat lifecycle via updateAgentState ──────────────────────
|
||||
|
||||
describe("heartbeat lifecycle via updateAgentState", () => {
|
||||
// NOTE: updateAgentState has a re-entrant withLock deadlock bug
|
||||
// (see FN-711). These tests exercise the *intended behavior*
|
||||
// via direct method calls rather than through the deadlock-prone
|
||||
// updateAgentState path.
|
||||
|
||||
it("idle → active intended to start heartbeat run (tested via direct call)", async () => {
|
||||
const agent = await store.createAgent({ name: "HBLifecycle", role: "executor" });
|
||||
|
||||
// Directly call startHeartbeatRun (what updateAgentState intends to do)
|
||||
const run = await store.startHeartbeatRun(agent.id);
|
||||
expect(run.status).toBe("active");
|
||||
|
||||
const active = await store.getActiveHeartbeatRun(agent.id);
|
||||
expect(active).not.toBeNull();
|
||||
expect(active!.id).toBe(run.id);
|
||||
});
|
||||
|
||||
it("terminated transition intended to end heartbeat run (tested via direct call)", async () => {
|
||||
const agent = await store.createAgent({ name: "HBEnd", role: "executor" });
|
||||
const run = await store.startHeartbeatRun(agent.id);
|
||||
|
||||
// Directly call endHeartbeatRun (what updateAgentState intends to do)
|
||||
await store.endHeartbeatRun(run.id, "terminated");
|
||||
|
||||
const active = await store.getActiveHeartbeatRun(agent.id);
|
||||
expect(active).toBeNull();
|
||||
|
||||
const completed = await store.getCompletedHeartbeatRuns(agent.id);
|
||||
expect(completed).toHaveLength(1);
|
||||
expect(completed[0].status).toBe("terminated");
|
||||
});
|
||||
});
|
||||
|
||||
// ── concurrency (withLock) ────────────────────────────────────────
|
||||
|
||||
describe("concurrency", () => {
|
||||
it("concurrent updateAgent calls on the same agent serialize correctly", async () => {
|
||||
const agent = await store.createAgent({ name: "ConcAgent", role: "executor" });
|
||||
|
||||
// Fire multiple updates concurrently
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
store.updateAgent(agent.id, { name: "Name-1" }),
|
||||
store.updateAgent(agent.id, { name: "Name-2" }),
|
||||
store.updateAgent(agent.id, { name: "Name-3" }),
|
||||
]);
|
||||
|
||||
// The last write wins since they're serialized
|
||||
const final = await store.getAgent(agent.id);
|
||||
expect(final!.name).toBe("Name-3");
|
||||
|
||||
// All three should have returned valid agents (no corruption)
|
||||
expect(r1.name).toBe("Name-1");
|
||||
expect(r2.name).toBe("Name-2");
|
||||
expect(r3.name).toBe("Name-3");
|
||||
});
|
||||
|
||||
it("concurrent recordHeartbeat calls don't corrupt the JSONL file", async () => {
|
||||
const agent = await store.createAgent({ name: "ConcHB", role: "executor" });
|
||||
|
||||
// Fire 10 heartbeats concurrently
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }, () => store.recordHeartbeat(agent.id, "ok"))
|
||||
);
|
||||
|
||||
const history = await store.getHeartbeatHistory(agent.id, 100);
|
||||
expect(history).toHaveLength(10);
|
||||
|
||||
// Each event should be parseable (no corruption)
|
||||
for (const event of history) {
|
||||
expect(event.status).toBe("ok");
|
||||
expect(event.runId).toBeDefined();
|
||||
expect(new Date(event.timestamp).getTime()).not.toBeNaN();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── filesystem persistence ────────────────────────────────────────
|
||||
|
||||
describe("filesystem persistence", () => {
|
||||
it("agent data survives store reinitialization", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "Persistent",
|
||||
role: "reviewer",
|
||||
metadata: { key: "val" },
|
||||
});
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
|
||||
// Create a new store instance pointing to the same rootDir
|
||||
const store2 = new AgentStore({ rootDir });
|
||||
await store2.init();
|
||||
|
||||
const found = await store2.getAgent(agent.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(agent.id);
|
||||
expect(found!.name).toBe("Persistent");
|
||||
expect(found!.role).toBe("reviewer");
|
||||
expect(found!.metadata).toEqual({ key: "val" });
|
||||
expect(found!.lastHeartbeatAt).toBeDefined();
|
||||
|
||||
// Heartbeat history persists too
|
||||
const history = await store2.getHeartbeatHistory(agent.id);
|
||||
expect(history).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user