Skip to content

fix(child-sessions): scope session_shutdown disposal to that session's children - #3204

Open
Jarcis-cy wants to merge 2 commits into
nicobailon:mainfrom
Jarcis-cy:fix/session-scoped-child-dispose
Open

Jarcis-cy wants to merge 2 commits into
nicobailon:mainfrom
Jarcis-cy:fix/session-scoped-child-dispose

Conversation

@Jarcis-cy

Copy link
Copy Markdown

Problem

In a multi-session host such as pi-web, one session's session_shutdown aborted every session's running in-process subagents.

The extension's shutdown handler called disposeChildSessions(), which disposes the process-wide factory's whole live set — not just the children of the session that is shutting down. In a single-session CLI process the two sets are identical, so nothing was visible there. In pi-web, which runs many sessions per process, the difference matters:

  1. pi-web's idle reaper closes any wrapper whose page stopped renewing its lease (a pane switched away, for example). That shutdown is legitimate and correct for the idle session.
  2. The closing wrapper emits session_shutdown to its own extension instance.
  3. The handler disposes the process-wide factory — aborting the other sessions' running children mid-flight.
  4. The victim child dies with Request was aborted (its provider fetch is aborted) while its parent session keeps running, so the loss masquerades as a provider failure.

Evidence (reproduced on a live pi-web host)

Two subagent runs in session 01a1ae62-26fc died at 09:47:18 (446 s of work) and 10:00:01 (952 s) with Request was aborted. Stack probes on the kill path showed both entering through disposeChildSessions() ← session_shutdown, while the wrapper-shutdown diagnostic log recorded the shutdowns on different, idle sessions:

[reaper] 01:47:17.993 sid=01a1ae62-83ed running=false pending=0 streaming=false
[shutdown] 01:47:17.994 sid=01a1ae62-83ed
  -> session_shutdown -> disposeChildSessions() -> child.abort()  # killed 26fc's child

The victim session's own wrapper was correctly spared (running=true) the whole time; only its child was killed by the sibling's cleanup. A kill of this shape also explains clusters where children of several sessions die within milliseconds of each other: one reaped wrapper, one global dispose.

Fix

  • Track each live child keyed by the session file of the parent that launched it (ChildSessionLaunch.parentSessionFile already carried it; children of file-less parents map to undefined).
  • disposeForParent(parentSessionFile) aborts and disposes only that session's attached children, including MCP waits still starting for it.
  • session_shutdown now calls disposeChildSessions(state.parentSessionFile); without an argument the whole-process behavior is kept for the detached runner's exit path.
  • Detached children stay out of both scopes, as before.

Tests

test/unit/child-session-scoped-dispose.test.ts covers: cross-session disposal leaves the other parent's child running, file-less-parent scoping, whole-process dispose() semantics, and detached children surviving both scopes. Full unit suite: 4358 pass / 0 fail; tsc --noEmit clean.

…s children

The extension's session_shutdown handler disposed every live in-process
child through the process-wide factory. In a single-session CLI process
that is exactly right, but a multi-session host such as pi-web runs many
sessions in one process and shuts their wrappers down one at a time (its
idle reaper closes any wrapper whose page stopped renewing its lease).
One such shutdown aborted every session's running subagents mid-flight:
the child died with `Request was aborted` while its parent session kept
running, so the loss looked like a provider failure.

Track each live child by the session file of the parent that launched it
and add disposeForParent(), which aborts and disposes only that session's
attached children (children of file-less parents match undefined).
session_shutdown now calls it with the extension's own session file;
dispose() keeps its whole-process semantics for the detached runner's
exit path, and detached children stay out of both scopes.
@greptile-apps

greptile-apps Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 0/5

[Medium impact] The change affects child cleanup in processes shared by multiple parent sessions.

Do not merge yet: shutdown still stops other sessions' children, and required contributor credit is missing.

Findings

  1. P0 Required contributor credit is missing ▶
  2. P1 Shutdown still stops other sessions ▶
  3. P1 File-less shutdown stops unrelated children ▶
  4. P1 Unrelated MCP startup fails ▶
  5. P1 Remote cleanup ignores the parent ▶
Fix with agent prompt
### Issue 1
CHANGELOG.md:7
Merge gate: the new `CHANGELOG.md` entry omits credit for @Jarcis-cy and PR #3204. The repository requires the contributor's login or name and a PR or issue number for material external work. Add that credit before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 2
src/extension/index.ts:1269
`runtimeEntry.cleanup()` sets `state.parentSessionFile` to `null` before this call reads it. Every normal shutdown therefore calls `disposeChildSessions(undefined)`, which disposes all attached children across the process. Closing one session still aborts other sessions' running children.

