Files
2026-09-17 22:57:27 +03:00

174 lines
7.0 KiB
Python

"""Владелец автономного launcher для MCP без запущенного VS Code."""
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import uuid
from .mcp_adapter import McpSession
from .session import SessionError
class SessionSupervisor:
def __init__(self, build: str, socket_path: str | None = None,
launcher_options: list[str] | None = None):
self.build = str(Path(build).expanduser().resolve())
self._temporary = None
if socket_path:
self.socket_path = str(Path(socket_path).expanduser())
else:
self._temporary = tempfile.TemporaryDirectory(prefix='sprinter-mcp-', dir='/tmp')
self.socket_path = str(Path(self._temporary.name) / 'session.sock')
self.launcher_options = list(launcher_options or [])
self.lock = threading.RLock()
self.phase = 'idle'
self.error: str | None = None
self.entry = None
self.mame_pid = None
self.process: subprocess.Popen | None = None
self.monitor: threading.Thread | None = None
self.log = None
self.client: McpSession | None = None
self.launch_id = None
def status(self) -> dict:
with self.lock:
result = {'phase': self.phase, 'socket': self.socket_path,
'launch_id': self.launch_id}
if self.error:
result['error'] = self.error
if self.phase == 'ready' and self.client is not None:
try:
result.update(self.client.status())
result['mame_pid'] = self.mame_pid
result['entry'] = self.entry
except (SessionError, OSError) as error:
self.phase = 'failed'
self.error = str(error)
result.update(phase='failed', error=self.error)
return result
def require_client(self) -> McpSession:
with self.lock:
if self.phase != 'ready' or self.client is None:
raise SessionError('C-сессия ещё не готова: ' + self.phase)
return self.client
def start(self) -> dict:
with self.lock:
if self.phase in ('starting', 'ready', 'stopping'):
raise SessionError('Автономная сессия уже запущена: ' + self.phase)
if self.process is not None and self.process.poll() is None:
raise SessionError('Предыдущий launcher ещё работает')
if self.monitor is not None and self.monitor.is_alive():
raise SessionError('Предыдущий launcher ещё завершает работу')
if not Path(self.build).is_dir():
raise SessionError('Debug-пакет не найден: ' + self.build)
command = [sys.executable, str(Path(__file__).resolve().parents[1] /
'sdbg_launcher.py'), '--build', self.build,
'--socket', self.socket_path, *self.launcher_options]
if self.log is not None:
self.log.close()
self.log = tempfile.TemporaryFile(mode='w+t', encoding='utf-8')
self.phase = 'starting'
self.error = None
self.entry = None
self.mame_pid = None
self.client = None
self.launch_id = uuid.uuid4().hex
try:
self.process = subprocess.Popen(command, stdout=subprocess.PIPE,
stderr=self.log, text=True)
except OSError:
self.phase = 'failed'
raise
self.monitor = threading.Thread(target=self._monitor, name='sdbg-supervisor',
daemon=True)
self.monitor.start()
return {'accepted': True, 'phase': self.phase,
'socket': self.socket_path, 'launch_id': self.launch_id}
def _diagnostics(self) -> str:
if self.log is None:
return ''
self.log.flush()
self.log.seek(0)
return self.log.read()[-4000:].strip()
def _monitor(self) -> None:
process = self.process
assert process is not None and process.stdout is not None
try:
line = process.stdout.readline()
if not line:
raise SessionError('Launcher завершился до main: ' + self._diagnostics())
ready = json.loads(line)
if not ready.get('ready') or ready.get('socket') != self.socket_path:
raise SessionError('Launcher вернул неверный ready')
client = McpSession(self.socket_path)
client.status()
with self.lock:
if self.phase == 'starting':
self.client = client
self.entry = ready['entry']
self.mame_pid = ready['pid']
self.phase = 'ready'
process.wait()
with self.lock:
if self.phase not in ('stopping', 'failed'):
self.phase = 'stopped'
self.error = 'Launcher завершился: ' + self._diagnostics()
elif self.phase == 'stopping':
self.phase = 'stopped'
self.client = None
except (OSError, ValueError, KeyError, SessionError) as error:
with self.lock:
if self.phase == 'stopping':
self.phase = 'stopped'
else:
self.phase = 'failed'
self.error = str(error)
self.client = None
def stop(self) -> dict:
with self.lock:
if self.phase not in ('starting', 'ready'):
return {'accepted': False, 'phase': self.phase}
self.phase = 'stopping'
if self.process is not None and self.process.poll() is None:
self.process.terminate()
return {'accepted': True, 'phase': 'stopping',
'socket': self.socket_path}
def close(self) -> None:
self.stop()
process = self.process
if process is not None:
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
if self.monitor is not None:
self.monitor.join(timeout=2)
if self.log is not None:
self.log.close()
if self._temporary is not None:
self._temporary.cleanup()
class ManagedMcpSession:
"""Совместимый с McpSession фасад, пока launcher проходит DSS."""
def __init__(self, supervisor: SessionSupervisor):
self.supervisor = supervisor
def status(self) -> dict:
return self.supervisor.status()
def __getattr__(self, name: str):
return getattr(self.supervisor.require_client(), name)