feat(FN-942): add resetAgent method and allow terminated→idle transition
- Add terminated→idle as a valid state transition in AGENT_VALID_TRANSITIONS - Add resetAgent() method that clears lastError, taskId, and ends heartbeat runs - Clear lastError automatically when transitioning away from terminated state - Remove inline heartbeat lifecycle from updateAgentState (moved to resetAgent) - Add comprehensive tests for state transitions, error clearing, and resetAgent behavior
This commit is contained in:
@@ -405,14 +405,11 @@ describe("AgentStore", () => {
|
||||
).rejects.toThrow("Invalid state transition: idle -> terminated");
|
||||
});
|
||||
|
||||
it("transition from terminated to invalid states throws", async () => {
|
||||
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, "idle")
|
||||
).rejects.toThrow("Invalid state transition: terminated -> idle");
|
||||
await expect(
|
||||
store.updateAgentState(agent.id, "paused")
|
||||
).rejects.toThrow("Invalid state transition: terminated -> paused");
|
||||
@@ -436,6 +433,26 @@ describe("AgentStore", () => {
|
||||
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");
|
||||
|
||||
@@ -501,6 +518,61 @@ describe("AgentStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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, { 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("clears lastError", async () => {
|
||||
const agent = await createTerminatedAgent(store, "ResetClearsError");
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
|
||||
expect(reset.lastError).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", () => {
|
||||
|
||||
@@ -255,24 +255,14 @@ export class AgentStore extends EventEmitter {
|
||||
...agent,
|
||||
state: newState,
|
||||
updatedAt: new Date().toISOString(),
|
||||
// Clear lastError when transitioning away from terminated
|
||||
...(currentState === "terminated" && newState !== "terminated" && { lastError: undefined }),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
this.emit("agent:stateChanged", agentId, currentState, newState);
|
||||
this.emit("agent:updated", updated, currentState);
|
||||
|
||||
// Handle heartbeat run lifecycle
|
||||
if (newState === "active" && !agent.lastHeartbeatAt) {
|
||||
// Starting first activity - start a heartbeat run
|
||||
await this.startHeartbeatRun(agentId);
|
||||
} else if (newState === "terminated") {
|
||||
// End the active run if any
|
||||
const activeRun = await this.getActiveHeartbeatRun(agentId);
|
||||
if (activeRun) {
|
||||
await this.endHeartbeatRun(activeRun.id, "terminated");
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -303,6 +293,37 @@ export class AgentStore extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset an agent from any state back to "idle".
|
||||
* Clears lastError, taskId, and ends any active heartbeat run.
|
||||
* Uses updateAgentState internally for proper validation and event emission.
|
||||
* @param agentId - The agent ID
|
||||
* @returns The reset agent
|
||||
* @throws Error if agent not found or transition is invalid
|
||||
*/
|
||||
async resetAgent(agentId: string): Promise<Agent> {
|
||||
// End any active heartbeat run before transitioning
|
||||
const activeRun = await this.getActiveHeartbeatRun(agentId);
|
||||
if (activeRun) {
|
||||
await this.endHeartbeatRun(activeRun.id, "terminated");
|
||||
}
|
||||
|
||||
// Transition state via updateAgentState (validates transition, emits events)
|
||||
const agent = await this.updateAgentState(agentId, "idle");
|
||||
|
||||
// Clear taskId and lastError on top of the state transition
|
||||
const reset: Agent = {
|
||||
...agent,
|
||||
taskId: undefined,
|
||||
lastError: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.writeAgent(reset);
|
||||
|
||||
return reset;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agents, optionally filtered by state.
|
||||
* @param filter - Optional filter criteria
|
||||
|
||||
@@ -1292,7 +1292,7 @@ export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
|
||||
running: ["active", "paused", "error", "terminated"],
|
||||
paused: ["active", "terminated"],
|
||||
error: ["active", "terminated"],
|
||||
terminated: ["active", "running"], // Can be restarted
|
||||
terminated: ["idle", "active", "running"], // Can be restarted or reset
|
||||
};
|
||||
|
||||
/** Single heartbeat event recorded for an agent */
|
||||
|
||||
Reference in New Issue
Block a user