refactor(agents): remove terminated AgentState; collapse to paused/error

Drops "terminated" from AGENT_STATES. The agent lifecycle now runs through
idle | active | running | paused | error. paused (carrying a pauseReason)
absorbs every former terminated use case — manual stop, heartbeat run
termination, spawned-child cleanup. Run status (agentRuns.status) is
unchanged: "terminated" stays a valid run-status value.

AGENT_VALID_TRANSITIONS allows direct any→idle transitions so resetAgent
no longer needs the intermediate hop.

Stack-wide:
- core/agent-store: lastError clearing + resetAgent simplified.
- engine/agent-heartbeat, executor, in-process-runtime: terminated state
  writes → paused; halt-state listener fires on paused/error.
- dashboard: AgentsView/AgentListModal/AgentDetailView lose the Terminated
  badge/option/state-block; agent pickers no longer filter terminated;
  agentHealth drops the Terminated branch; routes/state cast widened to
  the new AgentState union.

Tests across core and engine updated to assert paused for AgentState and
left "terminated" intact for run-status assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-05 19:30:42 -07:00
parent 7d41271601
commit 8eb5c3dc60
21 changed files with 81 additions and 157 deletions

View File

@@ -1500,11 +1500,11 @@ describe("AgentStore", () => {
expect(updated.state).toBe("paused");
});
it("active → terminated transition succeeds", async () => {
it("active → paused transition succeeds", async () => {
const agent = await createReadyAgent(store, "ActiveToTerminated");
await store.updateAgentState(agent.id, "active");
const updated = await store.updateAgentState(agent.id, "terminated");
expect(updated.state).toBe("terminated");
const updated = await store.updateAgentState(agent.id, "paused");
expect(updated.state).toBe("paused");
});
it("paused → active transition succeeds", async () => {
@@ -1515,14 +1515,6 @@ describe("AgentStore", () => {
expect(updated.state).toBe("active");
});
it("paused → terminated transition succeeds", async () => {
const agent = await createReadyAgent(store, "PausedToTerminated");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "paused");
const updated = await store.updateAgentState(agent.id, "terminated");
expect(updated.state).toBe("terminated");
});
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");
@@ -1537,55 +1529,29 @@ describe("AgentStore", () => {
).rejects.toThrow("Invalid state transition: idle -> paused");
});
it("idle → terminated throws", async () => {
const agent = await store.createAgent({ name: "BadTerminate", role: "executor" });
await expect(
store.updateAgentState(agent.id, "terminated")
).rejects.toThrow("Invalid state transition: idle -> terminated");
});
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, "paused")
).rejects.toThrow("Invalid state transition: terminated -> paused");
});
it("terminated → active transition succeeds", async () => {
it("paused → active transition succeeds", async () => {
const agent = await createReadyAgent(store, "RestartActive");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "terminated");
await store.updateAgentState(agent.id, "paused");
const updated = await store.updateAgentState(agent.id, "active");
expect(updated.state).toBe("active");
});
it("terminated → running transition succeeds", async () => {
const agent = await createReadyAgent(store, "RestartRunning");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "terminated");
const updated = await store.updateAgentState(agent.id, "running");
expect(updated.state).toBe("running");
});
it("terminated → idle transition succeeds", async () => {
it("paused → idle transition succeeds", async () => {
const agent = await createReadyAgent(store, "RestartIdle");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "terminated");
await store.updateAgentState(agent.id, "paused");
const updated = await store.updateAgentState(agent.id, "idle");
expect(updated.state).toBe("idle");
});
it("transitioning from terminated clears lastError", async () => {
it("transitioning into active 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");
await store.updateAgentState(agent.id, "paused");
const restarted = await store.updateAgentState(agent.id, "active");
expect(restarted.state).toBe("active");
@@ -1894,7 +1860,7 @@ describe("AgentStore", () => {
pauseReason: "manual",
lastError: "something broke",
});
await s.updateAgentState(agent.id, "terminated");
await s.updateAgentState(agent.id, "paused");
return agent;
}

View File

@@ -1173,7 +1173,9 @@ export class AgentStore extends EventEmitter {
state: newState,
updatedAt: new Date().toISOString(),
// Clear lastError when transitioning away from terminated
...(currentState === "terminated" && newState !== "terminated" && { lastError: undefined }),
// Clear lastError when an agent re-enters an actionable state so
// a resumed agent does not carry stale "Error" badges.
...((newState === "active" || newState === "running") && { lastError: undefined }),
};
await this.writeAgent(updated);
@@ -1432,11 +1434,9 @@ export class AgentStore extends EventEmitter {
await this.endHeartbeatRun(activeRun.id, "terminated");
}
// Normalize to terminated first when idle is not directly reachable.
if (agent.state !== "idle" && agent.state !== "terminated") {
agent = await this.updateAgentState(agentId, "terminated");
}
// Any non-idle state can transition directly to idle in the new
// lifecycle (see AGENT_VALID_TRANSITIONS in types.ts), so no
// intermediate hop is required.
if (agent.state !== "idle") {
agent = await this.updateAgentState(agentId, "idle");
}

View File

@@ -3201,17 +3201,16 @@ export interface PlanningSession {
// ── Agent Types ────────────────────────────────────────────────────────────
/** Agent lifecycle states */
export const AGENT_STATES = ["idle", "active", "running", "paused", "error", "terminated"] as const;
export const AGENT_STATES = ["idle", "active", "running", "paused", "error"] as const;
export type AgentState = (typeof AGENT_STATES)[number];
/** Valid state transitions for agents */
export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
idle: ["active"],
active: ["running", "paused", "terminated"],
running: ["active", "paused", "error", "terminated"],
paused: ["active", "terminated"],
error: ["active", "terminated"],
terminated: ["idle", "active", "running"], // Can be restarted or reset
active: ["idle", "running", "paused", "error"],
running: ["idle", "active", "paused", "error"],
paused: ["idle", "active"],
error: ["idle", "active"],
};
/**