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("-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))
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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()
|
||||
# снапшот мог быть записан другим процессом на большем 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:
|
||||
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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue