Skip to content

fix: hold a session for every call in flight, and make tree answers speak for the tree (#1127, #1129, #1128, #1137) - #1149

Merged
debugmcpdev merged 4 commits into
mainfrom
fix/idle-holds-tree-answers
Nov 13, 2026
Merged

debugmcpdev merged 4 commits into
mainfrom
fix/idle-holds-tree-answers

Conversation

@debugmcpdev

@debugmcpdev debugmcpdev commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Cluster 1 of the bugs gating 0.27.0: four defects in the idle policy (#1025 step 2) and the session tree (#1081/#1082).

Closes #1127, closes #1129, closes #1128, closes #1137.

What was wrong, and what changes

#1127 — the idle policy closed a session underneath its own call

A tool call counted as activity only when it was dispatched, and only wait_for_stop held its tree. A call that outlasted the window lost its session mid-flight.

The rule now: the idle clock runs from the end of the last call on the tree, and a call in flight holds it.

  • SessionManagerCore.holdForCall is the one primitive: it stamps the clock, holds the tree, and its release stamps again.
  • tool-dispatch.ts (holdForTheCall) takes it for every tool that names a session, under the gate the stamp already had (the tool takes sessionId, the caller may see the session). wait_for_stop's own hold is removed.
  • create_debug_session with an attach target names no session, so it takes the hold itself around its inline attach. The review found this path still open after my first fix.
  • sweepIdle marks a tree as closing before its first await. A call from inside the session's scope that arrives during that close waits for it and answers closedBy: "idle-policy" with the final outcome. Before, it ran against a session being torn down.
  • idleMsOf answers 0 while the tree is held, so list_debug_sessions (idleMs, expiresInMs) and /health no longer show a session with a call in flight as about to expire.

#1129 — MCP_DEBUG_SESSION_IDLE_MS displayed as [REDACTED]

/session[_-]?id/i is a substring rule and SESSION_IDLE contains SESSION_ID.

The rule still masks whatever follows session…id, and now knows one exception: the word that starts at id is "idle". It fails closed, so a name it cannot read stays masked.

The regex suggested in the issue, /session[_-]?id(?![a-z])/i, is not used: under /i the lookahead also rejects an upper-case letter, so sessionIdValue and SESSION_IDS would have stopped being masked.

Masking changes, measured against the old pattern over 372 spellings:

Names
Masked before, shown now Only the idle words: SESSION_IDLE, SESSION_IDLE_MS, sessionIdle, sessionIdleMs, SESSION_IDLING, sessionidle
Shown before, masked now The pair with a separator the old pattern did not know: session.id, SESSION ID
Unchanged Everything else, including SESSION_IDX, SESSION_IDENTITY and SESSION_IDHASH (still masked)

/auth/ and /token/ stay broad: I scanned the project's own setting names and none trips them. A new guard test (tests/unit/shared/own-settings-stay-readable.test.ts) fails when any MCP_*, DEBUG_MCP_* or DEV_PROXY_* name in the sources is masked without being listed there as a secret on purpose.

#1128 — "without hitting any breakpoint" after a child session stopped on one

summarizeProgramEnd read the launched process's own stop record and breakpoints. A child's stops live on the child's row, which is gone once the child is closed.

  • A stop the agent can see on a descendant is counted on every ancestor (ManagedSession.descendantStops, per run).
  • The summary gets the rule a single session already has: once the tree stopped, the unhit list is withheld. The sentence becomes The program ran to completion (exit code 0). Its child sessions stopped 2 times during this run.

#1137 — childrenReleased: 1 with no explanation

  • adoptReportedChild records why with the count, as a category (child sessions off, cap reached, proxy cannot host children, adoption failed).
  • Every launch, restart and wait_for_stop message that carries the count ends with one sentence built from that record: 1 child process was released to run undebugged (child sessions are off: DEBUG_MCP_MAX_CHILD_SESSIONS=0): a breakpoint only it reaches will not hit. At the cap it reads (the tree was at its child-session cap of 32, which DEBUG_MCP_MAX_CHILD_SESSIONS raises).

Behaviour a client can observe

  • idleMs is 0 while a call on the session runs, and counts from the call's answer afterwards.
  • A call made while the idle policy is closing its session waits for the close (bounded by the close path's own timeouts) and answers closedBy: "idle-policy".
  • unhitBreakpoints is absent from a session's end answer when a child session stopped during the run.
  • Messages that carry childrenReleased are one sentence longer. No field is removed or reshaped; the tool list is unchanged at 29.

Evidence

Each new test was run failing before its fix.

Check Result
#1127 on a real server before the fix (python-idle-policy.test.ts, 800 ms window) start_debugging answered Session not found for its own session
Same, create_debug_session with an attach target, before its fix the new session was gone within three windows
Both cases after the fixes pass
The issue's Java reproduction (1 s window, sweep 200 ms, PauseTest.java) after the fix launch answered success after 2,797 ms; the row read idleMs=2 expiresInMs=998; 3 s later closedBy: "idle-policy"
#1128 with real debugpy and js-debug before the fix the exact messages of the issue, in both languages
#1128 after the fix, live through the dev proxy (child stops twice, child closed, parent ends) The program ran to completion (exit code 0). Its child sessions stopped 2 times during this run.
#1137 after the fix, live with DEBUG_MCP_MAX_CHILD_SESSIONS=0 the launch answer ends with the released-children sentence
Unit suite 424 files, 8,984 tests pass
Python and JavaScript subprocess-tree suites and the idle tests, real adapters 15 of 15 pass
Pre-push hook, integration project 38 files, 187 tests pass
typecheck:all, lint, check-docs, changelog:check pass

The first push was refused by the hook: one Go case timed out on Delve's first build under the hook's load, the known flake #889. The file passed alone (3 of 3) and the second push passed with no change.

#1129 could not be checked through the already-running dev proxies (they loaded the old sanitizer at start); the unit test at backend-logger.mjs covers the surface the issue shows, asserting mode: 'shared'.

No test ran the idle policy against a real server before this PR; that is how #1127 got through. tests/adapters/python/integration/python-idle-policy.test.ts is the first.

Review

One independent review with a shell: it ran the suites and a 34-mutation pass. No Critical finding. Fixed in the last commit:

  • Important: create_debug_session with an attach target was still swept under its own attach.
  • Closing a child did not restart its root's clock.
  • A caller outside the owner scope could be made to wait on an idle close, for a child id whose row was already gone.
  • My first Env sanitizer masks MCP_DEBUG_SESSION_IDLE_MS: /session[_-]?id/ matches SESSION_IDLE #1129 rule showed SESSION_IDSTR, SESSION_IDHASH and similar names. It now fails closed.
  • Wording of the release causes; three doc sentences that still named wait_for_stop as the only hold; a doc sentence that called every child stop a breakpoint hit.
  • Seven test gaps the mutation pass found, six of them closed. The seventh, the hot-restart message site, is reachable only on Flutter, which has no child sessions. One assertion in the JavaScript cap-0 integration case stays conditional on the launch answering while the program still runs.

Not fixed here, in #1150: the "never bound" warning beside the new sentences, and a grandchild released under a child that the root's answers do not count.

Calls I made

Known limits (follow-up issue #1150)

🤖 Generated with Claude Code

cynarlab and others added 4 commits November 12, 2026 08:03
…olicy (#1127)

With MCP_DEBUG_SESSION_IDLE_MS set, a tool call counted as activity only at
the moment it was dispatched, and only wait_for_stop held its tree. A call
that outlasted the window had its session closed underneath it: a 2 s Java
launch under a 1 s window answered "Proxy exited during initialization", and
a 6 s Python launch on a loaded host lost its session under a 5 s window.

The rule now: the idle clock runs from the END of the last call that named
the tree, and a call in flight holds it.

- tool-dispatch: holdForTheCall stamps the clock, holds the tree for the
  call, and on the way out releases and stamps again — under the gate the
  stamp already had (the tool takes sessionId, the caller may see the
  session). wait_for_stop's own hold is gone; one mechanism.
- sweepIdle marks the tree as closing before its first await, and a call
  that arrives during that close waits for it and answers closedBy:
  "idle-policy" with the final outcome, where it used to run against a
  session being torn down. Nothing is awaited unless a close is in flight,
  so the check, the stamp and the hold run in one turn.
- idleMsOf answers 0 while the tree is held, so list_debug_sessions and
  /health no longer show a session with a call in flight as about to expire.

No test ran the idle policy against a real server; that is how this got
through. tests/adapters/python/integration/python-idle-policy.test.ts does:
an 800 ms window under a real debugpy launch. It fails on main with
"Session not found" for the launch's own session.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…a substring (#1129)

/session[_-]?id/i matched SESSION_IDLE, so the server's own idle-policy
settings (MCP_DEBUG_SESSION_IDLE_MS, MCP_DEBUG_SESSION_IDLE_SWEEP_MS) were
displayed as [REDACTED] by dev_server_status and every log line that goes
through sanitizeEnvForLogging. The values reached the backend; the operator
could not read back which window was in force.

The rule now reads the word that follows `session` to its end and masks
when it is id, ids, identifier or identifiers: SESSION_ID, sessionIds,
SessionIDValue (an acronym ends before the capital that opens the next
word), SESSION_IDENTIFIER. A run-on spelling (sessionid, JSESSIONID) has no
boundary to say where its words end and is masked whatever follows.

The regex the issue suggested, /session[_-]?id(?![a-z])/i, would have
unmasked sessionIdValue and SESSION_IDS: under /i the lookahead rejects an
upper-case letter too.

What changes, measured against the old pattern over 264 spellings:
- masked before, shown now: after a boundary, `id` beginning a longer word
  (SESSION_IDLE, SESSION_IDX, SESSION_IDP, SESSION_IDENTITY, SESSION_IDEA,
  SESSION_IDEMPOTENT, sessionIdle, session_idx) and the all-caps run-on
  SESSION_IDVALUE, which cannot be told from SESSION_IDLE;
- shown before, masked now: the pair with a separator the old pattern did
  not know (session.id, SESSION ID).

tests/unit/shared/own-settings-stay-readable.test.ts guards the class:
every MCP_*, DEBUG_MCP_* and DEV_PROXY_* name in the sources must display
its value unless it is listed there as a secret on purpose. /auth/ and
/token/ stay broad: no setting of ours trips them today.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…1137)

Two answers on a session with children described the launched process
alone and misled the caller about the tree. They edit the same sentence and
the same per-run record, so they land together.

#1128. The documented flow — a breakpoint in the child's code, start the
parent, wait — ended with the parent answering "ran to completion (exit code
0) without hitting any breakpoint. Verified but never hit: child.py:12",
after a child session had stopped on that line twice. summarizeProgramEnd
read the root's own lastStop and breakpoints; a child's stops live on the
child's row, which is gone once the child is closed.

- A stop the agent can see on a descendant is counted on every ancestor
  (ManagedSession.descendantStops, per run, reset at launch). Auto-continued
  entry stops, and js-debug's leaked module-entry pause, are not counted.
- The summary gets the rule a single session already has: once the tree
  stopped, the unhit list is withheld and the sentence counts the stops —
  "Its child sessions stopped 2 times during this run." All three callers
  (launch, hot restart, wait_for_stop) go through the one builder.

No per-breakpoint attribution: debugpy never sends hitBreakpointIds, so it
would have rested on someone having read each stop's location.

#1137. A released child left `childrenReleased: 1` and nothing else, while
the pending message promised a pause only that child could reach.

- adoptReportedChild records why with the count, as a category
  (adoption-off, cap, unsupported, adoption-failed) and the cap in force —
  never the free-text reason, which embeds an adapter error.
- describeReleasedChildren (tree-settle.ts) builds one sentence from that
  record, and every launch, restart and wait message that carries the count
  ends with it: "1 child process was released to run undebugged (child
  sessions are off: DEBUG_MCP_MAX_CHILD_SESSIONS=0): a breakpoint only it
  reaches will not hit." The detach answer keeps its own childrenReleased,
  which counts what the detach itself released.

Measured with real debugpy and js-debug before and after: the Python and
JavaScript subprocess-tree suites carry the issues' flows, the closed-child
one included.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…id rule, scoped waits (#1127, #1129, #1137)

An independent review of this branch, run with a shell and a mutation pass,
found one important gap and several smaller ones. Each fix here was written
against a test that failed first.

#1127 was still open on one path. create_debug_session with an attach
target attaches inside the call and names no session, so the dispatch had
nothing to hold: under an 800 ms window the new session was closed under
its own attach ("Created session but attach failed: … Proxy exited during
initialization"), reproduced on a real server.

- SessionManagerCore.holdForCall is now the one primitive for "a tool call
  is in flight on this tree": it stamps the clock, holds the tree, and its
  release stamps again. The dispatch takes it for every tool that names a
  session; create_debug_session takes it around its inline attach.
- The root is captured when the hold is taken, so a call that removes the
  session it named (closing a child) still restarts the tree's clock. It
  used to stamp the removed id, a no-op: after a 60 s close the root read
  60 s idle.
- idleCloseInFlight takes the caller's key. A child's row is removed before
  its tree's close is over, where the dispatch's scope check can no longer
  see it, and a stranger naming that id was made to wait — a timing
  difference from an id that never existed.
- The dispatch never waits twice for the same close, so a mark that
  outlived its close cannot spin it.

#1129: the rule fails closed. The word-end rule of the first fix showed
SESSION_IDSTR, SESSION_IDHASH, SESSION_IDCOOKIE, sessionIdcookie and
SESSION_IDENTIFIERs — names of a session id. The rule now masks whatever
follows `session…id`, as the old pattern did, and knows one exception:
the word is "idle". The only names shown that were masked before are the
idle ones. A second `session…id` later in the same name is read too.

#1137 wording: the cap cause is past tense (the answer may come long after
the tree was full), "adoption failed" has no pronoun (the count may be
plural) and no semicolon (causes are joined with one).

Docs: three sentences still named wait_for_stop as the only hold; the
"stopped N times" count is every visible stop of a child, not breakpoint
hits, and the docs no longer say otherwise.

Tests added for the gaps the mutation pass found: a stray sessionId the
caller does own, the released cap's reset at relaunch, the error-state
answer, a wait that answers for a child while the root has released
children, and a real-server case for create+attach.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.57143% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/session/session-manager-core.ts 97.22% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@debugmcpdev debugmcpdev left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 12bd50d against main 785f199, with independent passes over idle holds/close races and owner scoping, session-tree stop/release reporting, and environment redaction. No additional confirmed defects requiring changes were found.

The call hold is acquired before yielding, released in finally, and refreshes the root after a child closes. Inline create-and-attach holds its newly created session. Idle-close markers retain owner scoping after rows disappear. Descendant stop counts survive child removal and reset on relaunch; release explanations use the same record as childrenReleased. The redaction review also exercised 3,024 sensitive-name combinations without unmasking session-ID names.

Verified CI on this exact head: Linux 462 files / 9,171 tests passed (6 skipped); Windows 462 files / 9,172 tests passed (5 skipped). Lint, CodeQL, container, language-host and both MCP conformance jobs succeeded. A fresh local suite run was not completed because the isolated environment could not set up dependencies under its filesystem restrictions.

Codecov's patch check remains in progress; its report states 98.57% patch coverage with two uncovered lines. The known root-only warning and grandchild-release limitations are explicitly tracked in #1150 and are not introduced by this change.

@debugmcpdev
debugmcpdev merged commit 0f1eb6a into main Nov 13, 2026
15 of 16 checks passed
@debugmcpdev
debugmcpdev deleted the fix/idle-holds-tree-answers branch November 13, 2026 03:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment