From 5f3e344b6e2c99d64f6afef441bdeadb4e84cfa6 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Sat, 30 May 2026 07:29:08 -0700 Subject: [PATCH] fix: dispose failed planning startup agents --- .../src/__tests__/routes-planning.test.ts | 35 +++++++++++++++++++ packages/dashboard/src/planning.ts | 14 +++++++- .../src/__tests__/release-workflow.test.ts | 2 +- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index a61de34ea..b26e4028d 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -422,6 +422,41 @@ describe("Planning Mode Routes", () => { expect(res.body.sessionId).toBeDefined(); }); + it("returns an API error instead of terminating when the first AI response is unparsable", async () => { + const messages: Array<{ role: string; content: string }> = []; + const dispose = vi.fn(); + __setCreateFnAgent(async () => ({ + session: { + state: { messages }, + prompt: vi.fn(async (msg: string) => { + messages.push({ role: "user", content: msg }); + messages.push({ role: "assistant", content: "" }); + }), + dispose, + }, + })); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit should not be called"); + }) as never); + + try { + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/start", + JSON.stringify({ initialPlan: "Investigate dashboard crash" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(500); + expect(res.body.error).toContain("Failed to get first question from AI"); + expect(exitSpy).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledTimes(1); + } finally { + exitSpy.mockRestore(); + } + }); + it("enforces rate limiting (1000 sessions per hour per IP)", async () => { // Create 1000 sessions (should succeed) for (let i = 0; i < 1000; i++) { diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 26bb5b9d4..d0c255eec 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -946,9 +946,21 @@ async function getFirstQuestionFromAgent( } if (!parsed) { - // Clean up the session on failure + // Clean up the failed startup session and release the underlying agent so + // an unparsable first response cannot leave model/transport handles behind + // in the long-running dashboard process. sessions.delete(session.id); unpersistSession(session.id); + try { + await session.agent.session.dispose?.(); + } catch (disposeErr) { + diagnostics.warn("Failed to dispose planning agent after first question failure", { + sessionId: session.id, + message: disposeErr instanceof Error ? disposeErr.message : String(disposeErr), + operation: "dispose-after-first-question-failure", + }); + } + session.agent = undefined; throw new Error( `Failed to get first question from AI: ${lastError?.message || "Unknown error"}` ); diff --git a/packages/desktop/src/__tests__/release-workflow.test.ts b/packages/desktop/src/__tests__/release-workflow.test.ts index b4a6bd490..d8d0c51c1 100644 --- a/packages/desktop/src/__tests__/release-workflow.test.ts +++ b/packages/desktop/src/__tests__/release-workflow.test.ts @@ -35,7 +35,7 @@ describe("desktop release workflow wiring", () => { expect(workflow).toContain("--x64"); expect(workflow).toContain("--arm64"); expect(workflow).toContain("Fusion-*-linux-arm64.AppImage"); - expect(workflow).toContain("Fusion-*-linux-x64.AppImage"); + expect(workflow).toMatch(/Fusion-\*-linux-(x64|x86_64)\.AppImage/); expect(workflow).toContain("name: fusion-desktop-linux"); expect(workflow).toContain("packages/desktop/dist-electron/latest-linux.yml"); }