/** * 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 { TaskStore } from "./store.js"; import { rm } from "node:fs/promises"; import { join } from "node:path"; import { mkdtempSync, existsSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { createHash } from "node:crypto"; import { CheckoutConflictError, type AgentCapability, type 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("persists soul and memory fields on create", async () => { const agent = await store.createAgent({ name: "With Soul", role: "executor", soul: "Calm and precise.", memory: "Prefers concise code examples.", }); expect(agent.soul).toBe("Calm and precise."); expect(agent.memory).toBe("Prefers concise code examples."); const persisted = await store.getAgent(agent.id); expect(persisted?.soul).toBe("Calm and precise."); expect(persisted?.memory).toBe("Prefers concise code examples."); }); 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(); }); }); // ── getAccessState ──────────────────────────────────────────────── describe("getAccessState", () => { it("returns computed state for an executor agent", async () => { const created = await store.createAgent({ name: "Executor", role: "executor", }); const state = await store.getAccessState(created.id); expect(state).not.toBeNull(); expect(state?.agentId).toBe(created.id); expect(state?.canExecuteTasks).toBe(true); expect(state?.canAssignTasks).toBe(false); expect(state?.taskAssignSource).toBe("denied"); }); it("returns null for non-existent agent", async () => { const state = await store.getAccessState("agent-missing"); expect(state).toBeNull(); }); it("reflects explicit permissions when set", async () => { const created = await store.createAgent({ name: "Explicit", role: "executor", permissions: { "tasks:assign": true }, }); const state = await store.getAccessState(created.id); expect(state).not.toBeNull(); expect(state?.canAssignTasks).toBe(true); expect(state?.taskAssignSource).toBe("explicit_grant"); expect(state?.explicitPermissions.has("tasks:assign")).toBe(true); }); }); // ── Budget Management ───────────────────────────────────────────── describe("Budget Management", () => { describe("getBudgetStatus", () => { it("throws if agent not found", async () => { await expect(store.getBudgetStatus("nonexistent")).rejects.toThrow("not found"); }); it("returns no-limit status when agent has no budgetConfig", async () => { const agent = await store.createAgent({ name: "No Budget Config", role: "executor", }); const status = await store.getBudgetStatus(agent.id); expect(status.currentUsage).toBe(0); expect(status.budgetLimit).toBeNull(); expect(status.usagePercent).toBeNull(); expect(status.thresholdPercent).toBeNull(); expect(status.isOverBudget).toBe(false); expect(status.isOverThreshold).toBe(false); expect(status.lastResetAt).toBeNull(); expect(status.nextResetAt).toBeNull(); }); it("returns no-limit status when budgetConfig has no tokenBudget", async () => { const agent = await store.createAgent({ name: "Threshold Only", role: "executor", runtimeConfig: { budgetConfig: { usageThreshold: 0.9, }, }, }); const status = await store.getBudgetStatus(agent.id); expect(status.budgetLimit).toBeNull(); expect(status.usagePercent).toBeNull(); expect(status.thresholdPercent).toBeNull(); }); it("computes usage from totalInputTokens + totalOutputTokens", async () => { const agent = await store.createAgent({ name: "Usage Counter", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 20000, }, }, }); await store.updateAgent(agent.id, { totalInputTokens: 5000, totalOutputTokens: 3000, }); const status = await store.getBudgetStatus(agent.id); expect(status.currentUsage).toBe(8000); }); it("detects over-budget when usage >= tokenBudget", async () => { const agent = await store.createAgent({ name: "Over Budget", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 1000, }, }, }); await store.updateAgent(agent.id, { totalInputTokens: 800, totalOutputTokens: 300, }); const status = await store.getBudgetStatus(agent.id); expect(status.isOverBudget).toBe(true); }); it("detects over-threshold when usagePercent >= thresholdPercent", async () => { const agent = await store.createAgent({ name: "Threshold Hit", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 10000, usageThreshold: 0.5, }, }, }); await store.updateAgent(agent.id, { totalInputTokens: 3000, totalOutputTokens: 2500, }); const status = await store.getBudgetStatus(agent.id); expect(status.usagePercent).toBeCloseTo(55, 10); expect(status.thresholdPercent).toBe(50); expect(status.isOverThreshold).toBe(true); }); it("is not over-threshold when below threshold", async () => { const agent = await store.createAgent({ name: "Threshold Safe", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 10000, usageThreshold: 0.8, }, }, }); await store.updateAgent(agent.id, { totalInputTokens: 2500, totalOutputTokens: 2500, }); const status = await store.getBudgetStatus(agent.id); expect(status.usagePercent).toBe(50); expect(status.isOverThreshold).toBe(false); }); it("clamps usagePercent to 100 when over budget", async () => { const agent = await store.createAgent({ name: "Clamp Usage", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 100, }, }, }); await store.updateAgent(agent.id, { totalInputTokens: 400, totalOutputTokens: 100, }); const status = await store.getBudgetStatus(agent.id); expect(status.usagePercent).toBe(100); }); it("returns lastResetAt from runtimeConfig.budgetResetAt", async () => { const budgetResetAt = "2026-01-01T00:00:00.000Z"; const agent = await store.createAgent({ name: "Has Reset Timestamp", role: "executor", runtimeConfig: { budgetResetAt, }, }); const status = await store.getBudgetStatus(agent.id); expect(status.lastResetAt).toBe(budgetResetAt); }); it("returns null nextResetAt for lifetime budget period", async () => { const agent = await store.createAgent({ name: "Lifetime Budget", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 1000, budgetPeriod: "lifetime", }, }, }); const status = await store.getBudgetStatus(agent.id); expect(status.nextResetAt).toBeNull(); }); it("computes nextResetAt for daily period as next midnight", async () => { const agent = await store.createAgent({ name: "Daily Budget", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 1000, budgetPeriod: "daily", }, }, }); const now = Date.now(); const status = await store.getBudgetStatus(agent.id); expect(status.nextResetAt).not.toBeNull(); const nextResetAt = new Date(status.nextResetAt!); expect(nextResetAt.getTime()).toBeGreaterThan(now); expect(nextResetAt.getHours()).toBe(0); expect(nextResetAt.getMinutes()).toBe(0); expect(nextResetAt.getSeconds()).toBe(0); expect(nextResetAt.getMilliseconds()).toBe(0); }); it("computes nextResetAt for weekly period using resetDay", async () => { const agent = await store.createAgent({ name: "Weekly Budget", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 1000, budgetPeriod: "weekly", resetDay: 1, }, }, }); const now = Date.now(); const status = await store.getBudgetStatus(agent.id); expect(status.nextResetAt).not.toBeNull(); const nextResetAt = new Date(status.nextResetAt!); expect(nextResetAt.getTime()).toBeGreaterThan(now); expect(nextResetAt.getDay()).toBe(1); expect(nextResetAt.getHours()).toBe(0); expect(nextResetAt.getMinutes()).toBe(0); expect(nextResetAt.getSeconds()).toBe(0); expect(nextResetAt.getMilliseconds()).toBe(0); expect(nextResetAt.getTime() - now).toBeLessThanOrEqual(8 * 24 * 60 * 60 * 1000); }); it("clamps resetDay to month length for monthly period", async () => { const agent = await store.createAgent({ name: "Monthly Budget", role: "executor", runtimeConfig: { budgetConfig: { tokenBudget: 1000, budgetPeriod: "monthly", resetDay: 31, }, }, }); const now = Date.now(); const status = await store.getBudgetStatus(agent.id); expect(status.nextResetAt).not.toBeNull(); const nextResetAt = new Date(status.nextResetAt!); const lastDayOfMonth = new Date(nextResetAt.getFullYear(), nextResetAt.getMonth() + 1, 0).getDate(); expect(nextResetAt.getTime()).toBeGreaterThan(now); expect(nextResetAt.getDate()).toBe(Math.min(31, lastDayOfMonth)); expect(nextResetAt.getHours()).toBe(0); expect(nextResetAt.getMinutes()).toBe(0); expect(nextResetAt.getSeconds()).toBe(0); expect(nextResetAt.getMilliseconds()).toBe(0); }); }); describe("resetBudgetUsage", () => { it("throws if agent not found", async () => { await expect(store.resetBudgetUsage("nonexistent")).rejects.toThrow("not found"); }); it("resets totalInputTokens and totalOutputTokens to 0", async () => { const agent = await store.createAgent({ name: "Reset Usage", role: "executor", }); await store.updateAgent(agent.id, { totalInputTokens: 1200, totalOutputTokens: 800, }); await store.resetBudgetUsage(agent.id); const updated = await store.getAgent(agent.id); expect(updated).not.toBeNull(); expect(updated?.totalInputTokens).toBe(0); expect(updated?.totalOutputTokens).toBe(0); }); it("sets budgetResetAt to current timestamp", async () => { const agent = await store.createAgent({ name: "Reset Timestamp", role: "executor", }); const beforeReset = Date.now(); await store.resetBudgetUsage(agent.id); const updated = await store.getAgent(agent.id); const rawBudgetResetAt = (updated?.runtimeConfig as Record | undefined)?.budgetResetAt; expect(typeof rawBudgetResetAt).toBe("string"); const parsedResetAt = new Date(rawBudgetResetAt as string).getTime(); expect(parsedResetAt).toBeGreaterThanOrEqual(beforeReset - 5000); expect(parsedResetAt).toBeGreaterThan(Date.now() - 5000); }); it("preserves other runtimeConfig values", async () => { const agent = await store.createAgent({ name: "Preserve Config", role: "executor", runtimeConfig: { heartbeatIntervalMs: 30000, budgetConfig: { tokenBudget: 1000, }, }, }); await store.resetBudgetUsage(agent.id); const updated = await store.getAgent(agent.id); const runtimeConfig = updated?.runtimeConfig as Record; expect(runtimeConfig.heartbeatIntervalMs).toBe(30000); expect(runtimeConfig.budgetConfig).toEqual({ tokenBudget: 1000 }); expect(typeof runtimeConfig.budgetResetAt).toBe("string"); }); }); }); // ── 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("updates soul and memory fields", async () => { const created = await store.createAgent({ name: "Knowledge Agent", role: "executor", }); const updated = await store.updateAgent(created.id, { soul: "Collaborative, practical mentor", memory: "Avoids broad rewrites; prefers incremental changes.", }); expect(updated.soul).toBe("Collaborative, practical mentor"); expect(updated.memory).toBe("Avoids broad rewrites; prefers incremental changes."); const persisted = await store.getAgent(created.id); expect(persisted?.soul).toBe("Collaborative, practical mentor"); expect(persisted?.memory).toBe("Avoids broad rewrites; prefers incremental changes."); }); it("does not clear soul when updates.soul is undefined", async () => { const created = await store.createAgent({ name: "Stable Soul", role: "executor", soul: "Patient reviewer", }); const updated = await store.updateAgent(created.id, { soul: undefined, memory: "Remembers coding preferences", }); expect(updated.soul).toBe("Patient reviewer"); expect(updated.memory).toBe("Remembers coding preferences"); }); it("allows clearing optional fields via explicit undefined", async () => { const created = await store.createAgent({ name: "Clearable", role: "executor", title: "Worker", instructionsText: "Initial instructions", }); const withTransientState = await store.updateAgent(created.id, { pauseReason: "manual", lastError: "oops", }); expect(withTransientState.pauseReason).toBe("manual"); expect(withTransientState.lastError).toBe("oops"); const cleared = await store.updateAgent(created.id, { title: undefined, instructionsText: undefined, pauseReason: undefined, lastError: undefined, }); expect(cleared.title).toBeUndefined(); expect(cleared.instructionsText).toBeUndefined(); expect(cleared.pauseReason).toBeUndefined(); expect(cleared.lastError).toBeUndefined(); }); it("rejects whitespace-only names", async () => { const created = await store.createAgent({ name: "Rename Me", role: "executor", }); await expect(store.updateAgent(created.id, { name: " " })).rejects.toThrow("Agent name cannot be empty"); }); 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); }); }); // ── config revisions ─────────────────────────────────────────────── describe("config revisions", () => { it("records revision when name changes", async () => { const created = await store.createAgent({ name: "Original", role: "executor" }); await store.updateAgent(created.id, { name: "Renamed" }); const revisions = await store.getConfigRevisions(created.id); expect(revisions).toHaveLength(1); expect(revisions[0].source).toBe("user"); expect(revisions[0].diffs).toEqual( expect.arrayContaining([ expect.objectContaining({ field: "name", oldValue: "Original", newValue: "Renamed" }), ]), ); expect(revisions[0].summary).toContain("name"); expect(revisions[0].before.name).toBe("Original"); expect(revisions[0].after.name).toBe("Renamed"); }); it("records revisions for runtimeConfig, permissions, instructions, soul, and memory changes", async () => { const created = await store.createAgent({ name: "Configurable", role: "executor", runtimeConfig: { heartbeatIntervalMs: 30000 }, permissions: { canReview: false }, }); await store.updateAgent(created.id, { runtimeConfig: { heartbeatIntervalMs: 10000 } }); await store.updateAgent(created.id, { permissions: { canReview: true, canExecute: true } }); await store.updateAgent(created.id, { instructionsPath: "docs/agent.md" }); await store.updateAgent(created.id, { instructionsText: "Follow safety checks." }); await store.updateAgent(created.id, { soul: "Thoughtful collaborator" }); await store.updateAgent(created.id, { memory: "Knows the repository architecture" }); const revisions = await store.getConfigRevisions(created.id); const changedFields = revisions.flatMap((revision) => revision.diffs.map((diff) => diff.field)); expect(changedFields).toContain("runtimeConfig"); expect(changedFields).toContain("permissions"); expect(changedFields).toContain("instructionsPath"); expect(changedFields).toContain("instructionsText"); expect(changedFields).toContain("soul"); expect(changedFields).toContain("memory"); }); it("does not create a revision when only non-config fields change", async () => { const created = await store.createAgent({ name: "No Diff", role: "executor" }); await store.updateAgent(created.id, { totalInputTokens: 120, totalOutputTokens: 80, pauseReason: "manual", lastError: "temporary", }); const revisions = await store.getConfigRevisions(created.id); expect(revisions).toEqual([]); }); it("returns revisions in reverse chronological order and respects limit", async () => { const created = await store.createAgent({ name: "Chrono", role: "executor" }); await store.updateAgent(created.id, { name: "Chrono-1" }); await store.updateAgent(created.id, { name: "Chrono-2" }); await store.updateAgent(created.id, { name: "Chrono-3" }); const revisions = await store.getConfigRevisions(created.id); expect(revisions).toHaveLength(3); expect(revisions[0].after.name).toBe("Chrono-3"); expect(revisions[1].after.name).toBe("Chrono-2"); expect(revisions[2].after.name).toBe("Chrono-1"); const limited = await store.getConfigRevisions(created.id, 2); expect(limited).toHaveLength(2); expect(limited.map((revision) => revision.after.name)).toEqual(["Chrono-3", "Chrono-2"]); }); it("getConfigRevisions returns empty for agents with no revisions and non-existent agents", async () => { const created = await store.createAgent({ name: "No Revisions", role: "executor" }); expect(await store.getConfigRevisions(created.id)).toEqual([]); expect(await store.getConfigRevisions("agent-missing")).toEqual([]); }); it("getConfigRevision returns matching revision and null when missing", async () => { const created = await store.createAgent({ name: "Find Revision", role: "executor" }); await store.updateAgent(created.id, { name: "Find Revision v2" }); const [revision] = await store.getConfigRevisions(created.id); const found = await store.getConfigRevision(created.id, revision.id); expect(found).not.toBeNull(); expect(found!.id).toBe(revision.id); expect(await store.getConfigRevision(created.id, "revision-missing")).toBeNull(); expect(await store.getConfigRevision("agent-missing", revision.id)).toBeNull(); }); it("rollbackConfig restores previous config and records rollback revision", async () => { const created = await store.createAgent({ name: "Rollback Me", role: "executor", runtimeConfig: { heartbeatTimeoutMs: 60000 }, }); await store.updateAgent(created.id, { name: "Rollback Me v2", runtimeConfig: { heartbeatTimeoutMs: 90000 }, }); const [targetRevision] = await store.getConfigRevisions(created.id); const result = await store.rollbackConfig(created.id, targetRevision.id); expect(result.agent.name).toBe("Rollback Me"); expect(result.agent.runtimeConfig).toEqual({ heartbeatTimeoutMs: 60000 }); expect(result.revision.source).toBe("rollback"); expect(result.revision.rollbackToRevisionId).toBe(targetRevision.id); const revisions = await store.getConfigRevisions(created.id); expect(revisions[0].id).toBe(result.revision.id); expect(revisions[0].source).toBe("rollback"); }); it("rollbackConfig supports chained rollbacks", async () => { const created = await store.createAgent({ name: "Version 1", role: "executor" }); await store.updateAgent(created.id, { name: "Version 2" }); await store.updateAgent(created.id, { name: "Version 3" }); const revisions = await store.getConfigRevisions(created.id); const revToV2 = revisions.find((revision) => revision.after.name === "Version 3"); const revToV1 = revisions.find((revision) => revision.after.name === "Version 2"); expect(revToV2).toBeDefined(); expect(revToV1).toBeDefined(); await store.rollbackConfig(created.id, revToV2!.id); const afterFirstRollback = await store.getAgent(created.id); expect(afterFirstRollback!.name).toBe("Version 2"); await store.rollbackConfig(created.id, revToV1!.id); const afterSecondRollback = await store.getAgent(created.id); expect(afterSecondRollback!.name).toBe("Version 1"); }); it("rollbackConfig throws for missing revision", async () => { const created = await store.createAgent({ name: "Rollback Missing", role: "executor" }); await expect(store.rollbackConfig(created.id, "revision-missing")).rejects.toThrow( `Config revision revision-missing not found for agent ${created.id}`, ); }); it("rollbackConfig throws when revision belongs to a different agent", async () => { const agentA = await store.createAgent({ name: "Agent A", role: "executor" }); const agentB = await store.createAgent({ name: "Agent B", role: "reviewer" }); await store.updateAgent(agentA.id, { name: "Agent A v2" }); const [revisionA] = await store.getConfigRevisions(agentA.id); await expect(store.rollbackConfig(agentB.id, revisionA.id)).rejects.toThrow( `Config revision ${revisionA.id} belongs to agent ${agentA.id}`, ); }); it("emits agent:configRevision on config updates and rollback, but not updateAgentState", async () => { const created = await store.createAgent({ name: "Events", role: "executor" }); const handler = vi.fn(); store.on("agent:configRevision", handler); await store.updateAgent(created.id, { name: "Events v2" }); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenLastCalledWith( created.id, expect.objectContaining({ agentId: created.id, source: "user" }), ); await store.updateAgentState(created.id, "idle"); expect(handler).toHaveBeenCalledTimes(1); const [firstRevision] = await store.getConfigRevisions(created.id); await store.rollbackConfig(created.id, firstRevision.id); expect(handler).toHaveBeenCalledTimes(2); expect(handler).toHaveBeenLastCalledWith( created.id, expect.objectContaining({ source: "rollback", rollbackToRevisionId: firstRevision.id }), ); }); it("persists revisions to an append-only JSONL file without affecting heartbeat files", async () => { const created = await store.createAgent({ name: "Persisted", role: "executor" }); await store.updateAgent(created.id, { name: "Persisted v2" }); await store.updateAgent(created.id, { name: "Persisted v3" }); await store.recordHeartbeat(created.id, "ok"); const revisionsPath = join(rootDir, "agents", `${created.id}-revisions.jsonl`); const heartbeatsPath = join(rootDir, "agents", `${created.id}-heartbeats.jsonl`); expect(existsSync(revisionsPath)).toBe(true); expect(existsSync(heartbeatsPath)).toBe(true); const revisionLines = readFileSync(revisionsPath, "utf-8").trim().split("\n").filter(Boolean); expect(revisionLines).toHaveLength(2); const parsedRevisions = revisionLines.map((line) => JSON.parse(line) as { agentId: string }); expect(parsedRevisions.every((line) => line.agentId === created.id)).toBe(true); const heartbeatLines = readFileSync(heartbeatsPath, "utf-8").trim().split("\n").filter(Boolean); expect(heartbeatLines.length).toBeGreaterThan(0); const parsedHeartbeat = JSON.parse(heartbeatLines[0]) as { status: string }; expect(parsedHeartbeat.status).toBe("ok"); }); }); // ── 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"); }); }); // ── Org Hierarchy ──────────────────────────────────────────────── describe("getChainOfCommand", () => { it("returns empty array for nonexistent agent", async () => { const chain = await store.getChainOfCommand("agent-missing"); expect(chain).toEqual([]); }); it("returns only self when agent has no manager", async () => { const solo = await store.createAgent({ name: "Solo", role: "executor" }); const chain = await store.getChainOfCommand(solo.id); expect(chain.map((agent) => agent.id)).toEqual([solo.id]); }); it("returns self → manager → grand-manager", async () => { const grandManager = await store.createAgent({ name: "Grand", role: "executor" }); const manager = await store.createAgent({ name: "Manager", role: "executor", reportsTo: grandManager.id, }); const agent = await store.createAgent({ name: "Worker", role: "executor", reportsTo: manager.id, }); const chain = await store.getChainOfCommand(agent.id); expect(chain.map((item) => item.id)).toEqual([agent.id, manager.id, grandManager.id]); }); it("stops traversal when a cycle is detected", async () => { const a = await store.createAgent({ name: "Cycle A", role: "executor" }); const b = await store.createAgent({ name: "Cycle B", role: "executor", reportsTo: a.id, }); await store.updateAgent(a.id, { reportsTo: b.id }); const chain = await store.getChainOfCommand(a.id); expect(chain.map((agent) => agent.id)).toEqual([a.id, b.id]); expect(chain.length).toBeLessThanOrEqual(20); }); }); describe("getOrgTree", () => { it("returns empty array when no agents exist", async () => { const tree = await store.getOrgTree(); expect(tree).toEqual([]); }); it("returns all agents as roots when no one has reportsTo", async () => { vi.useFakeTimers(); try { vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); const first = await store.createAgent({ name: "First", role: "executor" }); vi.setSystemTime(new Date("2026-01-02T00:00:00Z")); const second = await store.createAgent({ name: "Second", role: "executor" }); const tree = await store.getOrgTree(); expect(tree).toHaveLength(2); expect(tree.map((node) => node.agent.id)).toEqual([first.id, second.id]); expect(tree[0].children).toEqual([]); expect(tree[1].children).toEqual([]); } finally { vi.useRealTimers(); } }); it("builds a nested hierarchy and sorts children by createdAt ascending", async () => { vi.useFakeTimers(); try { vi.setSystemTime(new Date("2026-02-01T00:00:00Z")); const root = await store.createAgent({ name: "Root", role: "executor" }); vi.setSystemTime(new Date("2026-02-02T00:00:00Z")); const childOlder = await store.createAgent({ name: "Child Older", role: "executor", reportsTo: root.id, }); vi.setSystemTime(new Date("2026-02-03T00:00:00Z")); const childYounger = await store.createAgent({ name: "Child Younger", role: "executor", reportsTo: root.id, }); vi.setSystemTime(new Date("2026-02-04T00:00:00Z")); const grandChild = await store.createAgent({ name: "Grand Child", role: "executor", reportsTo: childOlder.id, }); const tree = await store.getOrgTree(); expect(tree).toHaveLength(1); expect(tree[0].agent.id).toBe(root.id); expect(tree[0].children.map((node) => node.agent.id)).toEqual([ childOlder.id, childYounger.id, ]); expect(tree[0].children[0].children.map((node) => node.agent.id)).toEqual([ grandChild.id, ]); } finally { vi.useRealTimers(); } }); it("treats agents with missing managers as root nodes", async () => { const root = await store.createAgent({ name: "Root", role: "executor" }); const orphan = await store.createAgent({ name: "Orphan", role: "executor", reportsTo: "agent-nonexistent", }); const tree = await store.getOrgTree(); expect(tree.map((node) => node.agent.id).sort()).toEqual([root.id, orphan.id].sort()); }); }); describe("resolveAgent", () => { it("resolves by exact agent ID", async () => { const created = await store.createAgent({ name: "ID Match", role: "executor" }); const resolved = await store.resolveAgent(created.id); expect(resolved?.id).toBe(created.id); }); it("resolves by normalized name", async () => { const created = await store.createAgent({ name: "My Agent", role: "executor" }); const resolved = await store.resolveAgent("my-agent"); expect(resolved?.id).toBe(created.id); }); it("returns null when multiple agents share the same normalized shortname", async () => { await store.createAgent({ name: "My Agent", role: "executor" }); await store.createAgent({ name: "my-agent", role: "reviewer" }); const resolved = await store.resolveAgent("my-agent"); expect(resolved).toBeNull(); }); it("returns null for unknown shortnames", async () => { await store.createAgent({ name: "Known Agent", role: "executor" }); const resolved = await store.resolveAgent("not-found"); expect(resolved).toBeNull(); }); it("matches shortnames case-insensitively", async () => { const created = await store.createAgent({ name: "My Agent", role: "executor" }); const resolved = await store.resolveAgent("MY-AGENT"); expect(resolved?.id).toBe(created.id); }); it("normalizes special characters in names", async () => { const created = await store.createAgent({ name: "Test Agent v2!", role: "executor" }); const resolved = await store.resolveAgent("test-agent-v2"); expect(resolved?.id).toBe(created.id); }); }); // ── 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 paused still 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, "paused") ).rejects.toThrow("Invalid state transition: terminated -> paused"); }); it("terminated → active transition succeeds", async () => { const agent = await createReadyAgent(store, "RestartActive"); await store.updateAgentState(agent.id, "active"); await store.updateAgentState(agent.id, "terminated"); const updated = await store.updateAgentState(agent.id, "active"); expect(updated.state).toBe("active"); }); it("terminated → running transition succeeds", async () => { const agent = await createReadyAgent(store, "RestartRunning"); await store.updateAgentState(agent.id, "active"); await store.updateAgentState(agent.id, "terminated"); const updated = await store.updateAgentState(agent.id, "running"); expect(updated.state).toBe("running"); }); it("terminated → idle transition succeeds", async () => { const agent = await createReadyAgent(store, "RestartIdle"); await store.updateAgentState(agent.id, "active"); await store.updateAgentState(agent.id, "terminated"); const updated = await store.updateAgentState(agent.id, "idle"); expect(updated.state).toBe("idle"); }); it("transitioning from terminated clears lastError", async () => { const agent = await createReadyAgent(store, "ClearError"); await store.updateAgentState(agent.id, "active"); await store.updateAgent(agent.id, { lastError: "something broke" }); await store.updateAgentState(agent.id, "terminated"); const restarted = await store.updateAgentState(agent.id, "active"); expect(restarted.state).toBe("active"); expect(restarted.lastError).toBeUndefined(); }); 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("emits 'agent:assigned' event when assigning a task", async () => { const agent = await store.createAgent({ name: "AssignEvent", role: "executor" }); const handler = vi.fn(); store.on("agent:assigned", handler); await store.assignTask(agent.id, "KB-003"); expect(handler).toHaveBeenCalledOnce(); const [updatedAgent, taskId] = handler.mock.calls[0]; expect(updatedAgent.id).toBe(agent.id); expect(updatedAgent.taskId).toBe("KB-003"); expect(taskId).toBe("KB-003"); }); it("does NOT emit 'agent:assigned' when clearing taskId", async () => { const agent = await store.createAgent({ name: "UnassignEvent", role: "executor" }); await store.assignTask(agent.id, "KB-004"); const handler = vi.fn(); store.on("agent:assigned", handler); await store.assignTask(agent.id, undefined); expect(handler).not.toHaveBeenCalled(); }); it("throws for non-existent agent", async () => { await expect( store.assignTask("agent-missing", "KB-001") ).rejects.toThrow("Agent agent-missing not found"); }); }); describe("checkout leasing", () => { let taskStore: TaskStore; let holderId: string; let otherAgentId: string; let taskId: string; beforeEach(async () => { taskStore = new TaskStore(rootDir); await taskStore.init(); store = new AgentStore({ rootDir, taskStore }); await store.init(); const holder = await store.createAgent({ name: "Checkout Holder", role: "executor" }); const other = await store.createAgent({ name: "Checkout Other", role: "executor" }); const task = await taskStore.createTask({ description: "Task for checkout leasing tests" }); holderId = holder.id; otherAgentId = other.id; taskId = task.id; }); it("checkoutTask acquires a lease and stamps checkedOutAt", async () => { const updated = await store.checkoutTask(holderId, taskId); expect(updated.checkedOutBy).toBe(holderId); expect(updated.checkedOutAt).toBeDefined(); const persisted = await taskStore.getTask(taskId); expect(persisted?.checkedOutBy).toBe(holderId); expect(persisted?.checkedOutAt).toBeDefined(); }); it("checkoutTask is idempotent when the same agent re-checks out", async () => { const first = await store.checkoutTask(holderId, taskId); const second = await store.checkoutTask(holderId, taskId); expect(second.checkedOutBy).toBe(holderId); expect(second.checkedOutAt).toBe(first.checkedOutAt); }); it("checkoutTask throws CheckoutConflictError when already held by another agent", async () => { await store.checkoutTask(holderId, taskId); try { await store.checkoutTask(otherAgentId, taskId); throw new Error("Expected checkout conflict"); } catch (error) { expect(error).toBeInstanceOf(CheckoutConflictError); const conflict = error as CheckoutConflictError; expect(conflict.taskId).toBe(taskId); expect(conflict.currentHolderId).toBe(holderId); expect(conflict.requestedById).toBe(otherAgentId); } }); it("checkoutTask throws when agent is missing", async () => { await expect(store.checkoutTask("agent-missing", taskId)).rejects.toThrow("Agent agent-missing not found"); }); it("checkoutTask throws when task is missing", async () => { await expect(store.checkoutTask(holderId, "FN-404")).rejects.toThrow("Task FN-404 not found"); }); it("releaseTask clears checkedOutBy and checkedOutAt for the holder", async () => { await store.checkoutTask(holderId, taskId); const released = await store.releaseTask(holderId, taskId); expect(released.checkedOutBy).toBeUndefined(); expect(released.checkedOutAt).toBeUndefined(); const persisted = await taskStore.getTask(taskId); expect(persisted?.checkedOutBy).toBeUndefined(); expect(persisted?.checkedOutAt).toBeUndefined(); }); it("releaseTask throws for a non-holder agent", async () => { await store.checkoutTask(holderId, taskId); await expect(store.releaseTask(otherAgentId, taskId)).rejects.toThrow("Cannot release: not the checkout holder"); }); it("releaseTask is idempotent when task is already released", async () => { const released = await store.releaseTask(holderId, taskId); expect(released.checkedOutBy).toBeUndefined(); expect(released.checkedOutAt).toBeUndefined(); }); it("forceReleaseTask clears checkout regardless of holder", async () => { await store.checkoutTask(holderId, taskId); const released = await store.forceReleaseTask(taskId); expect(released.checkedOutBy).toBeUndefined(); expect(released.checkedOutAt).toBeUndefined(); }); it("getCheckedOutBy returns holder ID when checked out and undefined otherwise", async () => { expect(await store.getCheckedOutBy(taskId)).toBeUndefined(); await store.checkoutTask(holderId, taskId); expect(await store.getCheckedOutBy(taskId)).toBe(holderId); }); }); // ── resetAgent ──────────────────────────────────────────────────── describe("resetAgent", () => { // Helper: create an agent and transition it to terminated with error/task async function createTerminatedAgent(s: AgentStore, name: string) { const agent = await s.createAgent({ name, role: "executor" }); await s.recordHeartbeat(agent.id, "ok"); await s.recordHeartbeat(agent.id, "missed"); await s.updateAgentState(agent.id, "active"); await s.assignTask(agent.id, "KB-999"); await s.updateAgent(agent.id, { pauseReason: "manual", lastError: "something broke", }); await s.updateAgentState(agent.id, "terminated"); return agent; } it("transitions terminated agent to idle", async () => { const agent = await createTerminatedAgent(store, "ResetToIdle"); const reset = await store.resetAgent(agent.id); expect(reset.state).toBe("idle"); }); it("can reset directly from running", async () => { const agent = await store.createAgent({ name: "RunningReset", role: "executor" }); await store.recordHeartbeat(agent.id, "ok"); await store.updateAgentState(agent.id, "active"); await store.updateAgentState(agent.id, "running"); await store.assignTask(agent.id, "KB-123"); await store.updateAgent(agent.id, { pauseReason: "stalled", lastError: "runner failed", }); const reset = await store.resetAgent(agent.id); expect(reset.state).toBe("idle"); expect(reset.taskId).toBeUndefined(); expect(reset.pauseReason).toBeUndefined(); expect(reset.lastError).toBeUndefined(); }); it("clears lastError", async () => { const agent = await createTerminatedAgent(store, "ResetClearsError"); const reset = await store.resetAgent(agent.id); expect(reset.lastError).toBeUndefined(); }); it("clears pauseReason", async () => { const agent = await createTerminatedAgent(store, "ResetClearsPause"); const reset = await store.resetAgent(agent.id); expect(reset.pauseReason).toBeUndefined(); }); it("clears taskId", async () => { const agent = await createTerminatedAgent(store, "ResetClearsTask"); const reset = await store.resetAgent(agent.id); expect(reset.taskId).toBeUndefined(); }); it("starts fresh heartbeat tracking on subsequent active transition", async () => { const agent = await createTerminatedAgent(store, "ResetHeartbeat"); await store.resetAgent(agent.id); // After reset, explicitly start a heartbeat run (as the caller would) const run = await store.startHeartbeatRun(agent.id); const activeRun = await store.getActiveHeartbeatRun(agent.id); expect(activeRun).not.toBeNull(); expect(activeRun!.id).toBe(run.id); }); it("throws for non-existent agent", async () => { await expect( store.resetAgent("agent-ghost") ).rejects.toThrow("Agent agent-ghost 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' removes from active and adds to completed", 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 should NOT appear in active runs const active = await store.getActiveHeartbeatRun(agent.id); expect(active).toBeNull(); // A completed run should appear in completed runs with terminal status const completed = await store.getCompletedHeartbeatRuns(agent.id); expect(completed).toHaveLength(1); expect(completed[0].id).toBe(run.id); expect(completed[0].status).toBe("completed"); expect(completed[0].endedAt).toBeDefined(); }); 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); }); it("after completion, a new run can start without stale active-run blockage", async () => { const agent = await store.createAgent({ name: "RestartRun", role: "executor" }); // Start and complete first run const run1 = await store.startHeartbeatRun(agent.id); await store.endHeartbeatRun(run1.id, "completed"); // Verify first run is not active const active1 = await store.getActiveHeartbeatRun(agent.id); expect(active1).toBeNull(); // Start second run - should succeed without conflict const run2 = await store.startHeartbeatRun(agent.id); expect(run2.id).not.toBe(run1.id); expect(run2.status).toBe("active"); // Verify second run is now the active run const active2 = await store.getActiveHeartbeatRun(agent.id); expect(active2).not.toBeNull(); expect(active2!.id).toBe(run2.id); }); it("startHeartbeatRun persists the run to structured storage", async () => { const agent = await store.createAgent({ name: "PersistRun", role: "executor" }); const run = await store.startHeartbeatRun(agent.id); // Verify run is persisted const detail = await store.getRunDetail(agent.id, run.id); expect(detail).not.toBeNull(); expect(detail!.id).toBe(run.id); expect(detail!.agentId).toBe(agent.id); expect(detail!.status).toBe("active"); expect(detail!.endedAt).toBeNull(); // Verify run appears in recent runs const recent = await store.getRecentRuns(agent.id); expect(recent.some((r) => r.id === run.id)).toBe(true); }); it("endHeartbeatRun updates the persisted run with terminal state", async () => { const agent = await store.createAgent({ name: "UpdateRun", role: "executor" }); const run = await store.startHeartbeatRun(agent.id); // Complete the run await store.endHeartbeatRun(run.id, "completed"); // Verify persisted run is updated const detail = await store.getRunDetail(agent.id, run.id); expect(detail).not.toBeNull(); expect(detail!.status).toBe("completed"); expect(detail!.endedAt).toBeDefined(); }); it("getCompletedHeartbeatRuns returns terminal runs in newest-first order", async () => { const agent = await store.createAgent({ name: "OrderRuns", role: "executor" }); vi.useFakeTimers(); try { vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); const run1 = await store.startHeartbeatRun(agent.id); await store.endHeartbeatRun(run1.id, "completed"); vi.setSystemTime(new Date("2026-01-02T00:00:00Z")); const run2 = await store.startHeartbeatRun(agent.id); await store.endHeartbeatRun(run2.id, "completed"); const completed = await store.getCompletedHeartbeatRuns(agent.id); expect(completed).toHaveLength(2); expect(completed[0].id).toBe(run2.id); // Newest first expect(completed[1].id).toBe(run1.id); } finally { vi.useRealTimers(); } }); it("handles mixed structured and legacy run data", async () => { const agent = await store.createAgent({ name: "MixedRuns", role: "executor" }); // Structured run (new behavior) const structuredRun = await store.startHeartbeatRun(agent.id); await store.endHeartbeatRun(structuredRun.id, "completed"); // Legacy completed run (simulated by checking legacy fallback) // The fallback still reconstructs runs from heartbeat events const completed = await store.getCompletedHeartbeatRuns(agent.id); expect(completed.some((r) => r.id === structuredRun.id)).toBe(true); }); }); // ── blocked state persistence ───────────────────────────────────── describe("blocked state persistence", () => { it("roundtrips last blocked state via set/get", async () => { const agent = await store.createAgent({ name: "BlockedState", role: "executor" }); const snapshot = { taskId: "FN-123", blockedBy: "FN-122", recordedAt: new Date().toISOString(), contextHash: "abc123hash", }; await store.setLastBlockedState(agent.id, snapshot); const loaded = await store.getLastBlockedState(agent.id); expect(loaded).toEqual(snapshot); }); it("returns null when no blocked-state file exists", async () => { const agent = await store.createAgent({ name: "NoBlockedState", role: "executor" }); const loaded = await store.getLastBlockedState(agent.id); expect(loaded).toBeNull(); }); it("clearLastBlockedState removes persisted snapshot", async () => { const agent = await store.createAgent({ name: "ClearBlockedState", role: "executor" }); await store.setLastBlockedState(agent.id, { taskId: "FN-999", blockedBy: "FN-998", recordedAt: new Date().toISOString(), contextHash: "will-clear", }); await store.clearLastBlockedState(agent.id); const loaded = await store.getLastBlockedState(agent.id); expect(loaded).toBeNull(); expect(existsSync(join(rootDir, "agents", `${agent.id}-last-blocked.json`))).toBe(false); }); }); // ── 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); }); }); describe("rating methods", () => { const addSequencedRatings = async ( agentId: string, scores: number[], inputOverrides?: Partial<{ category: string; comment: string; runId: string; taskId: string; raterId: string }>, ) => { vi.useFakeTimers(); const base = new Date("2026-01-01T00:00:00.000Z").getTime(); try { const ratings = []; for (let i = 0; i < scores.length; i++) { vi.setSystemTime(new Date(base + i * 1000)); ratings.push( await store.addRating(agentId, { raterType: "user", score: scores[i], ...inputOverrides, }), ); } return ratings; } finally { vi.useRealTimers(); } }; it("addRating creates a rating and emits rating:added", async () => { const agent = await store.createAgent({ name: "Rated Agent", role: "executor" }); const handler = vi.fn(); store.on("rating:added", handler); const rating = await store.addRating(agent.id, { raterType: "user", score: 5, comment: "Great run", }); expect(rating.id).toMatch(/^rating-[a-f0-9]{8}$/); expect(rating.agentId).toBe(agent.id); expect(rating.raterType).toBe("user"); expect(rating.score).toBe(5); expect(rating.comment).toBe("Great run"); expect(new Date(rating.createdAt).getTime()).not.toBeNaN(); expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenCalledWith(rating); }); it("addRating rejects scores outside 1..5", async () => { const agent = await store.createAgent({ name: "Validator", role: "reviewer" }); await expect( store.addRating(agent.id, { raterType: "system", score: 0 }), ).rejects.toThrow("Rating score must be between 1 and 5"); await expect( store.addRating(agent.id, { raterType: "system", score: 6 }), ).rejects.toThrow("Rating score must be between 1 and 5"); }); it("addRating stores all optional fields", async () => { const agent = await store.createAgent({ name: "Optional Fields", role: "executor" }); const rating = await store.addRating(agent.id, { raterType: "agent", raterId: "agent-rater", score: 4, category: "quality", comment: "Strong implementation", runId: "run-123", taskId: "FN-1000", }); expect(rating.raterId).toBe("agent-rater"); expect(rating.category).toBe("quality"); expect(rating.comment).toBe("Strong implementation"); expect(rating.runId).toBe("run-123"); expect(rating.taskId).toBe("FN-1000"); }); it("getRatings returns ratings ordered by createdAt desc", async () => { const agent = await store.createAgent({ name: "Order Agent", role: "executor" }); const created = await addSequencedRatings(agent.id, [2, 3, 5]); const ratings = await store.getRatings(agent.id); expect(ratings.map((rating) => rating.id)).toEqual([ created[2].id, created[1].id, created[0].id, ]); }); it("getRatings applies category filter", async () => { const agent = await store.createAgent({ name: "Category Agent", role: "executor" }); await store.addRating(agent.id, { raterType: "user", score: 4, category: "quality" }); await store.addRating(agent.id, { raterType: "user", score: 2, category: "speed" }); await store.addRating(agent.id, { raterType: "user", score: 5, category: "quality" }); const ratings = await store.getRatings(agent.id, { category: "quality" }); expect(ratings).toHaveLength(2); expect(ratings.every((rating) => rating.category === "quality")).toBe(true); }); it("getRatings respects the limit option", async () => { const agent = await store.createAgent({ name: "Limit Agent", role: "executor" }); await addSequencedRatings(agent.id, [1, 2, 3, 4]); const ratings = await store.getRatings(agent.id, { limit: 2 }); expect(ratings).toHaveLength(2); expect(ratings[0].score).toBe(4); expect(ratings[1].score).toBe(3); }); it("getRatingSummary returns an empty summary when no ratings exist", async () => { const agent = await store.createAgent({ name: "Empty Summary", role: "executor" }); const summary = await store.getRatingSummary(agent.id); expect(summary).toEqual({ agentId: agent.id, averageScore: 0, totalRatings: 0, categoryAverages: {}, recentRatings: [], trend: "insufficient-data", }); }); it("getRatingSummary computes averages and categoryAverages", async () => { const agent = await store.createAgent({ name: "Summary Agent", role: "executor" }); await addSequencedRatings(agent.id, [5], { category: "quality" }); await addSequencedRatings(agent.id, [3], { category: "quality" }); await addSequencedRatings(agent.id, [4], { category: "speed" }); await addSequencedRatings(agent.id, [2]); const summary = await store.getRatingSummary(agent.id); expect(summary.averageScore).toBe(3.5); expect(summary.totalRatings).toBe(4); expect(summary.categoryAverages).toEqual({ quality: 4, speed: 4, }); expect(summary.recentRatings).toHaveLength(4); expect(summary.trend).toBe("insufficient-data"); }); it("getRatingSummary trend is improving when recent average is higher", async () => { const agent = await store.createAgent({ name: "Improving Agent", role: "executor" }); await addSequencedRatings(agent.id, [1, 1, 2, 2, 2, 4, 4, 5, 5, 5]); const summary = await store.getRatingSummary(agent.id); expect(summary.trend).toBe("improving"); }); it("getRatingSummary trend is declining when recent average is lower", async () => { const agent = await store.createAgent({ name: "Declining Agent", role: "executor" }); await addSequencedRatings(agent.id, [5, 5, 4, 4, 4, 2, 2, 1, 1, 1]); const summary = await store.getRatingSummary(agent.id); expect(summary.trend).toBe("declining"); }); it("getRatingSummary trend is stable when windows are approximately equal", async () => { const agent = await store.createAgent({ name: "Stable Agent", role: "executor" }); await addSequencedRatings(agent.id, [3, 3, 3, 3, 3, 3, 3, 3, 3, 3]); const summary = await store.getRatingSummary(agent.id); expect(summary.trend).toBe("stable"); }); it("deleteRating removes the rating", async () => { const agent = await store.createAgent({ name: "Delete Agent", role: "executor" }); const first = await store.addRating(agent.id, { raterType: "user", score: 4 }); await store.addRating(agent.id, { raterType: "user", score: 5 }); await store.deleteRating(first.id); const ratings = await store.getRatings(agent.id); expect(ratings).toHaveLength(1); expect(ratings[0].id).not.toBe(first.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"); }); }); // ── API Keys ────────────────────────────────────────────────────── describe("API Keys", () => { it("createApiKey returns key metadata and one-time plaintext token", async () => { const agent = await store.createAgent({ name: "KeyAgent", role: "executor" }); const result = await store.createApiKey(agent.id); expect(result.key.id).toMatch(/^key-[a-f0-9]{8}$/); expect(result.key.agentId).toBe(agent.id); expect(result.key.tokenHash).toMatch(/^[a-f0-9]{64}$/); expect(result.token).toMatch(/^[a-f0-9]{64}$/); expect(new Date(result.key.createdAt).getTime()).not.toBeNaN(); expect(result.key.revokedAt).toBeUndefined(); const expectedHash = createHash("sha256").update(result.token).digest("hex"); expect(result.key.tokenHash).toBe(expectedHash); const keyPath = join(rootDir, "agents", `${agent.id}-keys.jsonl`); expect(existsSync(keyPath)).toBe(true); const persisted = readFileSync(keyPath, "utf-8"); expect(persisted).not.toContain(result.token); }); it("createApiKey with label persists the label", async () => { const agent = await store.createAgent({ name: "LabeledKeyAgent", role: "executor" }); const { key } = await store.createApiKey(agent.id, { label: "CI Key" }); const keys = await store.listApiKeys(agent.id); expect(key.label).toBe("CI Key"); expect(keys).toHaveLength(1); expect(keys[0].label).toBe("CI Key"); }); it("createApiKey omits empty labels", async () => { const agent = await store.createAgent({ name: "NoLabelKeyAgent", role: "executor" }); const { key } = await store.createApiKey(agent.id, { label: " " }); expect(key.label).toBeUndefined(); }); it("createApiKey throws when agent is not found", async () => { await expect(store.createApiKey("agent-missing")).rejects.toThrow( "Agent agent-missing not found" ); }); it("listApiKeys returns keys for one agent and empty array for an agent with no keys", async () => { const withKeys = await store.createAgent({ name: "WithKeys", role: "executor" }); const noKeys = await store.createAgent({ name: "NoKeys", role: "executor" }); const other = await store.createAgent({ name: "Other", role: "reviewer" }); const first = await store.createApiKey(withKeys.id); const second = await store.createApiKey(withKeys.id); await store.createApiKey(other.id); const withKeysList = await store.listApiKeys(withKeys.id); expect(withKeysList).toHaveLength(2); expect(withKeysList.map((key) => key.id)).toEqual([first.key.id, second.key.id]); const noKeysList = await store.listApiKeys(noKeys.id); expect(noKeysList).toEqual([]); }); it("listApiKeys throws when agent is not found", async () => { await expect(store.listApiKeys("agent-missing")).rejects.toThrow( "Agent agent-missing not found" ); }); it("revokeApiKey sets revokedAt and revoked key remains in list", async () => { const agent = await store.createAgent({ name: "RevokeKeyAgent", role: "executor" }); const { key } = await store.createApiKey(agent.id); const revoked = await store.revokeApiKey(agent.id, key.id); expect(revoked.id).toBe(key.id); expect(revoked.revokedAt).toBeDefined(); const keys = await store.listApiKeys(agent.id); expect(keys).toHaveLength(1); expect(keys[0].id).toBe(key.id); expect(keys[0].revokedAt).toBe(revoked.revokedAt); }); it("revokeApiKey already revoked is a no-op", async () => { const agent = await store.createAgent({ name: "RevokeTwiceAgent", role: "executor" }); const { key } = await store.createApiKey(agent.id); const firstRevocation = await store.revokeApiKey(agent.id, key.id); const secondRevocation = await store.revokeApiKey(agent.id, key.id); expect(firstRevocation.revokedAt).toBeDefined(); expect(secondRevocation.revokedAt).toBe(firstRevocation.revokedAt); }); it("revokeApiKey throws when key is not found", async () => { const agent = await store.createAgent({ name: "MissingKeyAgent", role: "executor" }); await expect(store.revokeApiKey(agent.id, "key-missing")).rejects.toThrow( `API key key-missing not found for agent ${agent.id}` ); }); it("revokeApiKey throws when agent is not found", async () => { await expect(store.revokeApiKey("agent-missing", "key-1234")).rejects.toThrow( "Agent agent-missing not found" ); }); it("multiple keys can be listed and revoking one does not affect others", async () => { const agent = await store.createAgent({ name: "MultiKeyAgent", role: "executor" }); const key1 = await store.createApiKey(agent.id, { label: "key-1" }); const key2 = await store.createApiKey(agent.id, { label: "key-2" }); const key3 = await store.createApiKey(agent.id, { label: "key-3" }); const revoked = await store.revokeApiKey(agent.id, key2.key.id); const keys = await store.listApiKeys(agent.id); expect(keys).toHaveLength(3); const byId = new Map(keys.map((key) => [key.id, key])); expect(byId.get(key1.key.id)?.revokedAt).toBeUndefined(); expect(byId.get(key2.key.id)?.revokedAt).toBe(revoked.revokedAt); expect(byId.get(key3.key.id)?.revokedAt).toBeUndefined(); }); it("API keys survive store reinitialization", async () => { const agent = await store.createAgent({ name: "KeyPersistence", role: "executor" }); const { key } = await store.createApiKey(agent.id, { label: "persist" }); const store2 = new AgentStore({ rootDir }); await store2.init(); const keys = await store2.listApiKeys(agent.id); expect(keys).toHaveLength(1); expect(keys[0].id).toBe(key.id); expect(keys[0].label).toBe("persist"); }); }); // ── 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(); } }); it("concurrent createApiKey calls don't corrupt the JSONL file", async () => { const agent = await store.createAgent({ name: "ConcKeys", role: "executor" }); const results = await Promise.all( Array.from({ length: 10 }, () => store.createApiKey(agent.id)) ); const keys = await store.listApiKeys(agent.id); expect(keys).toHaveLength(10); const ids = new Set(results.map(({ key }) => key.id)); expect(ids.size).toBe(10); }); }); // ── 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); }); }); });