feat(FN-3535): restore terminated agent state and align lifecycle controls
This merge completes the agent terminated state alignment (FN-3535), adding "running → terminated" transition support with consistent styling and lifecycle controls across the heartbeat engine, agent store, and dashboard UI, plus plugin author documentation improvements (FN-3537) and plugin loader t Fusion-Task-Id: FN-3535
This commit is contained in:
@@ -1525,6 +1525,69 @@ describe("AgentStore", () => {
|
||||
expect(updated.state).toBe("active");
|
||||
});
|
||||
|
||||
it("active → terminated 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");
|
||||
});
|
||||
|
||||
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("error → terminated transition succeeds", async () => {
|
||||
const agent = await createReadyAgent(store, "ErrorToTerminated");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
await store.updateAgentState(agent.id, "error");
|
||||
const updated = await store.updateAgentState(agent.id, "terminated");
|
||||
expect(updated.state).toBe("terminated");
|
||||
});
|
||||
|
||||
it("running → terminated transition succeeds", async () => {
|
||||
const agent = await createReadyAgent(store, "RunningToTerminated");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
await store.updateAgentState(agent.id, "running");
|
||||
const updated = await store.updateAgentState(agent.id, "terminated");
|
||||
expect(updated.state).toBe("terminated");
|
||||
});
|
||||
|
||||
it("terminated → idle|active|running transitions succeed", async () => {
|
||||
const idleAgent = await createReadyAgent(store, "TerminatedToIdle");
|
||||
await store.updateAgentState(idleAgent.id, "active");
|
||||
await store.updateAgentState(idleAgent.id, "terminated");
|
||||
expect((await store.updateAgentState(idleAgent.id, "idle")).state).toBe("idle");
|
||||
|
||||
const activeAgent = await createReadyAgent(store, "TerminatedToActive");
|
||||
await store.updateAgentState(activeAgent.id, "active");
|
||||
await store.updateAgentState(activeAgent.id, "terminated");
|
||||
expect((await store.updateAgentState(activeAgent.id, "active")).state).toBe("active");
|
||||
|
||||
const runningAgent = await createReadyAgent(store, "TerminatedToRunning");
|
||||
await store.updateAgentState(runningAgent.id, "active");
|
||||
await store.updateAgentState(runningAgent.id, "terminated");
|
||||
expect((await store.updateAgentState(runningAgent.id, "running")).state).toBe("running");
|
||||
});
|
||||
|
||||
it("clears lastError when leaving terminated for actionable states", async () => {
|
||||
const agent = await createReadyAgent(store, "TerminatedClearsError");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
await store.updateAgentState(agent.id, "terminated");
|
||||
await store.updateAgent(agent.id, { lastError: "old error" });
|
||||
|
||||
const toActive = await store.updateAgentState(agent.id, "active");
|
||||
expect(toActive.lastError).toBeUndefined();
|
||||
|
||||
await store.updateAgentState(agent.id, "terminated");
|
||||
await store.updateAgent(agent.id, { lastError: "old error again" });
|
||||
const toRunning = await store.updateAgentState(agent.id, "running");
|
||||
expect(toRunning.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -1842,6 +1905,7 @@ describe("AgentStore", () => {
|
||||
lastError: "something broke",
|
||||
});
|
||||
await s.updateAgentState(agent.id, "paused");
|
||||
await s.updateAgentState(agent.id, "terminated");
|
||||
return agent;
|
||||
}
|
||||
|
||||
|
||||
@@ -1172,10 +1172,13 @@ export class AgentStore extends EventEmitter {
|
||||
...agent,
|
||||
state: newState,
|
||||
updatedAt: new Date().toISOString(),
|
||||
// Clear lastError when transitioning away from terminated
|
||||
// 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 }),
|
||||
// Clear lastError when leaving terminated for an actionable state so
|
||||
// resumed agents do not carry stale error badges.
|
||||
...(
|
||||
currentState === "terminated" &&
|
||||
(newState === "idle" || newState === "active" || newState === "running") &&
|
||||
{ lastError: undefined }
|
||||
),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
|
||||
@@ -3232,16 +3232,17 @@ export interface PlanningSession {
|
||||
// ── Agent Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Agent lifecycle states */
|
||||
export const AGENT_STATES = ["idle", "active", "running", "paused", "error"] as const;
|
||||
export const AGENT_STATES = ["idle", "active", "running", "paused", "error", "terminated"] 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: ["idle", "running", "paused", "error"],
|
||||
running: ["idle", "active", "paused", "error"],
|
||||
paused: ["idle", "active"],
|
||||
error: ["idle", "active"],
|
||||
active: ["idle", "running", "paused", "error", "terminated"],
|
||||
running: ["idle", "active", "paused", "error", "terminated"],
|
||||
paused: ["idle", "active", "terminated"],
|
||||
error: ["idle", "active", "terminated"],
|
||||
terminated: ["idle", "active", "running"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,6 +93,7 @@ const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: strin
|
||||
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
terminated: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
@@ -557,10 +558,16 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
</>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<button className="btn btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Pause">
|
||||
<Pause size={14} />
|
||||
<span className="agent-detail-control-label">Pause</span>
|
||||
</button>
|
||||
<>
|
||||
<button className="btn btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Pause">
|
||||
<Pause size={14} />
|
||||
<span className="agent-detail-control-label">Pause</span>
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "paused" && (
|
||||
<>
|
||||
@@ -568,6 +575,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Play size={14} />
|
||||
<span className="agent-detail-control-label">Resume</span>
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
@@ -580,7 +591,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Pause size={14} />
|
||||
<span className="agent-detail-control-label">Pause</span>
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
@@ -592,12 +603,24 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Play size={14} />
|
||||
Retry
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<Play size={14} />
|
||||
Reactivate
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Utility actions: refresh + close */}
|
||||
|
||||
@@ -300,6 +300,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -405,7 +406,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -425,7 +426,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -452,7 +453,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -472,7 +473,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -480,7 +481,25 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* terminated state removed; delete is shown alongside idle/paused via existing handlers */}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Reactivate"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -607,7 +626,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -627,7 +646,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -654,7 +673,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -674,7 +693,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -682,6 +701,25 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Reactivate"
|
||||
>
|
||||
<Play size={14} /> Reactivate
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,6 +61,8 @@ function getStateBadgeClass(state: AgentState): string {
|
||||
return "agent-badge--paused";
|
||||
case "error":
|
||||
return "agent-badge--error";
|
||||
case "terminated":
|
||||
return "agent-badge--terminated";
|
||||
case "idle":
|
||||
default:
|
||||
return "agent-badge--idle";
|
||||
@@ -80,6 +82,8 @@ function getStateCardClass(
|
||||
return `${prefix}--paused`;
|
||||
case "error":
|
||||
return `${prefix}--error`;
|
||||
case "terminated":
|
||||
return `${prefix}--terminated`;
|
||||
case "idle":
|
||||
default:
|
||||
return `${prefix}--idle`;
|
||||
@@ -852,6 +856,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -857,6 +857,24 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("transitions running agent to terminated when Stop is clicked", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByText("Stop"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "terminated", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Retry and Stop buttons for error agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" }));
|
||||
|
||||
@@ -874,6 +892,24 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("transitions error agent to terminated when Stop is clicked", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByText("Stop"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "terminated", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("groups lifecycle and utility controls under a shared header action cluster", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
|
||||
@@ -595,7 +595,7 @@ describe("AgentListModal", () => {
|
||||
fireEvent.click(stopButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined);
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "terminated", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -229,6 +229,61 @@ describe("agent modal mobile CSS structure", () => {
|
||||
expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.agent-detail-modal[\s\S]*?height:\s*100dvh/);
|
||||
expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.agent-detail-modal[\s\S]*?border-radius:\s*0/);
|
||||
});
|
||||
|
||||
it("shows stop control for active agents", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce({
|
||||
id: "agent-active",
|
||||
name: "Active Agent",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
runtimeConfig: {},
|
||||
heartbeatHistory: [],
|
||||
activeRun: null,
|
||||
completedRuns: [],
|
||||
} as any);
|
||||
|
||||
render(<AgentDetailView agentId="agent-active" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
const controls = await waitFor(() => {
|
||||
const node = document.querySelector(".agent-detail-controls");
|
||||
expect(node).toBeTruthy();
|
||||
return node as HTMLElement;
|
||||
});
|
||||
|
||||
expect(within(controls).getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows terminated controls for terminated agents", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce({
|
||||
id: "agent-terminated",
|
||||
name: "Terminated Agent",
|
||||
role: "executor",
|
||||
state: "terminated",
|
||||
taskId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
runtimeConfig: {},
|
||||
heartbeatHistory: [],
|
||||
activeRun: null,
|
||||
completedRuns: [],
|
||||
} as any);
|
||||
|
||||
render(<AgentDetailView agentId="agent-terminated" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
const controls = await waitFor(() => {
|
||||
const node = document.querySelector(".agent-detail-controls");
|
||||
expect(node).toBeTruthy();
|
||||
return node as HTMLElement;
|
||||
});
|
||||
|
||||
expect(within(controls).getByRole("button", { name: "Reactivate" })).toBeInTheDocument();
|
||||
expect(within(controls).getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentGenerationModal", () => {
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("AgentsView mobile adaptations", () => {
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
const optionValues = Array.from(select.options).map((option) => option.value);
|
||||
expect(optionValues).toEqual(["all", "idle", "active", "running", "paused", "error"]);
|
||||
expect(optionValues).toEqual(["all", "idle", "active", "running", "paused", "error", "terminated"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,16 @@ describe("getAgentHealthStatus", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("terminated state", () => {
|
||||
it('returns "Terminated" for terminated agents', () => {
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Terminated");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-paused-text)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error state", () => {
|
||||
it('returns "Error" for error agents without lastError', () => {
|
||||
const agent = makeAgent({ state: "error" });
|
||||
@@ -331,6 +341,12 @@ describe("getAgentHealthStatus", () => {
|
||||
expectedLabel: "Running",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "terminated",
|
||||
agent: makeAgent({ state: "terminated" }),
|
||||
expectedLabel: "Terminated",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "error without lastError",
|
||||
agent: makeAgent({ state: "error" }),
|
||||
@@ -429,6 +445,7 @@ describe("getAgentHealthStatus", () => {
|
||||
{ agent: makeAgent({ state: "error" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" },
|
||||
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "terminated" }), expectedIconType: "Pause" },
|
||||
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Pause" },
|
||||
{
|
||||
@@ -524,6 +541,7 @@ describe("AgentHealthStatus reason field", () => {
|
||||
{ name: "error", agent: makeAgent({ state: "error" }) },
|
||||
{ name: "paused", agent: makeAgent({ state: "paused" }) },
|
||||
{ name: "running", agent: makeAgent({ state: "running" }) },
|
||||
{ name: "terminated", agent: makeAgent({ state: "terminated" }) },
|
||||
{ name: "idle", agent: makeAgent({ state: "idle" }) },
|
||||
{
|
||||
name: "healthy",
|
||||
|
||||
@@ -94,7 +94,7 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
* state, runtimeConfig, and last heartbeat timestamp.
|
||||
*
|
||||
* Health labels (in priority order):
|
||||
* (agent.state === "terminated" was removed in the lifecycle refactor)
|
||||
* - "Terminated" — agent.state === "terminated"
|
||||
* - "Error" — agent.state === "error" (uses lastError if available)
|
||||
* - "Paused" — agent.state === "paused" (uses pauseReason if available)
|
||||
* - "Running" — agent.state === "running", or a detected task worker in "active"
|
||||
@@ -113,6 +113,15 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
const isHeartbeatEnabled = isTaskWorker || runtimeConfig?.enabled !== false;
|
||||
|
||||
// Terminal states - these always take precedence
|
||||
if (state === "terminated") {
|
||||
return {
|
||||
label: "Terminated",
|
||||
icon: <Pause size={14} />,
|
||||
color: "var(--state-paused-text)",
|
||||
stateDerived: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return {
|
||||
label: lastError ?? "Error",
|
||||
|
||||
@@ -297,6 +297,19 @@ describe("executeHeartbeat", () => {
|
||||
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
|
||||
});
|
||||
|
||||
it("completes with invalid_state when agent state is terminated", async () => {
|
||||
const store = createStoreWithAgentForExec({ state: "terminated" });
|
||||
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(mockedCreateFnAgent).not.toHaveBeenCalled();
|
||||
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
|
||||
});
|
||||
|
||||
it("completes as failed when agent not found in store", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
(store.getAgent as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
@@ -734,12 +734,12 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "paused" as const,
|
||||
state: "terminated" 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: "paused", metadata: {} } as import("@fusion/core").Agent);
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "terminated", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should be cleared for terminated agents
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
|
||||
@@ -572,7 +572,7 @@ describe("Budget Governance", () => {
|
||||
});
|
||||
|
||||
expect(store.getBudgetStatus).not.toHaveBeenCalled();
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
|
||||
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
|
||||
});
|
||||
|
||||
|
||||
@@ -891,7 +891,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, "paused");
|
||||
await this.store.updateAgentState(agentId, "terminated");
|
||||
} else {
|
||||
// Completed successfully - back to active
|
||||
await this.store.updateAgentState(agentId, "active");
|
||||
@@ -2518,9 +2518,9 @@ const OVERDUE_FIRE_JITTER_MS = 5_000;
|
||||
* - "idle" — Agent is between tasks, waiting for work (FN-2289 fix)
|
||||
*
|
||||
* States where timers should be cleared:
|
||||
* - "paused" — Agent halted (manual stop, run terminated, child cleanup)
|
||||
* - "error" — Agent encountered an error
|
||||
* - "paused" — Agent is paused by budget exhaustion or manual action
|
||||
* - "error" — Agent encountered an error
|
||||
* - "terminated" — Agent was explicitly stopped/terminated
|
||||
*/
|
||||
function isTickableState(state: Agent["state"]): boolean {
|
||||
return state === "active" || state === "running" || state === "idle";
|
||||
|
||||
Reference in New Issue
Block a user