diff --git a/README.md b/README.md new file mode 100644 index 0000000..831e973 --- /dev/null +++ b/README.md @@ -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 -r "причина" +./venv/bin/python cli.py promote -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/портала \ No newline at end of file diff --git a/cli.py b/cli.py index 8ae230f..2cfc645 100644 --- a/cli.py +++ b/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)) diff --git a/harness/__pycache__/commands.cpython-312.pyc b/harness/__pycache__/commands.cpython-312.pyc index 3370ef9..f519eef 100644 Binary files a/harness/__pycache__/commands.cpython-312.pyc and b/harness/__pycache__/commands.cpython-312.pyc differ diff --git a/harness/__pycache__/es.cpython-312.pyc b/harness/__pycache__/es.cpython-312.pyc index 0b6a243..a39efd1 100644 Binary files a/harness/__pycache__/es.cpython-312.pyc and b/harness/__pycache__/es.cpython-312.pyc differ diff --git a/harness/commands.py b/harness/commands.py index 50a59d2..858aaff 100644 --- a/harness/commands.py +++ b/harness/commands.py @@ -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 diff --git a/harness/es.py b/harness/es.py index d73cf5c..00f6dec 100644 --- a/harness/es.py +++ b/harness/es.py @@ -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()