Skip to content
C.W.K.
Stream
Lesson 04 of 07 · published

Persistent Subprocess Pools (Agent SDK)

~17 min · subprocess, pool, agent-sdk

Level 0Observer
0 XP0/64 lessons0/13 achievements
0/150 XP to next level150 XP to go0% complete

Why a pool

Spinning up a Claude Agent SDK subprocess per request is expensive (startup cost, MCP attach, system prompt warm-up). For chat-style workloads, a pool of long-lived clients keyed by conversation_id is dramatically faster. cwkPippa's ClaudeSessionManager began as exactly this pool. Since 2026-05-14 it starts a fresh client for every turn and replays the whole conversation into it, so the pool now exists for per-turn locking, releasing the client after the turn, and a reaper for entries that error paths leave behind. Either shape needs the hygiene below.

What the pool owns

The pool owns: lifecycle (connect on first use, disconnect on TTL or shutdown), error handling (replace dead clients), capacity caps (max concurrent subprocesses), and metrics (active count, lifetime, last-use timestamp). Treat it like a database connection pool — the same disciplines apply.

Stale subprocess hygiene

Long-lived subprocesses leak resources if you forget about them. Sweep the pool periodically: drop clients that have sat unused for N minutes, free their MCP connections, log the reason. Bound the pool size; refuse new sessions when full and surface the failure rather than queuing forever.

Idle means nobody holds it

Unused is the word that bites. A turn that streams for ten minutes has not touched its last-use timestamp since it began, so a sweep that judges idleness from that timestamp alone calls a working client idle and disconnects it mid-stream. Tie liveness to ownership instead. The caller holds the client's lock for the whole turn, the sweep skips any client whose lock is held, and the idle clock starts when the turn ends. The pool below does exactly that. The test beside it calls the real sweep on a turn that the sweep's clock puts twenty minutes in. A test that copies the sweep's condition into its own body checks only the copy, and it keeps passing while the real sweep kills turns.

Principle: A pool is its own component. Build it like infrastructure, not like glue code.

Code

A session pool whose sweep never touches a running turn·python
import asyncio, time
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions

@dataclass
class Entry:
    client: ClaudeSDKClient
    last_used: float = field(default_factory=time.monotonic)
    turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock)

class SessionManager:
    def __init__(self, max_size: int = 50, ttl_seconds: int = 1800):
        self._entries: dict[str, Entry] = {}
        self._pool_lock = asyncio.Lock()
        self.max_size = max_size
        self.ttl = ttl_seconds

    @asynccontextmanager
    async def turn(self, conv_id: str, options: ClaudeAgentOptions):
        """Lend one client for one whole turn."""
        async with self._pool_lock:
            await self.sweep()
            entry = self._entries.get(conv_id)
            if entry is None:
                if len(self._entries) >= self.max_size:
                    raise RuntimeError("session pool full")
                client = ClaudeSDKClient(options=options)
                await client.connect()
                entry = self._entries[conv_id] = Entry(client)
            entry.last_used = time.monotonic()  # checked out, so not idle
        async with entry.turn_lock:  # held until the turn is over
            try:
                yield entry.client
            finally:
                entry.last_used = time.monotonic()  # the idle clock starts here

    async def sweep(self, now: float | None = None):
        """One pass. A client whose turn holds its lock is never idle."""
        now = time.monotonic() if now is None else now
        for conv_id, entry in list(self._entries.items()):
            if entry.turn_lock.locked():
                continue
            if now - entry.last_used > self.ttl:
                del self._entries[conv_id]
                await entry.client.disconnect()

    async def reaper(self, every: float = 60):
        """Background loop; it runs the same pass the tests call."""
        while True:
            await asyncio.sleep(every)
            async with self._pool_lock:
                await self.sweep()


# One turn, start to finish, inside the lock:
#
#     async with pool.turn(conv_id, options) as client:
#         await client.query(prompt)
#         async for message in client.receive_response():
#             ...
The test calls the real sweep, not a copy of its condition·python
import asyncio, time
import pytest
import session_pool

class FakeClient:
    def __init__(self, options=None):
        self.connected = False
    async def connect(self):
        self.connected = True
    async def disconnect(self):
        self.connected = False

@pytest.fixture
def pool(monkeypatch):
    monkeypatch.setattr(session_pool, "ClaudeSDKClient", FakeClient)
    return session_pool.SessionManager(ttl_seconds=300)

def test_a_turn_in_progress_is_never_swept(pool):
    async def scenario():
        async with pool.turn("conv-a", options=None) as client:
            # Twenty minutes into one long turn, by the sweep's clock.
            await pool.sweep(now=time.monotonic() + 1200)
            return client.connected
    assert asyncio.run(scenario())  # delete the lock check: this goes red

def test_a_client_idle_past_the_ttl_is_swept(pool):
    async def scenario():
        async with pool.turn("conv-a", options=None) as client:
            pass
        await pool.sweep(now=time.monotonic() + 301)
        return client.connected
    assert not asyncio.run(scenario())

External links

Exercise

Add a TTL sweeper to your Claude session pool (or build one from the code above). Test it by calling the real sweep, never a copy of its condition: a client idle for TTL + 1 seconds is disconnected, and a client whose turn is still running survives a sweep whose clock says twenty minutes have passed. Then delete the lock check and confirm the running-turn test fails.
Hint
If your pool grew unbounded in production, a missing sweep is the bug, not a 'memory leak in the SDK'. If long turns die on the same clock every time, suspect your own sweep before the vendor.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.