"""Simbiot Harness — изолированный раннер LLM-тика. Strands держит глобальный asyncio-рантайм: несколько create_harness() в одном процессе валит его («cannot schedule new futures after shutdown»). Решение — каждый LLM-тик в ОТДЕЛЬНОМ сабпроцессе: чистый asyncio-рантайм на тик, полная изоляция, ноль утечек. Демон живёт вечно. """ import json import os import signal import subprocess import sys import time _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) from . import paths as _paths # t-261004113351-2858c1: БД из SIMBIOT_HOME _VENV_PY = os.path.join(_HARNESS_DIR, "venv", "bin", "python") _WORKER = os.path.join(_HARNESS_DIR, "harness", "_llm_worker.py") MARKER = "@@SIMBIOT_RESULT@@" def run_llm_tick(level_cfg, prompt: str, session_id=None, trace_prefix=None, timeout=None): """Запускает LLM-тик в сабпроцессе. Возвращает (text, usage, trace_id). События трассы воркер пишет в стор сам (у него есть es). Протокол: воркер пишет результат в JSON-ФАЙЛ (путь передан в env); stdout/stderr только для лога — библиотечный шум strands не мешает. """ import tempfile result_path = tempfile.mktemp(prefix="simbiot_tick_", suffix=".json", dir="/tmp") # BudgetSpent пишет сам воркер — ИНКРЕМЕНТАЛЬНО, после каждого модельного # вызова (см. _llm_worker._wire_budget_hooks). Раньше событие писалось # здесь, ПОСЛЕ возврата воркера: таймаут убивал процесс до записи, и # spend молча терял весь тик (task t-260929170544-adebea). payload = { "model": level_cfg.model, "effort": level_cfg.effort, "instructions": level_cfg.mandate, "prompt": prompt, "session_id": None, # сессии пока выключены: .agent/sessions растёт без очистки "trace_prefix": trace_prefix, "can_shell": level_cfg.can_shell, "ctx_tokens": level_cfg.ctx_tokens, "result_path": result_path, "level": level_cfg.name, # для записи BudgetSpent самим воркером } env = dict(os.environ) # t-261004113351-2858c1: БД из paths (SIMBIOT_HOME → /db/harness.db); # SIMBIOT_HOME и SIMBIOT_DB наследуются воркеру через dict(os.environ). env["SIMBIOT_DB"] = env.get("SIMBIOT_DB", _paths.db_path()) if timeout is None: # таймаут уровня: тик может быть длиннее интервала — занятый уровень # просто поглощает такты (TickAbsorbed), это норма архитектуры timeout = getattr(level_cfg, "tick_timeout", 300) timeout = max(timeout, int(level_cfg.every * 0.8)) # start_new_session: воркер становится лидером СВОЕЙ процесс-группы — # добивание на таймауте достаёт всю группу LLM-потомков по proc.pid, # без чтения /proc//status. proc = subprocess.Popen( [_VENV_PY, _WORKER], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=_HARNESS_DIR, env=env, start_new_session=True, ) try: stdout, stderr = proc.communicate( input=json.dumps(payload).encode(), timeout=timeout) except subprocess.TimeoutExpired: # воркер не уложился в таймаут. subprocess.communicate() бросает # TimeoutExpired БЕЗ .pid — subprocess.run не отдавал Popen, и # аварийная зачистка падала с AttributeError ('TimeoutExpired' # object has no attribute 'pid', e-261003004801) ДО добивания # группы: LLM-потомки выживали и писали в стор после смерти тика # (race, burn роутера, t-261002090242). С Popen pid есть всегда. pgid = proc.pid try: if pgid > 1: os.killpg(pgid, signal.SIGKILL) except (ProcessLookupError, PermissionError, OSError): pass for _ in range(10): # до 5с на смерть группы, иначе — SIGKILL ещё раз try: os.killpg(pgid, 0) time.sleep(0.5) except (ProcessLookupError, PermissionError) as ex: break else: try: os.killpg(pgid, signal.SIGKILL) except OSError: pass for stream in (proc.stdout, proc.stderr, proc.stdin): try: if stream: stream.close() except OSError: pass try: proc.wait(timeout=5) # reap: без этого — зомби до смерти родителя except subprocess.TimeoutExpired: pass raise else: out = None if os.path.exists(result_path): try: with open(result_path) as f: out = json.load(f) except ValueError: out = None try: os.unlink(result_path) except OSError: pass if out is None: return None, {"error": f"worker result file missing: stdout={(stdout or b'')[:200]!r} stderr={(stderr or b'').decode(errors='replace')[-300:]}"}, None if not out.get("ok"): return None, {"error": (out.get("error") or "unknown")[:500]}, None return out.get("text", ""), out.get("usage", {}), out.get("trace_id")