simbiot-harness/harness/isolated_runner.py
Simbiot Harness 3dc6ca6ab9 t-261004113351: изоляция памяти инстанса — SIMBIOT_HOME (репо = чистый код)
- harness/paths.py: единая карта персистентных путей; SIMBIOT_HOME → db/ rag/
  env/ docs/ digests/ audit/ inbox/ run/ voice/; без него — legacy-пути без
  изменений; точечные env (SIMBIOT_DB и пр.) сильнее схемы
- es/tg_inbox/voice/portal_channel/aum/audit/runtime/rag_lookup/isolated_
  runner/cli: все записи-чтения памяти через paths
- scripts/init_instance.py: новый инстанс — чистая база + идентичность
  владельца + его доки (+RAG) + селективный импорт выжимок/доков
- tests/test_instance_isolation.py: копия с пустой памятью не вспоминает
  наши проекты (контекст тика/RAG/прод — 14 проверок ALL PASS, регресс 7/7)
- scripts/migrate_to_home.sh: перенос боевого инстанса в хом (тихое окно,
  идемпотентно, RAG-корпус коопа сохраняется точечным SIMBIOT_RAG_DB)
- install.sh/service/README: свежие установки сразу с ~/.simbiot-data
2026-10-04 12:34:45 +00:00

122 lines
No EOL
6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 → <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/<pid>/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")