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.
This commit is contained in:
can1357
2026-07-18 19:37:07 +02:00
parent ab77aeb245
commit bf94f05a8e
2 changed files with 54 additions and 0 deletions
+17
View File
@@ -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]) {
@@ -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<void> {
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);
});
});