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
This commit is contained in:
Simbiot Harness 2026-10-04 12:34:45 +00:00
parent 7d995e75da
commit 3dc6ca6ab9
18 changed files with 856 additions and 22 deletions

4
.gitignore vendored
View file

@ -18,3 +18,7 @@ harness/harness_es.db
harness/hg_tg_inbox.db harness/hg_tg_inbox.db
hg_tg_inbox.db hg_tg_inbox.db
store.db store.db
# изоляция памяти (t-261004113351): локальный хом, если запустили без env
.simbiot-data/
harness/channel_*.json
harness/channel_*.json.tmp

View file

@ -92,8 +92,44 @@ SIMBIOT_TG_ALLOWED, ROUTER_KEY, SIMBIOT_MODEL и др.); в systemd-режиме
(глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`) (глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`)
- `harness/commands.py` — command side - `harness/commands.py` — command side
- `harness/tg_commands.py` — без-LLM команды бота (сек-рефлекс) - `harness/tg_commands.py` — без-LLM команды бота (сек-рефлекс)
- `harness/paths.py` — карта персистентных путей (SIMBIOT_HOME)
- `cli.py` — init/task/task-await/tasks/status/events/requeue - `cli.py` — init/task/task-await/tasks/status/events/requeue
## Изоляция памяти инстанса — SIMBIOT_HOME (t-261004113351)
Память агента — данные, не код: при переносе инстанса другому владельцу
(мультитенантность) он не должен «думать» чужими проектами. Всё, что живёт
дольше процесса — ES-база, RAG-индекс знаний, `.env`, ключи аудита, выжимки,
штампы каналов, доки инстанса — размещается в одном каталоге-хоме, репо
остаётся чистым кодом. Свежие установки: `~/.simbiot-data` (install.sh
проставляет в обёртку и systemd-юнит автоматически).
```
SIMBIOT_HOME=/path/to/home venv/bin/python cli.py status
```
| файл | назначение |
|---|---|
| `<home>/db/harness.db` | ES-база (события, проекции, delivery-леджер) |
| `<home>/rag/rag.db` | RAG-индекс знаний инстанса |
| `<home>/env/.env` | секреты (токены, ключи роутера), 0600 |
| `<home>/docs/` | проектные документы владельца (aum/balances.json) |
| `<home>/digests/` | файлы-выжимки уровней |
| `<home>/audit/` | ключи крипто-аудита ed25519 |
| `<home>/inbox/`, `<home>/run/`, `<home>/voice/` | каналы, штампы, кэш ASR |
Приоритет: точечная env (`SIMBIOT_DB`, `SIMBIOT_RAG_DB`, …) → `SIMBIOT_HOME`
→ legacy-пути из корня репо (обратная совместимость: не задан `SIMBIOT_HOME`
— всё работает по-старому).
Новый инстанс (например, для Юры): `scripts/init_instance.py` — чистая база +
идентичность владельца + его доки (+RAG-индексация) + опциональный селективный
импорт выжимок/доков из инстанса-источника. Тест изоляции:
`tests/test_instance_isolation.py` (поднять копию с пустой памятью — контекст
тика и RAG не содержат чужих проектов, 14 проверок). Перенос существующего
инстанса в хом: `scripts/migrate_to_home.sh` (ждёт тихое окно без LLM-воркеров,
идемпотентен).
## CLI ## CLI
```bash ```bash

114
cli.py
View file

