Repository navigation
fix(desktop): release a native chat when you leave it, so its idle clock can start - #22801
Conversation
12c5548 to
b815e8c
Compare
35be8c1 to
38f744d
Compare
b815e8c to
8bbedb6
Compare
38f744d to
634e222
Compare
Review statusVerdict: ready to merge in stack order (#22802, then #22794, then this PR). Live Electron QA for the stack is being attached separately. The review made no code changes. The head is still Review loop. One adversarial review loop ran and came back clean without needing any changes, so no further loops ran. It confirmed the root cause and that the fix removes it rather than guarding around it. Before, the desktop Streams affected. The change reaches exactly four desktop streams: Streams that end on their own. A stream that sends Mobile, web and remote. Nothing changes for phone, paired-web or remote-host streams. No wire format and no persisted state changed. Readiness checklist. Run at the start and the end of the review. Both passed with no P0 or P1 findings. Validation.
PR description. Two sentences are corrected. The three chat streams also return right after setup, but they already honored a late abort. The other transports reach the same outcome by a different mechanism. Non-blocking notes, not changed:
Expected change users will notice. A desktop chat you leave now has its agent process stopped after 30 idle minutes, unless a turn, subagent or background command is still running. Coming back re-attaches it, with a short start-up delay. This relies on #22802 and #22794 being merged first. |
8bbedb6 to
55a9106
Compare
634e222 to
2c7ab99
Compare
Live Electron QA (whole stack)Isolated dev instance (hidden window, driven over CDP), macOS, real Claude Code sessions. The idle window was shortened to 45 s in the QA build only. A "before" build swaps one fix's files back to
The scenario that crashes Claude mid-chat was not driven live; the #22802 integration tests cover it. Windows, Linux and SSH were not run live: the changes are host-side and not platform-specific, and no wire format changed. |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This stacked PR (base: brennanb2025/chat-idle-background-work) makes a desktop runtime:unsubscribe actually end the stream it names, so leaving a native chat releases its provider hold and the idle clock can start.
src/main/ipc/runtime.ts— drops the.finally(stop)that deleted each stream'sAbortControllerfromsenderSubscriptionswhen the dispatch promise settled. The controller now lives untilruntime:unsubscribeaborts+deletes it or sender retirement aborts+clears the map, which is exactly what the streaming handlers listen to.src/main/runtime/rpc/methods/session-tabs-inventory.ts— routes the transport abort toruntime.cleanupSubscription(subscriptionId)for the stream's whole life. PreviouslyabortInventoryonly aborted the census controller and the abort listener was removed once setup finished, sosession.tabs.subscribeAllkept its host listener. Listener removal now lives in the registered cleanup.runtime-subscribe-lifecycle.test.ts/session-tabs-inventory-rpc.test.ts— new regression coverage: tworuntime:subscribecases where the handler settles right after setup (an explicit unsubscribe and a sender navigation must still abort), and asession.tabs.subscribeAllcase where a later transport abort removes the host listener and emitsend.use-structured-agent-session-transport.ts— comment-only correction of the stated idle window.
I confirmed the new tests are not theatre: restoring the old .finally(stop) fails both runtime:subscribe cases, and restoring the old onTransportAbort = inventoryController.abort() fails the session-tabs case. The focused suites (runtime-subscribe-lifecycle, runtime, session-tabs-inventory-rpc, structured-agent-session-hold) pass, and npx oxlint is clean on the changed files.
I also traced the transports: the mobile/remote websocket dispatch controller only aborts on socket close/error (not on handler settle), so the new session-tabs abort path cannot prematurely end a mobile stream, and cleanup stays idempotent via RuntimeSubscriptionRegistry.cleanupAndWait plus owned registrations on the chat streams.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
The desktop runtime:subscribe handler dropped each stream's abort controller as soon as the method handler's promise settled. Most streaming handlers return right after setup and keep streaming until their signal aborts, so a later runtime:unsubscribe found nothing to abort. Those streams, and anything they hold, lived until the window reloaded or closed. For a native chat that meant the chat's history stream kept the session held after the user left it, and the idle release clock never started. The controller now lives until runtime:unsubscribe or the sender's retirement, which is when the renderer is actually done with the stream.
…rts it session.tabs.subscribeAll stopped listening for its transport's abort once the first inventory was sent, so a desktop unsubscribe left its host listener running until the window reloaded. The abort now routes to the stream's own cleanup for its whole life. Also corrects the idle window stated in the chat transport's comment.
55a9106 to
fecd6f5
Compare
2c7ab99 to
d716636
Compare
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (5)
Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughThe runtime keeps subscription controllers registered after streaming dispatch settles, until unsubscribe or sender retirement. Session inventory subscriptions now use transport abort to clean up the runtime subscription. Tests cover both lifecycle paths. A renderer comment now describes session eviction in terms of the host’s idle window. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The subscription cleanup changes are ready to merge after normal checks; no concrete remaining risk is identified. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to The change restores cleanup when a user leaves a chat without changing who may access the subscription. One failure path may retain subscriptions until the window is retired, which could affect the desktop app’s availability. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Full details: Description checkExplanation The description is detailed and explains the user impact, implementation, rationale, testing, and platform scope. However, it does not provide the required Linked Issue entry in the Resolution Add a valid linked issue using the required
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Visual proof: leaving a desktop chat now lets its agent process stopLive runs in a hidden dev instance of Orca. The instance ran
Before: the chat you left kept its Claude process running indefinitely, well past the idle window. The chat looks identical in both builds; the difference is only whether its process is released. After: once the process has stopped, coming back to the chat still shows the full history, with no "Could not load conversation" error. Sending a new message starts a new process and gets a reply. |




ELI5
On desktop, when you leave a native chat (switch worktree or close its pane), the app tells the background process "I'm done reading this chat". That message was being ignored, so every chat you had ever opened stayed marked as "being viewed" until you reloaded or quit the app. Its agent process was never stopped when idle. Now leaving a chat really ends the read, and the idle clock can start.
What Changed
src/main/ipc/runtime.ts: theruntime:subscribehandler dropped each stream's abort handle as soon as the RPC handler's promise settled. Most streaming handlers, includingagentSession.subscribe, return right after setup and keep streaming until their signal aborts. So by the time the renderer sentruntime:unsubscribe, there was nothing left to abort. The handle now lives untilruntime:unsubscribeor the window's retirement (reload, crash, close), which is when the renderer is actually done with the stream.session.tabs.subscribeAllstopped listening for that abort once its first inventory was sent. It now routes the abort to its own cleanup for its whole life, so turning structured chat off no longer leaves a host listener running. The three chat streams (agentSession.subscribe,subscribeStatus,subscribeTurnCompletions) also return after setup, but they already listened for the abort for their whole life, so they needed no change.use-structured-agent-session-transport.ts(it said 15 s; it is 30 minutes).Why
The bug was in the transport, not in any one method, so the fix is in the transport. Every desktop stream that ends by abort was affected the same way. A per-method unsubscribe RPC for chats would have added a second release path beside the abort that the handlers already wire up.
Orca's other transports reach the same outcome by a different mechanism. A paired desktop's remote subscription closes its own socket, which runs the stream's cleanup. On the mobile websocket, the per-request abort only covers setup; its streams end through the connection's cleanup or a per-method unsubscribe call. The desktop renderer is one long-lived connection, so it needs a per-stream end, and the stream's abort is the one every desktop handler already honors.
What this newly triggers: I audited every desktop stream that goes through this path (
agentSession.subscribe,agentSession.subscribeStatus,agentSession.subscribeTurnCompletions,session.tabs.subscribeAll). Each one's cleanup key includes the per-subscription id (desktop-<uuid>), so ending one stream can never tear down another, including React StrictMode's unmount-and-remount. The chat streams use owned cleanup registrations, which ignore a stale release.Linked Issue
No separate issue. Found while fixing #22794, which this depends on.
Merge order: #22794 before this PR. This PR now targets
maindirectly and no longer carries #22794's commits. Each of these fixes something this PR would otherwise expose on every desktop chat you leave:Visual Proof
Live Electron QA on an isolated dev instance (hidden window, CDP), macOS, real Claude Code sessions. The idle window was shortened to 45 s in the QA build only. "Before" builds swap in one file set from
mainat a time and restart the app. Process liveness comes frompson the provider pid the app spawned.Not visual on its own: the change is when the provider process stops after you leave a chat.
main)After, coming back to the stopped chat re-attaches it: history intact, and a new provider process answers the next send.
Testing
runtime-subscribe-lifecycle.test.ts: two new cases in which the dispatched handler returns right after setup, as real streaming handlers do. An explicit unsubscribe aborts it, and so does a navigation. The existing fake stream only settled on abort, which is why this was never caught. Both new cases fail without the fix.session-tabs-inventory-rpc.test.ts: a set-upsubscribeAllstream ends (host listener removed,endsent) when its transport aborts afterwards. Fails without the fix.The existing
structured-agent-session-hold.test.tscase "releases a desktop subscription when its renderer transport dies" already covers the host side: the abort releases the chat's hold and the idle clock evicts it.202 test files and 2,060 tests across IPC, session tabs, native chat and the structured session host pass.
pnpm tc:node,pnpm tc:web, oxlint andcheck:code-quality:changedare clean.I manually tested these changes locally
Automated tests added/updated, or explained why not below
AI Disclosure
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
agentSession.unsubscribewhen it leaves a chat, so that stream's hold lasts until the socket drops (for example, when the app goes to the background). It is a separate client change and is not in this PR.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)