simbiot-harness/harness/scheduler.py
Simbiot Harness 94cc921ccd feat: Telegram-инбокс — сообщения Алекса как события
- harness/tg_inbox.py: long-polling поллер (@juno42bot) + send_reply
- события MessageReceived/ReplySent/MessageIgnored (агрегат inbox)
- проекция inbox в read model (кап 100)
- контекст минутного тика: входящие ждущие ответа + мандат триажа
- cli.py tg-reply: Telegram-отправка + ReplySent закрывает входящее
- systemd simbiot-harness-tg.service
- E2E: «йоу» от Алекса → минутный тик ответил в TG + дайджест в сторе
2026-09-29 15:52:46 +00:00

247 lines
No EOL
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Simbiot Harness — scheduler: тактовая лестница + cycle runners.
Правила:
- тик уровня идемпотентен (UNIQUE level:seq);
- один исполнитель на уровень → сериализация;
- занятый уровень поглощает тик (TickAbsorbed) и пишет
детерминированную дайджест-заглушку — без LLM;
- запущенная задача не прерывается.
"""
import json
import os
import time
import traceback
from datetime import datetime, timezone, timedelta
from . import es
from .levels import LEVELS
from . import runtime
# интервал демона
POLL = 1.0
# уровни, включённые в v0.1 (порядок для лога)
ACTIVE = ["sec", "min", "hour", "day"]
# состояние исполнителей уровня: level → {"busy": bool, "task": id|None}
_runners = {}
def _now():
return datetime.now(timezone.utc)
def _log(msg: str):
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
def tick_number(level_cfg, ts: int):
return ts // level_cfg.every
def emit_tick(level_cfg, seq: int):
"""CycleTicked идемпотентно. Возвращает True если это новый тик."""
r = es.append_tick(level_cfg.name, seq)
return r is not None
def deterministic_digest_stub(level_cfg, seq: int, reason: str):
"""Заглушка дайджеста для поглощённого тика — детерминированно, без LLM."""
period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc)
es.append_event("cycle", f"stub:{level_cfg.name}:{seq}", "DigestWritten", {
"level": level_cfg.name,
"period": period.isoformat(),
"summary_ref": None,
"text": f"[absorbed:{reason}] уровень {level_cfg.name} был занят/пропущен; такт {seq}",
"stub": True,
})
def find_task(level_cfg, model: dict):
"""Ищет ready-задачу доступного классу уровня. Возвращает task_id или None."""
classes = level_cfg.task_classes
for tid, t in model["tasks"].items():
if t["status"] == "ready" and t.get("size") in classes:
return tid
return None
def build_context(level_cfg, model: dict, task=None):
"""Сборка контекста тика: мандат + память уровня + выжимки нижних + задача."""
parts = [level_cfg.mandate]
# идентичность/цели v0.1 — статично из policy (если есть)
policy = model.get("policy")
if policy and policy.get("identity"):
parts.append("ИДЕНТИЧНОСТЬ: " + policy["identity"])
# выжимки нижних уровней: последние дайджесты
order = ["min", "hour", "day"]
idx = order.index(level_cfg.name) if level_cfg.name in order else 0
lower = order[:idx]
digests = []
for lvl in lower:
for key, d in model["digests"].items():
if d["level"] == lvl and not d.get("stub"):
digests.append(d)
digests.sort(key=lambda d: d["ts"], reverse=True)
if digests:
parts.append("ВЫЖИМКИ НИЖНИХ УРОВНЕЙ (последние, сжатые):")
for d in digests[:6]:
parts.append(f"- [{d['level']}] {d['text'][:400]}")
# инбокс: входящие TG-сообщения, ждущие ответа (в приоритете)
inbox = [m for m in (model.get("inbox") or {}).values()
if not m.get("replied") and not m.get("ignored") and m.get("text")]
if inbox:
lines = []
for m in inbox[:10]:
m_id = "m-" + str(m.get("update_id"))
who = m.get("username") or m.get("first_name") or m.get("user_id")
lines.append(f"- [{m_id}] от {who}: {m['text'][:300]}")
parts.append("ВХОДЯЩИЕ СООБЩЕНИЯ (ответь через: ./venv/bin/python cli.py tg-reply <id> \"текст\"):\n" + "\n".join(lines))
# доска задач
tasks = [t for t in model["tasks"].values() if t["status"] in ("ready", "running")]
if tasks:
lines = [f"- {t['id']}: {t['title'][:80]} [{t['size']}] {t['status']}" for t in tasks[:15]]
parts.append("ДОСКА ЗАДАЧ:\n" + "\n".join(lines))
if task:
parts.append(f"ТЕКУЩАЯ ЗАДАЧА: {task['id']}: {task['title']}\nВыполни её и выдай результат.")
return "\n\n".join(parts)
def run_level_tick(level_cfg, seq: int, model: dict):
"""Один тик уровня с LLM: дайджест + (опц.) задача. Возвращает текст.
LLM-тик гоняется в изолированном сабпроцессе (isolated_runner):
у Strands глобальный asyncio-рантайм, второй create_harness()
в том же процессе валит его («cannot schedule new futures»).
"""
from . import isolated_runner
task_id = find_task(level_cfg, model)
task = model["tasks"].get(task_id) if task_id else None
if task:
es.append_event("task", task_id, "TaskClaimed", {
"level": level_cfg.name, "session_id": f"s-{level_cfg.name}-{seq}",
})
prompt = build_context(level_cfg, model, task)
text, usage, trace_id = isolated_runner.run_llm_tick(
level_cfg, prompt,
session_id=f"s-{level_cfg.name}-{seq}",
trace_prefix=f"{level_cfg.name}-{seq}",
)
if text is None:
# воркер упал — задача не выполнена, откатываем claim
if task:
es.append_event("task", task_id, "TaskFailed", {
"error": str(usage.get("error", "llm worker failed"))[:500],
})
raise RuntimeError(str(usage.get("error", "llm worker failed")))
# дайджест тика
period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc)
es.append_event("cycle", f"digest:{level_cfg.name}:{seq}", "DigestWritten", {
"level": level_cfg.name, "period": period.isoformat(),
"summary_ref": trace_id, "text": (text or "")[:2000],
})
if usage:
es.append_event("harness", "budget", "BudgetSpent", {
"level": level_cfg.name, **{k: v for k, v in usage.items() if k != "error"},
})
if task:
es.append_event("task", task_id, "TaskCompleted", {
"result_ref": trace_id, "answer": (text or "")[:2000],
})
return text, task_id
def process_level(level_cfg, model: dict):
"""Тик одного уровня: занят? → absorb; иначе работа."""
st = _runners.setdefault(level_cfg.name, {"busy": False, "task": None})
if st["busy"]:
seq = tick_number(level_cfg, int(time.time()))
deterministic_digest_stub(level_cfg, seq, "busy")
es.append_event("cycle", f"absorbed:{level_cfg.name}:{seq}", "TickAbsorbed",
{"level": level_cfg.name, "reason": "busy"})
return "absorbed"
seq = tick_number(level_cfg, int(time.time()))
if not emit_tick(level_cfg, seq):
return "already" # тик этого такта уже был (идемпотентность)
st["busy"] = True
try:
if not level_cfg.uses_llm:
# секундный уровень: детерминированный рефлекс без LLM
text, task_id = _reflex_tick(level_cfg, seq, model)
return f"reflex ok {task_id or ''}".strip()
text, task_id = run_level_tick(level_cfg, seq, model)
return f"ran (task={task_id or '-'})"
except Exception as e:
es.append_event("harness", "errors", "CycleError", {
"level": level_cfg.name, "error": str(e)[:1000],
"trace": traceback.format_exc()[-2000:],
})
return "error: " + str(e)[:120]
finally:
st["busy"] = False
def _reflex_tick(level_cfg, seq: int, model: dict):
"""Секундный рефлекс: телеметрия + алерты, без LLM."""
import os
load = None
try:
load = os.getloadavg()[0]
except Exception:
pass
period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc)
payload = {"level": "sec", "seq": seq, "load1": load,
"ts": period.isoformat()}
# алерт-порог из policy (по умолчанию 8 — как simbiotd)
thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8)
if load is not None and load >= thr:
payload["alert"] = f"load {load:.2f} >= {thr}"
es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload)
return payload, None
def main_loop():
"""Демон: уровни тикают независимо, каждый в своём потоке.
LLM-тик одного уровня больше не блокирует остальные
(раньше минутный тик замораживал и сек-рефлекс, и час)."""
import threading
es.init_db()
_log("Simbiot Harness запущен; уровни: " + ", ".join(ACTIVE))
last_seq = {}
threads = {}
while True:
for name in ACTIVE:
cfg = LEVELS[name]
seq = tick_number(cfg, int(time.time()))
if last_seq.get(name) == seq:
continue # этого такта ещё не было — ждём границу
last_seq[name] = seq
if cfg.uses_llm and _runners.get(name, {}).get("busy"):
# уровень ещё работает в предыдущем такте — тик поглощается,
# но остальные уровни продолжают жить
continue
t = threads.get(name)
if t is not None and t.is_alive():
continue
def _run(cfg=cfg, seq=seq):
model = es.run_projections()
state = process_level(cfg, model)
if state not in ("already",):
_log(f"{cfg.name} #{seq}: {state}")
t = threading.Thread(target=_run, daemon=True, name=f"tick-{name}")
threads[name] = t
t.start()
time.sleep(POLL)
if __name__ == "__main__":
main_loop()