Skip to content

Stop a Claude Agent SDK Bot's process when the browser leaves mid-answer - #1500

Open
phmucelin wants to merge 1 commit into
CopilotKit:mainfrom
phmucelin:fix/harness-stop-claude-on-disconnect
Open

phmucelin wants to merge 1 commit into
CopilotKit:mainfrom
phmucelin:fix/harness-stop-claude-on-disconnect

Conversation

@phmucelin

@phmucelin phmucelin commented Nov 11, 2026 •

Copy link
Copy Markdown

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 claude process running in the harness, with nobody listening, until the harness restarted.

Why. ag-ui-claude-sdk streams a run through Starlette's StreamingResponse. When the client disconnects, Starlette cancels the response's anyio cancel scope, and anyio re-delivers that cancellation at every await in the task. The cleanup in OpenBotClaudeAgentAdapter.run's finally (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:

  • the query was never interrupted,
  • the pump task was never cancelled,
  • SessionWorker.stop() never ran,
  • the CLI's stdin was never closed.

Fix. Run that cleanup in anyio.CancelScope(shield=True). This is the same approach claude_agent_sdk takes in its own Query.close() / SubprocessCLITransport.close(). interrupt is bounded: the SDK's interrupt control request has a 60s timeout, and SessionWorker.stop waits 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-json processes, some over an hour old:

  • about 230 MB RSS each, about 300% CPU combined;
  • uvicorn still held all three stdio sockets for each of them, so transport.close() had never run;
  • the Docker VM was at load average 70 with swap full.

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/:id went from 5–20s (and dropped connections) to under 30 ms.

Tests.

  • tests/test_browser_leaves_mid_reply.py runs adapter.run inside 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 on main (timeout waiting for the interrupt) and passes with this change. The full agent-claude-sdk/tests suite passes (122).
  • End to end, I used real uvicorn + this app + 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. On main, 3 of 3 processes were still running 20s later. With this change, 0 of 5 were.

Not in this PR. SessionWorker.stop() in ag-ui-claude-sdk gives the worker 15s and then does a bare task.cancel(). As SubprocessCLITransport.close()'s docstring notes, a raw asyncio cancellation that lands inside close() skips the terminate/kill escalation. So on a very slow machine a process could still be left behind. That belongs upstream in ag-ui-claude-sdk.

Where it runs

  • New state that outlives a request? None. The change only makes existing per-run cleanup actually run.
  • What happens on the second replica? Nothing different. Each harness process cleans up its own run, as before.
  • Anything serialised? No.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? No.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act. (No acting paths change.)
  • New refusals and new failures each write a row. (None added.)
  • Nothing new is trusted from the client that the server can resolve itself.

Changelog

  • A line in CHANGELOG.md under Unreleased → Conversations.

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
phmucelin force-pushed the fix/harness-stop-claude-on-disconnect branch from 7f75cb9 to ab3ea4b Compare November 11, 2026 18:16

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