feat: эскалации (min→hour→day) + фикс хвоста событий в CLI

Часть 1 (часовой уровень, задача t-260929155421-9f7bff):
- EscalationRaised/Acknowledged в ES-спину, проекция escalations
- CLI: escalate / escalations / escalation-ack
- уровни видят открытые эскалации в контексте; scheduler перезапускает
  критическую эскалацию сразу (не дожидаясь границы такта)

Часть 2 (минутный, этот тик):
- fix(cli): 'events -n N' показывал первые N событий вместо хвоста стора
  (README обещает хвост) — теперь хвост; --after N — от id N вперёд
- + es.count_events()
This commit is contained in:
simbiot-min 2026-09-29 16:12:50 +00:00
parent 94cc921ccd
commit ed502650f4
14 changed files with 206 additions and 7 deletions

View file

@ -20,6 +20,7 @@ Strands Harness SDK как исполнительное ядро, модели
`CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`, `CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`,
`TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`, `TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`,
`EscalationRaised`, `EscalationAcknowledged`,
`BudgetSpent`, `CycleError`, `HarnessInit`, `PolicySet` — append-only, `BudgetSpent`, `CycleError`, `HarnessInit`, `PolicySet` — append-only,
проекции пересобираются replay'ем, стейт = производное от событий. проекции пересобираются replay'ем, стейт = производное от событий.
@ -43,9 +44,22 @@ Strands Harness SDK как исполнительное ядро, модели
./venv/bin/python cli.py events -n 50 ./venv/bin/python cli.py events -n 50
./venv/bin/python cli.py requeue <task-id> -r "причина" ./venv/bin/python cli.py requeue <task-id> -r "причина"
./venv/bin/python cli.py promote <task-id> -r "вне класса уровня" # S→M→L ./venv/bin/python cli.py promote <task-id> -r "вне класса уровня" # S→M→L
./venv/bin/python cli.py escalate "причина" -f min -c "контекст" -s critical
./venv/bin/python cli.py escalations --open # открытые эскалации
./venv/bin/python cli.py escalation-ack <esc-id> -n "что сделал" # закрыть
./venv/bin/python cli.py tg-reply <msg-id> "текст" # ответ на входящее TG ./venv/bin/python cli.py tg-reply <msg-id> "текст" # ответ на входящее TG
``` ```
## Эскалации
Важное с нижних уровней поднимается наверх событием `EscalationRaised`
(min→hour→day; day — вершина, эскалировать некуда). Открытые эскалации
инжектятся в контекст верхних уровней (`build_context`) в приоритетной секции
— нижний уровень не тонет в чужих проблемах, верхний видит выжимку, а не сырь.
Уровень-адресат закрывает эскалацию `escalation-ack` (событие
`EscalationAcknowledged`). Падение LLM-тика (`CycleError`) эскалируется
автоматически — scheduler поднимает warn-эскалацию на уровень выше.
## Запуск ## Запуск
```bash ```bash

Binary file not shown.

View file

@ -322,6 +322,8 @@
<ul> <ul>
<li><span class="m">PolicyChanged</span> {budgets, mandates}</li> <li><span class="m">PolicyChanged</span> {budgets, mandates}</li>
<li><span class="m">BudgetSpent</span> {level, tokens, $}</li> <li><span class="m">BudgetSpent</span> {level, tokens, $}</li>
<li><span class="m">EscalationRaised</span> {from_level, to_level, reason, context, severity} — важное с нижнего уровня наверх</li>
<li><span class="m">EscalationAcknowledged</span> {level, note} — верхний уровень принял/отработал</li>
<li><span class="m">LevelPaused</span> / <span class="m">LevelResumed</span></li> <li><span class="m">LevelPaused</span> / <span class="m">LevelResumed</span></li>
</ul> </ul>
</div> </div>

48
cli.py
View file

