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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user