[GH-ISSUE #195] bug: the claude CLI subprocess is torn down at the end of every turn, killing in-flight background agents #112

Closed
opened 2026-10-18 14:03:03 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @penradioGables on GitHub (Jul 28, 2026).
Original GitHub issue: https://github.com/open-webui/computer/issues/195

Summary

run_claude_code_agent() disconnects its ClaudeSDKClient in a finally at the end of every turn, which SIGTERM/SIGKILLs the underlying claude CLI subprocess. Any background/Task-tool agent spawned during that turn is an in-process async task inside that subprocess, so it is destroyed at the turn boundary regardless of how much work it had left. Every turn also pays a full process spawn plus session replay.

Verified still present in v0.9.15 — cptr/utils/agents/claude_code.py is byte-identical between the v0.9.10 and v0.9.15 tags.

Symptom

A background agent started in one turn never returns results. The user sees a <task-notification status="stopped"> with wording that hedges about whether the process exited — "It may have been stopped, or it may have been running when the previous Claude Code process exited". In practice the exit is deterministic, not ambiguous: it happens at the end of every turn, in-flight work or not.

Root cause

The client is constructed fresh, connected, given exactly one query(), streamed to ResultMessage, then unconditionally torn down (L146–147 and L241–242):

        client = sdk.ClaudeSDKClient(options)
        await client.connect()
        finally:
            await client.disconnect()

disconnect() → Query.close() → SubprocessCLITransport.close(), which closes stdin then escalates SIGTERM→SIGKILL within ~10s.

Observed directly on a running instance: walking /proc during normal use showed multiple claude CLI subprocesses alive simultaneously — one with --resume=<uuid> and one without — i.e. a new subprocess per turn rather than one long-lived process per conversation.

claude_agent_sdk's own ClaudeSDKClient.query() docstring documents the intended usage as one long-lived client across multiple query() calls, with set_model() / set_permission_mode() to change things mid-conversation.

Suggested fix

Cache the connected client per Claude session id — cptr already threads session_id through resume_state on every call — and reuse it across turns, disconnecting only on error, on a workspace/cwd change (which can't be applied to a live process), or after an idle timeout.

Known trade-off, which is why this is filed as a problem statement rather than a PR

system_prompt is a CLI launch flag fixed at connect() time; there is no way to update it on a live client. So on a cache hit the per-turn dynamic system-prompt scaffolding (memory recall, conversation-summary / plan-mode / voice-mode injections) stops being refreshed.

Conversation content is unaffected — cptr only ever sends latest_user_text() per turn and relies on the CLI's own resumed session for history — so this touches prompt scaffolding only. But it does need a real answer from upstream (re-inject the scaffolding as a user-turn preamble? accept staleness? reconnect only when the scaffolding actually changed?) rather than a decision made in a patch.

I have run the caching approach locally and it behaves as described, including surviving background agents across turn boundaries. Glad to turn it into a PR if you have a preference on how to handle the system_prompt question.

Originally created by @penradioGables on GitHub (Jul 28, 2026). Original GitHub issue: https://github.com/open-webui/computer/issues/195 ### Summary `run_claude_code_agent()` disconnects its `ClaudeSDKClient` in a `finally` at the end of **every** turn, which SIGTERM/SIGKILLs the underlying `claude` CLI subprocess. Any background/Task-tool agent spawned during that turn is an in-process async task inside that subprocess, so it is destroyed at the turn boundary regardless of how much work it had left. Every turn also pays a full process spawn plus session replay. **Verified still present in v0.9.15** — `cptr/utils/agents/claude_code.py` is byte-identical between the `v0.9.10` and `v0.9.15` tags. ### Symptom A background agent started in one turn never returns results. The user sees a `<task-notification status="stopped">` with wording that hedges about whether the process exited — *"It may have been stopped, or it may have been running when the previous Claude Code process exited"*. In practice the exit is deterministic, not ambiguous: it happens at the end of every turn, in-flight work or not. ### Root cause The client is constructed fresh, connected, given exactly one `query()`, streamed to `ResultMessage`, then unconditionally torn down (L146–147 and L241–242): ```python client = sdk.ClaudeSDKClient(options) await client.connect() ``` ```python finally: await client.disconnect() ``` `disconnect()` → `Query.close()` → `SubprocessCLITransport.close()`, which closes stdin then escalates SIGTERM→SIGKILL within ~10s. Observed directly on a running instance: walking `/proc` during normal use showed multiple `claude` CLI subprocesses alive simultaneously — one with `--resume=<uuid>` and one without — i.e. a new subprocess per turn rather than one long-lived process per conversation. `claude_agent_sdk`'s own `ClaudeSDKClient.query()` docstring documents the intended usage as one long-lived client across *multiple* `query()` calls, with `set_model()` / `set_permission_mode()` to change things mid-conversation. ### Suggested fix Cache the connected client per Claude session id — cptr already threads `session_id` through `resume_state` on every call — and reuse it across turns, disconnecting only on error, on a workspace/cwd change (which can't be applied to a live process), or after an idle timeout. ### Known trade-off, which is why this is filed as a problem statement rather than a PR `system_prompt` is a CLI launch flag fixed at `connect()` time; there is no way to update it on a live client. So on a cache hit the per-turn dynamic system-prompt scaffolding (memory recall, conversation-summary / plan-mode / voice-mode injections) stops being refreshed. Conversation *content* is unaffected — cptr only ever sends `latest_user_text()` per turn and relies on the CLI's own resumed session for history — so this touches prompt scaffolding only. But it does need a real answer from upstream (re-inject the scaffolding as a user-turn preamble? accept staleness? reconnect only when the scaffolding actually changed?) rather than a decision made in a patch. I have run the caching approach locally and it behaves as described, including surviving background agents across turn boundaries. Glad to turn it into a PR if you have a preference on how to handle the `system_prompt` question.
Author
Owner

@tjbck commented on GitHub (Jul 31, 2026):

Should be addressed!

<!-- gh-comment-id:5147557151 --> @tjbck commented on GitHub (Jul 31, 2026): Should be addressed!
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
open-webui/computer#112
No description provided.