fix: межпроцессная гонка проекций — read model всегда со снапшота на диске; капы spend/digests; воркер пишет результат в файл (шум strands в stdout не ломает протокол); сессии выкл до очистки; systemd unit + README

This commit is contained in:
Simbiot Harness 2026-09-29 15:40:51 +00:00
parent 0d90b49d3a
commit c941ec7ec8
6 changed files with 121 additions and 6 deletions

65
README.md Normal file
View file

@ -0,0 +1,65 @@
# Simbiot Harness
Ритмичный агент: живёт тактами, как человек. CQRS + Event Sourcing на спине,
Strands Harness SDK как исполнительное ядро, модели через 9router.
## Лестница (v0.1)
| Уровень | Интервал | Контекст | Модель | Задачи |
|---|---|---|---|---|
| sec | 1 с | 0 (без LLM) | — | рефлекс: телеметрия, алерты |
| min | 1 мин | ~4K | auto→flash | S-триаж: входящие, быстрые фиксы |
| hour | 1 ч | ~32K | auto | S/M-задачи |
| day | 1 д | ~128K | auto | M/L-задачи, манифест дня |
Принципы: тик идемпотентен (`UNIQUE(level, seq)`); занятый уровень поглощает
тик (`TickAbsorbed` + детерминированная заглушка дайджеста, без LLM);
запущенная задача не прерывается; наверх идут только дайджесты.
## События (ES-спина)
`CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`,
`TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`,
`BudgetSpent`, `CycleError`, `HarnessInit`, `PolicySet` — append-only,
проекции пересобираются replay'ем, стейт = производное от событий.
## Структура
- `harness/es.py` — event store + проекции (checkpoint + replay)
- `harness/levels.py` — конфигурация уровней
- `harness/scheduler.py` — демон лестницы
- `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе
(глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`)
- `harness/commands.py` — command side
- `cli.py` — init/task/tasks/status/events/requeue
## CLI
```bash
./venv/bin/python cli.py init --identity "…"
./venv/bin/python cli.py task "…" -m 240 # -m minutes-to-live estimate: S/M/L
./venv/bin/python cli.py tasks
./venv/bin/python cli.py status
./venv/bin/python cli.py events -n 50
./venv/bin/python cli.py requeue <task-id> -r "причина"
./venv/bin/python cli.py promote <task-id> -r "вне класса уровня" # S→M→L
```
## Запуск
```bash
systemctl enable --now simbiot-harness
journalctl -u simbiot-harness -f
```
## Модели
9router (router.cil.by), отдельный ключ `sk-simbiot` в `.env`, комбо `auto`:
glm-5.3 → kimi-k3 → deepseek-v4-pro → v4.1-flash → flash. Бюджет виден
в событиях `BudgetSpent` (по уровням) и в дашборде 9router по ключу.
## v0.2 (roadmap)
- прерывание срочной реакцией / пауза (сейчас absorbed детерминирован)
- уровни week/month/quarter/year
- инбокс из Telegram/портала

8
cli.py
View file

@ -27,6 +27,10 @@ def main():
p_rq.add_argument("task_id")
p_rq.add_argument("-r", "--reason", default="manual requeue")
p_pr = sub.add_parser("promote", help="промоушен ready-задачи на уровень выше (S→M→L)")
p_pr.add_argument("task_id")
p_pr.add_argument("-r", "--reason", default="manual promote")
sub.add_parser("status", help="состояние харнеса")
sub.add_parser("tasks", help="доска задач")
@ -53,6 +57,10 @@ def main():
es.init_db()
tid = commands.requeue_task(args.task_id, args.reason)
print(f"OK: задача {tid} возвращена в очередь")
elif args.cmd == "promote":
es.init_db()
tid, new_size = commands.promote_task(args.task_id, args.reason)
print(f"OK: задача {tid} повышена до [{new_size}]")
elif args.cmd == "status":
es.init_db()
print(json.dumps(commands.status(), ensure_ascii=False, indent=1))

View file

