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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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