@ -35,12 +35,29 @@ def main():
p_tgr.add_argument("msg_id", help="id входящего (m-NNN)") p_tgr.add_argument("msg_id", help="id входящего (m-NNN)")
p_tgr.add_argument("text") p_tgr.add_argument("text")
p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)")
p_esc.add_argument("reason")
p_esc.add_argument("-f", "--from-level", default="min", choices=["min", "hour"],
help="кто эскалирует")
p_esc.add_argument("-c", "--context", default="", help="контекст для верхнего уровня")
p_esc.add_argument("-s", "--severity", default="warn", choices=["info", "warn", "critical"])
p_esc.add_argument("-t", "--task-id", default=None, help="связанная задача")
p_escl = sub.add_parser("escalations", help="список эскалаций")
p_escl.add_argument("--open", action="store_true", help="только открытые")
p_ack = sub.add_parser("escalation-ack", help="закрыть эскалацию (принята уровнем выше)")
p_ack.add_argument("esc_id")
p_ack.add_argument("-l", "--level", default="hour", help="кто принял")
p_ack.add_argument("-n", "--note", default="", help="что сделали с эскалацией")
sub.add_parser("status", help="состояние харнеса") sub.add_parser("status", help="состояние харнеса")
sub.add_parser("tasks", help="доска задач") sub.add_parser("tasks", help="доска задач")
p_ev = sub.add_parser("events", help="хвост стора") p_ev = sub.add_parser("events", help="хвост стора (последние N событий)")
p_ev.add_argument("-n", type=int, default=15) p_ev.add_argument("-n", type=int, default=15)
p_ev.add_argument("--after", type=int, default=0) p_ev.add_argument("--after", type=int, default=None,
help="вывести события ПОСЛЕ этого id (с головы, по возрастанию)")
p_init = sub.add_parser("init", help="инициализация стора") p_init = sub.add_parser("init", help="инициализация стора")
p_init.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива") p_init.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива")
@ -70,6 +87,25 @@ def main():
from harness import tg_inbox from harness import tg_inbox
mid = tg_inbox.send_reply(args.msg_id, args.text) mid = tg_inbox.send_reply(args.msg_id, args.text)
print(f"OK: ответ на {mid} отправлен") print(f"OK: ответ на {mid} отправлен")
elif args.cmd == "escalate":
es.init_db()
eid, to_level = commands.raise_escalation(
args.from_level, args.reason, args.context, args.severity, args.task_id)
print(f"OK: эскалация {eid} → {to_level} ({args.severity})")
elif args.cmd == "escalations":
es.init_db()
rows = commands.list_escalations(only_open=args.open)
if not rows:
print("(эскалаций нет)")
for e in rows:
ctx = f" | {e.get('context','')[:60]}" if e.get("context") else ""
print(f"{e['id']} [{e.get('severity','info'):8}] {e.get('status','?'):5} "
f"{e.get('from_level')} → {e.get('to_level')} "
f"{e.get('reason','')[:70]}{ctx}")
elif args.cmd == "escalation-ack":
es.init_db()
eid = commands.acknowledge_escalation(args.esc_id, args.level, args.note)
print(f"OK: эскалация {eid} закрыта ({args.level})")
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))
@ -79,7 +115,13 @@ def main():
print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}") print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}")
elif args.cmd == "events": elif args.cmd == "events":
es.init_db() es.init_db()
for ev in es.get_events(args.after, args.n): if args.after is None:
# хвост стора: последние N по возрастанию id
total = es.count_events()
evs = es.get_events(max(0, total - args.n), args.n)
else:
evs = es.get_events(args.after, args.n)
for ev in evs:
d = ev["data"][:110] d = ev["data"][:110]
print(f"{ev['id']:4} {ev['timestamp'][11:19]} {ev['aggregate']:8} {ev['event_type']:16} {d}") print(f"{ev['id']:4} {ev['timestamp'][11:19]} {ev['aggregate']:8} {ev['event_type']:16} {d}")

View file

