diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c4777f2be..05854fdc1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed the Docker `natives-builder` stage failing to build releases ≥ 17.1.1: the native audio stack added bindgen (miniaudio needs libclang) and a bundled-opus CMake build (needs cmake + make), none of which were installed in the slim builder image. - Fixed `omp usage` duplicating org-less legacy accounts as "no usage data" rows whenever any sibling report carried an organization (mixed pools of pre-org-capture rows and fresh org-scoped logins): an org-less account is now covered by its own org-less report, while org-attributed sibling reports still never count as its coverage. - `omp usage` revalidates the broker credential snapshot before rendering: live usage reports were previously paired with a disk-cached account list up to an hour old, so a just-completed re-login (org-less row upserted to org-scoped) rendered as a phantom duplicate until the cache expired. +- Fixed `debug` (js-debug/`pwa-node`) stateful commands misrouting after launch: a lazily-attached `[worker N]` child session (or the threadless root launcher) would steal the active-session focus from the stopped script child, so `threads` listed only the worker thread, post-launch breakpoints read back as pending/unbound, and there was no way to step/continue/evaluate the script's thread. Focus now follows stops rather than registrations, and `threads` aggregates every live thread across the session tree ([#6663](https://github.com/can1357/oh-my-pi/issues/6663)). ## [17.1.3] - 2026-07-24 diff --git a/packages/coding-agent/src/dap/session.ts b/packages/coding-agent/src/dap/session.ts index 02f7b72e9..91daa3eb9 100644 --- a/packages/coding-agent/src/dap/session.ts +++ b/packages/coding-agent/src/dap/session.ts @@ -960,16 +960,41 @@ export class DapSessionManager { signal?: AbortSignal, timeoutMs: number = 30_000, ): Promise<{ snapshot: DapSessionSummary; threads: DapThread[] }> { - const session = this.#touchActiveSession(); - const response = await this.#sendRequestWithConfig( - session, - "threads", - undefined, - signal, - timeoutMs, - ); - session.threads = response?.threads ?? []; - return { snapshot: buildSummary(session), threads: session.threads }; + const anchor = this.#touchActiveSession(); + // A js-debug launch is a session tree: the root is a threadless launcher + // and each real thread lives in a child (main script, `[worker N]`, …). + // Querying only the active session would surface just one child's threads, + // so aggregate across every live thread-owning session in the tree. + const targets = this.#threadOwningSessions(anchor); + const merged: DapThread[] = []; + const seen = new Set(); + for (const target of targets) { + let threads: DapThread[]; + try { + const response = await this.#sendRequestWithConfig( + target, + "threads", + undefined, + signal, + timeoutMs, + ); + threads = response?.threads ?? []; + } catch (error) { + logger.warn("Failed to list threads for debug session", { + sessionId: target.id, + error: toErrorMessage(error), + }); + continue; + } + target.threads = threads; + for (const thread of threads) { + const key = `${thread.id}\0${thread.name}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(thread); + } + } + return { snapshot: buildSummary(anchor), threads: merged }; } async stackTrace( @@ -1371,7 +1396,13 @@ export class DapSessionManager { if (parentSessionId) { this.#sessions.get(parentSessionId)?.childSessionIds.add(session.id); } - this.#activeSessionId = session.id; + // Focus follows stops, not registrations: a lazily-attached child (e.g. a + // js-debug `[worker N]` session) must not steal focus from a sibling that + // is already stopped at a breakpoint / entry. Only claim focus when no + // live, stopped session currently holds it. + if (!this.#hasLiveStoppedActiveSession()) { + this.#activeSessionId = session.id; + } const heartbeat = setInterval(() => { if (!client.isAlive()) { session.status = "terminated"; @@ -1696,6 +1727,12 @@ export class DapSessionManager { return session; } + /** True when the current active session is live and paused at a stop. */ + #hasLiveStoppedActiveSession(): boolean { + const active = this.#getActiveSessionOrNull(); + return active !== null && active.status === "stopped" && active.client.isAlive(); + } + #getActiveSessionOrThrow(): DapSession { const session = this.#getActiveSessionOrNull(); if (!session) { @@ -1729,6 +1766,27 @@ export class DapSessionManager { return sessions; } + /** + * Live sessions in `session`'s tree that can own threads. The threadless + * root launcher (a js-debug coordinator with live children) is dropped when + * any real child is alive, but kept as a last resort so a collapsed tree + * still has a target. + */ + #threadOwningSessions(session: DapSession): DapSession[] { + const live = this.#getTreeSessions(session).filter( + candidate => candidate.status !== "terminated" && candidate.client.isAlive(), + ); + if (live.length === 0) return [session]; + const nonLauncher = live.filter(candidate => !this.#isLauncherSession(candidate, live)); + return nonLauncher.length > 0 ? nonLauncher : live; + } + + /** A root session that has spawned a still-live child is a threadless launcher. */ + #isLauncherSession(session: DapSession, live: DapSession[]): boolean { + if (session.parentSessionId) return false; + return live.some(candidate => candidate.parentSessionId === session.id); + } + #touchSessionAndAncestors(session: DapSession): void { const now = Date.now(); let current: DapSession | undefined = session; 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 25ca1c1eb..1e4fa19a5 100644 --- a/packages/coding-agent/test/debug/dap-multi-session.test.ts +++ b/packages/coding-agent/test/debug/dap-multi-session.test.ts @@ -6,6 +6,7 @@ import type { DapClientState, DapEventMessage, DapResolvedAdapter, + DapThread, } from "@oh-my-pi/pi-coding-agent/dap/types"; const TEST_ADAPTER: DapResolvedAdapter = { @@ -25,6 +26,13 @@ const TEST_ADAPTER: DapResolvedAdapter = { type EventHandler = (body: unknown, event: DapEventMessage) => void | Promise; type ReverseHandler = (args: unknown) => unknown | Promise; +interface FakeOptions { + /** Threads returned by this session's `threads` request. */ + threads?: DapThread[]; + /** Thread id reported by the synthetic `stopped` event (defaults to 7). */ + stopThreadId?: number; +} + class FakeDapClient { readonly proc: DapClientState["proc"]; readonly port = 8123; @@ -39,6 +47,7 @@ class FakeDapClient { readonly childConfiguration?: Record, readonly childRequest: "launch" | "attach" = "launch", readonly stopOnStart = true, + readonly options: FakeOptions = {}, ) { this.proc = { exited: this.#exited.promise, @@ -71,10 +80,10 @@ class FakeDapClient { }); }); } else if (this.stopOnStart) { - queueMicrotask(() => this.#emit("stopped", { reason: "entry", threadId: 7 })); + queueMicrotask(() => this.#emit("stopped", { reason: "entry", threadId: this.options.stopThreadId ?? 7 })); } } - if (command === "threads") return { threads: [{ id: 7, name: "target.js" }] }; + if (command === "threads") return { threads: this.options.threads ?? [{ id: 7, name: "target.js" }] }; if (command === "stackTrace") { return { stackFrames: [{ id: 70, name: "main", line: 2, column: 1, source: { path: "/tmp/target.js" } }], @@ -127,6 +136,11 @@ class FakeDapClient { this.#emit(event, body); } + /** Drive an adapter-initiated reverse request (e.g. a late `startDebugging`). */ + async triggerReverse(command: string, args: unknown): Promise { + await this.#emitReverse(command, args); + } + async #emitReverse(command: string, args: unknown): Promise { const handler = this.#reverseHandlers.get(command); if (!handler) throw new Error(`Missing reverse handler for ${command}`); @@ -246,4 +260,63 @@ describe("DAP multi-session debugging", () => { await manager.terminate(undefined, 100); }); + + it("keeps focus on the stopped script child when a worker attaches later", async () => { + const root = new FakeDapClient({ + name: "script.mts", + type: "pwa-node", + __pendingTargetId: "main", + program: "/tmp/script.mts", + }); + // The script child stops on entry (thread 1), then a worker session + // attaches afterwards via a late reverse `startDebugging`. + const main = new FakeDapClient(undefined, "launch", true, { + threads: [{ id: 1, name: "script.mts" }], + stopThreadId: 1, + }); + const worker = new FakeDapClient(undefined, "launch", false, { + threads: [{ id: 1, name: "[worker 1]" }], + }); + const children = [main, worker]; + spyOn(DapClient, "spawn").mockResolvedValue(root as unknown as DapClient); + spyOn(DapClient, "connect").mockImplementation(async () => { + const next = children.shift(); + if (!next) throw new Error("Unexpected child DAP connection"); + return next as unknown as DapClient; + }); + const manager = new DapSessionManager(); + + const launched = await manager.launch( + { adapter: TEST_ADAPTER, program: "/tmp/script.mts", cwd: "/tmp" }, + undefined, + 1_000, + ); + expect(launched.status).toBe("stopped"); + const scriptSessionId = launched.id; + + // A worker_threads spawn triggers a late child attach on the launcher. + await root.triggerReverse("startDebugging", { + request: "launch", + configuration: { name: "[worker 1]", type: "pwa-node" }, + }); + expect(manager.listSessions()).toHaveLength(3); + + // Focus must stay on the stopped script child, not jump to the worker. + const active = manager.getActiveSession(); + expect(active?.id).toBe(scriptSessionId); + expect(active?.threadId).toBe(1); + + // `threads` must surface every live thread across the tree, not just one. + const threads = await manager.threads(undefined, 1_000); + expect(threads.threads).toEqual( + expect.arrayContaining([ + { id: 1, name: "script.mts" }, + { id: 1, name: "[worker 1]" }, + ]), + ); + // The threadless launcher is never queried while real children are live. + expect(root.requests.filter(request => request.command === "threads")).toHaveLength(0); + + await manager.terminate(undefined, 1_000); + }); });