Capture the parent's file before cleanup. Add a focused test through the actual `session_shutdown` handler; the new factory-only tests miss this ordering.

### Issue 3
src/runs/shared/child-session.ts:826-827
`disposeChildSessions(undefined)` means whole-process disposal, but the extension also passes `undefined` for a parent without a session file. Even after fixing the cleanup ordering, shutting down a file-less parent will still abort file-backed parents' children.

Distinguish an omitted argument from an explicitly supplied `undefined`, or expose separate scoped and whole-process helpers, so file-less shutdown reaches `disposeForParent(undefined)`.

### Issue 4
src/runs/shared/child-session.ts:514-516
`disposeForParent()` now increments the shared `disposals` counter. If parent B is opening an MCP child while parent A is disposed, B sees the changed counter at line 715 or 719 and shuts its session down, even though its parent did not match.

Track scoped disposal separately for each parent while retaining a whole-process counter. Cover disposal of A while B waits for its MCP tools.

### Issue 5
src/runs/shared/herdr-placed-run.ts:175
The new placement `disposeForParent()` disposes the entire `remote` set and clears it without checking `parentSessionFile`. The process-wide placement factory can hold children from several parents, so scoped cleanup for A also closes B's bridge and connection. B loses control of its running remote work.

Record each remote child's launching parent and dispose only matching attached children, leaving the others tracked.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds parent-scoped child disposal and factory-level tests.

  • Each session shutdown now targets its own local children.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Session shuts down] --> B[Cleanup clears parent file]
  B --> C[Disposal receives undefined]
  C --> D[Whole-process disposal]
  D --> E[Other sessions lose attached children]
  F[Scoped factory disposal] --> G[Shared counter changes]
  G --> H[Unrelated MCP launch fails]
  F --> I[Placement wrapper selects every remote child]
  I --> J[Other parents lose remote connections]
Loading

Reviews (1) · Last reviewed commit: "fix(child-sessions): scope session_shutd..." · Reviewed by Greptile

Comment thread src/extension/index.ts Resolved
Comment thread src/runs/shared/child-session.ts Resolved
Comment thread src/runs/shared/child-session.ts Resolved
Comment thread src/runs/shared/herdr-placed-run.ts Resolved
Comment thread CHANGELOG.md Resolved
…ent disposal epochs

Review follow-ups on nicobailon#3204:

- Capture the parent session file before runtimeEntry.cleanup() clears it:
  the handler read state.parentSessionFile after cleanup had already nulled
  it, so every session_shutdown disposed the whole process and the scoping
  never took effect. A new extension-level test drives the real handlers and
  asserts the scoped call, which fails against that ordering.
- Split disposeChildSessionsForParent(parent) from the whole-process
  disposeChildSessions() so a parent without a session file no longer rides
  the whole-process path; undefined is now an explicit file-less scope.
- Track disposal epochs per parent (plus a global counter): a scoped
  disposal of one parent no longer trips the launch-race guard of another
  parent's child waiting for its MCP tools.
- Key the placement wrapper's remote children by their launching parent so
  disposeForParent closes only that parent's remote bridges.
- Credit the CHANGELOG entry per the repository's contributor rule.
@greptile-apps

greptile-apps Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Your organization has run out of review credits. Add credits to resume code reviews.

@Jarcis-cy

Copy link
Copy Markdown
Author

Thanks for the review — all five findings were valid, fixed in 8a58b9f (typecheck clean, full unit suite 4359 pass / 0 fail):

  • P1 ordering (the big one): the handler now captures state.parentSessionFile as its first statement, before runtimeEntry.cleanup() nulls it. New test/unit/extension-session-shutdown-scope.test.ts drives the real session_start/session_shutdown handlers against a scripted factory and asserts exactly one disposeForParent(<this session's file>) and zero whole-process dispose() — that test fails against the previous ordering.
  • P1 undefined overloading: split disposeChildSessionsForParent(parent) out of disposeChildSessions(). The extension now calls the former; undefined is an explicit file-less scope, and the whole-process path is only the runner's exit.
  • P1 shared disposals counter: disposal epochs are now per parent (plus a global counter bumped only by whole-process dispose()), so disposing parent A cannot trip parent B's launch-race guard. A direct unit test of the MCP-wait race would need the built-in MCP scaffolding the suite doesn't have today; the guard now reads disposedSinceStart() = own-parent epoch moved OR global dispose, which is the property that fix preserves.
  • P1 remote cleanup: the placement wrapper now keys its remote children by launching parent and disposeForParent closes only matching bridges.
  • P0 changelog credit: added Thanks to @Jarcis-cy for #3204 per the repository rule.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant