Skip to content

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

Description

@penradioGables

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.15cptr/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions