Repository navigation
Conversation
phmucelin
requested review from
MikeRyanDev,
davidmckayv,
dustywusty,
guidovizoso,
mxmzb,
samjulien and
tylerslaton
as code owners
November 11, 2026 18:06
When the browser closes a run's stream (Stop, a reload, another screen, or the server dropping the call), Starlette cancels the response's anyio cancel scope, and anyio re-delivers that cancellation at every await in the task. `run`'s cleanup, `await self.interrupt(thread_id)`, was one of those awaits: it was cancelled at its first step, so the query was never interrupted, the pump task never cancelled, its SessionWorker never stopped, and its `claude` process kept running with nobody listening until the harness restarted. Run the cleanup in a shielded cancel scope, as claude_agent_sdk does for its own close(). Measured end to end (real uvicorn, Starlette, claude_agent_sdk and SessionWorker, with a stand-in CLI that replies until interrupted): before, 3 of 3 processes were still running 20s after the client disconnected; after, 0 of 5. A live OpenBot Desktop install had 14 such processes in its harness (~230 MB RSS each, 300% CPU combined).
phmucelin
force-pushed
the
fix/harness-stop-claude-on-disconnect
branch
from
November 11, 2026 18:16
7f75cb9 to
ab3ea4b
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
A Claude Agent SDK Bot whose browser leaves mid-answer (Stop, a reload, another screen, or the server dropping the call) left its
claudeprocess running in the harness, with nobody listening, until the harness restarted.Why.
ag-ui-claude-sdkstreams a run through Starlette'sStreamingResponse. When the client disconnects, Starlette cancels the response's anyio cancel scope, and anyio re-delivers that cancellation at everyawaitin the task. The cleanup inOpenBotClaudeAgentAdapter.run'sfinally(await self.interrupt(thread_id)) is one of those awaits, so it was cancelled at its first step (super().interrupt→SessionWorker.interrupt). As a result:SessionWorker.stop()never ran,Fix. Run that cleanup in
anyio.CancelScope(shield=True). This is the same approachclaude_agent_sdktakes in its ownQuery.close()/SubprocessCLITransport.close().interruptis bounded: the SDK's interrupt control request has a 60s timeout, andSessionWorker.stopwaits at most 15s.Seen in the wild. On OpenBot Desktop 1.0.2 (macOS, Docker Desktop with 7.6 GB), the harness had accumulated 14 orphaned
claude --output-format stream-jsonprocesses, some over an hour old:transport.close()had never run;The Postgres container then crashed repeatedly under that load ("server process exited with exit code 2", recovery mode, 20s fsyncs). The UI showed "Couldn't load earlier messages", and a Bot's page stayed on its "Bot" placeholder. Restarting the harness with this change took it from about 300% CPU / 1.7 GB to 0.2% / 62 MB.
GET /api/agents/:idwent from 5–20s (and dropped connections) to under 30 ms.Tests.
tests/test_browser_leaves_mid_reply.pyrunsadapter.runinside an anyio task group and cancels the group mid-reply, as Starlette does. It then asserts that the SDK client is interrupted and disconnected and that the turn and worker are gone. It fails onmain(timeout waiting for the interrupt) and passes with this change. The fullagent-claude-sdk/testssuite passes (122).claude_agent_sdk+SessionWorker, with a stand-in CLI (via_find_cli) that replies until it is interrupted, and dropped the HTTP connection after the reply started. Onmain, 3 of 3 processes were still running 20s later. With this change, 0 of 5 were.Not in this PR.
SessionWorker.stop()inag-ui-claude-sdkgives the worker 15s and then does a baretask.cancel(). AsSubprocessCLITransport.close()'s docstring notes, a raw asyncio cancellation that lands insideclose()skips the terminate/kill escalation. So on a very slow machine a process could still be left behind. That belongs upstream inag-ui-claude-sdk.Where it runs
Boundary and audit
Changelog
CHANGELOG.mdunderUnreleased→ Conversations.