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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,7 +95,6 @@ const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: strin
|
||||
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)" },
|
||||
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
||||
@@ -583,7 +582,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("terminated")} disabled={isTransitioning}>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
@@ -595,24 +594,12 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Play size={14} />
|
||||
Retry
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} 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} />
|
||||
Start
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Utility actions: refresh + close */}
|
||||
@@ -3185,7 +3172,7 @@ function ConfigTab({
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
const isDeletableState = agent.state === "idle" || agent.state === "terminated" || agent.state === "paused";
|
||||
const isDeletableState = agent.state === "idle" || agent.state === "paused";
|
||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const previousAgentRuntimeSyncRef = useRef<{ id: string; updatedAt: string } | null>(null);
|
||||
const lastSavedSignatureRef = useRef<string | null>(null);
|
||||
|
||||
@@ -76,13 +76,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
});
|
||||
}, [agents, optimisticStateOverrides]);
|
||||
|
||||
// Filter agents for display: hide terminated agents in default "All States" view
|
||||
// but show them when the user explicitly filters to "terminated"
|
||||
// Display ordering: paused agents accumulate over time and would crowd
|
||||
// active agents at the top; sort them to the bottom in the default
|
||||
// "All States" view, breaking ties by `updatedAt` desc.
|
||||
const displayAgents = useMemo(() => {
|
||||
if (filterState === "all") {
|
||||
return optimisticAgents.filter((a) => a.state !== "terminated");
|
||||
}
|
||||
return optimisticAgents;
|
||||
if (filterState !== "all") return optimisticAgents;
|
||||
return [...optimisticAgents].sort((a, b) => {
|
||||
const aPaused = a.state === "paused" ? 1 : 0;
|
||||
const bPaused = b.state === "paused" ? 1 : 0;
|
||||
if (aPaused !== bPaused) return aPaused - bPaused;
|
||||
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
||||
});
|
||||
}, [optimisticAgents, filterState]);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
@@ -296,7 +300,6 @@ 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>
|
||||
|
||||
@@ -402,7 +405,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -422,7 +425,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -449,7 +452,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -469,7 +472,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -477,15 +480,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{/* terminated state removed; delete is shown alongside idle/paused via existing handlers */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -612,7 +607,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -632,7 +627,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -659,7 +654,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -679,7 +674,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -687,15 +682,15 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
{agent.state === "paused" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm btn-task-create"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Start"
|
||||
title="Resume"
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
<Play size={14} /> Resume
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
|
||||
@@ -61,8 +61,6 @@ 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";
|
||||
@@ -82,8 +80,6 @@ function getStateCardClass(
|
||||
return `${prefix}--paused`;
|
||||
case "error":
|
||||
return `${prefix}--error`;
|
||||
case "terminated":
|
||||
return `${prefix}--terminated`;
|
||||
case "idle":
|
||||
default:
|
||||
return `${prefix}--idle`;
|
||||
@@ -844,7 +840,6 @@ 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>
|
||||
|
||||
@@ -1441,16 +1436,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<Play size={14} /> Retry
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Start"
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm agent-card-details-btn"
|
||||
onClick={() => openAgentDetail(agent.id)}
|
||||
@@ -1459,7 +1444,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
{(agent.state === "idle" || agent.state === "terminated" || agent.state === "paused") && (
|
||||
{(agent.state === "idle" || agent.state === "paused") && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
|
||||
@@ -926,7 +926,7 @@ export function InlineCreateCard({
|
||||
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="dep-dropdown-search-header">Select agent</div>
|
||||
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
|
||||
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
|
||||
{!agentsLoading && agents.filter((a) => true).map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
|
||||
@@ -941,7 +941,7 @@ export function InlineCreateCard({
|
||||
<span className="dep-dropdown-title">{a.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
|
||||
{!agentsLoading && agents.filter((a) => true).length === 0 && (
|
||||
<div className="dep-dropdown-empty">No agents available</div>
|
||||
)}
|
||||
{selectedAgentId && (
|
||||
|
||||
@@ -386,7 +386,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="dep-dropdown-search-header">Select agent</div>
|
||||
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
|
||||
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
|
||||
{!agentsLoading && agents.filter((a) => true).map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
|
||||
@@ -402,7 +402,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
<span className="dep-dropdown-title">{a.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
|
||||
{!agentsLoading && agents.filter((a) => true).length === 0 && (
|
||||
<div className="dep-dropdown-empty">No agents available</div>
|
||||
)}
|
||||
{selectedAgentId && (
|
||||
|
||||
@@ -1720,7 +1720,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
>
|
||||
<div className="dep-dropdown-search-header">Select agent</div>
|
||||
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
|
||||
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
|
||||
{!agentsLoading && agents.filter((a) => true).map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
|
||||
@@ -1736,7 +1736,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
<span className="dep-dropdown-title">{a.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
|
||||
{!agentsLoading && agents.filter((a) => true).length === 0 && (
|
||||
<div className="dep-dropdown-empty">No agents available</div>
|
||||
)}
|
||||
{selectedAgentId && (
|
||||
|
||||
@@ -2136,7 +2136,7 @@ export function TaskDetailContent({
|
||||
{showAgentPicker && (
|
||||
<div className="agent-picker-dropdown">
|
||||
{agentsLoading && <div className="agent-picker-loading">Loading agents...</div>}
|
||||
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
|
||||
{!agentsLoading && agents.filter((a) => true).map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
className={`agent-picker-item${task.assignedAgentId === a.id ? " selected" : ""}`}
|
||||
@@ -2147,7 +2147,7 @@ export function TaskDetailContent({
|
||||
<span className="agent-picker-role">{a.role}</span>
|
||||
</button>
|
||||
))}
|
||||
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
|
||||
{!agentsLoading && agents.filter((a) => true).length === 0 && (
|
||||
<div className="agent-picker-empty">No agents available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -673,9 +673,9 @@ export function TodoView({ projectId, addToast, onPlanningMode, onTaskCreated }:
|
||||
>
|
||||
{agentsLoading ? (
|
||||
<div className="todo-agent-picker-loading">Loading agents...</div>
|
||||
) : agents.filter((agent) => agent.state !== "terminated").length > 0 ? (
|
||||
) : agents.filter((agent) => true).length > 0 ? (
|
||||
agents
|
||||
.filter((agent) => agent.state !== "terminated")
|
||||
.filter((agent) => true)
|
||||
.map((agent) => (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JSX } from "react";
|
||||
import { Bot, Heart, Activity, Pause, Square } from "lucide-react";
|
||||
import { Bot, Heart, Activity, Pause } from "lucide-react";
|
||||
import type { Agent } from "../api";
|
||||
import { resolveHeartbeatIntervalMs } from "./heartbeatIntervals";
|
||||
|
||||
@@ -94,7 +94,7 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
* state, runtimeConfig, and last heartbeat timestamp.
|
||||
*
|
||||
* Health labels (in priority order):
|
||||
* - "Terminated" — agent.state === "terminated"
|
||||
* (agent.state === "terminated" was removed in the lifecycle refactor)
|
||||
* - "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,15 +113,6 @@ 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: <Square size={14} />,
|
||||
color: "var(--state-error-text)",
|
||||
stateDerived: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return {
|
||||
label: lastError ?? "Error",
|
||||
|
||||
@@ -63,7 +63,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "paused" | "terminated"; role?: AgentCapability; includeEphemeral?: boolean });
|
||||
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "running" | "paused" | "error"; role?: AgentCapability; includeEphemeral?: boolean });
|
||||
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
||||
res.json(sanitizedAgents);
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -481,7 +481,7 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
|
||||
});
|
||||
}
|
||||
|
||||
if (nextState === "active" || nextState === "terminated") {
|
||||
if (nextState === "active") {
|
||||
const pausedTasks = await scopedStore.getTasksByAssignedAgent(agentId, {
|
||||
pausedOnly: true,
|
||||
excludeArchived: true,
|
||||
|
||||
@@ -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