Files
alishahryar1--free-claude-code/src/free_claude_code/cli/managed/session.py
T
wehub-resource-sync 5296d0e97c
CI / Ban suppressions and legacy annotations (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:44 +08:00

294 lines
11 KiB
Python

"""Managed Claude Code subprocess session."""
import asyncio
import os
from collections.abc import AsyncGenerator
from loguru import logger
from free_claude_code.cli.process_registry import (
kill_pid_tree_best_effort,
register_pid,
unregister_pid,
)
from free_claude_code.core.trace import trace_event
from .claude import (
ManagedClaudeConfig,
ManagedClaudeParseState,
ManagedClaudeTaskRequest,
build_managed_claude_invocation,
parse_managed_claude_stdout_line,
)
from .diagnostics import classify_managed_claude_stderr
# Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained.
_MAX_STDERR_CAPTURE_BYTES = 256 * 1024
class ManagedClaudeSession:
"""Manages a single persistent Claude Code subprocess."""
def __init__(
self,
workspace_path: str,
proxy_root_url: str,
allowed_dirs: list[str] | None = None,
claude_bin: str = "claude",
auth_token: str = "",
*,
log_raw_cli_diagnostics: bool = False,
):
self.config = ManagedClaudeConfig(
workspace_path=os.path.normpath(os.path.abspath(workspace_path)),
proxy_root_url=proxy_root_url,
allowed_dirs=[os.path.normpath(d) for d in (allowed_dirs or [])],
claude_bin=claude_bin,
auth_token=auth_token,
)
self.workspace = self.config.workspace_path
self.proxy_root_url = self.config.proxy_root_url
self.allowed_dirs = self.config.allowed_dirs
self.claude_bin = self.config.claude_bin
self.auth_token = self.config.auth_token
self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
self.process: asyncio.subprocess.Process | None = None
self.current_session_id: str | None = None
self._is_busy = False
self._cli_lock = asyncio.Lock()
self._lifecycle_lock = asyncio.Lock()
self._closed = False
@staticmethod
async def _drain_stderr_bounded(
process: asyncio.subprocess.Process,
*,
max_bytes: int = _MAX_STDERR_CAPTURE_BYTES,
) -> bytes:
"""Read stderr concurrently with stdout to avoid subprocess pipe deadlocks.
Retains at most ``max_bytes`` for logging; any excess is discarded, but
the pipe is read until EOF so a noisy child cannot fill the buffer and
block forever.
"""
if not process.stderr:
return b""
parts: list[bytes] = []
received = 0
while True:
chunk = await process.stderr.read(65_536)
if not chunk:
break
if received < max_bytes:
take = min(len(chunk), max_bytes - received)
if take:
parts.append(chunk[:take])
received += take
# If already at cap, keep reading and discarding until EOF.
return b"".join(parts)
@property
def is_busy(self) -> bool:
"""Check if a task is currently running."""
return self._is_busy
async def start_task(
self, prompt: str, session_id: str | None = None, fork_session: bool = False
) -> AsyncGenerator[dict]:
"""
Start a new task or continue an existing session.
Args:
prompt: The user's message/prompt
session_id: Optional session ID to resume
Yields:
Event dictionaries from the CLI
"""
async with self._cli_lock:
process: asyncio.subprocess.Process | None = None
termination_confirmed = False
try:
async with self._lifecycle_lock:
if self._closed:
raise RuntimeError("Managed Claude session is closed.")
self._is_busy = True
invocation = build_managed_claude_invocation(
config=self.config,
request=ManagedClaudeTaskRequest(
prompt=prompt,
session_id=session_id,
fork_session=fork_session,
),
base_env=os.environ,
)
trace_event(
stage="claude_cli",
event="claude_cli.process.launch",
source="claude_cli",
**invocation.trace_metadata,
)
process = await asyncio.create_subprocess_exec(
*invocation.argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=invocation.cwd,
env=invocation.env,
)
self.process = process
if process.pid:
register_pid(process.pid)
if not process.stdout:
yield {"type": "exit", "code": 1}
return
parse_state = ManagedClaudeParseState(
log_raw_cli_diagnostics=self._log_raw_cli_diagnostics
)
buffer = bytearray()
stderr_task: asyncio.Task[bytes] | None = None
if process.stderr:
stderr_task = asyncio.create_task(
self._drain_stderr_bounded(process)
)
try:
while True:
chunk = await process.stdout.read(65536)
if not chunk:
if buffer:
line_str = buffer.decode(
"utf-8", errors="replace"
).strip()
if line_str:
async for event in self._handle_line_gen(
line_str, parse_state
):
yield event
break
buffer.extend(chunk)
while True:
newline_pos = buffer.find(b"\n")
if newline_pos == -1:
break
line = buffer[:newline_pos]
buffer = buffer[newline_pos + 1 :]
line_str = line.decode("utf-8", errors="replace").strip()
if line_str:
async for event in self._handle_line_gen(
line_str, parse_state
):
yield event
except asyncio.CancelledError:
# Cancelling the handler task should not leave a Claude CLI
# subprocess running in the background.
await asyncio.shield(self.stop())
raise
finally:
stderr_bytes = b""
if stderr_task is not None:
stderr_bytes = await stderr_task
stderr_text = None
if stderr_bytes:
raw_stderr_text = stderr_bytes.decode(
"utf-8", errors="replace"
).strip()
if raw_stderr_text:
diagnostics = classify_managed_claude_stderr(raw_stderr_text)
if diagnostics.has_benign:
logger.info(
"Claude CLI benign stderr diagnostics: lines={}",
len(diagnostics.benign_lines),
)
stderr_text = diagnostics.fatal_text
if stderr_text:
if self._log_raw_cli_diagnostics:
logger.error("Claude CLI stderr: {}", stderr_text)
else:
logger.error(
"Claude CLI stderr: bytes={} text_chars={}",
len(stderr_bytes),
len(stderr_text),
)
logger.info("CLI_SESSION: Yielding error event from stderr")
yield {"type": "error", "error": {"message": stderr_text}}
return_code = await process.wait()
termination_confirmed = True
logger.info(
f"Claude CLI exited with code {return_code}, stderr_present={bool(stderr_text)}"
)
if return_code != 0 and not stderr_text:
logger.warning(
f"CLI_SESSION: Process exited with code {return_code} but no stderr captured"
)
yield {
"type": "exit",
"code": return_code,
"stderr": stderr_text,
}
finally:
self._is_busy = False
if (
process
and process.pid
and (termination_confirmed or process.returncode is not None)
):
unregister_pid(process.pid)
async def _handle_line_gen(
self, line_str: str, parse_state: ManagedClaudeParseState
) -> AsyncGenerator[dict]:
"""Process a single line and yield events."""
for event in parse_managed_claude_stdout_line(line_str, parse_state):
if isinstance(event, dict) and event.get("type") == "session_info":
session_id = event.get("session_id")
if isinstance(session_id, str):
self.current_session_id = session_id
yield event
async def stop(self) -> bool:
"""Stop the CLI process, retaining PID ownership until exit is confirmed."""
async with self._lifecycle_lock:
self._closed = True
process = self.process
if process is None:
return True
if process.returncode is not None:
if process.pid:
unregister_pid(process.pid)
return True
try:
logger.info(f"Stopping Claude CLI process {process.pid}")
kill_pid_tree_best_effort(process.pid)
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except TimeoutError:
process.kill()
await process.wait()
if process.pid:
unregister_pid(process.pid)
return True
except Exception as e:
if self._log_raw_cli_diagnostics:
logger.error(
"Error stopping process: {}: {}",
type(e).__name__,
e,
)
else:
logger.error(
"Error stopping process: exc_type={}",
type(e).__name__,
)
return False