@ -22,6 +22,24 @@ def submit_task(title: str, details: str = "", minutes: int = 5, source: str = "
return task_id, size
def promote_task(task_id: str, reason: str = "manual promote"):
"""Промоушен ready-задачи на уровень выше (TaskPromoted → ready, size выше)."""
order = ["S", "M", "L"]
model = es.run_projections()
t = model["tasks"].get(task_id)
if t is None:
raise ValueError(f"задача {task_id} не найдена")
if t["status"] != "ready":
raise ValueError(f"задача {task_id} в статусе {t['status']} — promote только из ready")
i = order.index(t["size"]) if t["size"] in order else 0
if i >= len(order) - 1:
raise ValueError(f"задача {task_id} уже размера {t['size']} — выше некуда")
new_size = order[i + 1]
es.append_event("task", task_id, "TaskPromoted",
{"to": new_size, "from": t["size"], "reason": reason})
return task_id, new_size
def requeue_task(task_id: str, reason: str = "manual requeue"):
"""Вернуть failed/прерванную задачу в очередь (TaskPromoted → ready)."""
model = es.run_projections()
@ -30,7 +48,8 @@ def requeue_task(task_id: str, reason: str = "manual requeue"):
raise ValueError(f"задача {task_id} не найдена")
if t["status"] not in ("failed", "done"):
raise ValueError(f"задача {task_id} в статусе {t['status']} — requeue не нужен")
es.append_event("task", task_id, "TaskPromoted", {"to": t["size"], "reason": reason})
es.append_event("task", task_id, "TaskPromoted",
{"to": t["size"], "from": t["size"], "reason": reason})
return task_id

View file

@ -156,6 +156,8 @@ def _apply_event(model: dict, ev: dict):
elif etype == "TaskPromoted":
t = model["tasks"].get(aid)
if t:
if data.get("from") and data.get("to") and data["from"] != data["to"]:
t.update(size=data["to"])
t.update(status="ready", level=None, session_id=None,
promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1)
elif etype == "DigestWritten":
@ -166,6 +168,12 @@ def _apply_event(model: dict, ev: dict):
"text": data.get("text", ""),
"ts": ev["timestamp"],
}
# кап дайджестов: держим по 40 последних на уровень —
# «наверх идут только дайджесты» не значит «все дайджесты навсегда»
if len(model["digests"]) > 400:
keep = sorted(model["digests"].items(),
key=lambda kv: kv[1].get("ts", ""), reverse=True)[:400]
model["digests"] = dict(keep)
elif etype == "CycleTicked":
level = data.get("level", "?")
cs = model["cycle_state"].setdefault(level, {"busy": False, "current_task": None, "ticks": 0})
@ -182,7 +190,10 @@ def _apply_event(model: dict, ev: dict):
elif etype == "PolicyChanged":
model.setdefault("policy", {}).update(data)
elif etype == "BudgetSpent":
model.setdefault("spend", []).append(data)
spend = model.setdefault("spend", [])
spend.append(data)
if len(spend) > 500: # кап: не растить модель линейно
model["spend"] = spend[-500:]
# trace-события (InvocationStarted/ToolCalled/ToolResult) не материализуем — они для дебага
@ -208,20 +219,32 @@ def _save_model(db, model):
def run_projections():
"""Инкрементальная проекция с персистентным read model."""
"""Инкрементальная проекция с персистентным read model.
Гонка устранена: модель ВСЕГДА стартует с персистентного снапшота
(read_model-таблица), затем применяется дельта событий. In-memory
кэш между процессами не доверяем — два процесса с общим чекпоинтом
затирали друг друга (демон не видел задачу, поданную из CLI).
"""
global _read_model
with _LOCK:
db = _conn()
last = _load_meta(db, "main")
# ВСЕГДА стартуем со снапшота на диске — устраняет межпроцессную гонку
_read_model = _load_model(db) or _default_model()
# снапшот мог быть записан другим процессом на большем id, чем meta
snap_last = int(_read_model.get("_last_event", 0))
if snap_last > last:
last = snap_last
rows = db.execute("SELECT * FROM events WHERE id > ? ORDER BY id", (last,)).fetchall()
if _read_model is None:
_read_model = _load_model(db) or _default_model()
if not rows:
db.close()
return _read_model
for r in rows:
_apply_event(_read_model, dict(r))
_save_meta(db, "main", rows[-1]["id"])
new_last = rows[-1]["id"]
_read_model["_last_event"] = new_last
_save_meta(db, "main", new_last)
_save_model(db, _read_model)
db.commit()
db.close()