From 019f6b282e3ce229979e1f8de9896006431c76eb Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 21 Jul 2026 15:39:34 +0200 Subject: [PATCH] fix: release finalized Pi runtimes --- backend/src/routes/sessions.ts | 22 ++++++++++----- backend/test/routes-sessions.test.ts | 41 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 17b782be..86de25f8 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -111,13 +111,21 @@ export function sessionRoutes( } } d.hub.publish(id, e.type, e); - if ( - e.type === "system_event" - && e.event === "agent_end" - && d.mgr.get(id) !== rt - && boundRuntimes.get(id) === rt - ) { - boundRuntimes.delete(id); + if (e.type === "system_event" && e.event === "agent_end") { + if (d.mgr.get(id) !== rt && boundRuntimes.get(id) === rt) { + boundRuntimes.delete(id); + } else if (typeof runner.sessionShow === "function") { + void withSessionLifecycle(id, async () => { + if (d.mgr.get(id) !== rt || boundRuntimes.get(id) !== rt) return; + const manifest = await runner.sessionShow(id, workspace); + if (manifest?.status !== "finalized") return; + if (d.mgr.teardownIfCurrent(id, rt) && boundRuntimes.get(id) === rt) { + boundRuntimes.delete(id); + } + }).catch((error: unknown) => { + console.error(`[session:${id}] terminal runtime cleanup failed:`, error); + }); + } } }); } catch (error) { diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 91307844..5b7d503d 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -1304,6 +1304,47 @@ test("an unexpectedly exited runtime publishes its terminal sequence then releas ]); }); +test("agent_end releases the Pi runtime after the session was finalized", async () => { + let listener: ((event: any) => void) | undefined; + const bridge = { + onClientEvent: (next: (event: any) => void) => { listener = next; }, + emitClientEvent: (event: any) => listener?.(event), + }; + const runtime = { bridge }; + let current: any; + let teardownCalls = 0; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + mgr: { + get: () => current, + createFor: () => { current = runtime; return runtime; }, + configure: async () => {}, + start: () => {}, + teardownIfCurrent: (_id: string, expected: any) => { + if (current !== expected) return false; + teardownCalls += 1; + current = undefined; + return true; + }, + } as any, + thtRunner: { + sessionNew: async () => ({ id: "s1" }), + sessionShow: async () => ({ status: "finalized" }), + searchPack: async () => {}, + failSession: async () => {}, + } as any, + readiness: { ensure: async () => ({ ok: true }) } as any, + getSettings: () => ({ workspace: "local" }) as any, + }); + await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + await new Promise((resolve) => setImmediate(resolve)); + + bridge.emitClientEvent({ type: "system_event", event: "agent_end" }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(teardownCalls).toBe(1); + expect(current).toBeUndefined(); +}); + test("POST resume tears down a created runtime when bridge binding fails", async () => { const delivered: string[] = []; const actualHub = new SseHub();