diff --git a/.changeset/add-stop-agent-run.md b/.changeset/add-stop-agent-run.md new file mode 100644 index 000000000..4d77731f0 --- /dev/null +++ b/.changeset/add-stop-agent-run.md @@ -0,0 +1,5 @@ +--- +"@gsxdsm/fusion": patch +--- + +Add ability to stop an active agent run from the dashboard. diff --git a/packages/dashboard/app/__tests__/agent-runs-ui.test.ts b/packages/dashboard/app/__tests__/agent-runs-ui.test.ts index a7632d3ed..990bd668a 100644 --- a/packages/dashboard/app/__tests__/agent-runs-ui.test.ts +++ b/packages/dashboard/app/__tests__/agent-runs-ui.test.ts @@ -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\?/); + }); }); }); diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index 0d73d7b81..faad0730e 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -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 { return api(withProjectId("/agents/stats", projectId)); diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index d2495fa67..ce529b857 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -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} )} + {isActive && ( + + )} {run.status} @@ -1147,13 +1175,24 @@ function RunsTab({ {runs.length} run{runs.length !== 1 ? "s" : ""} {hasActiveRun && Live} - +
+ {hasActiveRun && ( + + )} + +
)} {activeRuns.map((run, i) => renderRunCard(run, i, true))} diff --git a/packages/dashboard/app/components/AgentRunHistory.tsx b/packages/dashboard/app/components/AgentRunHistory.tsx index c2c08484b..c5cb98e22 100644 --- a/packages/dashboard/app/components/AgentRunHistory.tsx +++ b/packages/dashboard/app/components/AgentRunHistory.tsx @@ -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([]); 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
Loading runs...
; } @@ -80,6 +101,20 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist {run.triggerDetail} )} + {run.status === "active" && ( + + )} ); })} diff --git a/packages/dashboard/src/__tests__/routes-agent-runs.test.ts b/packages/dashboard/src/__tests__/routes-agent-runs.test.ts index f039b6bbe..0769de282 100644 --- a/packages/dashboard/src/__tests__/routes-agent-runs.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-runs.test.ts @@ -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; let mockExecuteHeartbeat: ReturnType; + let mockStopRun: ReturnType; 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" }; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index aa4b66cbc..31cda4cd5 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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. diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index a5ac4151f..f8c2659c4 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -74,6 +74,7 @@ export interface ServerOptions { heartbeatMonitor?: { startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record }): Promise; executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string; contextSnapshot?: Record }): Promise; + stopRun(agentId: string): Promise; }; } diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 3c06a8a93..c279dfbb9 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -389,6 +389,72 @@ export class HeartbeatMonitor { this.onRunCompleted?.(agentId, completedRun); } + /** + * Stop an active heartbeat run for an agent. + * + * If an in-memory tracked session exists, dispose it and complete the run as terminated. + * If no tracked session exists, fall back to persisted active-run state and terminate that run record. + * + * No-op when no active run exists. + */ + async stopRun(agentId: string): Promise { + const tracked = this.trackedAgents.get(agentId); + + if (tracked) { + heartbeatLog.log(`Stopping tracked run ${tracked.runId} for ${agentId}`); + + try { + tracked.session.dispose(); + } catch (error) { + heartbeatLog.warn(`Failed to dispose tracked session while stopping run for ${agentId}: ${error instanceof Error ? error.message : String(error)}`); + } + + this.untrackAgent(agentId); + + await this.completeRun(agentId, tracked.runId, { + status: "terminated", + stderrExcerpt: "Run stopped by user", + }); + + try { + await this.store.updateAgentState(agentId, "active"); + } catch { + // Best effort — if already active or transition is currently invalid, ignore. + } + + this.clearRunState(agentId); + return; + } + + const activeRun = await this.store.getActiveHeartbeatRun(agentId); + if (!activeRun) { + this.clearRunState(agentId); + return; + } + + heartbeatLog.log(`Stopping persisted run ${activeRun.id} for ${agentId} (no tracked session)`); + + const existingRun = await this.store.getRunDetail(agentId, activeRun.id); + if (existingRun) { + await this.store.saveRun({ + ...existingRun, + endedAt: new Date().toISOString(), + status: "terminated", + stderrExcerpt: existingRun.stderrExcerpt ?? "Run stopped by user", + }); + } + + await this.store.endHeartbeatRun(activeRun.id, "terminated"); + + try { + await this.store.updateAgentState(agentId, "active"); + } catch { + // Best effort — if the state cannot be transitioned right now, don't fail stop semantics. + } + + this.clearRunState(agentId); + } + /** * Remove an agent from monitoring. * Does NOT end the heartbeat run - caller's responsibility.