@ -7,6 +7,7 @@ import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from harness import commands, es from harness import commands, es
from harness import paths as _paths # t-261004113351-2858c1: .env инстанса
def main(): def main():
@ -38,6 +39,10 @@ def main():
p_cl.add_argument("task_id") p_cl.add_argument("task_id")
p_cl.add_argument("-r", "--reason", default="manual close") p_cl.add_argument("-r", "--reason", default="manual close")
p_bl = sub.add_parser("block", help="заблокировать живую задачу (ждёт уточнений); requeue вернёт в очередь")
p_bl.add_argument("task_id")
p_bl.add_argument("-r", "--reason", default="ожидает уточнений")
p_cmp = sub.add_parser("complete", help="закрыть running-задачу с результатом (батч-тик, t-260930101325)") p_cmp = sub.add_parser("complete", help="закрыть running-задачу с результатом (батч-тик, t-260930101325)")
p_cmp.add_argument("task_id") p_cmp.add_argument("task_id")
p_cmp.add_argument("-a", "--answer", required=True, help="результат задачи") p_cmp.add_argument("-a", "--answer", required=True, help="результат задачи")
@ -149,6 +154,23 @@ def main():
help="модель уровней (по умолчанию auto — как в harness/levels.py)") help="модель уровней (по умолчанию auto — как в harness/levels.py)")
p_wiz.add_argument("--no-init", action="store_true", help="не инициализировать стор") p_wiz.add_argument("--no-init", action="store_true", help="не инициализировать стор")
# Подключение Telegram без полного визарда (просьба Alex, 2026-10-04):
# токен/чаты в .env + самотест getMe + рестарт TG-поллера + меню команд.
p_tgc2 = sub.add_parser("tg-connect",
help="подключить Telegram: токен бота в .env, самотест, рестарт поллера")
p_tgc2.add_argument("token", nargs="?", default=None,
help="токен TG-бота (SIMBIOT_TG_TOKEN)")
p_tgc2.add_argument("--token", dest="token_opt", default=None,
help="токен TG-бота флагом (то же, что позиционным)")
p_tgc2.add_argument("--allowed", default=None,
help="разрешённые chat-id через запятую (SIMBIOT_TG_ALLOWED)")
p_tgc2.add_argument("--no-restart", action="store_true",
help="не перезапускать simbiot-harness-tg (токен подхватится при следующем рестарте)")
p_tgc2.add_argument("--no-commands", action="store_true",
help="не регистрировать меню команд в чате (setMyCommands)")
p_tgs2 = sub.add_parser("tg-status",
help="состояние Telegram-подключения: токен, чаты, поллер, меню бота")
p_rag = sub.add_parser("rag-query", help="семантический поиск по базе кооператива (вендор /root/rag)") p_rag = sub.add_parser("rag-query", help="семантический поиск по базе кооператива (вендор /root/rag)")
p_rag.add_argument("question") p_rag.add_argument("question")
p_rag.add_argument("-k", type=int, default=5, help="сколько чанков (<=5)") p_rag.add_argument("-k", type=int, default=5, help="сколько чанков (<=5)")
@ -164,6 +186,10 @@ def main():
elif args.cmd == "wizard": elif args.cmd == "wizard":
_run_wizard(args) _run_wizard(args)
elif args.cmd == "tg-connect":
_run_tg_connect(args)
elif args.cmd == "tg-status":
_run_tg_status(args)
elif args.cmd == "task": elif args.cmd == "task":
# t-261003125513/e-261003125707: голое 'list'/'tasks'/'task list' — # t-261003125513/e-261003125707: голое 'list'/'tasks'/'task list' —
# показать доску, а не плодить задачи-клоны # показать доску, а не плодить задачи-клоны
@ -205,6 +231,11 @@ def main():
es.init_db() es.init_db()
tid = commands.close_task(args.task_id, args.reason) tid = commands.close_task(args.task_id, args.reason)
print(f"OK: задача {tid} закрыта (closed — архив; requeue вернёт в очередь)") print(f"OK: задача {tid} закрыта (closed — архив; requeue вернёт в очередь)")
elif args.cmd == "block":
# t-261004122019: blocked — задача есть, но не ready (ждёт уточнений)
es.init_db()
tid = commands.block_task(args.task_id, args.reason)
print(f"OK: задача {tid} заблокирована (blocked; requeue вернёт в очередь)")
elif args.cmd == "promote": elif args.cmd == "promote":
es.init_db() es.init_db()
tid, new_size = commands.promote_task(args.task_id, args.reason) tid, new_size = commands.promote_task(args.task_id, args.reason)
@ -543,8 +574,89 @@ def _tg_selftest(token: str) -> str:
return f"ОШИБКА ({e.__class__.__name__}) — токен не принят, но запись в .env сделал" return f"ОШИБКА ({e.__class__.__name__}) — токен не принят, но запись в .env сделал"
# ============================================================================
# Подключение Telegram из CLI (просьба Alex, 2026-10-04): раньше токен был
# только внутри интерактивного wizard'а — теперь точечные команды.
# ============================================================================
def _tg_restart_poller() -> str:
"""Рестарт TG-поллера, если он под systemd (иначе подхватит при след. старте)."""
import subprocess
if not os.path.exists("/run/systemd/system"):
return "systemd не обнаружен — поллер подхватит токен при следующем старте"
r = subprocess.run(["systemctl", "restart", "simbiot-harness-tg"],
capture_output=True, text=True, timeout=30)
if r.returncode == 0:
return "OK: simbiot-harness-tg перезапущен — читает токен из .env"
return f"рестарт pollera не удался: {(r.stderr or r.stdout).strip()[:200]}"
def _run_tg_connect(args) -> None:
token = (args.token or getattr(args, "token_opt", None) or "").strip()
env_path = _paths.env_path()
vals = {}
if token:
vals["SIMBIOT_TG_TOKEN"] = token
if args.allowed is not None and args.allowed.strip():
vals["SIMBIOT_TG_ALLOWED"] = args.allowed.strip()
if not vals:
cur = _dotenv_get(env_path, "SIMBIOT_TG_TOKEN")
print("Токен не передан." if not cur
else f"Токен уже задан ({_mask(cur)}). Передай его аргументом: "
"simbiot tg-connect <ТОКЕН> [--allowed 123,456]")
return
if "SIMBIOT_TG_TOKEN" in vals:
print(f" TG-токен: {_tg_selftest(vals['SIMBIOT_TG_TOKEN'])}")
for key, val in vals.items():
_dotenv_set(env_path, key, val)
print(f" .env ← {key}={_mask(val) if 'TOKEN' in key else val}")
if not args.no_commands:
from harness import tg_commands
reg = tg_commands.register_bot_commands()
print(" меню команд: " + ({"cyrillic": "зарегистрировано (кириллица)",
"latin": "зарегистрировано (транслит)"}.get(reg, "не удалось"))
if reg else " меню команд: пропущено (нет токена/недоступен TG)")
if not args.no_restart:
print(" " + _tg_restart_poller())
def _run_tg_status(args) -> None:
import subprocess
import urllib.request
from harness import tg_inbox
tg_inbox._load_env()
tok = tg_inbox.TG_TOKEN
print("🔌 Telegram-подключение")
if not tok:
print(" токен: НЕ задан — simbiot tg-connect <ТОКЕН>")
return
print(f" токен: задан ({_mask(tok)})")
try:
with urllib.request.urlopen(f"https://api.telegram.org/bot{tok}/getMe", timeout=5) as r:
me = json.loads(r.read().decode()).get("result", {})
print(f" getMe: ок, бот @{me.get('username')} (id {me.get('id')})")
except Exception as e:
print(f" getMe: ОШИБКА ({e.__class__.__name__}) — токен не принимается")
allowed = sorted(tg_inbox.ALLOWED)
print(" разрешённые чаты: " + (", ".join(allowed) if allowed else "не заданы"))
import subprocess
if os.path.exists("/run/systemd/system"):
for unit in ("simbiot-harness-tg.service", "simbiot-harness.service"):
st = subprocess.run(["systemctl", "is-active", unit],
capture_output=True, text=True).stdout.strip()
print(f" {unit}: {st}")
cmds = [("никак", [])]
try:
from harness import tg_commands
cmds = tg_commands.get_my_commands() or [("нет", [])]
except Exception:
pass
names = ", ".join(c[0] for c in cmds) if cmds and cmds[0][0] not in ("никак", "нет") else "нет"
print(f" меню бота: {names}")
def _run_wizard(args) -> None: def _run_wizard(args) -> None:
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") env_path = _paths.env_path()
vals = { vals = {
"SIMBIOT_TG_TOKEN": args.tg_token if args.tg_token is not None else None, "SIMBIOT_TG_TOKEN": args.tg_token if args.tg_token is not None else None,
"SIMBIOT_TG_ALLOWED": args.tg_allowed, "SIMBIOT_TG_ALLOWED": args.tg_allowed,

View file

@ -42,7 +42,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
HARNESS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HARNESS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
KEY_DIR = os.environ.get("SIMBIOT_AUDIT_KEY_DIR", os.path.join(HARNESS_ROOT, ".audit")) from . import paths as _paths # t-261004113351-2858c1: ключи аудита в хоме
KEY_DIR = os.environ.get("SIMBIOT_AUDIT_KEY_DIR", "") or _paths.audit_key_dir()
PRIV_PATH = os.path.join(KEY_DIR, "ed25519.key") PRIV_PATH = os.path.join(KEY_DIR, "ed25519.key")
PUB_PATH = os.path.join(KEY_DIR, "ed25519.pub") PUB_PATH = os.path.join(KEY_DIR, "ed25519.pub")

View file

@ -38,8 +38,9 @@ from datetime import datetime, timezone
ROUTER_DB = os.environ.get( ROUTER_DB = os.environ.get(
"SIMBIOT_ROUTER_DB", "/root/.9router/db/data.sqlite") "SIMBIOT_ROUTER_DB", "/root/.9router/db/data.sqlite")
from . import paths as _paths # t-261004113351-2858c1: доки инстанса
HARNESS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HARNESS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BALANCES_JSON = os.path.join(HARNESS_ROOT, "docs", "aum", "balances.json") BALANCES_JSON = os.path.join(_paths.docs_dir(), "aum", "balances.json")
COOP_JSON = os.environ.get("SIMBIOT_COOP_JSON", "/root/coop_data.json") COOP_JSON = os.environ.get("SIMBIOT_COOP_JSON", "/root/coop_data.json")
RATE_FLASH = 0.001022 # у.е./токен glm-5.3-flash (дайджест 25.09–02.10) RATE_FLASH = 0.001022 # у.е./токен glm-5.3-flash (дайджест 25.09–02.10)
@ -98,7 +99,7 @@ def _http_json(url: str, headers: dict):
def refresh_openrouter_credits(env_path: str = None): def refresh_openrouter_credits(env_path: str = None):
"""Доступные кредиты OpenRouter коопа (USD) или None (нет ключа/ошибка).""" """Доступные кредиты OpenRouter коопа (USD) или None (нет ключа/ошибка)."""
env_path = env_path or os.path.join(HARNESS_ROOT, ".env") env_path = env_path or _paths.env_path() # t-261004113351: .env инстанса
key = _env_file_value(env_path, "OPENROUTER_KEY") key = _env_file_value(env_path, "OPENROUTER_KEY")
if not key: if not key:
return None return None

View file

@ -10,7 +10,10 @@ import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
DB_PATH = os.environ.get("SIMBIOT_DB", os.path.join(os.path.dirname(__file__), "harness.db")) # t-261004113351-2858c1: путь БД из paths (SIMBIOT_HOME → <home>/db/harness.db;
# без хома — legacy harness/harness.db). Точечная SIMBIOT_DB сильнее схемы.
from . import paths as _paths
DB_PATH = _paths.db_path()
# RLock обязателен: rebuild_projections держит лок и зовёт # RLock обязателен: rebuild_projections держит лок и зовёт
# run_projections, который берёт его же — обычный Lock давил сам себя. # run_projections, который берёт его же — обычный Lock давил сам себя.
@ -390,6 +393,15 @@ def _apply_event(model: dict, ev: dict):
t = model["tasks"].get(aid) t = model["tasks"].get(aid)
if t: if t:
t.update(status="done", result_ref=data.get("result_ref"), finished=ev["timestamp"]) t.update(status="done", result_ref=data.get("result_ref"), finished=ev["timestamp"])
elif etype == "TaskBlocked":
# t-261004122019: задача есть, но не ready — ждёт уточнений/внешнего
# события. scheduler берёт только ready, так что blocked выпадает из
# батчей; обратно — requeue (blocked → ready).
t = model["tasks"].get(aid)
if t:
t.update(status="blocked", block_reason=(data.get("reason") or "")[:300],
blocked_by=data.get("by") or "manual",
blocked_at=ev["timestamp"])
elif etype == "TaskFailed": elif etype == "TaskFailed":
t = model["tasks"].get(aid) t = model["tasks"].get(aid)
if t: if t:

View file

@ -13,6 +13,7 @@ import sys
import time import time
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _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") _VENV_PY = os.path.join(_HARNESS_DIR, "venv", "bin", "python")
_WORKER = os.path.join(_HARNESS_DIR, "harness", "_llm_worker.py") _WORKER = os.path.join(_HARNESS_DIR, "harness", "_llm_worker.py")
MARKER = "@@SIMBIOT_RESULT@@" MARKER = "@@SIMBIOT_RESULT@@"
@ -44,7 +45,9 @@ def run_llm_tick(level_cfg, prompt: str, session_id=None, trace_prefix=None, tim
"level": level_cfg.name, # для записи BudgetSpent самим воркером "level": level_cfg.name, # для записи BudgetSpent самим воркером
} }
env = dict(os.environ) env = dict(os.environ)
env["SIMBIOT_DB"] = env.get("SIMBIOT_DB", os.path.join(_HARNESS_DIR, "harness", "harness.db")) # 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: if timeout is None:
# таймаут уровня: тик может быть длиннее интервала — занятый уровень # таймаут уровня: тик может быть длиннее интервала — занятый уровень
# просто поглощает такты (TickAbsorbed), это норма архитектуры # просто поглощает такты (TickAbsorbed), это норма архитектуры

143
harness/paths.py Normal file
View file

@ -0,0 +1,143 @@
"""Simbiot Harness — единая карта персистентных путей (SIMBIOT_HOME).
t-261004113351-2858c1 (мультитенантность): память и знания инстанса — данные,
а не код. Репозиторий = чистый код; всё, что живёт дольше процесса (ES-база,
RAG-индекс знаний, входящие каналы, выжимки, секреты, доки инстанса),
размещается в одном каталоге-хоме.
SIMBIOT_HOME (env) — корень данных инстанса. Задан → структура:
<home>/db/harness.db — ES-база (события, проекции, delivery-леджер)
<home>/rag/rag.db — RAG-индекс знаний (векторные чанки)
<home>/env/.env — секреты инстанса (токены, ключи роутера)
<home>/inbox/ — входящие каналов (portal/state, legacy outbox)
<home>/digests/ — файлы-выжимки уровней (summary_ref)
<home>/audit/ — ключи крипто-аудита (ed25519)
<home>/docs/ — проектные документы инстанса (aum/balances.json)
<home>/voice/ — кэш голосовых (если используется ASR)
<home>/run/ — runtime-штампы каналов (channel_<tg|portal>.json)
Не задан (legacy) → всё по-старому из корня репо: harness/harness.db, .env,
docs/, out/, .audit/ — обратная совместимость существующих установок и
systemd-юнитов без правки окружения.
Приоритет источников (12-фактор): точечная env-переменная (SIMBIOT_DB,
SIMBIOT_RAG_DB, SIMBIOT_AUDIT_KEY_DIR, ...) сильнее схемы SIMBIOT_HOME,
SIMBIOT_HOME сильнее legacy-дефолта.
"""
import os
_HARNESS_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(_HARNESS_DIR)
_HOME = os.environ.get("SIMBIOT_HOME", "").strip()
IS_ISOLATED = bool(_HOME)
def home() -> str:
"""Корень данных инстанса (SIMBIOT_HOME или корень репо — legacy)."""
return _HOME or REPO_ROOT
# ── ES-база: события/проекции/идемпотентная доставка ────────────────────────
def db_path() -> str:
env = os.environ.get("SIMBIOT_DB", "").strip()
if env:
return env
if IS_ISOLATED:
return os.path.join(_HOME, "db", "harness.db")
return os.path.join(_HARNESS_DIR, "harness.db") # legacy: как было
# ── .env: секреты инстанса ──────────────────────────────────────────────────
def env_path() -> str:
if IS_ISOLATED:
return os.path.join(_HOME, "env", ".env")
return os.path.join(REPO_ROOT, ".env")
# ── RAG-индекс знаний ───────────────────────────────────────────────────────
def rag_db() -> str:
env = os.environ.get("SIMBIOT_RAG_DB", "").strip()
if env:
return env
if IS_ISOLATED:
return os.path.join(_HOME, "rag", "rag.db")
return "/root/rag/rag.db" # legacy (общая база кооп-хоста, как было)
# ── выжимки уровней (файлы summary_ref) ─────────────────────────────────────
def digests_dir() -> str:
if IS_ISOLATED:
return os.path.join(_HOME, "digests")
return os.path.join(REPO_ROOT, "out") # legacy out/
# ── проектные документы инстанса ────────────────────────────────────────────
def docs_dir() -> str:
if IS_ISOLATED:
return os.path.join(_HOME, "docs")
return os.path.join(REPO_ROOT, "docs")
# ── ключи крипто-аудита ─────────────────────────────────────────────────────
def audit_key_dir() -> str:
env = os.environ.get("SIMBIOT_AUDIT_KEY_DIR", "").strip()
if env:
return env
if IS_ISOLATED:
return os.path.join(_HOME, "audit")
return os.path.join(REPO_ROOT, ".audit")
# ── входящие каналов: state + legacy-каталоги файлопередачи ─────────────────
def channel_state_file() -> str:
if IS_ISOLATED:
return os.path.join(_HOME, "inbox", ".channel-last")
return os.path.join(REPO_ROOT, "portal", "inbox", ".channel-last")
def inbox_pending_dir() -> str:
if IS_ISOLATED:
return os.path.join(_HOME, "inbox", "pending")
return os.path.join(REPO_ROOT, "portal", "inbox", "pending")
def inbox_outbox_dir() -> str:
if IS_ISOLATED:
return os.path.join(_HOME, "inbox", "outbox")
return os.path.join(REPO_ROOT, "portal", "inbox", "outbox")
# ── runtime-штампы каналов для дашборда ─────────────────────────────────────
def stamp_dir() -> str:
if IS_ISOLATED:
return os.path.join(_HOME, "run")
return REPO_ROOT # legacy: <repo>/harness/channel_<name>.json
# ── кэш голосовых ───────────────────────────────────────────────────────────
def voice_dir() -> str:
env = os.environ.get("SIMBIOT_VOICE_DIR", "").strip()
if env:
return env
if IS_ISOLATED:
return os.path.join(_HOME, "voice")
return "/var/lib/simbiot-harness/voice" # legacy
def ensure_instance_dirs() -> list:
"""Создать структуру хома изолированного инстанса. Идемпотентно.
В legacy-режиме ничего не создаёт (там всё уже лежит как лежало).
Возвращает список созданных каталогов.
"""
if not IS_ISOLATED:
return []
made = []
for sub in ("db", "rag", "env", "inbox", "digests", "audit", "docs", "voice", "run"):
p = os.path.join(_HOME, sub)
if not os.path.isdir(p):
os.makedirs(p, exist_ok=True)
made.append(p)
return made

View file

@ -27,10 +27,12 @@ import datetime
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from . import paths as _paths # t-261004113351-2858c1: SIMBIOT_HOME
REPORTER_ENV = "/etc/coop-reporter.env" REPORTER_ENV = "/etc/coop-reporter.env"
STATE_FILE = "/root/simbiot-harness/portal/inbox/.channel-last" STATE_FILE = _paths.channel_state_file() # t-261004113351-2858c1: SIMBIOT_HOME
PENDING_DIR = "/root/simbiot-harness/portal/inbox/pending" # legacy-путь (пусто) PENDING_DIR = _paths.inbox_pending_dir()
OUTBOX_DIR = "/root/simbiot-harness/portal/inbox/outbox" # legacy fallback OUTBOX_DIR = _paths.inbox_outbox_dir()
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTTP_TIMEOUT = 8 # портал отвечает быстро; висеть дольше — вред HTTP_TIMEOUT = 8 # портал отвечает быстро; висеть дольше — вред
@ -43,6 +45,27 @@ def _now_ms() -> int:
return int(time.time() * 1000) return int(time.time() * 1000)
# ── живой статус каналов для дашборда (t-261004103718-d8112b) ──────────────
# Каждый реальный пользователь канала (сек-рефлекс портала, TG-поллер) после
# УСПЕШНОГО обмена пишет штамп {"ok":bool,"ts":ms,...} в harness/channel_<n>.json.
# Дашборд читает штампы (не дёргает портал сам) — возраст штампа = возраст данных.
STAMP_DIR = _paths.stamp_dir() # legacy: корень репо; SIMBIOT_HOME → <home>/run
def write_channel_stamp(name: str, ok: bool, **extra) -> None:
"""Атомарный штамп успеха канала: harness/channel_<portal|tg>.json."""
try:
path = os.path.join(STAMP_DIR, "harness", f"channel_{name}.json")
d = {"ok": bool(ok), "ts": _now_ms()}
d.update(extra)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(d, f)
os.replace(tmp, path)
except OSError:
pass # штамп — диагностика, не должен ломать канал
def _cfg() -> dict: def _cfg() -> dict:
cfg = {"portal_url": "", "agent_token": "", "agent_name": ""} cfg = {"portal_url": "", "agent_token": "", "agent_name": ""}
for k, ek in (("portal_url", "SIMBIOT_PORTAL_URL"), for k, ek in (("portal_url", "SIMBIOT_PORTAL_URL"),
@ -266,7 +289,7 @@ def _apply_tg_command(text: str) -> None:
# 0) идемпотентность: тот же токен уже в .env — не применяем повторно # 0) идемпотентность: тот же токен уже в .env — не применяем повторно
# (не дёргаем getMe/рестарт, не спамим отчётом при ретраях канала) # (не дёргаем getMe/рестарт, не спамим отчётом при ретраях канала)
try: try:
with open(os.path.join(_HARNESS_DIR, ".env")) as f: with open(_paths.env_path()) as f:
for line in f: for line in f:
if line.startswith("SIMBIOT_TG_TOKEN="): if line.startswith("SIMBIOT_TG_TOKEN="):
if line.split("=", 1)[1].strip() == token: if line.split("=", 1)[1].strip() == token:
@ -290,14 +313,14 @@ def _apply_tg_command(text: str) -> None:
allowed = payload.get("allowed") or payload.get("owner_tg") or "" allowed = payload.get("allowed") or payload.get("owner_tg") or ""
if not allowed: if not allowed:
try: try:
with open(os.path.join(_HARNESS_DIR, ".env")) as f: with open(_paths.env_path()) as f:
for line in f: for line in f:
if line.startswith("SIMBIOT_TG_ALLOWED="): if line.startswith("SIMBIOT_TG_ALLOWED="):
allowed = line.split("=", 1)[1].strip() allowed = line.split("=", 1)[1].strip()
except OSError: except OSError:
pass pass
# 3) атомарная запись .env (в .gitignore — в git не попадает) # 3) атомарная запись .env (в .gitignore — в git не попадает)
env_path = os.path.join(_HARNESS_DIR, ".env") env_path = _paths.env_path()
tmp_path = env_path + ".tmp" tmp_path = env_path + ".tmp"
try: try:
with open(env_path) as f: with open(env_path) as f:
@ -347,7 +370,8 @@ def sec_reflex() -> dict:
return {"skipped": "disabled"} # мастер-харнес: канал выключен флагом return {"skipped": "disabled"} # мастер-харнес: канал выключен флагом
_last_reflex["ts"] = now _last_reflex["ts"] = now
out = {} out = {}
out["ingested"] = pull() out["ingested"] = pull() # доехали сюда = портал ответил HTTP (успех обмена)
write_channel_stamp("portal", ok=True, ingested=out["ingested"])
out["outbox_files"] = _flush_outbox_files() out["outbox_files"] = _flush_outbox_files()
# legacy: file-pending (если портал когда-то писал файлами) # legacy: file-pending (если портал когда-то писал файлами)
try: try:

View file

@ -18,14 +18,15 @@ import os
import subprocess import subprocess
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from . import paths as _paths # t-261004113351-2858c1: RAG-индекс инстанса
_RAG_VENV_PY = "/root/rag-venv/bin/python" _RAG_VENV_PY = "/root/rag-venv/bin/python"
_RAG_SCRIPT = "/root/rag/rag.py" _RAG_SCRIPT = "/root/rag/rag.py"
# База по умолчанию: корпус кооператива (12.5k чанков) — то, что просил Алекс # База по умолчанию: корпус кооператива (12.5k чанков) — то, что просил Алекс
# для мин-пульса; hermes.db (54k) — опцией RAG_DB для верхних уровней. # для мин-пульса; hermes.db (54k) — опцией RAG_DB для верхних уровней.
# SIMBIOT_RAG_DB — для второго инстанса харнеса (агент со своей базой, # SIMBIOT_RAG_DB — точечное переопределение; SIMBIOT_HOME → <home>/rag/rag.db
# t-261001124130: мигрирующий агент получает собственный RAG-индекс). # (t-261004113351: инстанс Юры не должен искать по нашим знаниям).
DEFAULT_DB = os.environ.get("SIMBIOT_RAG_DB", "/root/rag/rag.db") DEFAULT_DB = _paths.rag_db()
# Лимиты на тик: 1 запрос, k<=5, таймаут 20с (эмбеддинг ~2.7с + запас) # Лимиты на тик: 1 запрос, k<=5, таймаут 20с (эмбеддинг ~2.7с + запас)
MAX_K = 5 MAX_K = 5

View file

@ -9,6 +9,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from . import paths as _paths # t-261004113351-2858c1: .env инстанса
def _env_or_file(name: str, default: str = "") -> str: def _env_or_file(name: str, default: str = "") -> str:
@ -21,7 +22,7 @@ def _env_or_file(name: str, default: str = "") -> str:
val = os.environ.get(name, "") val = os.environ.get(name, "")
if val: if val:
return val return val
path = os.path.join(_HARNESS_DIR, ".env") path = _paths.env_path()
try: try:
with open(path) as f: with open(path) as f:
for line in f: for line in f:

View file

@ -18,8 +18,10 @@ import urllib.error
from . import es from . import es
from . import paths as _paths # t-261004113351-2858c1: .env инстанса
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_ENV_PATH = os.path.join(_HARNESS_DIR, ".env") _ENV_PATH = _paths.env_path()
TG_TOKEN = None TG_TOKEN = None
ALLOWED = set() ALLOWED = set()
@ -336,6 +338,13 @@ def poll_loop():
while True: while True:
try: try:
updates = _api("getUpdates", {"offset": offset, "timeout": 30, "limit": 16}) updates = _api("getUpdates", {"offset": offset, "timeout": 30, "limit": 16})
# getUpdates ответил — канал жив: штамп для дашборда
# (t-261004103718-d8112b). getUpdates сам НЕ пробуем в дашборде:
# конфликт long polling с этим процессом (сдвиг offset).
from . import portal_channel as _pch
_pch.write_channel_stamp(
"tg", ok=True,
updates=len((updates or {}).get("result") or []))
for u in updates.get("result", []): for u in updates.get("result", []):
offset = max(offset, u["update_id"] + 1) offset = max(offset, u["update_id"] + 1)
msg = u.get("message") or u.get("edited_message") msg = u.get("message") or u.get("edited_message")

View file

@ -29,17 +29,19 @@ from . import es
# ─── конфигурация ─────────────────────────────────────────────────────────── # ─── конфигурация ───────────────────────────────────────────────────────────
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_ENV_PATH = os.path.join(_HARNESS_DIR, ".env") from . import paths as _paths # t-261004113351-2858c1
_ENV_PATH = _paths.env_path()
_FW_WORKER = os.path.join(_HARNESS_DIR, "harness", "_fw_asr_worker.py") _FW_WORKER = os.path.join(_HARNESS_DIR, "harness", "_fw_asr_worker.py")
_FW_PYTHON = "/usr/bin/python3" # faster-whisper живёт в системном python _FW_PYTHON = "/usr/bin/python3" # faster-whisper живёт в системном python
ASR_URL = os.environ.get("SIMBIOT_ASR_URL", "http://127.0.0.1:8084") ASR_URL = os.environ.get("SIMBIOT_ASR_URL", "http://127.0.0.1:8084")
VOICE_DIR = _paths.voice_dir() # SIMBIOT_HOME → <home>/voice; legacy без хома
# что расшифровываем: voice — главное; audio/video_note — та же речь # что расшифровываем: voice — главное; audio/video_note — та же речь
TRANSCRIBABLE_KINDS = {"voice", "audio", "video_note"} TRANSCRIBABLE_KINDS = {"voice", "audio", "video_note"}
# длина, сек: длиннее не тянем (поллер не должен вязнуть в 10-мин озвучках) # длина, сек: длиннее не тянем (поллер не должен вязнуть в 10-мин озвучках)
MAX_DURATION = 300 MAX_DURATION = 300
# аудио-кэш: file_unique_id → ogg-файл # аудио-кэш: file_unique_id → ogg-файл (путь задаёт paths.voice_dir выше)
VOICE_DIR = os.environ.get("SIMBIOT_VOICE_DIR", "/var/lib/simbiot-harness/voice")
TG_TOKEN = None TG_TOKEN = None

196
scripts/init_instance.py Executable file
View file

@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Simbiot — инициализация нового изолированного инстанса (t-261004113351).
Мультитенантность: память инстанса (ES-база, RAG-индекс, .env, доки) живёт
в отдельном SIMBIOT_HOME, репозиторий остаётся чистым кодом. Скрипт:
1) создаёт структуру хома (db/ rag/ env/ inbox/ digests/ audit/ docs/ …)
2) инициализирует ЧИСТУЮ ES-базу (никаких событий старого инстанса)
3) пишет PolicyChanged с идентичностью нового владельца
4) копирует его проектные документы в <home>/docs/ (+ RAG-индексация,
если вендор доступен)
5) опционально: селективный импорт знаний из инстанса-источника —
последние выжимки (--import-digests) и выбранные доки (--import-docs)
6) печатает, как поднять: демоном (systemd) и разово (CLI)
Пример (инстанс Юры на той же машине):
venv/bin/python scripts/init_instance.py --home /var/lib/simbiot-yura \
--name Юра --identity "Ты — агент Юры…" \
--docs /tmp/yura-project/*.md --env-from /root/yura.env
На машине Юры (после install.sh):
~/simbiot-harness/venv/bin/python scripts/init_instance.py \
--home ~/simbiot-data --name Юра --env-from ~/yura.env --docs ~/my-project
"""
import argparse
import glob
import json
import os
import shutil
import subprocess
import sys
import tempfile
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_RAG_VENV_PY = "/root/rag-venv/bin/python"
_RAG_SCRIPT = "/root/rag/rag.py"
# точечные переопределения окружения вызывающего НЕ должны протекать в новый
# инстанс (например, SIMBIOT_DB боя, выставленная воркером часа)
_POINT_ENV = ("SIMBIOT_DB", "SIMBIOT_RAG_DB", "SIMBIOT_AUDIT_KEY_DIR",
"SIMBIOT_VOICE_DIR")
def _jlog(msg: str) -> None:
print(json.dumps({"ts": __import__("datetime").datetime.now().isoformat(),
"msg": msg}, ensure_ascii=False))
def _rag_index(files: list, rag_db: str) -> dict:
"""Индексация файлов в RAG-базу инстанса (вендор дедупит по sha)."""
res = {"indexed": [], "failed": [], "error": None}
if not (os.path.exists(_RAG_VENV_PY) and os.path.exists(_RAG_SCRIPT)):
res["error"] = "rag-вендор не найден — документы скопированы, индексация пропущена"
return res
os.makedirs(os.path.dirname(rag_db), exist_ok=True)
env = dict(os.environ, RAG_DB=rag_db)
for path in files:
try:
proc = subprocess.run([_RAG_VENV_PY, _RAG_SCRIPT, "add", path],
capture_output=True, text=True, timeout=120,
env=env)
(res["indexed"] if proc.returncode == 0 else res["failed"]).append(
os.path.basename(path))
except Exception as e:
res["failed"].append({"file": os.path.basename(path),
"err": str(e)[:200]})
return res
def _expand_docs(patterns: list) -> list:
out = []
for p in patterns or []:
for m in (glob.glob(p) if any(c in p for c in "*?[") else [p]):
if os.path.isfile(m):
out.append(os.path.abspath(m))
return out
def _export_digests(src_db: str, per_level: int, out_dir: str) -> list:
"""Выжимки инстанса-источника → md-файлы (для селективного переноса)."""
import sqlite3
made = []
os.makedirs(out_dir, exist_ok=True)
src = sqlite3.connect(f"file:{src_db}?mode=ro", uri=True)
src.row_factory = sqlite3.Row
for lvl in ("min", "hour", "day"):
rows = src.execute(
"SELECT timestamp, data FROM events WHERE event_type='DigestWritten'"
" AND json_extract(data,'$.level')=? AND json_extract(data,'$.stub')"
" IS NULL ORDER BY id DESC LIMIT ?", (lvl, per_level)).fetchall()
for r in rows:
d = json.loads(r["data"])
fn = os.path.join(out_dir, f"digest-{lvl}-{r['timestamp'][:16].replace(':','')}.md")
with open(fn, "w", encoding="utf-8") as f:
f.write(f"# Выжимка {lvl} {r['timestamp']}\n\n{d.get('text','')}\n")
made.append(fn)
src.close()
return made
def main() -> int:
ap = argparse.ArgumentParser(description="init изолированного инстанса Simbiot")
ap.add_argument("--home", required=True, help="SIMBIOT_HOME нового инстанса")
ap.add_argument("--name", default="agent", help="имя владельца (для идентичности)")
ap.add_argument("--identity", default=None,
help="идентичность (PolicyChanged); по умолчанию — по имени")
ap.add_argument("--docs", nargs="*", default=[],
help="проектные документы нового владельца (файлы/глоб)")
ap.add_argument("--env-from", default=None,
help="готовый .env (токены/ключи) → <home>/env/.env (0600)")
ap.add_argument("--src-db", default=None,
help="ES-база инстанса-источника (для селективного импорта)")
ap.add_argument("--import-digests", type=int, default=0,
help="сколько последних выжимок на уровень перенести из --src-db")
ap.add_argument("--import-docs", nargs="*", default=[],
help="файлы-источники знаний (доки/выжимки) → RAG нового инстанса")
ap.add_argument("--force", action="store_true", help="работать даже если хом не пуст")
args = ap.parse_args()
home = os.path.abspath(os.path.expanduser(args.home))
# SIMBIOT_HOME должен быть в env ДО импорта harness.* (paths читает при старте)
for k in _POINT_ENV:
os.environ.pop(k, None)
os.environ["SIMBIOT_HOME"] = home
sys.path.insert(0, _REPO)
if os.path.isdir(home) and os.listdir(home) and not args.force:
print(f"ОТКАЗ: {home} не пуст (запуск повторно?). --force чтобы продолжить.")
return 2
os.makedirs(home, exist_ok=True)
from harness import paths, es, commands # после выставления SIMBIOT_HOME
made = paths.ensure_instance_dirs()
if made:
_jlog({"home_dirs": len(made), "home": home})
# 1) чистая база + идентичность
es.init_db()
n_events = es._conn().execute("SELECT COUNT(*) c FROM events").fetchone()["c"]
if n_events:
print(f"ОТКАЗ: в {paths.db_path()} уже {n_events} событий — это не чистый инстанс.")
return 2
identity = args.identity or (
f"Ты — Simbiot-агент {args.name}. Помогаешь {args.name} в его проектах; "
"чужих проектов и контекстов у тебя нет — если спрашивают о том, чего "
"нет в твоей базе, так и говори.")
commands.set_policy(identity=identity)
_jlog({"identity_set": True, "db": paths.db_path()})
# 2) .env инстанса
if args.env_from:
dst = paths.env_path()
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(args.env_from, dst)
os.chmod(dst, 0o600)
_jlog({"env": dst})
# 3) доки владельца → <home>/docs (+RAG)
report = {"docs_copied": [], "rag": None, "digests_imported": []}
docs = _expand_docs(args.docs)
for p in docs:
dst = os.path.join(paths.docs_dir(), os.path.basename(p))
shutil.copyfile(p, dst)
report["docs_copied"].append(os.path.basename(p))
# 4) селективный импорт знаний
to_index = list(docs and [os.path.join(paths.docs_dir(), os.path.basename(p))
for p in docs] or [])
if args.import_digests and args.src_db:
exported = _export_digests(args.src_db, args.import_digests,
os.path.join(home, "digests", "imported"))
report["digests_imported"] = [os.path.basename(x) for x in exported]
to_index.extend(exported)
to_index.extend(_expand_docs(args.import_docs))
if to_index:
rag = _rag_index(to_index, paths.rag_db())
if not rag["failed"]:
rag.pop("failed") # пустой список — не шумим в отчёте
report["rag"] = rag
print(json.dumps({"ok": True, "home": home, "db": paths.db_path(),
"env": paths.env_path(), "rag_db": paths.rag_db(),
"docs_dir": paths.docs_dir(), **report},
ensure_ascii=False, indent=2))
print("\nКак поднять:")
print(f" разово: SIMBIOT_HOME={home} {_REPO}/venv/bin/python {_REPO}/cli.py status")
print(f" демон: SIMBIOT_HOME={home} {_REPO}/venv/bin/python -m harness.scheduler")
print(" systemd: скопируй scripts/simbiot-harness.service, добавь строку")
print(f" Environment=SIMBIOT_HOME={home}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -118,6 +118,9 @@ cat > "$BIN_DIR/simbiot" <<EOF
# Simbiot Harness CLI (установлен scripts/install.sh в $INSTALL_DIR) # Simbiot Harness CLI (установлен scripts/install.sh в $INSTALL_DIR)
unset PYTHONPATH unset PYTHONPATH
unset PYTHONHOME unset PYTHONHOME
# память инстанса — вне репо (t-261004113351): репо = чистый код
export SIMBIOT_HOME="\${SIMBIOT_HOME:-$HOME/.simbiot-data}"
mkdir -p "\$SIMBIOT_HOME" 2>/dev/null || true
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
exec ./venv/bin/python cli.py "\$@" exec ./venv/bin/python cli.py "\$@"
EOF EOF
@ -135,6 +138,9 @@ cat > "$INSTALL_DIR/bin/simbiot" <<'EOF'
DIR="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)" DIR="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
unset PYTHONPATH unset PYTHONPATH
unset PYTHONHOME unset PYTHONHOME
# память инстанса — вне репо (t-261004113351): репо = чистый код
export SIMBIOT_HOME="${SIMBIOT_HOME:-$HOME/.simbiot-data}"
mkdir -p "$SIMBIOT_HOME" 2>/dev/null || true
cd "$DIR" || exit 1 cd "$DIR" || exit 1
PY="$DIR/venv/bin/python" PY="$DIR/venv/bin/python"
[ -x "$PY" ] || PY="$(command -v python3)" [ -x "$PY" ] || PY="$(command -v python3)"
@ -202,7 +208,11 @@ fi
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
if [ "${FORCE_SYSTEMD:-}" = "yes" ]; then if [ "${FORCE_SYSTEMD:-}" = "yes" ]; then
if [ "$(id -u)" = "0" ] && command -v systemctl >/dev/null 2>&1 && systemctl is-system-running >/dev/null 2>&1; then if [ "$(id -u)" = "0" ] && command -v systemctl >/dev/null 2>&1 && systemctl is-system-running >/dev/null 2>&1; then
cp "$INSTALL_DIR/scripts/simbiot-harness.service" /etc/systemd/system/ 2>/dev/null || sed -e "s|/root/simbiot-harness|$INSTALL_DIR|g" -e "s|root|$USER|g" "$INSTALL_DIR/scripts/simbiot-harness.service" > /etc/systemd/system/simbiot-harness.service # рендер шаблона: плейсхолдеры __INSTALL_DIR__/__DATA_DIR__ всегда
# подставляются (голый cp клал бы юнит с заглушками — t-261004113351)
sed -e "s|__INSTALL_DIR__|$INSTALL_DIR|g" \
-e "s|__DATA_DIR__|${SIMBIOT_DATA_DIR:-$HOME/.simbiot-data}|g" \
"$INSTALL_DIR/scripts/simbiot-harness.service" > /etc/systemd/system/simbiot-harness.service
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now simbiot-harness systemctl enable --now simbiot-harness
say "${GREEN}→${NC} systemd: simbiot-harness запущен (systemctl status simbiot-harness)" say "${GREEN}→${NC} systemd: simbiot-harness запущен (systemctl status simbiot-harness)"

140
scripts/migrate_to_home.sh Executable file
View file

@ -0,0 +1,140 @@
#!/usr/bin/env bash
# ============================================================================
# Simbiot — перенос памяти инстанса из репо в SIMBIOT_HOME (t-261004113351).
#
# Репозиторий становится чистым кодом: ES-база, .env, ключи аудита, балансы,
# выжимки, штампы каналов переезжают в один каталог-хом (по умолчанию
# /var/lib/simbiot-harness). Идемпотентен: повторный запуск — SKIP уже
# перенесённого.
#
# БЕЗОПАСНОСТЬ: скрипт сам ждёт «тихого окна» (нет живых LLM-воркеров —
# паттерн scripts/safe-restart.sh), останавливает три сервиса харнеса,
# переносит файлы, добавляет SIMBIOT_HOME в systemd-юниты и обёртки,
# поднимает сервисы и проверяет здоровье. Откат: см. конец файла.
#
# scripts/migrate_to_home.sh [HOME] # по умолчанию /var/lib/simbiot-harness
# ============================================================================
set -u
REPO="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
HOME_DIR="${1:-/var/lib/simbiot-harness}"
SERVICES="simbiot-harness.service simbiot-harness-tg.service simbiot-dashboard.service"
LOG="${SIMBIOT_MIGRATION_LOG:-/tmp/simbiot-migrate-to-home.log}"
MAX_WAIT="${SIMBIOT_MIGRATION_WAIT:-1800}"
PAT='/harness/_llm_worker\.py$'
log() { echo "[$(date -u '+%F %T')] $*" >> "$LOG"; echo "$*"; }
# ── 0) тихое окно: мигрировать можно только когда ни один тик не пишет в БД ─
deadline=$(( $(date +%s) + MAX_WAIT ))
while :; do
if ! pgrep -f "$PAT" > /dev/null; then
sleep 3
if ! pgrep -f "$PAT" > /dev/null; then
log "тихое окно поймано — начинаю миграцию в $HOME_DIR"
break
fi
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
log "тихого окна нет ${MAX_WAIT}s — отмена (повторить позже)"
exit 0
fi
sleep 10
done
# ── 1) структура хома (audit НЕ создаём: туда переедет каталог целиком) ─────
mkdir -p "$HOME_DIR"/{db,env,inbox,digests,run,voice,docs/aum}
log "home создан: $HOME_DIR"
moved() { # mv если источник есть и цель отсутствует
[ -e "$1" ] || { [ -L "$1" ] && rm -f "$1"; return 0; }
if [ -e "$2" ]; then log "SKIP (цель есть): $2"; return 0; fi
mkdir -p "$(dirname "$2")"
mv "$1" "$2" && log "moved: $1 → $2"
}
copied() { # cp если источник есть (переносить нельзя: git-трекается)
[ -e "$1" ] || return 0
if [ -e "$2" ]; then log "SKIP (цель есть): $2"; return 0; fi
mkdir -p "$(dirname "$2")"
cp -a "$1" "$2" && log "copied: $1 → $2"
}
# ── 2) стоп сервисов (писателей больше нет — mv БД с WAL безопасен) ─────────
systemctl stop $SERVICES
log "сервисы остановлены: $SERVICES"
# ── 3) перенос памяти ───────────────────────────────────────────────────────
moved "$REPO/harness/harness.db" "$HOME_DIR/db/harness.db"
moved "$REPO/harness/harness.db-wal" "$HOME_DIR/db/harness.db-wal"
moved "$REPO/harness/harness.db-shm" "$HOME_DIR/db/harness.db-shm"
moved "$REPO/.env" "$HOME_DIR/env/.env"
moved "$REPO/.audit" "$HOME_DIR/audit" # ключи ed25519
copied "$REPO/docs/aum/balances.json" "$HOME_DIR/docs/aum/balances.json"
copied "$REPO/out" "$HOME_DIR/digests/out" # выжимки-файлы
copied "$REPO/portal/inbox/.channel-last" "$HOME_DIR/inbox/.channel-last"
for f in "$REPO"/harness/channel_*.json; do
[ -e "$f" ] && { cp -a "$f" "$HOME_DIR/run/" && log "copied: $f"; }
done
[ -f "$HOME_DIR/env/.env" ] && chmod 600 "$HOME_DIR/env/.env"
# ── 4) SIMBIOT_HOME в юнитах и обёртках ─────────────────────────────────────
# RAG-корпус кооператива (/root/rag/rag.db) — общая инфраструктура хоста,
# его индексируют и другие инструменты (board hook): основному инстансу
# оставляем точечное переопределение, иначе SIMBIOT_HOME уведёт поиск на
# пустой <home>/rag/rag.db (t-261004113351).
RAG_KEEP=""
if [ -e /root/rag/rag.db ]; then
RAG_KEEP="/root/rag/rag.db"
fi
for svc in $SERVICES; do
f="/etc/systemd/system/$svc"
[ -f "$f" ] || continue
if ! grep -q "^Environment=SIMBIOT_HOME=" "$f"; then
sed -i "/^\[Service\]/a Environment=SIMBIOT_HOME=$HOME_DIR" "$f"
log "юнит обновлён: $f (+SIMBIOT_HOME)"
fi
if [ -n "$RAG_KEEP" ] && ! grep -q "^Environment=SIMBIOT_RAG_DB=" "$f"; then
sed -i "/^Environment=SIMBIOT_HOME=/a Environment=SIMBIOT_RAG_DB=$RAG_KEEP" "$f"
log "юнит обновлён: $f (+SIMBIOT_RAG_DB=$RAG_KEEP)"
fi
done
systemctl daemon-reload
for w in "$REPO/bin/simbiot" /root/.local/bin/simbiot; do
[ -f "$w" ] || continue
if ! grep -q "SIMBIOT_HOME" "$w"; then
sed -i "3a export SIMBIOT_HOME=\"\${SIMBIOT_HOME:-$HOME_DIR}\"" "$w"
log "обёртка обновлена: $w"
fi
done
# ── 5) подъём и проверка здоровья ───────────────────────────────────────────
systemctl start simbiot-harness.service simbiot-harness-tg.service simbiot-dashboard.service
sleep 8
health="FAIL"
if systemctl is-active --quiet simbiot-harness.service \
&& [ -s "$HOME_DIR/db/harness.db" ] \
&& ! ls "$REPO/harness/harness.db" >/dev/null 2>&1; then
health="OK"
fi
log "health: $health (db: $(du -h "$HOME_DIR/db/harness.db" 2>/dev/null | cut -f1))"
# контрольный прогон CLI из хома (не должен пересоздавать репо-базу)
SIMBIOT_HOME="$HOME_DIR" "$REPO/venv/bin/python" "$REPO/cli.py" status >/dev/null 2>&1 \
&& log "cli status: OK" || log "cli status: FAIL"
[ -e "$REPO/harness/harness.db" ] && log "ВНИМАНИЕ: репо-база пересоздана чем-то!"
# отчёт + идемпотентное уведомление (не curl — защита от дублей в cli.py)
cat > "$HOME_DIR/migration-report.json" <<EOF
{"task": "t-261004113351-2858c1", "home": "$HOME_DIR",
"health": "$health", "ts": "$(date -u '+%F %T')"}
EOF
SIMBIOT_HOME="$HOME_DIR" "$REPO/venv/bin/python" "$REPO/cli.py" tg-notify \
"📦 Память харнеса изолирована в $HOME_DIR (репо = чистый код). Здоровье: $health" \
-k t-261004113351-migrated 2>/dev/null || true
log "миграция завершена: $health"
# ОТКАТ (если что-то не так): systemctl stop $SERVICES;
# mv "$HOME_DIR"/db/harness.db* "$REPO/harness/"; mv "$HOME_DIR/env/.env" "$REPO/.env";
# убрать Environment=SIMBIOT_HOME из юнитов; systemctl daemon-reload; start
exit 0

View file

@ -10,6 +10,8 @@ ExecStart=__INSTALL_DIR__/venv/bin/python -m harness.scheduler
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
# память инстанса (база/.env/выжимки) — вне репо (t-261004113351)
Environment=SIMBIOT_HOME=__DATA_DIR__
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -0,0 +1,137 @@
"""Изоляция памяти инстанса Simbiot (t-261004113351-2858c1).
Тест постановки: «поднять копию с пустой памятью, спросить про проекты —
не должна вспоминать наши». Проверяем три контура утечки:
1) ES-память: контекст тика нового инстанса (выжимки/эскалации/доска/
AUM-цель из policy) — пустые, идентичность — нового владельца;
2) RAG-знания: индекс нового инстанса — только его документы (вендор
возвращает top-k на любой запрос, поэтому смотрим содержимое индекса);
его тема — находится (положительный контроль: поиск живой);
3) прод не задет: в боевой БД нет событий тестового инстанса.
Запуск: venv/bin/python tests/test_instance_isolation.py
"""
import json
import os
import shutil
import subprocess
import sqlite3
import sys
import tempfile
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PY = os.path.join(_REPO, "venv", "bin", "python")
_PROD_DB = os.path.join(_REPO, "harness", "harness.db")
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
if not cond:
fails.append(name)
# ── стенд: новый инстанс Юры с одним его документом ────────────────────────
home = tempfile.mkdtemp(prefix="simbiot-iso-yura-")
docs = tempfile.mkdtemp(prefix="simbiot-iso-docs-")
doc = os.path.join(docs, "greenhouse.md")
with open(doc, "w", encoding="utf-8") as f:
f.write("# Проект Юры: умная теплица\n\nДатчики влажности, автополив на ESP32, "
"урожай томатов к июлю.\n")
r = subprocess.run([_PY, os.path.join(_REPO, "scripts", "init_instance.py"),
"--home", home, "--name", "Юра", "--docs", doc],
capture_output=True, text=True, timeout=180)
out = {}
try:
lines = r.stdout.splitlines() # _jlog-строки (однострочный JSON)
i = next(i for i, ln in enumerate(lines) if ln.strip() == "{")
j = next(j for j in range(len(lines) - 1, -1, -1) if lines[j].strip() == "}")
out = json.loads("\n".join(lines[i:j + 1])) # итоговый pretty-JSON
except Exception:
pass
check("init: ok", r.returncode == 0 and out.get("ok") is True,
r.stdout[-300:] + r.stderr[-300:])
# ── 1) ES-память: контекст тика чистого инстанса ───────────────────────────
env = {k: v for k, v in os.environ.items()
if k not in ("SIMBIOT_DB", "SIMBIOT_RAG_DB", "SIMBIOT_HOME")}
env["SIMBIOT_HOME"] = home
code = """
import sys
sys.path.insert(0, %r)
from harness import es
from harness.levels import LEVELS
from harness.scheduler import build_context
model = es.run_projections()
ctx = build_context(LEVELS["hour"], model, batch=[])
print(ctx)
""" % _REPO
r2 = subprocess.run([_PY, "-c", code], capture_output=True, text=True,
env=env, timeout=120)
ctx = r2.stdout
check("context: построен", bool(ctx.strip()), r2.stderr[-200:])
for marker, why in [
("ВЫЖИМКИ НИЖНИХ УРОВНЕЙ", "наши выжимки протекли"),
("ЭСКАЛАЦИИ (", "наши эскалации протекли"),
("ДОСКА ЗАДАЧ", "наши задачи протекли"),
("ЦЕЛЬ (AUM)", "наша AUM-цель протекла"),
("ИНБOX", "наш инбокс протёк"),
("кооператив 42", "наш контекст протёк"),
]:
check(f"context: без «{marker}»", marker not in ctx, why)
check("context: идентичность Юры", "Юра" in ctx and "ИДЕНТИЧНОСТЬ" in ctx)
# события нового инстанса: только генезис (PolicyChanged), ничего нашего
db = sqlite3.connect(os.path.join(home, "db", "harness.db"))
evs = [t[0] for t in db.execute("SELECT event_type FROM events")]
db.close()
check("db: генезис только", set(evs) <= {"PolicyChanged"}, evs)
# ── 2) RAG-знания нового инстанса ──────────────────────────────────────────
# Вендор возвращает top-k ближайших на ЛЮБОЙ запрос (пустой базы не бывает),
# поэтому «наших знаний нет» проверяем по содержимому индекса: все чанки —
# только из документов владельца. Положительный контроль: его тема — находится.
env2 = dict(env)
r3 = subprocess.run([_PY, "-c", """
import sys, json
sys.path.insert(0, %r)
from harness import rag_lookup
his = rag_lookup.rag_query("теплица автополив датчики", k=3)
print(json.dumps({"his": [c["source"] for c in his]}))
""" % _REPO], capture_output=True, text=True, env=env2, timeout=120)
try:
rag = json.loads(r3.stdout.strip().splitlines()[-1])
except Exception:
rag = {"his": []}
rdb = sqlite3.connect(f"file:{os.path.join(home, 'rag', 'rag.db')}?mode=ro", uri=True)
paths = [p for (p,) in rdb.execute("SELECT DISTINCT path FROM chunks")]
rdb.close()
check("rag: в индексе только документы владельца",
paths and all(os.path.realpath(p).startswith(home + os.sep) for p in paths),
paths)
check("rag: его документ находится",
any("greenhouse" in s for s in rag["his"]), rag["his"])
check("rag: база инстанса отдельная",
os.path.exists(os.path.join(home, "rag", "rag.db"))
and os.path.realpath(os.path.join(home, "rag", "rag.db"))
!= os.path.realpath("/root/rag/rag.db"))
# ── 3) прод не задет: кросс-записей нет ────────────────────────────────────
# Прод живёт и пишет события каждую секунду; кроме того, трейсы ЭТОЙ сессии
# legitimately упоминают «greenhouse» (я писал тест). Настоящая утечка — если
# генезис тестового инстанса (PolicyChanged с его идентичностью) записался
# в боевую базу. Проверяем именно её.
pdb = sqlite3.connect(f"file:{_PROD_DB}?mode=ro", uri=True)
dirty = pdb.execute(
"SELECT COUNT(*) FROM events WHERE aggregate_id='policy' AND data LIKE ?",
("%агент Юра%",)).fetchone()[0]
pdb.close()
check("prod: генезис чужого инстанса не попал", dirty == 0, dirty)
shutil.rmtree(home, ignore_errors=True)
shutil.rmtree(docs, ignore_errors=True)
print(("=" * 8) + (" FAIL: " + str(len(fails)) if fails else " ALL PASS"))
sys.exit(1 if fails else 0)