fix(app-server): unsubscribe task threads after client disconnect - #707
fix(app-server): unsubscribe task threads after client disconnect#707thossullivan wants to merge 2 commits into
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
One race still looks possible here. addThreadOwner() cancels a retry timer, but it doesn't do anything about a thread/unsubscribe already in pendingUnsubscribes. If the last owner disconnects and that request is in flight while another client resumes the same thread, a late unsubscribe can leave the shared app-server connection unsubscribed even though threadSockets has a new owner.
I don't see a test for reacquiring a thread during an in-flight unsubscribe. Could we re-check ownership when the unsubscribe settles (and restore the subscription if needed), or otherwise serialize that transition?
Summary
The app-server broker multiplexes plugin tasks through one shared upstream connection. Starting or resuming a thread subscribes that connection to thread events.
Before this change, closing a downstream task socket removed request and stream routing state but retained the thread subscription. Repeated tasks could therefore accumulate thread runtimes and MCP process trees inside one broker session.
This change tracks thread ownership for every downstream socket. The broker sends
thread/unsubscribeonly after the final downstream owner releases a thread.Implementation
The broker maintains ownership in both directions. Downstream sockets reference thread IDs, and thread IDs reference their downstream owners. This structure preserves a shared subscription while another client still owns the thread.
Ownership covers started, resumed, forked, detached review, subagent, and automatically created child threads. Direct request results establish root ownership. Subagent notifications use
parentThreadIdor collaboration sender and receiver IDs to inherit ownership from the causal parent. They do not use whichever downstream client happens to be active when a delayed notification arrives.Requests from each downstream socket run in order. This prevents overlapping resume or review requests from releasing another request's provisional claim. A response received after its downstream socket closes also triggers immediate cleanup.
Normal socket closure and abrupt disconnection use the same final-owner cleanup path. Automatic unsubscribe failures are logged and retried with bounded backoff. Explicit downstream unsubscribe requests continue to preserve subscriptions owned by other clients.
Scope
This change does not alter MCP configuration or depend on Basic Memory. Basic Memory only provided visible reproduction evidence for the host-level lifecycle defect.
Codex retains unsubscribed threads during its documented 30-minute inactivity period. This change starts that bounded cleanup period but does not promise immediate MCP process termination.
Testing
The integration suite covers normal completion, shared resumed threads, detached reviews, forks, active-turn disconnection, subagents, late child threads, collaboration-only child discovery, explicit unsubscribe, and upstream failures.
The adversarial regression cases also cover delayed child notifications during an unrelated task, overlapping same-socket resume requests, transient unsubscribe retries, and test resource cleanup.
node --test tests/broker-subscriptions.test.mjs: 14 passednpm test: 105 passednpx tsc -p tsconfig.app-server.json --noEmit: passedReal-process verification used Codex CLI 0.150.1 and Basic Memory.
unsubscribednotSubscribedBoth cases started a real Basic Memory wrapper and Python server. Every isolated test descendant exited during cleanup.
After the adversarial hardening, patched UAT covered both downstream closure modes.
notSubscribednotSubscribedBoth brokers and app-server processes remained alive through natural MCP unload. Final cleanup left zero isolated descendants.
Related work
This change complements #543, #660, and #680. Those items address broker and app-server lifetime, while this change releases completed task threads inside a running broker.
The change does not depend on either lifecycle pull request.
Fixes #706