feat(FN-1298): add stop-agent-run flow across engine and dashboard

- Add heartbeat stopRun support in the engine and wire it into the dashboard server lifecycle
- Add an agent run stop API route and client helper with route-level test coverage
- Add stop controls in AgentDetailView and AgentRunHistory with updated UI tests
- Add a changeset for @gsxdsm/fusion documenting the new stop run feature
This commit is contained in:
gsxdsm
2026-04-08 16:17:34 -07:00
parent 02148d79b6
commit f60c06be79
9 changed files with 373 additions and 15 deletions

View File

@@ -8,10 +8,12 @@ import * as path from "path";
const agentsViewPath = path.join(__dirname, "../components/AgentsView.tsx");
const agentDetailViewPath = path.join(__dirname, "../components/AgentDetailView.tsx");
const agentRunHistoryPath = path.join(__dirname, "../components/AgentRunHistory.tsx");
const apiPath = path.join(__dirname, "../api.ts");
const agentsViewContent = fs.readFileSync(agentsViewPath, "utf-8");
const agentDetailViewContent = fs.readFileSync(agentDetailViewPath, "utf-8");
const agentRunHistoryContent = fs.readFileSync(agentRunHistoryPath, "utf-8");
const apiContent = fs.readFileSync(apiPath, "utf-8");
describe("Agent runs UI — static analysis", () => {
@@ -22,6 +24,11 @@ describe("Agent runs UI — static analysis", () => {
expect(apiContent).toMatch(/options\?\.\s*triggerDetail/);
});
it("exports stopAgentRun function", () => {
expect(apiContent).toMatch(/export function stopAgentRun\s*\(/);
expect(apiContent).toMatch(/\/runs\/stop/);
});
it("exports HeartbeatInvocationSource type", () => {
expect(apiContent).toMatch(/export type.*HeartbeatInvocationSource/);
});
@@ -121,5 +128,26 @@ describe("Agent runs UI — static analysis", () => {
it("has no-runs empty state", () => {
expect(agentDetailViewContent).toContain("No runs yet");
});
it("wires a stop run handler", () => {
expect(agentDetailViewContent).toMatch(/handleStopRun|handleStop/);
expect(agentDetailViewContent).toMatch(/confirm\("Stop the active run\?/);
});
it("references stopAgentRun and stop button copy", () => {
expect(agentDetailViewContent).toContain("stopAgentRun");
expect(agentDetailViewContent).toMatch(/Stop Run|Stop active run/);
});
});
describe("AgentRunHistory", () => {
it("imports stopAgentRun", () => {
expect(agentRunHistoryContent).toMatch(/import\s*\{[^}]*stopAgentRun[^}]*\}\s*from\s*"\.\.\/api"/);
});
it("renders stop control for active runs", () => {
expect(agentRunHistoryContent).toMatch(/run\.status === "active"/);
expect(agentRunHistoryContent).toMatch(/Stop this run\?/);
});
});
});

View File

@@ -2110,6 +2110,19 @@ export function startAgentRun(
});
}
/** Stop an active heartbeat run for an agent */
export function stopAgentRun(
agentId: string,
projectId?: string,
): Promise<{ ok: boolean; runId?: string; message?: string }> {
return api<{ ok: boolean; runId?: string; message?: string }>(
withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/stop`, projectId),
{
method: "POST",
},
);
}
/** Fetch aggregate agent stats */
export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
return api<AgentStats>(withProjectId("/agents/stats", projectId));

View File

@@ -6,7 +6,7 @@ import {
ChevronDown, ChevronRight
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -877,6 +877,21 @@ function RunsTab({
}
};
const handleStopRun = async () => {
if (!confirm("Stop the active run? The agent's work will be interrupted.")) {
return;
}
try {
await stopAgentRun(agentId, projectId);
addToast("Run stopped", "success");
setIsLoadingRuns(true);
void loadRuns();
} catch (err: any) {
addToast(`Failed to stop run: ${err.message}`, "error");
}
};
const canRunHeartbeat = agentState === "active" || agentState === "idle";
if (isLoadingRuns && runs.length === 0) {
@@ -974,6 +989,19 @@ function RunsTab({
{run.invocationSource}
</span>
)}
{isActive && (
<button
className="btn btn--sm btn--danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleStopRun();
}}
aria-label="Stop active run"
>
<Square size={12} /> Stop
</button>
)}
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
{run.status}
@@ -1147,13 +1175,24 @@ function RunsTab({
{runs.length} run{runs.length !== 1 ? "s" : ""}
{hasActiveRun && <span className="run-live-indicator" style={{ marginLeft: "8px" }}><span className="live-dot" />Live</span>}
</span>
<button
className="btn btn--sm btn--primary"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run heartbeat for ${agentName ?? agentId}`}
>
<Activity size={14} /> Run Heartbeat
</button>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{hasActiveRun && (
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStopRun()}
aria-label={`Stop active run for ${agentName ?? agentId}`}
>
<Square size={14} /> Stop Run
</button>
)}
<button
className="btn btn--sm btn--primary"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run heartbeat for ${agentName ?? agentId}`}
>
<Activity size={14} /> Run Heartbeat
</button>
</div>
</div>
)}
{activeRuns.map((run, i) => renderRunCard(run, i, true))}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { CheckCircle, XCircle, Loader2, Square, Clock } from "lucide-react";
import type { AgentHeartbeatRun } from "../api";
import { fetchAgentRuns } from "../api";
import { fetchAgentRuns, stopAgentRun } from "../api";
interface AgentRunHistoryProps {
agentId: string;
@@ -21,14 +21,35 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadRuns = useCallback(async () => {
setIsLoading(true);
fetchAgentRuns(agentId, 50, projectId)
.then(setRuns)
.catch(() => setRuns([]))
.finally(() => setIsLoading(false));
try {
const data = await fetchAgentRuns(agentId, 50, projectId);
setRuns(data);
} catch {
setRuns([]);
} finally {
setIsLoading(false);
}
}, [agentId, projectId]);
useEffect(() => {
void loadRuns();
}, [loadRuns]);
const handleStop = useCallback(async () => {
if (!confirm("Stop this run?")) {
return;
}
try {
await stopAgentRun(agentId, projectId);
await loadRuns();
} catch {
// No-op: keep history view usable even if stop fails.
}
}, [agentId, projectId, loadRuns]);
if (isLoading) {
return <div className="agent-run-loading"><Loader2 className="animate-spin" size={20} /> Loading runs...</div>;
}
@@ -80,6 +101,20 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
<span className="badge text-secondary">{run.triggerDetail}</span>
)}
</div>
{run.status === "active" && (
<button
className="btn btn--sm btn--danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleStop();
}}
aria-label="Stop run"
style={{ marginLeft: "8px" }}
>
<Square size={12} /> Stop
</button>
)}
</div>
);
})}

View File

@@ -11,6 +11,8 @@ const mockGetRecentRuns = vi.fn();
const mockGetRunDetail = vi.fn();
const mockRecordHeartbeat = vi.fn();
const mockUpdateAgentState = vi.fn();
const mockGetAgent = vi.fn();
const mockEndHeartbeatRun = vi.fn();
const mockListAgents = vi.fn().mockResolvedValue([]);
const mockGetActiveHeartbeatRun = vi.fn().mockResolvedValue(null);
@@ -24,6 +26,8 @@ vi.mock("@fusion/core", () => {
getRunDetail = mockGetRunDetail;
recordHeartbeat = mockRecordHeartbeat;
updateAgentState = mockUpdateAgentState;
getAgent = mockGetAgent;
endHeartbeatRun = mockEndHeartbeatRun;
listAgents = mockListAgents;
getActiveHeartbeatRun = mockGetActiveHeartbeatRun;
},
@@ -76,6 +80,8 @@ describe("Agent runs routes (without HeartbeatMonitor)", () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
mockGetAgent.mockResolvedValue({ id: "agent-001", state: "running" });
mockEndHeartbeatRun.mockResolvedValue(undefined);
mockGetActiveHeartbeatRun.mockResolvedValue(null);
store = new MockStore();
@@ -139,6 +145,88 @@ describe("Agent runs routes (without HeartbeatMonitor)", () => {
});
});
describe("POST /api/agents/:id/runs/stop", () => {
it("returns 200 with runId when a run is stopped", async () => {
const activeRun = createMockRun({ id: "run-001" });
mockGetActiveHeartbeatRun.mockResolvedValue(activeRun);
mockGetRunDetail.mockResolvedValue(activeRun);
mockSaveRun.mockResolvedValue(undefined);
mockEndHeartbeatRun.mockResolvedValue(undefined);
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "active" });
const response = await request(
app,
"POST",
"/api/agents/agent-001/runs/stop",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true, runId: "run-001" });
expect(mockSaveRun).toHaveBeenCalledWith(expect.objectContaining({
id: "run-001",
status: "terminated",
endedAt: expect.any(String),
}));
expect(mockEndHeartbeatRun).toHaveBeenCalledWith("run-001", "terminated");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active");
});
it("returns 200 with no active run message when no run exists", async () => {
mockGetActiveHeartbeatRun.mockResolvedValue(null);
const response = await request(
app,
"POST",
"/api/agents/agent-001/runs/stop",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true, message: "No active run" });
expect(mockSaveRun).not.toHaveBeenCalled();
expect(mockEndHeartbeatRun).not.toHaveBeenCalled();
});
it("returns 404 when agent not found", async () => {
mockGetAgent.mockResolvedValue(null);
const response = await request(
app,
"POST",
"/api/agents/agent-404/runs/stop",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(404);
expect((response.body as any).error).toContain("Agent not found");
});
it("falls back to direct AgentStore termination when HeartbeatMonitor is unavailable", async () => {
const activeRun = createMockRun({ id: "run-002" });
mockGetActiveHeartbeatRun.mockResolvedValue(activeRun);
mockGetRunDetail.mockResolvedValue(activeRun);
mockSaveRun.mockResolvedValue(undefined);
mockEndHeartbeatRun.mockResolvedValue(undefined);
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "active" });
await request(
app,
"POST",
"/api/agents/agent-001/runs/stop",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(mockSaveRun).toHaveBeenCalled();
expect(mockEndHeartbeatRun).toHaveBeenCalledWith("run-002", "terminated");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active");
});
});
describe("POST /api/agents/:id/heartbeat", () => {
it("records heartbeat and returns event", async () => {
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
@@ -263,20 +351,25 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let mockExecuteHeartbeat: ReturnType<typeof vi.fn>;
let mockStopRun: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
mockGetAgent.mockResolvedValue({ id: "agent-001", state: "running" });
mockEndHeartbeatRun.mockResolvedValue(undefined);
mockGetActiveHeartbeatRun.mockResolvedValue(null);
mockExecuteHeartbeat = vi.fn();
mockStopRun = vi.fn();
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any, {
heartbeatMonitor: {
executeHeartbeat: mockExecuteHeartbeat,
stopRun: mockStopRun,
},
});
});
@@ -336,6 +429,28 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
});
});
describe("POST /api/agents/:id/runs/stop", () => {
it("calls heartbeatMonitor.stopRun when monitor is available", async () => {
const activeRun = createMockRun({ id: "run-xyz" });
mockGetActiveHeartbeatRun.mockResolvedValue(activeRun);
mockStopRun.mockResolvedValue(undefined);
const response = await request(
app,
"POST",
"/api/agents/agent-001/runs/stop",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true, runId: "run-xyz" });
expect(mockStopRun).toHaveBeenCalledWith("agent-001");
expect(mockSaveRun).not.toHaveBeenCalled();
expect(mockEndHeartbeatRun).not.toHaveBeenCalled();
});
});
describe("POST /api/agents/:id/heartbeat with triggerExecution", () => {
it("triggers execution when triggerExecution=true and HeartbeatMonitor available", async () => {
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };

View File

@@ -8755,6 +8755,62 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
/**
* POST /api/agents/:id/runs/stop
* Stop the currently active heartbeat run for an agent.
*/
router.post("/agents/:id/runs/stop", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(req.params.id);
if (!agent) {
throw notFound("Agent not found");
}
const activeRun = await agentStore.getActiveHeartbeatRun(req.params.id);
if (!activeRun) {
res.status(200).json({ ok: true, message: "No active run" });
return;
}
if (hasHeartbeatExecutor && heartbeatMonitor) {
await heartbeatMonitor.stopRun(req.params.id);
} else {
const existingRun = await agentStore.getRunDetail(req.params.id, activeRun.id);
if (existingRun) {
await agentStore.saveRun({
...existingRun,
endedAt: new Date().toISOString(),
status: "terminated",
stderrExcerpt: existingRun.stderrExcerpt ?? "Run stopped by user",
});
}
await agentStore.endHeartbeatRun(activeRun.id, "terminated");
try {
await agentStore.updateAgentState(req.params.id, "active");
} catch {
// Best effort to restore an idle/active state for follow-up runs.
}
}
res.status(200).json({ ok: true, runId: activeRun.id });
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err.message?.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err);
}
});
/**
* GET /api/agents/:id/runs/:runId
* Get detail for a specific agent run.

View File

@@ -74,6 +74,7 @@ export interface ServerOptions {
heartbeatMonitor?: {
startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
stopRun(agentId: string): Promise<void>;
};
}