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 089c139bad
commit 7cb9ab78fe
4 changed files with 78 additions and 33 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
Newly created non-ephemeral agents now start in `state: "active"` so they immediately participate in heartbeat scheduling without requiring a manual Start action. Ephemeral/task-worker agents still start in `state: "idle"` and are activated by the engine when work is assigned. Existing agents are unaffected; operators who want a paused-from-birth durable agent can call `fn_agent_stop` (or click Stop in the dashboard) right after creation.
Audit note: heartbeat scheduler state handling and dashboard create-response consumption were reviewed and required no downstream code changes.

View File

@@ -243,7 +243,7 @@ describe("AgentStore", () => {
expect(agent.id).toMatch(/^agent-/); expect(agent.id).toMatch(/^agent-/);
expect(agent.name).toBe("Test Agent"); // trimmed expect(agent.name).toBe("Test Agent"); // trimmed
expect(agent.role).toBe("executor"); expect(agent.role).toBe("executor");
expect(agent.state).toBe("idle"); expect(agent.state).toBe("active");
expect(agent.metadata).toEqual({}); expect(agent.metadata).toEqual({});
expect(agent.runtimeConfig).toMatchObject({ expect(agent.runtimeConfig).toMatchObject({
enabled: true, enabled: true,
@@ -253,6 +253,35 @@ describe("AgentStore", () => {
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN(); 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 () => { it("defaults heartbeat procedure path to canonical display-name directory", async () => {
const agent = await store.createAgent({ const agent = await store.createAgent({
name: "CEO", name: "CEO",
@@ -411,7 +440,7 @@ describe("AgentStore", () => {
expect(found!.id).toBe(created.id); expect(found!.id).toBe(created.id);
expect(found!.name).toBe("Lookup Agent"); expect(found!.name).toBe("Lookup Agent");
expect(found!.role).toBe("executor"); expect(found!.role).toBe("executor");
expect(found!.state).toBe("idle"); expect(found!.state).toBe("active");
}); });
it("returns null for a non-existent ID", async () => { it("returns null for a non-existent ID", async () => {
@@ -1252,14 +1281,14 @@ describe("AgentStore", () => {
}); });
it("filters by state", async () => { 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" }); 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).toHaveLength(1);
expect(idle[0].id).toBe(a1.id); expect(idle[0].id).toBe(a1.id);
@@ -1278,13 +1307,13 @@ describe("AgentStore", () => {
}); });
it("filters by both state and role", async () => { it("filters by both state and role", async () => {
const a1 = await store.createAgent({ name: "ActiveExec", role: "executor" }); await store.createAgent({ name: "ActiveExec", role: "executor" });
await store.recordHeartbeat(a1.id, "ok"); await store.createAgent({
await store.updateAgentState(a1.id, "active"); name: "IdleExec",
await store.createAgent({ name: "IdleExec", role: "executor" }); role: "executor",
const a3 = await store.createAgent({ name: "ActiveReview", role: "reviewer" }); metadata: { agentKind: "task-worker" },
await store.recordHeartbeat(a3.id, "ok"); });
await store.updateAgentState(a3.id, "active"); await store.createAgent({ name: "ActiveReview", role: "reviewer" });
const result = await store.listAgents({ state: "active", role: "executor" }); const result = await store.listAgents({ state: "active", role: "executor" });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
@@ -1345,8 +1374,8 @@ describe("AgentStore", () => {
}); });
it("includeEphemeral filter works with state filter", async () => { it("includeEphemeral filter works with state filter", async () => {
// Create a normal agent (return value not needed — just to ensure it exists in DB) // Create a normal agent
await store.createAgent({ name: "Normal Agent", role: "executor" }); const normal = await store.createAgent({ name: "Normal Agent", role: "executor" });
// Create a task-worker agent // Create a task-worker agent
const taskWorker = await store.createAgent({ const taskWorker = await store.createAgent({
@@ -1359,12 +1388,13 @@ describe("AgentStore", () => {
// Without includeEphemeral filter - only returns active non-ephemeral agents // Without includeEphemeral filter - only returns active non-ephemeral agents
const activeNonEphemeral = await store.listAgents({ state: "active" }); 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 // With includeEphemeral: true, returns all active agents
const activeAll = await store.listAgents({ state: "active", includeEphemeral: true }); const activeAll = await store.listAgents({ state: "active", includeEphemeral: true });
expect(activeAll).toHaveLength(1); expect(activeAll).toHaveLength(2);
expect(activeAll[0].id).toBe(taskWorker.id); expect(activeAll.map((agent) => agent.id).sort()).toEqual([normal.id, taskWorker.id].sort());
}); });
it("filters out agents marked with metadata.internal", async () => { it("filters out agents marked with metadata.internal", async () => {
@@ -1576,17 +1606,16 @@ describe("AgentStore", () => {
// ── updateAgentState ────────────────────────────────────────────── // ── updateAgentState ──────────────────────────────────────────────
describe("updateAgentState", () => { describe("updateAgentState", () => {
// Helper: create an agent and set lastHeartbeatAt so that // Helper: create an active agent and set lastHeartbeatAt for tests that
// idle→active transitions don't trigger the re-entrant // exercise heartbeat-aware state transitions.
// startHeartbeatRun path (see FN-711 for the deadlock bug).
async function createReadyAgent(s: AgentStore, name: string) { async function createReadyAgent(s: AgentStore, name: string) {
const agent = await s.createAgent({ name, role: "executor" }); const agent = await s.createAgent({ name, role: "executor" });
await s.recordHeartbeat(agent.id, "ok"); await s.recordHeartbeat(agent.id, "ok");
return agent; return agent;
} }
it("idle → active transition succeeds", async () => { it("active → active transition succeeds as no-op", async () => {
const agent = await createReadyAgent(store, "IdleToActive"); const agent = await createReadyAgent(store, "ActiveToActive");
const updated = await store.updateAgentState(agent.id, "active"); const updated = await store.updateAgentState(agent.id, "active");
expect(updated.state).toBe("active"); expect(updated.state).toBe("active");
}); });
@@ -1641,13 +1670,14 @@ describe("AgentStore", () => {
it("same-state transition returns agent unchanged (no-op)", async () => { it("same-state transition returns agent unchanged (no-op)", async () => {
const agent = await store.createAgent({ name: "SameState", role: "executor" }); const agent = await store.createAgent({ name: "SameState", role: "executor" });
const unchanged = await store.updateAgentState(agent.id, "idle"); const unchanged = await store.updateAgentState(agent.id, "active");
expect(unchanged.state).toBe("idle"); expect(unchanged.state).toBe("active");
expect(unchanged.updatedAt).toBe(agent.updatedAt); expect(unchanged.updatedAt).toBe(agent.updatedAt);
}); });
it("idle → paused throws with descriptive error message", async () => { it("idle → paused throws with descriptive error message", async () => {
const agent = await store.createAgent({ name: "BadTransition", role: "executor" }); const agent = await store.createAgent({ name: "BadTransition", role: "executor" });
await store.updateAgentState(agent.id, "idle");
await expect( await expect(
store.updateAgentState(agent.id, "paused") store.updateAgentState(agent.id, "paused")
).rejects.toThrow("Invalid state transition: idle -> paused"); ).rejects.toThrow("Invalid state transition: idle -> paused");
@@ -1661,16 +1691,16 @@ describe("AgentStore", () => {
store.on("agent:stateChanged", stateHandler); store.on("agent:stateChanged", stateHandler);
store.on("agent:updated", updateHandler); store.on("agent:updated", updateHandler);
await store.updateAgentState(agent.id, "active"); await store.updateAgentState(agent.id, "idle");
expect(stateHandler).toHaveBeenCalledOnce(); 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 // agent:updated is called with updated agent and previousState
expect(updateHandler).toHaveBeenCalled(); expect(updateHandler).toHaveBeenCalled();
const [updatedAgent, previousState] = updateHandler.mock.calls[0]; const [updatedAgent, previousState] = updateHandler.mock.calls[0];
expect(updatedAgent.state).toBe("active"); expect(updatedAgent.state).toBe("idle");
expect(previousState).toBe("idle"); expect(previousState).toBe("active");
}); });
it("throws for non-existent agent", async () => { 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 * For non-ephemeral agents, ensures `runtimeConfig.heartbeatIntervalMs` is
* persisted at creation time — previously it was only ever written when the * persisted at creation time — previously it was only ever written when the
@@ -618,7 +618,10 @@ export class AgentStore extends EventEmitter {
id: agentId, id: agentId,
name: normalizedName, name: normalizedName,
role: input.role, 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, createdAt: now,
updatedAt: now, updatedAt: now,
metadata, metadata,

View File

@@ -2113,6 +2113,11 @@ describe("Agent create/update routes", () => {
}); });
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => { it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.updateAgentState(agentId, "idle");
const res = await REQUEST( const res = await REQUEST(
buildAgentApp(), buildAgentApp(),
"POST", "POST",