From c941ec7ec806298cd2d973246768b78711e4d723 Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Tue, 29 Sep 2026 15:40:51 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BC=D0=B5=D0=B6=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D1=81=D1=81=D0=BD=D0=B0=D1=8F=20=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20=E2=80=94=20read=20model=20=D0=B2=D1=81=D0=B5=D0=B3=D0=B4?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BE=20=D1=81=D0=BD=D0=B0=D0=BF=D1=88=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BD=D0=B0=20=D0=B4=D0=B8=D1=81=D0=BA=D0=B5;?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BF=D1=8B=20spend/digests;=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B5=D1=80=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=20=D0=B2?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=20(=D1=88=D1=83=D0=BC=20strands=20?= =?UTF-8?q?=D0=B2=20stdout=20=D0=BD=D0=B5=20=D0=BB=D0=BE=D0=BC=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB);=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8=20=D0=B2=D1=8B=D0=BA=D0=BB?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B8;?= =?UTF-8?q?=20systemd=20unit=20+=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 65 +++++++++++++++++++ cli.py | 8 +++ harness/__pycache__/commands.cpython-312.pyc | Bin 4321 -> 5517 bytes harness/__pycache__/es.cpython-312.pyc | Bin 12850 -> 14268 bytes harness/commands.py | 21 +++++- harness/es.py | 33 ++++++++-- 6 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 README.md 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 3370ef97b790d5aa3dddcc204ee8635f7c73125a..f519eefa7961a8f380c3ea447b2372a45a51c5ce 100644 GIT binary patch delta 1896 zcmZuyTWl0n7@o_{?EO{>Whre27Fq_Rm5V3}CPk^`(n6__h{nyrnbxJ<-FjxXTpY5? zB>@7)t)sC9h=usjhQzEOmvCu{PY``L3nny^J|GW1`2Yc;@df{XwuKTs$^3Kn`@i#F z&p-S7oX-~pe)0P~9IPhm+O9wR{ehYMss8#kvFXIAxrqxNx8@-p&E4rD-ow&%FPTAn zfcgP(kN_Y)QZ)q$0^%n#NeDP$%>$eYKmugiVQzbnRDulc71YR&mwphd)JB?4XBzcH zaz`SaS^P>JjZ>*b#a70%kLM=U9NgbG(;kpJEUmRVL0-^ zj1%L>MMQ%;$q~nC@f@7Ob&xk4M2f(U$lb4V9G(6jwsVpJCtatdmrlCzq#^Wiw9*jF zhprhpVndUp6i+<+B}2-1(HSm7nA8#PJ}7MWNB`^82TpBE;MD(;Q^AnnBv?Y%`{m3; zA{@8N2Y`7_2;~9>M}metX~N)1=#+TQ0cu!0XK;qxDRgjZINJ>X`>vE{BkVdGDIF>u zWf$2Eg=%rKcQL!nitHy=EFEE26n29Z6(At>Jd#Q$6n4IJ98V|<-jD0un`t_k&S*q2 zbFzEID~g`jr#+{r4XSAI9Twke@f$5E1AP6dAHBbDQdFLr-^)tQl5GD^r-2#_D5V3X zgAm0*@O}WCW#=LOApe!pTlT1cdW2moonTklh{CS0%jE=U1rVu#LIRPk!gHlV>=GD( zJc{7@I=hHFB|8gsXp@~^Igu1(e34xRK9qn4L2vn{ra3HHLMClFIw1&J3u@JAE_K#J z^obPF_E=(%mZI1r)k)F1&9S63B^tG)WSVF_7N4>Bgyl+UyJJ`_9WSao_rO=3Upwv#-1F6ze6@FdN`CE_%PcyVb-%f4ugu9e zjX7N>-YH1O*AIk`wcT;fnqscyR`}M6+mTyd){$h9)ZIXuxzc07%0l}*r)8U-rFi$Le2r~{!2mfRUn^k~F#kaS9;3jj3#lAEWic;lz%y%Py|3aN>ke62hldd*@bw?MXdx$i#Gh+u~$=O~K z&uHa1BwA)7eVG~YG)m2|ed9u_N8uYVdDZbE2}|ya_hdEQlHV({hqTm!@ZQOVTl*#NW@JJ%h5hqRwj%f6Sh7 z%TxYg_iHO?={69mJS8_#&n9ULs+OR@eyO~jpN^lta!>3FU<i6;-P{PiD1Q>r;3*ZJe0+YHxGh}HxKH}rVIPcH{bhaXWs1k*wNXV~kdA9IwM~97{RQ6jI%5x7oKCR#x-y$HH4G9+9?fXR4N*n4-GN2*tqO*kt#ac0-(TS>0H4w@_q)?qV{8y>{+U1QW2Mwct@A%wn*T z$g(T&Jdr<5YZtT}L6;V;yG$%gKm-2?p2Z(!*Nt;x7E}7PS%*NfqJ^0DH22ekp=JK2 K@GA|Cr2Zenm$X*^ diff --git a/harness/__pycache__/es.cpython-312.pyc b/harness/__pycache__/es.cpython-312.pyc index 0b6a243c5c49069c462d90af9f101ccf4d5b89da..a39efd1e9d43442887948957af7674b7157e1caf 100644 GIT binary patch delta 2917 zcmbVOeNY?M5x+ZqAS7XlPk|89feq&PwvCOA2{u$tZI^i3z)YQq6G!orT|gk`bl8|V z3bxZA64%IiaYJf4RoZk)26rMGyP-A^x6==%?M&~$sfkKwIvxMhe=-C!<2Ief-8~`f zcKTPJ=JxH|-S^vlyZ82VolFNe{4Ooc%)sdT)9sGmR9@Rq@8EW(OdfOuIrcKO=a#eI zq<0(+Oa26lRtzIhmTw4s|T4A}_$rUd^zUB5A@ z(WLa1Tqj#h1G%2(ONeoT)36wsC1%_}Hjf!(hLj>N8ykR1sR;{MugQ~v5uwuyj<-sd z|F-BzqfJ(4GbwwOA>~&qCUgk{!Vtm9^ruL+^$jUWFa$?;YiFv|&eVb_&9ITGWSIRX zu&d6apO!dsWfQ3ZL=V|Ard!vi%Bl3T^sF6ZEAb?SiNwjNWGgyIo)p&G5)L7SwVFHc z|Ld-jZ2uqbwy(qQ(BNI9d_8&^ab@P#tsCrIGpP9wgN(-pyEKC$nJ$-PbEKaB-c@bd zt;u$4vJBF2f=M2@-z=LuGU=krUHe4!%*j-fr?k4I%4X6?7$?@XArT2CU)VEwu%L*w z>yL}Ut~e(J1y*c>ywRY0JNpg#BX|AYPFRWoBwa`$_V;HA`d1BtXALzHm*D2mtf*LPlXVZ7t?`$ZfXelx?U!} z#y1lhA(v)}d@5gUoowc+>mYALPBC7ptAhSP&Wp znH&-0Y7@A)uG4=eo+1K@81mDH?h5ua-Q?Lu4||&0+E>AIyY-7lj|8(=0vqCnK+s1* zVi0*_&q?Bw{Bd2t-yJuGf}-RnaYMW0?+S^S(6~9|mx%wkuO}eIjS#HAn+O*ATaTL; zMbzs%{r;0;4YJ&^ka!lD5MCypr~mX6m@fkR8p{4LS;P}|hb?oYcer=dc-Q8MdVjqC z-TmWj?;VOsQ%_%$??`jrpZCqzeQnX6{4=T$o&b&MA+*ek<^ zMN`^{b=VqdyK8bTSvG`w-l!kd&0F#p3yPw((e0y00A=&*hn95=B25N2Iyv?neZ6?E z;Y~RC+~gmMpXChS0rq2BQu;F2!qFLh35}KJ4oE2=)rQO@Uv21>X7-7eHL+&lGe~J` zEF)=c9akOeeJH7e2KdB#nMCd9WKL$aO9Na#D>EJG3|%crw-de09%qyH4(=SZJ#)WK z)`dJ^lnqN4Z7ECJZ6NSVnz2kPd=tFP3;7Jwua_K2=Z^!Eu>ER7*3+1f(+Hj0h%%Cb zwBH~b9vc%5-!%!q5(6)pY)JN8$VLJER4Al-t!~{DAsMug=!<0qRzt7uIp#Dgn0o3s zI#l-jfZ5E$P^dDi%qX{%7_XjJ&#LFtK_v!)oAAWyd1V^ZH(+&Jy}&DPD(@OFro61j>8w2Y31n4X{y9r=t!TGc@#pB@-cUHNsod%qN z|Gy5tg~IFLMhoSMUE0af@h}F+t3$Bi7NlYpmY9H9WjYZ+;xtoQhHnp`s~eE5^UCBp z1L&pNkudEuYoS~R9IXUeK7fs>L+W|R4!VF91Uv(^yU2sxhy0gX4hU76zqr23*WMks z91ZwFQlfu~_(!0xvUOj}J}PAh1Bd?h2!?nVq=H?gGY~yLMR~B-2Eo9Y)Qx`2+5efTHrYr1Sw55;q4fiR|19RTFx&>S7J=+o0c4Wc! zVp#u)EpySjVPyaC{tE|}GTjgJvJLs*%89oX=`?sZPJXw-q%&RtofxI#)q>#%zTLYAXzIIVa&GizUQ8d&3s-Oc7dXlAq6LJjfXlB;y( delta 1641 zcmb_bZ%iCT6rb6>UH1O5#{s=#@0NorP>#FnaZs)emfCAc#5T2wG|@&{ygCN~jstg> z3b;u5V1elzd$kxxRoQUFY``G`86Ff!FtJW5Ic< zgt!E#%b=OS6QY-(CPeEEUSW$7f-c~DWfd_LLUO^FKaeh<5Z$Lx0hx!3`|i#k4S(r! z#YOZ#6wrbqPbnZ$+_2f$(2JtmC&aUIz1&jtXnpL_^ly*gw#OdB!>>P;9n>}eu-WRx zTXNa)*nfKN?FR*_2CYw!ObKH%FmKJjguuS+`D#C88EpjL@U_8y{JXE+JxE-XAqpu9 zAqs~G;PToK9K{D~Tj2?;*2bd&UTGlq9G4nNAT`JKC$$_uHi{BjGG|ef8cOyh#!|$V z7#&TfQNWA^e|39~9ZF6p%`}jCO;zc0s40k2M$NWf5|EncO`c7rHRWw$I!}J}Sa#Tt zAb#X8!D3x2&jOo$gU4NUr??eC-mqmK)&-$gu&)X3Wx>7ZTJo<7^=m?CSqNP_@uSeP z!Djm!BnG~TXBrQ3b0p&z*>4(OWVrW;`Yo1&XP7@Bo(Ud+I(Aqq9f;9kA1u;o#&MhE zm|`@eAxQ{0=hE!>X0XaF(ixRZegFwhXH=h#quxrq5Oz(oh;$Jg08Fu3 z`QsR{omi_Z*tRchJ9=I=0nJtP00@$XHW{7BQOvOUH3&31C4%E&5A=}w z3wW!!d>RwO3SIeuz&WtR1B-YMh@5BL?tJ&9S!P|VTJ$gWt%~)trn_Rbc>!(-49kWM&63g$}N*B83y5^6p zmsdY1Bbkl#8&2Tdu?g@;QFp~Jg7ZSlg|my=(xFS^*G!k*x+CnyKSygCe8l?!EoPI# zUn;%McjBSL5%y+ByparFMYzNJvO8M@mVckDDHuoF0^CKS{)&6sZji_3j_vUqO9r#_ z&Xjoy24@kX<=BC=hSM>r(%{nI*PxLyqg=#SW3uH#qSD@0|G;0zz7Mv-)Aj4AK beL8HWp#?fNB@XhN4kz^7Z>oT`3a$1Zib7=q 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()