fix(coding-agent): aggregate debug threads without assuming a threadless root
Review feedback: classifying every root-with-a-live-child as a threadless launcher dropped the root from thread aggregation, so a custom TCP adapter whose root process owns threads and also issues startDebugging would have its threads omitted. Drop the launcher heuristic: threads now fans out across all live sessions in the tree and merges results. A genuinely threadless launcher simply returns no threads (or an error we skip), so no topology guess is made. Fixes #6663
This commit is contained in:
@@ -961,11 +961,13 @@ export class DapSessionManager {
|
||||
timeoutMs: number = 30_000,
|
||||
): Promise<{ snapshot: DapSessionSummary; threads: DapThread[] }> {
|
||||
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);
|
||||
// A js-debug launch is a session tree: the root may be a threadless
|
||||
// launcher while each real thread lives in a child (main script,
|
||||
// `[worker N]`, …), and other adapters keep every thread on the root.
|
||||
// Querying only the active session would surface just one session's
|
||||
// threads, so aggregate across the whole live tree. No topology guess:
|
||||
// a threadless launcher simply returns no threads (or an error we skip).
|
||||
const targets = this.#liveTreeSessions(anchor);
|
||||
const merged: DapThread[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const target of targets) {
|
||||
@@ -1767,24 +1769,16 @@ export class DapSessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Live (non-terminated, connected) sessions in `session`'s tree, or the
|
||||
* session itself when the tree has collapsed. Used to fan `threads` out
|
||||
* across the whole tree; a threadless session just reports no threads, so
|
||||
* this makes no assumption about which node owns them.
|
||||
*/
|
||||
#threadOwningSessions(session: DapSession): DapSession[] {
|
||||
#liveTreeSessions(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);
|
||||
return live.length > 0 ? live : [session];
|
||||
}
|
||||
|
||||
#touchSessionAndAncestors(session: DapSession): void {
|
||||
|
||||
@@ -223,7 +223,9 @@ describe("DAP multi-session debugging", () => {
|
||||
expect(active?.parentSessionId).toBeDefined();
|
||||
expect(threads.threads).toEqual([{ id: 7, name: "target.js" }]);
|
||||
expect(child.requests.filter(request => request.command === "threads")).toHaveLength(1);
|
||||
expect(root.requests.filter(request => request.command === "threads")).toHaveLength(0);
|
||||
// The root is queried too (no topology guess); here it just echoes the
|
||||
// same thread, which dedupes away.
|
||||
expect(root.requests.filter(request => request.command === "threads")).toHaveLength(1);
|
||||
|
||||
await manager.terminate(undefined, 100);
|
||||
});
|
||||
@@ -262,12 +264,18 @@ describe("DAP multi-session debugging", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
const root = new FakeDapClient(
|
||||
{
|
||||
name: "script.mts",
|
||||
type: "pwa-node",
|
||||
__pendingTargetId: "main",
|
||||
program: "/tmp/script.mts",
|
||||
},
|
||||
"launch",
|
||||
true,
|
||||
// Threadless launcher: it answers `threads` with an empty list.
|
||||
{ threads: [] },
|
||||
);
|
||||
// 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, {
|
||||
@@ -308,14 +316,15 @@ describe("DAP multi-session debugging", () => {
|
||||
|
||||
// `threads` must surface every live thread across the tree, not just one.
|
||||
const threads = await manager.threads(undefined, 1_000);
|
||||
expect(threads.threads).toHaveLength(2);
|
||||
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);
|
||||
// The launcher is still queried, but being threadless it contributes none.
|
||||
expect(root.requests.filter(request => request.command === "threads")).toHaveLength(1);
|
||||
|
||||
await manager.terminate(undefined, 1_000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user