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:
@@ -10750,7 +10750,7 @@ describe("Agent Spawning - Child Termination", () => {
|
||||
expect(internals.childSessions.has(childId)).toBe(false);
|
||||
// Note: spawnedAgents cleanup is done by terminateAllChildren, not terminateChildAgent
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith(childId, "terminated");
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith(childId, "paused");
|
||||
});
|
||||
|
||||
it("terminateChildAgent handles missing session gracefully", async () => {
|
||||
@@ -10767,7 +10767,7 @@ describe("Agent Spawning - Child Termination", () => {
|
||||
|
||||
// Should still decrement counter and attempt state update
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("nonexistent-agent", "terminated");
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("nonexistent-agent", "paused");
|
||||
});
|
||||
|
||||
it("terminateAllChildren handles no children gracefully", async () => {
|
||||
@@ -10802,8 +10802,8 @@ describe("Agent Spawning - Child Termination", () => {
|
||||
expect(child2.dispose).toHaveBeenCalled();
|
||||
expect(internals.spawnedAgents.has("FN-PARENT")).toBe(false);
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c1", "terminated");
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c2", "terminated");
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c1", "paused");
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c2", "paused");
|
||||
});
|
||||
|
||||
it("terminateChildAgent handles AgentStore errors gracefully", async () => {
|
||||
|
||||
@@ -291,15 +291,15 @@ describe("executeHeartbeat", () => {
|
||||
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("completes with invalid_state when agent state is terminated", async () => {
|
||||
const store = createStoreWithAgentForExec({ state: "terminated" });
|
||||
it("completes with invalid_state when agent state is paused", async () => {
|
||||
const store = createStoreWithAgentForExec({ state: "paused" });
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.resultJson).toEqual({ reason: "invalid_state", state: "terminated" });
|
||||
expect(result.resultJson).toEqual({ reason: "invalid_state", state: "paused" });
|
||||
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
||||
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
|
||||
});
|
||||
|
||||
@@ -747,12 +747,12 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "terminated" as const,
|
||||
state: "paused" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "terminated", metadata: {} } as import("@fusion/core").Agent);
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "paused", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should be cleared for terminated agents
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
|
||||
@@ -573,7 +573,7 @@ describe("Budget Governance", () => {
|
||||
});
|
||||
|
||||
expect(store.getBudgetStatus).not.toHaveBeenCalled();
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
|
||||
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
|
||||
});
|
||||
|
||||
|
||||
@@ -839,7 +839,7 @@ export class HeartbeatMonitor {
|
||||
await this.store.updateAgentState(agentId, "error");
|
||||
await this.store.updateAgent(agentId, { lastError: completionResult.stderrExcerpt ?? "Run failed" });
|
||||
} else if (completionResult.status === "terminated") {
|
||||
await this.store.updateAgentState(agentId, "terminated");
|
||||
await this.store.updateAgentState(agentId, "paused");
|
||||
} else {
|
||||
// Completed successfully - back to active
|
||||
await this.store.updateAgentState(agentId, "active");
|
||||
@@ -2136,8 +2136,6 @@ export class HeartbeatMonitor {
|
||||
let health = "healthy";
|
||||
if (report.state === "paused") {
|
||||
health = report.pauseReason ? `paused (${report.pauseReason})` : "paused";
|
||||
} else if (report.state === "terminated") {
|
||||
health = "terminated";
|
||||
} else if (report.state === "error") {
|
||||
health = "**stuck**";
|
||||
} else if (report.state === "running") {
|
||||
@@ -2154,7 +2152,6 @@ export class HeartbeatMonitor {
|
||||
|
||||
const hasStuck = rows.some((row) => row.includes("**stuck**"));
|
||||
const hasStale = rows.some((row) => row.includes("**stale**"));
|
||||
const hasTerminated = rows.some((row) => row.includes("terminated"));
|
||||
|
||||
const actionLines = ["### Actions for Unresponsive Reports"];
|
||||
if (hasStuck) {
|
||||
@@ -2163,9 +2160,6 @@ export class HeartbeatMonitor {
|
||||
if (hasStale) {
|
||||
actionLines.push("- For **stale** reports: the agent may have lost its heartbeat trigger — create a follow-up task to investigate.");
|
||||
}
|
||||
if (hasTerminated) {
|
||||
actionLines.push("- For **terminated** reports: if they had active work, reassign their tasks or spawn replacement agents.");
|
||||
}
|
||||
|
||||
return [
|
||||
"## Reports Health Check",
|
||||
@@ -2482,7 +2476,7 @@ const OVERDUE_FIRE_JITTER_MS = 5_000;
|
||||
* - "idle" — Agent is between tasks, waiting for work (FN-2289 fix)
|
||||
*
|
||||
* States where timers should be cleared:
|
||||
* - "terminated" — Agent has completed/failed
|
||||
* - "paused" — Agent halted (manual stop, run terminated, child cleanup)
|
||||
* - "error" — Agent encountered an error
|
||||
* - "paused" — Agent is paused by budget exhaustion or manual action
|
||||
*/
|
||||
|
||||
@@ -6640,7 +6640,7 @@ and show an appropriate message to the user.\`
|
||||
}
|
||||
|
||||
try {
|
||||
await this.options.agentStore?.updateAgentState(childId, "terminated");
|
||||
await this.options.agentStore?.updateAgentState(childId, "paused");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
executorLog.warn(`Failed to update spawned child ${childId} state to 'terminated' during cleanup: ${msg}`);
|
||||
|
||||
Reference in New Issue
Block a user