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:
7
.changeset/FN-3773-agents-default-active.md
Normal file
7
.changeset/FN-3773-agents-default-active.md
Normal 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.
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user