feat(FN-3580): restore canonical agent lifecycle and remove terminated agen

This merge restores the canonical agent lifecycle with termination scoped at the run level (FN-3580, 4 steps), adds sender-side wake recipient override for messages, and introduces test isolation CI enforcement with a stuck-requeue race fix. UI changes remove terminated-agent indicators from AgentDe

Fusion-Task-Id: FN-3580
This commit is contained in:
Fusion
2026-05-06 10:18:27 -07:00
committed by gsxdsm
parent 7f90308485
commit a31c4323a8
24 changed files with 216 additions and 286 deletions

View File

@@ -93,7 +93,6 @@ 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)" },
};
@@ -563,7 +562,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>
@@ -575,10 +574,6 @@ 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
@@ -591,7 +586,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>
@@ -603,24 +598,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} />
Reactivate
</button>
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
<Trash2 size={14} />
Delete
</button>
</>
)}
</div>
{/* Utility actions: refresh + close */}

View File

@@ -300,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>
@@ -406,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"
>
@@ -424,14 +423,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
>
<Play size={14} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleDelete(agent.id, agent.name)}
@@ -453,7 +444,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"
>
@@ -473,7 +464,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"
>
@@ -481,25 +472,6 @@ 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} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleDelete(agent.id, agent.name)}
title="Delete"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
);
@@ -626,7 +598,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"
>
@@ -644,14 +616,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
>
<Play size={14} /> Resume
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} /> Stop
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleDelete(agent.id, agent.name)}
@@ -673,7 +637,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"
>
@@ -693,7 +657,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"
>
@@ -701,25 +665,6 @@ 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

@@ -758,8 +758,7 @@
border: 1px solid var(--state-idle-border);
}
.agent-badge--error,
.agent-badge--terminated {
.agent-badge--error {
background: var(--state-error-bg);
color: var(--state-error-text);
border: 1px solid var(--state-error-border);
@@ -772,8 +771,7 @@
border-top-color: var(--state-active-border);
background: var(--state-active-bg);
}
.agent-board-card--error,
.agent-board-card--terminated { border-top-color: var(--state-error-border); }
.agent-board-card--error { border-top-color: var(--state-error-border); }
.agent-card--idle,
.agent-card--active,
@@ -786,8 +784,7 @@
.agent-card--running:hover {
background: color-mix(in srgb, var(--state-active-border) 20%, var(--card-hover));
}
.agent-card--error,
.agent-card--terminated { border-left-color: var(--state-error-border); }
.agent-card--error { border-left-color: var(--state-error-border); }
.spin {
animation: spin 1s linear infinite;

View File

@@ -60,8 +60,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";
@@ -81,8 +79,6 @@ function getStateCardClass(
return `${prefix}--paused`;
case "error":
return `${prefix}--error`;
case "terminated":
return `${prefix}--terminated`;
case "idle":
default:
return `${prefix}--idle`;
@@ -298,7 +294,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
// Filter agents for display. "All States" means all non-ephemeral agents,
// including disabled/terminated agents that still carry configuration.
// including paused/error agents and heartbeat-disabled agents that still carry configuration.
// When "Show system agents" is enabled, include ephemeral/internal agents.
const displayAgents = useMemo(() => {
return optimisticAgents.filter((agent) => showSystemAgents || !isEphemeralAgent(agent));
@@ -850,7 +846,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>

View File

@@ -879,7 +879,7 @@ describe("AgentDetailView", () => {
});
});
it("transitions running agent to terminated when Stop is clicked", async () => {
it("transitions running agent to paused when Stop is clicked", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
render(
@@ -893,7 +893,7 @@ describe("AgentDetailView", () => {
await userEvent.click(await screen.findByText("Stop"));
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "terminated", undefined);
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused", undefined);
});
});
@@ -914,7 +914,7 @@ describe("AgentDetailView", () => {
});
});
it("transitions error agent to terminated when Stop is clicked", async () => {
it("transitions error agent to paused when Stop is clicked", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" }));
render(
@@ -928,7 +928,7 @@ describe("AgentDetailView", () => {
await userEvent.click(await screen.findByText("Stop"));
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "terminated", undefined);
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused", undefined);
});
});

View File

@@ -595,7 +595,7 @@ describe("AgentListModal", () => {
fireEvent.click(stopButton);
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "terminated", undefined);
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined);
});
});

View File

@@ -257,12 +257,12 @@ describe("agent modal mobile CSS structure", () => {
expect(within(controls).getByRole("button", { name: "Stop" })).toBeInTheDocument();
});
it("shows terminated controls for terminated agents", async () => {
it("shows paused controls for paused agents", async () => {
mockFetchAgent.mockResolvedValueOnce({
id: "agent-terminated",
name: "Terminated Agent",
id: "agent-paused",
name: "Paused Agent",
role: "executor",
state: "terminated",
state: "paused",
taskId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -273,7 +273,7 @@ describe("agent modal mobile CSS structure", () => {
completedRuns: [],
} as any);
render(<AgentDetailView agentId="agent-terminated" onClose={vi.fn()} addToast={vi.fn()} />);
render(<AgentDetailView agentId="agent-paused" onClose={vi.fn()} addToast={vi.fn()} />);
const controls = await waitFor(() => {
const node = document.querySelector(".agent-detail-controls");
@@ -281,7 +281,7 @@ describe("agent modal mobile CSS structure", () => {
return node as HTMLElement;
});
expect(within(controls).getByRole("button", { name: "Reactivate" })).toBeInTheDocument();
expect(within(controls).getByRole("button", { name: "Resume" })).toBeInTheDocument();
expect(within(controls).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
});

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", "terminated"]);
expect(optionValues).toEqual(["all", "idle", "active", "running", "paused", "error"]);
});
});