| """Managed Claude Code session pool for messaging.""" |
|
|
| import asyncio |
| import uuid |
|
|
| from loguru import logger |
|
|
| from free_claude_code.cli.claude_env import CLAUDE_BINARY_NAME |
|
|
| from .session import ManagedClaudeSession |
|
|
|
|
| class ManagedClaudeSessionManager: |
| """ |
| Manages multiple Claude Code sessions for parallel conversation processing. |
| |
| Each new conversation gets its own subprocess. Replies to existing |
| conversations reuse the same session instance. |
| """ |
|
|
| def __init__( |
| self, |
| workspace_path: str, |
| proxy_root_url: str, |
| allowed_dirs: list[str] | None = None, |
| claude_bin: str = CLAUDE_BINARY_NAME, |
| auth_token: str = "", |
| *, |
| log_raw_cli_diagnostics: bool = False, |
| log_messaging_error_details: bool = False, |
| ): |
| """ |
| Initialize the session manager. |
| |
| Args: |
| workspace_path: Working directory for CLI processes |
| proxy_root_url: Root URL for the local proxy |
| allowed_dirs: Directories the CLI is allowed to access |
| """ |
| self.workspace = workspace_path |
| self.proxy_root_url = proxy_root_url |
| self.allowed_dirs = allowed_dirs or [] |
| self.claude_bin = claude_bin |
| self.auth_token = auth_token |
| self._log_raw_cli_diagnostics = log_raw_cli_diagnostics |
| self._log_messaging_error_details = log_messaging_error_details |
|
|
| self._sessions: dict[str, ManagedClaudeSession] = {} |
| self._pending_sessions: dict[str, ManagedClaudeSession] = {} |
| self._temp_to_real: dict[str, str] = {} |
| self._real_to_temp: dict[str, str] = {} |
| self._closing_sessions: set[ManagedClaudeSession] = set() |
| self._lock = asyncio.Lock() |
|
|
| def _session_for_id(self, session_id: str) -> ManagedClaudeSession | None: |
| lookup_id = self._temp_to_real.get(session_id, session_id) |
| session = self._sessions.get(lookup_id) |
| if session is not None: |
| return session |
| return self._pending_sessions.get(lookup_id) |
|
|
| def _forget_session(self, session: ManagedClaudeSession) -> None: |
| pending_ids = [ |
| session_id |
| for session_id, owned in self._pending_sessions.items() |
| if owned is session |
| ] |
| real_ids = [ |
| session_id |
| for session_id, owned in self._sessions.items() |
| if owned is session |
| ] |
| for session_id in pending_ids: |
| self._pending_sessions.pop(session_id, None) |
| for real_id in real_ids: |
| self._sessions.pop(real_id, None) |
| temp_id = self._real_to_temp.pop(real_id, None) |
| if temp_id is not None: |
| self._temp_to_real.pop(temp_id, None) |
| self._closing_sessions.discard(session) |
|
|
| async def get_or_create_session( |
| self, session_id: str | None = None |
| ) -> tuple[ManagedClaudeSession, str, bool]: |
| """ |
| Get an existing session or create a new one. |
| |
| Returns: |
| Tuple of (session instance, session_id, is_new_session) |
| """ |
| async with self._lock: |
| if session_id: |
| lookup_id = self._temp_to_real.get(session_id, session_id) |
|
|
| if lookup_id in self._sessions: |
| session = self._sessions[lookup_id] |
| if session in self._closing_sessions: |
| raise RuntimeError("Managed Claude session is closing.") |
| return session, lookup_id, False |
| if lookup_id in self._pending_sessions: |
| session = self._pending_sessions[lookup_id] |
| if session in self._closing_sessions: |
| raise RuntimeError("Managed Claude session is closing.") |
| return session, lookup_id, False |
|
|
| temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}" |
|
|
| new_session = ManagedClaudeSession( |
| workspace_path=self.workspace, |
| proxy_root_url=self.proxy_root_url, |
| allowed_dirs=self.allowed_dirs, |
| claude_bin=self.claude_bin, |
| auth_token=self.auth_token, |
| log_raw_cli_diagnostics=self._log_raw_cli_diagnostics, |
| ) |
| self._pending_sessions[temp_id] = new_session |
|
|
| return new_session, temp_id, True |
|
|
| async def register_real_session_id( |
| self, temp_id: str, real_session_id: str |
| ) -> bool: |
| """Register the real session ID from CLI output.""" |
| async with self._lock: |
| session = self._pending_sessions.get(temp_id) |
| if session is None: |
| logger.warning(f"Temp session {temp_id} not found") |
| return False |
| if session in self._closing_sessions: |
| logger.warning("Cannot register a closing managed Claude session") |
| return False |
| existing = self._session_for_id(real_session_id) |
| if existing is not None and existing is not session: |
| logger.warning( |
| "Cannot register managed Claude session: real ID is already owned" |
| ) |
| return False |
|
|
| self._pending_sessions.pop(temp_id) |
| self._sessions[real_session_id] = session |
| self._temp_to_real[temp_id] = real_session_id |
| self._real_to_temp[real_session_id] = temp_id |
|
|
| logger.info(f"Registered session: {temp_id} -> {real_session_id}") |
| return True |
|
|
| async def remove_session(self, session_id: str) -> bool: |
| """Remove a session from the manager.""" |
| async with self._lock: |
| session = self._session_for_id(session_id) |
| if session is None: |
| return False |
| self._closing_sessions.add(session) |
| stopped = await session.stop() |
| if not stopped: |
| return False |
| self._forget_session(session) |
| return True |
|
|
| async def stop_all(self) -> None: |
| """Stop all sessions.""" |
| async with self._lock: |
| all_sessions = list( |
| dict.fromkeys( |
| [ |
| *self._sessions.values(), |
| *self._pending_sessions.values(), |
| *self._closing_sessions, |
| ] |
| ) |
| ) |
| self._closing_sessions.update(all_sessions) |
| failures = 0 |
| for session in all_sessions: |
| try: |
| stopped = await session.stop() |
| except Exception as e: |
| stopped = False |
| if self._log_messaging_error_details: |
| logger.error( |
| "Error stopping session: {}: {}", |
| type(e).__name__, |
| e, |
| ) |
| else: |
| logger.error( |
| "Error stopping session: exc_type={}", |
| type(e).__name__, |
| ) |
| if stopped: |
| self._forget_session(session) |
| else: |
| failures += 1 |
|
|
| if failures: |
| raise RuntimeError( |
| f"Managed Claude session shutdown failures: {failures}." |
| ) |
| logger.info("All sessions stopped") |
|
|
| def get_stats(self) -> dict: |
| """Get session statistics.""" |
| return { |
| "active_sessions": len(self._sessions), |
| "pending_sessions": len(self._pending_sessions), |
| "busy_count": sum(1 for s in self._sessions.values() if s.is_busy), |
| } |
|
|