From bf94f05a8efe783d244596e343bf05d4a7b96164 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 18 Jul 2026 19:37:07 +0200 Subject: [PATCH] fix(coding-agent): reactivate a live debug session when the active child terminates When the active js-debug child exits or terminates while other tree sessions remain alive, #activeSessionId pointed at the dead child and every subsequent tool action failed. Reassign to a live tree session (preferring stopped, then non-root) on exited/terminated/proc-exit. Regression test proves threads route to the surviving session. --- packages/coding-agent/src/dap/session.ts | 17 +++++++++ .../test/debug/dap-multi-session.test.ts | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/coding-agent/src/dap/session.ts b/packages/coding-agent/src/dap/session.ts index 57dd4976c..02f7b72e9 100644 --- a/packages/coding-agent/src/dap/session.ts +++ b/packages/coding-agent/src/dap/session.ts @@ -1359,10 +1359,12 @@ export class DapSessionManager { client.onEvent("exited", body => { session.exitCode = (body as DapExitedEventBody | undefined)?.exitCode; session.status = "terminated"; + this.#reactivateAfterTermination(session); this.#resolveTreeOutcome(session); }); client.onEvent("terminated", () => { session.status = "terminated"; + this.#reactivateAfterTermination(session); this.#resolveTreeOutcome(session); }); this.#sessions.set(session.id, session); @@ -1379,6 +1381,7 @@ export class DapSessionManager { void client.proc.exited.finally(() => { clearInterval(heartbeat); session.status = "terminated"; + this.#reactivateAfterTermination(session); this.#resolveTreeOutcome(session); }); return session; @@ -1735,6 +1738,20 @@ export class DapSessionManager { } } + /** Point the active session at a live tree member when the active one terminates. */ + #reactivateAfterTermination(session: DapSession): void { + if (this.#activeSessionId !== session.id) return; + const live = this.#getTreeSessions(session).filter( + candidate => candidate.status !== "terminated" && candidate.client.isAlive(), + ); + if (live.length === 0) return; + const replacement = + live.find(candidate => candidate.status === "stopped") ?? + live.find(candidate => candidate.parentSessionId !== undefined) ?? + live[0]; + this.#activeSessionId = replacement.id; + } + #resolveTreeOutcome(session: DapSession): void { const rootId = this.#getRootSession(session).id; for (const waiter of [...this.#treeOutcomeWaiters]) { diff --git a/packages/coding-agent/test/debug/dap-multi-session.test.ts b/packages/coding-agent/test/debug/dap-multi-session.test.ts index 1d1a45bde..25ca1c1eb 100644 --- a/packages/coding-agent/test/debug/dap-multi-session.test.ts +++ b/packages/coding-agent/test/debug/dap-multi-session.test.ts @@ -123,6 +123,10 @@ class FakeDapClient { for (const handler of this.#events.get(event) ?? []) void handler(body, message); } + emit(event: string, body: unknown): void { + this.#emit(event, body); + } + async #emitReverse(command: string, args: unknown): Promise { const handler = this.#reverseHandlers.get(command); if (!handler) throw new Error(`Missing reverse handler for ${command}`); @@ -209,4 +213,37 @@ describe("DAP multi-session debugging", () => { await manager.terminate(undefined, 100); }); + + it("reactivates a live session when the active child terminates", async () => { + const root = new FakeDapClient({ + name: "target.js", + type: "pwa-node", + __pendingTargetId: "child", + }); + const child = new FakeDapClient(); + spyOn(DapClient, "spawn").mockResolvedValue(root as unknown as DapClient); + spyOn(DapClient, "connect").mockResolvedValue(child as unknown as DapClient); + const manager = new DapSessionManager(); + + const launched = await manager.launch( + { adapter: TEST_ADAPTER, program: "/tmp/target.js", cwd: "/tmp" }, + undefined, + 1_000, + ); + expect(launched.parentSessionId).toBeDefined(); + + child.emit("terminated", {}); + await child.dispose(); + + const active = manager.getActiveSession(); + expect(active).not.toBeNull(); + expect(active?.id).not.toBe(launched.id); + expect(active?.status).not.toBe("terminated"); + + const threads = await manager.threads(undefined, 100); + expect(threads.threads).toEqual([{ id: 7, name: "target.js" }]); + expect(root.requests.filter(request => request.command === "threads")).toHaveLength(1); + + await manager.terminate(undefined, 100); + }); });