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:
@@ -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" };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user