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:
gsxdsm
2026-04-01 10:00:41 -07:00
parent 0ec0dab300
commit 83cbced678
2 changed files with 818 additions and 1 deletions

View 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);
});
});
});