@ -8,7 +8,63 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from . import es from . import es
from .levels import size_class from .levels import LEVELS, size_class
# лестница сверху вниз: min → hour → day (sec эскалации не шлёт — рефлекс без LLM)
_ORDER = ["sec", "min", "hour", "day"]
def level_above(level: str):
"""Уровень над данным или None (day эскалирует некуда — это уже вершина)."""
if level not in _ORDER:
return None
i = _ORDER.index(level)
return _ORDER[i + 1] if i + 1 < len(_ORDER) else None
def raise_escalation(from_level: str, reason: str, context: str = "", severity: str = "info",
task_id: str = None, source: str = "cli"):
"""EscalationRaised: важное с нижнего уровня поднимается наверх.
Проекция кладёт эскалацию в model['escalations'], scheduler inject'ит
открытые в контекст верхних уровней (build_context). Идемпотентно по id.
"""
if from_level not in _ORDER:
raise ValueError(f"неизвестный уровень {from_level}")
to_level = level_above(from_level)
if to_level is None:
raise ValueError(f"у {from_level} нет уровня выше — эскалация некуда")
if severity not in ("info", "warn", "critical"):
raise ValueError(f"неизвестный severity {severity} (info|warn|critical)")
esc_id = f"e-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}-{secrets.token_hex(3)}"
es.append_event("harness", esc_id, "EscalationRaised", {
"id": esc_id, "from_level": from_level, "to_level": to_level,
"reason": reason, "context": context[:4000], "severity": severity,
"task_id": task_id, "source": source,
})
return esc_id, to_level
def acknowledge_escalation(esc_id: str, level: str = "hour", note: str = ""):
"""Закрыть эскалацию (верхний уровень принял к сведению/отработал)."""
model = es.run_projections()
e = (model.get("escalations") or {}).get(esc_id)
if e is None:
raise ValueError(f"эскалация {esc_id} не найдена")
if e.get("status") == "acked":
raise ValueError(f"эскалация {esc_id} уже закрыта")
es.append_event("harness", esc_id, "EscalationAcknowledged", {
"id": esc_id, "level": level, "note": note[:1000],
})
return esc_id
def list_escalations(only_open: bool = False):
model = es.run_projections()
out = [e for e in (model.get("escalations") or {}).values()
if not only_open or e.get("status") == "open"]
out.sort(key=lambda e: e.get("ts", ""), reverse=True)
return out
def submit_task(title: str, details: str = "", minutes: int = 5, source: str = "cli"): def submit_task(title: str, details: str = "", minutes: int = 5, source: str = "cli"):
@ -77,10 +133,13 @@ def list_tasks():
def status(): def status():
model = es.run_projections() model = es.run_projections()
open_escalations = sum(1 for e in (model.get("escalations") or {}).values()
if e.get("status") == "open")
return { return {
"cycle_state": model["cycle_state"], "cycle_state": model["cycle_state"],
"tasks_ready": sum(1 for t in model["tasks"].values() if t["status"] == "ready"), "tasks_ready": sum(1 for t in model["tasks"].values() if t["status"] == "ready"),
"tasks_done": sum(1 for t in model["tasks"].values() if t["status"] == "done"), "tasks_done": sum(1 for t in model["tasks"].values() if t["status"] == "done"),
"digests": len(model["digests"]), "digests": len(model["digests"]),
"escalations_open": open_escalations,
"spend": model.get("spend", [])[-8:], "spend": model.get("spend", [])[-8:],
} }

View file

