fix: межпроцессная гонка проекций — read model всегда со снапшота на диске; капы spend/digests; воркер пишет результат в файл (шум strands в stdout не ломает протокол); сессии выкл до очистки; systemd unit + README
This commit is contained in:
parent
0d90b49d3a
commit
c941ec7ec8
6 changed files with 121 additions and 6 deletions
65
README.md
Normal file
65
README.md
Normal 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
8
cli.py
|
|
@ -27,6 +27,10 @@ def main():
|
||||||
p_rq.add_argument("task_id")
|
p_rq.add_argument("task_id")
|
||||||
p_rq.add_argument("-r", "--reason", default="manual requeue")
|
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("status", help="состояние харнеса")
|
||||||
sub.add_parser("tasks", help="доска задач")
|
sub.add_parser("tasks", help="доска задач")
|
||||||
|
|
||||||
|
|
@ -53,6 +57,10 @@ def main():
|
||||||
es.init_db()
|
es.init_db()
|
||||||
tid = commands.requeue_task(args.task_id, args.reason)
|
tid = commands.requeue_task(args.task_id, args.reason)
|
||||||
print(f"OK: задача {tid} возвращена в очередь")
|
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":
|
elif args.cmd == "status":
|
||||||
es.init_db()
|
es.init_db()
|
||||||
print(json.dumps(commands.status(), ensure_ascii=False, indent=1))
|
print(json.dumps(commands.status(), ensure_ascii=False, indent=1))
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -22,6 +22,24 @@ def submit_task(title: str, details: str = "", minutes: int = 5, source: str = "
|
||||||
return task_id, size
|
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"):
|
def requeue_task(task_id: str, reason: str = "manual requeue"):
|
||||||
"""Вернуть failed/прерванную задачу в очередь (TaskPromoted → ready)."""
|
"""Вернуть failed/прерванную задачу в очередь (TaskPromoted → ready)."""
|
||||||
model = es.run_projections()
|
model = es.run_projections()
|
||||||
|
|
@ -30,7 +48,8 @@ def requeue_task(task_id: str, reason: str = "manual requeue"):
|
||||||
raise ValueError(f"задача {task_id} не найдена")
|
raise ValueError(f"задача {task_id} не найдена")
|
||||||
if t["status"] not in ("failed", "done"):
|
if t["status"] not in ("failed", "done"):
|
||||||
raise ValueError(f"задача {task_id} в статусе {t['status']} — requeue не нужен")
|
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
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,8 @@ def _apply_event(model: dict, ev: dict):
|
||||||
elif etype == "TaskPromoted":
|
elif etype == "TaskPromoted":
|
||||||
t = model["tasks"].get(aid)
|
t = model["tasks"].get(aid)
|
||||||
if t:
|
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,
|
t.update(status="ready", level=None, session_id=None,
|
||||||
promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1)
|
promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1)
|
||||||
elif etype == "DigestWritten":
|
elif etype == "DigestWritten":
|
||||||
|
|
@ -166,6 +168,12 @@ def _apply_event(model: dict, ev: dict):
|
||||||
"text": data.get("text", ""),
|
"text": data.get("text", ""),
|
||||||
"ts": ev["timestamp"],
|
"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":
|
elif etype == "CycleTicked":
|
||||||
level = data.get("level", "?")
|
level = data.get("level", "?")
|
||||||
cs = model["cycle_state"].setdefault(level, {"busy": False, "current_task": None, "ticks": 0})
|
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":
|
elif etype == "PolicyChanged":
|
||||||
model.setdefault("policy", {}).update(data)
|
model.setdefault("policy", {}).update(data)
|
||||||
elif etype == "BudgetSpent":
|
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) не материализуем — они для дебага
|
# trace-события (InvocationStarted/ToolCalled/ToolResult) не материализуем — они для дебага
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -208,20 +219,32 @@ def _save_model(db, model):
|
||||||
|
|
||||||
|
|
||||||
def run_projections():
|
def run_projections():
|
||||||
"""Инкрементальная проекция с персистентным read model."""
|
"""Инкрементальная проекция с персистентным read model.
|
||||||
|
|
||||||
|
Гонка устранена: модель ВСЕГДА стартует с персистентного снапшота
|
||||||
|
(read_model-таблица), затем применяется дельта событий. In-memory
|
||||||
|
кэш между процессами не доверяем — два процесса с общим чекпоинтом
|
||||||
|
затирали друг друга (демон не видел задачу, поданную из CLI).
|
||||||
|
"""
|
||||||
global _read_model
|
global _read_model
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
db = _conn()
|
db = _conn()
|
||||||
last = _load_meta(db, "main")
|
last = _load_meta(db, "main")
|
||||||
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()
|
_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 not rows:
|
if not rows:
|
||||||
db.close()
|
db.close()
|
||||||
return _read_model
|
return _read_model
|
||||||
for r in rows:
|
for r in rows:
|
||||||
_apply_event(_read_model, dict(r))
|
_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)
|
_save_model(db, _read_model)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue