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:
Fusion
2026-05-06 03:01:23 -07:00
committed by gsxdsm
parent 348f343b00
commit 94a5dca53c
17 changed files with 304 additions and 35 deletions

View File

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

View File

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

View File

@@ -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"],
};
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

@@ -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"]);
});
});

View File

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

View File

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

View File

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

View File

@@ -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");

View File

@@ -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" });
});

View File

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