@ -106,12 +106,20 @@ def get_events(after_id: int = 0, limit: int = 1000):
return [dict(r) for r in rows] return [dict(r) for r in rows]
def count_events() -> int:
db = _conn()
n = db.execute("SELECT COUNT(*) AS c FROM events").fetchone()["c"]
db.close()
return n
# ────────────────────────── ПРОЕКЦИИ ────────────────────────── # ────────────────────────── ПРОЕКЦИИ ──────────────────────────
def _default_model(): def _default_model():
return { return {
"tasks": {}, # task_id → {title, size, status, level, session_id, result_ref, ...} "tasks": {}, # task_id → {title, size, status, level, session_id, result_ref, ...}
"digests": {}, # (level, period) → {summary_ref, text} "digests": {}, # (level, period) → {summary_ref, text}
"escalations": {}, # esc_id → {from_level, to_level, reason, status: open|acked}
"cycle_state": {}, # level → {busy, current_task, last_tick} "cycle_state": {}, # level → {busy, current_task, last_tick}
"metrics": [], # [{level, ticks, absorbed, llm_ticks, tokens, cost}] "metrics": [], # [{level, ticks, absorbed, llm_ticks, tokens, cost}]
"agents": {}, # level → {model, calls} "agents": {}, # level → {model, calls}
@ -135,6 +143,8 @@ def _apply_event(model: dict, ev: dict):
etype, data = ev["event_type"], json.loads(ev["data"]) etype, data = ev["event_type"], json.loads(ev["data"])
aid = ev["aggregate_id"] aid = ev["aggregate_id"]
if not isinstance(data, dict):
data = {}
if etype == "TaskSubmitted": if etype == "TaskSubmitted":
model["tasks"][aid] = { model["tasks"][aid] = {
"id": aid, "title": data.get("title", ""), "size": data.get("size", "M"), "id": aid, "title": data.get("title", ""), "size": data.get("size", "M"),
@ -174,6 +184,20 @@ def _apply_event(model: dict, ev: dict):
keep = sorted(model["digests"].items(), keep = sorted(model["digests"].items(),
key=lambda kv: kv[1].get("ts", ""), reverse=True)[:400] key=lambda kv: kv[1].get("ts", ""), reverse=True)[:400]
model["digests"] = dict(keep) model["digests"] = dict(keep)
elif etype == "EscalationRaised":
model.setdefault("escalations", {})[aid] = {
**data, "status": "open", "ts": ev["timestamp"],
}
elif etype == "EscalationAcknowledged":
e = model.setdefault("escalations", {}).get(aid)
if e:
e["status"] = "acked"
e["acked_at"] = ev["timestamp"]
e["ack_level"] = data.get("level")
else:
model["escalations"][aid] = {
"status": "acked", "ack_level": data.get("level"), "ts": ev["timestamp"],
}
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})
@ -214,6 +238,12 @@ def _apply_event(model: dict, ev: dict):
m["ignored"] = True m["ignored"] = True
else: else:
model["inbox"][aid] = {"ignored": True, "reason": data.get("reason"), "ts": ev["timestamp"]} model["inbox"][aid] = {"ignored": True, "reason": data.get("reason"), "ts": ev["timestamp"]}
elif etype == "CycleError":
# счётчик ошибок по уровням — материал для эскалаций (демон/CLI)
cs = model.setdefault("cycle_state", {}).setdefault(
data.get("level", "?"), {"busy": False, "current_task": None, "ticks": 0})
cs["errors"] = cs.get("errors", 0) + 1
cs["last_error"] = (data.get("error") or "")[:300]
# trace-события (InvocationStarted/ToolCalled/ToolResult) не материализуем — они для дебага # trace-события (InvocationStarted/ToolCalled/ToolResult) не материализуем — они для дебага

View file

@ -48,7 +48,11 @@ LEVELS = {
"cli.py tg-reply <id> \"текст\" (тон: кратко, по-русски, тёпло; можно " "cli.py tg-reply <id> \"текст\" (тон: кратко, по-русски, тёпло; можно "
"выполнить просьбу инструментами и ответить результатом). Быстрые " "выполнить просьбу инструментами и ответить результатом). Быстрые "
"мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. " "мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. "
"Если просьба большая — подай её как задачу (cli.py task) и скажи это."), "Если просьба большая — подай её как задачу (cli.py task) и скажи это. "
"Важное, что не по зубам уровню (падение тика, повторная ошибка, "
"требуется фикс с большим контекстом) — эскалируй наверх: "
"cli.py escalate \"причина\" -c \"контекст\" -s critical|warn|info; "
"эскалацию увидит часовой/дневной уровень в своём контексте."),
can_shell=True, can_shell=True,
), ),
"hour": CycleLevel( "hour": CycleLevel(
@ -56,14 +60,20 @@ LEVELS = {
model="auto", effort="medium", ctx_tokens=32_000, task_classes=("S", "M"), model="auto", effort="medium", ctx_tokens=32_000, task_classes=("S", "M"),
mandate=("Ты — часовой поток харнеса Simbiot. Дайджест часа: что произошло, " mandate=("Ты — часовой поток харнеса Simbiot. Дайджест часа: что произошло, "
"статусы фоновых задач, батч мелочей (S/M). Консолидируй минутные " "статусы фоновых задач, батч мелочей (S/M). Консолидируй минутные "
"события в выжимку — сырое наверх не поднимай."), "события в выжимку — сырое наверх не поднимай. "
"Эскалации от нижних уровней (секция ЭСКАЛАЦИИ в контексте) — твоя "
"зона: разберись и закрой cli.py escalation-ack <id> -n \"что сделал\"; "
"если фикс больше твоего контекста — переэскалируй "
"cli.py escalate \"причина\" -f hour -s critical."),
can_shell=True, can_shell=True,
), ),
"day": CycleLevel( "day": CycleLevel(
name="day", label="День · манифест", every=DAY, uses_llm=True, name="day", label="День · манифест", every=DAY, uses_llm=True,
model="auto", effort="high", ctx_tokens=128_000, task_classes=("S", "M", "L"), model="auto", effort="high", ctx_tokens=128_000, task_classes=("S", "M", "L"),
mandate=("Ты — дневной цикл харнеса Simbiot. Манифест дня: план, выбор задач " mandate=("Ты — дневной цикл харнеса Simbiot. Манифест дня: план, выбор задач "
"L-класса, дневной отчёт. Думай глубоко, планируй на день вперёд."), "L-класса, дневной отчёт. Думай глубоко, планируй на день вперёд. "
"Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, "
"поднятое с нижних уровней; закрой cli.py escalation-ack <id>."),
can_shell=True, can_shell=True,
), ),
} }

