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:
gsxdsm
2026-04-04 17:56:07 -07:00
parent 63fbeb8d37
commit d916628442
3 changed files with 110 additions and 17 deletions

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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 */