feat(FN-3773): default non-ephemeral agents to active and add polling task

Non-ephemeral agents now default to `active` state on creation (FN-3773), with aligned test coverage across the agent store and dashboard routes. The even-realities plugin gains a polling notification system including a notification store, card rendering, diff logic, and API routes. Review state per

Fusion-Task-Id: FN-3773
This commit is contained in:
Fusion
2026-05-08 16:39:00 -07:00
committed by gsxdsm
parent 89051d6f28
commit 0da7aa8c0f
4 changed files with 78 additions and 33 deletions

View File

@@ -243,7 +243,7 @@ describe("AgentStore", () => {
expect(agent.id).toMatch(/^agent-/);
expect(agent.name).toBe("Test Agent"); // trimmed
expect(agent.role).toBe("executor");
expect(agent.state).toBe("idle");
expect(agent.state).toBe("active");
expect(agent.metadata).toEqual({});
expect(agent.runtimeConfig).toMatchObject({
enabled: true,
@@ -253,6 +253,35 @@ describe("AgentStore", () => {
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
});
it("starts newly created non-ephemeral agents in active state", async () => {
const agent = await store.createAgent({
name: "DefaultActive",
role: "executor",
});
expect(agent.state).toBe("active");
});
it("starts task-worker agents in idle state", async () => {
const agent = await store.createAgent({
name: "executor-FN-3773",
role: "executor",
metadata: { agentKind: "task-worker" },
});
expect(agent.state).toBe("idle");
});
it("starts legacy taskWorker-marked agents in idle state", async () => {
const agent = await store.createAgent({
name: "executor-legacy-FN-3773",
role: "executor",
metadata: { taskWorker: true },
});
expect(agent.state).toBe("idle");
});
it("defaults heartbeat procedure path to canonical display-name directory", async () => {
const agent = await store.createAgent({
name: "CEO",
@@ -411,7 +440,7 @@ describe("AgentStore", () => {
expect(found!.id).toBe(created.id);
expect(found!.name).toBe("Lookup Agent");
expect(found!.role).toBe("executor");
expect(found!.state).toBe("idle");
expect(found!.state).toBe("active");
});
it("returns null for a non-existent ID", async () => {
@@ -1252,14 +1281,14 @@ describe("AgentStore", () => {
});
it("filters by state", async () => {
const a1 = await store.createAgent({ name: "Idle", role: "executor" });
const a1 = await store.createAgent({
name: "IdleTaskWorker",
role: "executor",
metadata: { agentKind: "task-worker" },
});
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" });
const idle = await store.listAgents({ state: "idle", includeEphemeral: true });
expect(idle).toHaveLength(1);
expect(idle[0].id).toBe(a1.id);
@@ -1278,13 +1307,13 @@ describe("AgentStore", () => {
});
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");
await store.createAgent({ name: "ActiveExec", role: "executor" });
await store.createAgent({
name: "IdleExec",
role: "executor",
metadata: { agentKind: "task-worker" },
});
await store.createAgent({ name: "ActiveReview", role: "reviewer" });
const result = await store.listAgents({ state: "active", role: "executor" });
expect(result).toHaveLength(1);
@@ -1345,8 +1374,8 @@ describe("AgentStore", () => {
});
it("includeEphemeral filter works with state filter", async () => {
// Create a normal agent (return value not needed — just to ensure it exists in DB)
await store.createAgent({ name: "Normal Agent", role: "executor" });
// Create a normal agent
const normal = await store.createAgent({ name: "Normal Agent", role: "executor" });
// Create a task-worker agent
const taskWorker = await store.createAgent({
@@ -1359,12 +1388,13 @@ describe("AgentStore", () => {
// Without includeEphemeral filter - only returns active non-ephemeral agents
const activeNonEphemeral = await store.listAgents({ state: "active" });
expect(activeNonEphemeral).toHaveLength(0);
expect(activeNonEphemeral).toHaveLength(1);
expect(activeNonEphemeral[0].id).toBe(normal.id);
// With includeEphemeral: true, returns all active agents
const activeAll = await store.listAgents({ state: "active", includeEphemeral: true });
expect(activeAll).toHaveLength(1);
expect(activeAll[0].id).toBe(taskWorker.id);
expect(activeAll).toHaveLength(2);
expect(activeAll.map((agent) => agent.id).sort()).toEqual([normal.id, taskWorker.id].sort());
});
it("filters out agents marked with metadata.internal", async () => {
@@ -1576,17 +1606,16 @@ describe("AgentStore", () => {
// ── 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).
// Helper: create an active agent and set lastHeartbeatAt for tests that
// exercise heartbeat-aware state transitions.
async function createReadyAgent(s: AgentStore, name: string) {
const agent = await s.createAgent({ name, role: "executor" });
await s.recordHeartbeat(agent.id, "ok");
return agent;
}
it("idle → active transition succeeds", async () => {
const agent = await createReadyAgent(store, "IdleToActive");
it("active → active transition succeeds as no-op", async () => {
const agent = await createReadyAgent(store, "ActiveToActive");
const updated = await store.updateAgentState(agent.id, "active");
expect(updated.state).toBe("active");
});
@@ -1641,13 +1670,14 @@ describe("AgentStore", () => {
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");
const unchanged = await store.updateAgentState(agent.id, "active");
expect(unchanged.state).toBe("active");
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 store.updateAgentState(agent.id, "idle");
await expect(
store.updateAgentState(agent.id, "paused")
).rejects.toThrow("Invalid state transition: idle -> paused");
@@ -1661,16 +1691,16 @@ describe("AgentStore", () => {
store.on("agent:stateChanged", stateHandler);
store.on("agent:updated", updateHandler);
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "idle");
expect(stateHandler).toHaveBeenCalledOnce();
expect(stateHandler).toHaveBeenCalledWith(agent.id, "idle", "active");
expect(stateHandler).toHaveBeenCalledWith(agent.id, "active", "idle");
// 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");
expect(updatedAgent.state).toBe("idle");
expect(previousState).toBe("active");
});
it("throws for non-existent agent", async () => {

View File

@@ -559,7 +559,7 @@ export class AgentStore extends EventEmitter {
}
/**
* Create a new agent with "idle" state.
* Create a new agent with a default state based on ephemeral classification.
*
* For non-ephemeral agents, ensures `runtimeConfig.heartbeatIntervalMs` is
* persisted at creation time — previously it was only ever written when the
@@ -618,7 +618,10 @@ export class AgentStore extends EventEmitter {
id: agentId,
name: normalizedName,
role: input.role,
state: "idle",
// Non-ephemeral agents start active so they immediately participate in
// heartbeat scheduling; ephemeral/task-worker agents start idle and are
// activated by the engine when work is assigned.
state: ephemeral ? "idle" : "active",
createdAt: now,
updatedAt: now,
metadata,