Files

157 lines
6.9 KiB
Python

"""Per-session persistent SDK client pool (lever A of the TTFT work, gated by
OSW_TTFT_PERSISTENT_CLIENT=1, default OFF). One live Claude CLI per session, reused across
follow-up turns so the ~0.5s subprocess + MCP boot is paid once, not per message.
Safety model, from the red-teamed plan: reuse is gated on a BOOT FINGERPRINT (a hash of every
boot-frozen input), never on session flags. Any change to the booted config (MCPActivate growing
mcp_servers, branch switch, compaction, provider env, selection-context system prompt) changes the
fingerprint and forces a dispose+respawn, so "live client with stale config" is unrepresentable.
Every error path collapses to dispose+respawn, which IS today's one-shot behavior, never worse."""
import asyncio
import hashlib
import json
import logging
import os
import time
from typing import Awaitable, Callable, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, InstanceOf
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession
logger = logging.getLogger(__name__)
# Options entries that are per-turn or non-serializable; everything else is boot-frozen and hashed.
P_NON_BOOT_KEYS = frozenset({"can_use_tool", "stderr", "hooks", "resume", "fork_session"})
def persistent_client_enabled() -> bool:
"""Default ON (soak-proven: warm turns 535ms -> 6ms). Kill switch: OPENSWARM_PERSISTENT_CLIENT=0."""
return os.environ.get("OPENSWARM_PERSISTENT_CLIENT", "1") != "0"
# Per-session field-level digests from the last fingerprint call; lets a mismatch log WHICH boot field drifted (probe-gated diagnostics only).
p_last_field_digests: Dict[str, Dict[str, str]] = {}
@typechecked
def boot_fingerprint(options_kwargs: Dict, session: AgentSession) -> str:
"""Hash of every input the CLI subprocess freezes at boot. Includes the full mcp_servers config
(so MCPActivate / model-env changes respawn), the composed system prompt (so per-turn selection
context respawns instead of silently not applying), branch, and the compaction cutoff (else a
live client would keep the untrimmed transcript forever)."""
frozen = {k: v for k, v in options_kwargs.items() if k not in P_NON_BOOT_KEYS}
frozen["p_branch"] = session.active_branch_id
frozen["p_compacted_through"] = session.compacted_through_msg_id
# Pool diagnostics (OPENSWARM_POOL_DIAG=1): on a respawn, names WHICH boot field drifted; the tool for debugging respawn churn (e.g. the thinking short/long-prompt flip) in the field.
if os.environ.get("OPENSWARM_POOL_DIAG") == "1":
digests = {k: hashlib.sha256(json.dumps(v, sort_keys=True, default=str).encode()).hexdigest()[:10] for k, v in frozen.items()}
prev = p_last_field_digests.get(session.id)
if prev is not None:
changed = [k for k in digests if prev.get(k) != digests.get(k)] + [k for k in prev if k not in digests]
if changed:
logger.info(f"[client-pool] {session.id}: fingerprint fields changed: {sorted(set(changed))}")
p_last_field_digests[session.id] = digests
blob = json.dumps(frozen, sort_keys=True, default=str)
return hashlib.sha256(blob.encode()).hexdigest()
class ClientHandle(BaseModel):
model_config = ConfigDict(validate_assignment=True)
fingerprint: str
client: InstanceOf[object]
lock: InstanceOf[asyncio.Lock]
connected_at: float
last_used: float
turns_served: int = 0
# A pooled CLI holds ~100MB+ per session; evict clients idle past this so parked chats don't accumulate subprocesses (respawn on the next message is the normal cold path).
IDLE_EVICT_SECONDS = float(os.environ.get("OSW_CLIENT_IDLE_EVICT_SECONDS", "1800"))
@typechecked
async def evict_idle_clients(pool: Dict[str, "ClientHandle"]) -> None:
"""Dispose every handle idle past the TTL, skipping any mid-turn (lock held)."""
now = time.monotonic()
for sid in list(pool.keys()):
handle = pool.get(sid)
if handle is None or handle.lock.locked():
continue
if now - handle.last_used > IDLE_EVICT_SECONDS:
logger.info(f"[client-pool] {sid}: idle-evict after {int(now - handle.last_used)}s")
await dispose_client(pool, sid)
@typechecked
async def acquire_client(
pool: Dict[str, ClientHandle],
session_id: str,
fingerprint: str,
connect_fn: Callable[[], Awaitable[object]],
force_respawn: bool = False,
) -> ClientHandle:
"""Return a live client whose boot matches `fingerprint`, connecting fresh when there is none,
the fingerprint mismatches, or the caller demands a fresh session (needs_fresh/fork consumed
upstream, so the flag must be read BEFORE build_agent_options and passed in)."""
await evict_idle_clients(pool)
existing = pool.get(session_id)
if existing is not None:
if not force_respawn and existing.fingerprint == fingerprint:
existing.last_used = time.monotonic()
return existing
reason = "force_respawn" if force_respawn else "fingerprint_changed"
logger.info(f"[client-pool] {session_id}: respawn ({reason})")
await dispose_client(pool, session_id)
client = await connect_fn()
now = time.monotonic()
handle = ClientHandle(
fingerprint=fingerprint, client=client, lock=asyncio.Lock(), connected_at=now, last_used=now,
)
pool[session_id] = handle
logger.info(f"[client-pool] {session_id}: connected fresh client")
return handle
@typechecked
async def dispose_client(pool: Dict[str, ClientHandle], session_id: str) -> None:
"""Pop first so a concurrent turn can never re-grab a disposing client, then disconnect
(terminates the CLI subprocess). Never raises: teardown must not block a turn or a close."""
handle = pool.pop(session_id, None)
if handle is None:
return
try:
await handle.client.disconnect()
except Exception:
logger.exception(f"[client-pool] {session_id}: disconnect failed (subprocess may already be dead)")
@typechecked
def dispose_client_soon(pool: Dict[str, ClientHandle], session_id: str) -> None:
"""Sync-context teardown (purge_session_memory): pop now, disconnect in a detached task."""
handle = pool.pop(session_id, None)
if handle is None:
return
async def p_bg() -> None:
try:
await handle.client.disconnect()
except Exception:
logger.exception(f"[client-pool] {session_id}: background disconnect failed")
try:
asyncio.get_running_loop().create_task(p_bg())
except RuntimeError:
logger.warning(f"[client-pool] {session_id}: no loop for background disconnect; subprocess reaped on exit")
@typechecked
async def dispose_all_clients(pool: Dict[str, ClientHandle]) -> None:
"""Process-shutdown hook: a persistent subprocess outlives turns, so uvicorn reload/quit would
orphan one CLI per live session without this."""
for sid in list(pool.keys()):
await dispose_client(pool, sid)