free-claude-code/cli/manager.py
Claude 99f99fce90
Remove max_cli_sessions — CLI session pool is now unbounded
The max_sessions cap in CLISessionManager was the only thing enforcing
a limit on concurrent CLI processes. Now that provider concurrency is
controlled at the streaming layer (PROVIDER_MAX_CONCURRENCY semaphore),
the CLI session pool cap is redundant and removed entirely.

Changes:
- cli/manager.py: remove max_sessions param, cap check, _cleanup_idle_sessions_unlocked, max_sessions from get_stats()
- config/settings.py: remove max_cli_sessions field
- api/app.py: remove max_sessions=settings.max_cli_sessions from CLISessionManager constructor
- messaging/handler.py: remove "Waiting for slot" status check; stats display no longer shows Max CLI
- .env.example: remove MAX_CLI_SESSIONS line
- tests/cli/test_cli.py: remove max_sessions args and assertion from manager tests
- tests/cli/test_cli_manager_edge_cases.py: remove two tests for cap/cleanup behavior
- tests/api/test_app_lifespan_and_errors.py: remove max_cli_sessions from all SimpleNamespace settings
- tests/config/test_config.py: remove max_cli_sessions isinstance assertion
- tests/conftest.py: remove max_sessions from mock stats
- tests/messaging/test_handler.py: merge slot/capacity tests into single new-conversation test; remove Max CLI assertion from stats test
- tests/messaging/test_handler_markdown_and_status_edges.py: remove "Waiting for slot" assertion; drop max_sessions from all stats mocks

https://claude.ai/code/session_014mrF1WMNgmNjtPBuoQHsbg
2026-02-19 14:31:47 +00:00

146 lines
5 KiB
Python

"""
CLI Session Manager for Multi-Instance Claude CLI Support
Manages a pool of CLISession instances, each handling one conversation.
This enables true parallel processing where multiple conversations run
simultaneously in separate CLI processes.
"""
import asyncio
import uuid
from loguru import logger
from .session import CLISession
class CLISessionManager:
"""
Manages multiple CLISession instances for parallel conversation processing.
Each new conversation gets its own CLISession with its own subprocess.
Replies to existing conversations reuse the same CLISession instance.
"""
def __init__(
self,
workspace_path: str,
api_url: str,
allowed_dirs: list[str] | None = None,
plans_directory: str | None = None,
):
"""
Initialize the session manager.
Args:
workspace_path: Working directory for CLI processes
api_url: API URL for the proxy
allowed_dirs: Directories the CLI is allowed to access
plans_directory: Directory for Claude Code CLI plan files (passed via --settings)
"""
self.workspace = workspace_path
self.api_url = api_url
self.allowed_dirs = allowed_dirs or []
self.plans_directory = plans_directory
self._sessions: dict[str, CLISession] = {}
self._pending_sessions: dict[str, CLISession] = {}
self._temp_to_real: dict[str, str] = {}
self._lock = asyncio.Lock()
logger.info("CLISessionManager initialized")
async def get_or_create_session(
self, session_id: str | None = None
) -> tuple[CLISession, str, bool]:
"""
Get an existing session or create a new one.
Returns:
Tuple of (CLISession 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:
return self._sessions[lookup_id], lookup_id, False
if lookup_id in self._pending_sessions:
return self._pending_sessions[lookup_id], lookup_id, False
temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}"
new_session = CLISession(
workspace_path=self.workspace,
api_url=self.api_url,
allowed_dirs=self.allowed_dirs,
plans_directory=self.plans_directory,
)
self._pending_sessions[temp_id] = new_session
logger.info(f"Created new session: {temp_id}")
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:
if temp_id not in self._pending_sessions:
logger.warning(f"Temp session {temp_id} not found")
return False
session = self._pending_sessions.pop(temp_id)
self._sessions[real_session_id] = session
self._temp_to_real[temp_id] = real_session_id
logger.info(f"Registered session: {temp_id} -> {real_session_id}")
return True
async def get_real_session_id(self, temp_id: str) -> str | None:
"""Get the real session ID for a temporary ID."""
async with self._lock:
return self._temp_to_real.get(temp_id)
async def remove_session(self, session_id: str) -> bool:
"""Remove a session from the manager."""
async with self._lock:
if session_id in self._pending_sessions:
session = self._pending_sessions.pop(session_id)
await session.stop()
return True
if session_id in self._sessions:
session = self._sessions.pop(session_id)
await session.stop()
for temp, real in list(self._temp_to_real.items()):
if real == session_id:
del self._temp_to_real[temp]
return True
return False
async def stop_all(self):
"""Stop all sessions."""
async with self._lock:
all_sessions = list(self._sessions.values()) + list(
self._pending_sessions.values()
)
for session in all_sessions:
try:
await session.stop()
except Exception as e:
logger.error(f"Error stopping session: {e}")
self._sessions.clear()
self._pending_sessions.clear()
self._temp_to_real.clear()
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),
}