View file

@ -16,6 +16,8 @@ from datetime import datetime, timezone, timedelta
from . import es from . import es
from .levels import LEVELS from .levels import LEVELS
from . import runtime from . import runtime
from . import commands
from .commands import _ORDER
# интервал демона # интервал демона
POLL = 1.0 POLL = 1.0
@ -65,6 +67,11 @@ def find_task(level_cfg, model: dict):
return None return None
def _lvl_rank(name: str) -> int:
"""Ранг уровня в лестнице; неизвестный → ниже всех (эскалации ему не адресованы)."""
return _ORDER.index(name) if name in _ORDER else len(_ORDER)
def build_context(level_cfg, model: dict, task=None): def build_context(level_cfg, model: dict, task=None):
"""Сборка контекста тика: мандат + память уровня + выжимки нижних + задача.""" """Сборка контекста тика: мандат + память уровня + выжимки нижних + задача."""
parts = [level_cfg.mandate] parts = [level_cfg.mandate]
@ -89,6 +96,27 @@ def build_context(level_cfg, model: dict, task=None):
for d in digests[:6]: for d in digests[:6]:
parts.append(f"- [{d['level']}] {d['text'][:400]}") parts.append(f"- [{d['level']}] {d['text'][:400]}")
# эскалации: открытые, адресованные этому уровню и выше — в приоритете
escs = []
for e in (model.get("escalations") or {}).values():
if not isinstance(e, dict) or e.get("status") != "open" or e.get("stub"):
continue
to_level = e.get("to_level", "day")
if to_level not in _ORDER or _lvl_rank(level_cfg.name) < _lvl_rank(to_level):
continue
escs.append(e)
if escs:
escs.sort(key=lambda e: ({'critical': 0, 'warn': 1, 'info': 2}.get(e.get("severity"), 3),
e.get("ts", "")), reverse=False)
lines = []
for e in escs[:10]:
who = f"от {e.get('from_level')}" if e.get("from_level") else ""
lines.append(f"- [{e['id']}] ({e.get('severity','info')}) {who} → "
f"{e.get('to_level')}: {e.get('reason','')[:300]}"
+ (f"\n контекст: {e.get('context','')[:400]}" if e.get("context") else ""))
parts.append("ЭСКАЛАЦИИ (важное с нижних уровней; отработай и закрой: "
"./venv/bin/python cli.py escalation-ack <id>):\n" + "\n".join(lines))
# инбокс: входящие TG-сообщения, ждущие ответа (в приоритете) # инбокс: входящие TG-сообщения, ждущие ответа (в приоритете)
inbox = [m for m in (model.get("inbox") or {}).values() 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 not m.get("replied") and not m.get("ignored") and m.get("text")]
@ -184,6 +212,20 @@ def process_level(level_cfg, model: dict):
"level": level_cfg.name, "error": str(e)[:1000], "level": level_cfg.name, "error": str(e)[:1000],
"trace": traceback.format_exc()[-2000:], "trace": traceback.format_exc()[-2000:],
}) })
# воркер LLM-тика умер — наверх, чтобы верхний уровень узнал сразу:
# нижний сам себя не чинит (контекст мал), фикс идёт сверху.
try:
import secrets as _secrets
esc_id = f"e-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}-{_secrets.token_hex(3)}"
es.append_event("harness", esc_id, "EscalationRaised", {
"id": esc_id, "from_level": level_cfg.name,
"to_level": commands.level_above(level_cfg.name) or "day",
"reason": f"тик {level_cfg.name} упал: {str(e)[:200]}",
"context": traceback.format_exc()[-1500:],
"severity": "warn", "task_id": None, "source": "scheduler",
})
except Exception:
pass # эскалация — best effort: не маскировать исходную ошибку
return "error: " + str(e)[:120] return "error: " + str(e)[:120]
finally: finally:
st["busy"] = False st["busy"] = False