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:
parent
94cc921ccd
commit
ed502650f4
14 changed files with 206 additions and 7 deletions
14
README.md
14
README.md
|
|
@ -20,6 +20,7 @@ Strands Harness SDK как исполнительное ядро, модели
|
|||
|
||||
`CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`,
|
||||
`TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`,
|
||||
`EscalationRaised`, `EscalationAcknowledged`,
|
||||
`BudgetSpent`, `CycleError`, `HarnessInit`, `PolicySet` — append-only,
|
||||
проекции пересобираются replay'ем, стейт = производное от событий.
|
||||
|
||||
|
|
@ -43,9 +44,22 @@ Strands Harness SDK как исполнительное ядро, модели
|
|||
./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
|
||||
./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
|
||||
```
|
||||
|
||||
## Эскалации
|
||||
|
||||
Важное с нижних уровней поднимается наверх событием `EscalationRaised`
|
||||
(min→hour→day; day — вершина, эскалировать некуда). Открытые эскалации
|
||||
инжектятся в контекст верхних уровней (`build_context`) в приоритетной секции
|
||||
— нижний уровень не тонет в чужих проблемах, верхний видит выжимку, а не сырь.
|
||||
Уровень-адресат закрывает эскалацию `escalation-ack` (событие
|
||||
`EscalationAcknowledged`). Падение LLM-тика (`CycleError`) эскалируется
|
||||
автоматически — scheduler поднимает warn-эскалацию на уровень выше.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -322,6 +322,8 @@
|
|||
<ul>
|
||||
<li><span class="m">PolicyChanged</span> {budgets, mandates}</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>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
48
cli.py
48
cli.py
|
|
@ -35,12 +35,29 @@ def main():
|
|||
p_tgr.add_argument("msg_id", help="id входящего (m-NNN)")
|
||||
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("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("--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.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива")
|
||||
|
|
@ -70,6 +87,25 @@ def main():
|
|||
from harness import tg_inbox
|
||||
mid = tg_inbox.send_reply(args.msg_id, args.text)
|
||||
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":
|
||||
es.init_db()
|
||||
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]}")
|
||||
elif args.cmd == "events":
|
||||
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]
|
||||
print(f"{ev['id']:4} {ev['timestamp'][11:19]} {ev['aggregate']:8} {ev['event_type']:16} {d}")
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -8,7 +8,63 @@ import secrets
|
|||
from datetime import datetime, timezone
|
||||
|
||||
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"):
|
||||
|
|
@ -77,10 +133,13 @@ def list_tasks():
|
|||
|
||||
def status():
|
||||
model = es.run_projections()
|
||||
open_escalations = sum(1 for e in (model.get("escalations") or {}).values()
|
||||
if e.get("status") == "open")
|
||||
return {
|
||||
"cycle_state": model["cycle_state"],
|
||||
"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"),
|
||||
"digests": len(model["digests"]),
|
||||
"escalations_open": open_escalations,
|
||||
"spend": model.get("spend", [])[-8:],
|
||||
}
|
||||
|
|
@ -106,12 +106,20 @@ def get_events(after_id: int = 0, limit: int = 1000):
|
|||
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():
|
||||
return {
|
||||
"tasks": {}, # task_id → {title, size, status, level, session_id, result_ref, ...}
|
||||
"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}
|
||||
"metrics": [], # [{level, ticks, absorbed, llm_ticks, tokens, cost}]
|
||||
"agents": {}, # level → {model, calls}
|
||||
|
|
@ -135,6 +143,8 @@ def _apply_event(model: dict, ev: dict):
|
|||
etype, data = ev["event_type"], json.loads(ev["data"])
|
||||
aid = ev["aggregate_id"]
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
if etype == "TaskSubmitted":
|
||||
model["tasks"][aid] = {
|
||||
"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(),
|
||||
key=lambda kv: kv[1].get("ts", ""), reverse=True)[:400]
|
||||
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":
|
||||
level = data.get("level", "?")
|
||||
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
|
||||
else:
|
||||
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) не материализуем — они для дебага
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ LEVELS = {
|
|||
"cli.py tg-reply <id> \"текст\" (тон: кратко, по-русски, тёпло; можно "
|
||||
"выполнить просьбу инструментами и ответить результатом). Быстрые "
|
||||
"мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. "
|
||||
"Если просьба большая — подай её как задачу (cli.py task) и скажи это."),
|
||||
"Если просьба большая — подай её как задачу (cli.py task) и скажи это. "
|
||||
"Важное, что не по зубам уровню (падение тика, повторная ошибка, "
|
||||
"требуется фикс с большим контекстом) — эскалируй наверх: "
|
||||
"cli.py escalate \"причина\" -c \"контекст\" -s critical|warn|info; "
|
||||
"эскалацию увидит часовой/дневной уровень в своём контексте."),
|
||||
can_shell=True,
|
||||
),
|
||||
"hour": CycleLevel(
|
||||
|
|
@ -56,14 +60,20 @@ LEVELS = {
|
|||
model="auto", effort="medium", ctx_tokens=32_000, task_classes=("S", "M"),
|
||||
mandate=("Ты — часовой поток харнеса Simbiot. Дайджест часа: что произошло, "
|
||||
"статусы фоновых задач, батч мелочей (S/M). Консолидируй минутные "
|
||||
"события в выжимку — сырое наверх не поднимай."),
|
||||
"события в выжимку — сырое наверх не поднимай. "
|
||||
"Эскалации от нижних уровней (секция ЭСКАЛАЦИИ в контексте) — твоя "
|
||||
"зона: разберись и закрой cli.py escalation-ack <id> -n \"что сделал\"; "
|
||||
"если фикс больше твоего контекста — переэскалируй "
|
||||
"cli.py escalate \"причина\" -f hour -s critical."),
|
||||
can_shell=True,
|
||||
),
|
||||
"day": CycleLevel(
|
||||
name="day", label="День · манифест", every=DAY, uses_llm=True,
|
||||
model="auto", effort="high", ctx_tokens=128_000, task_classes=("S", "M", "L"),
|
||||
mandate=("Ты — дневной цикл харнеса Simbiot. Манифест дня: план, выбор задач "
|
||||
"L-класса, дневной отчёт. Думай глубоко, планируй на день вперёд."),
|
||||
"L-класса, дневной отчёт. Думай глубоко, планируй на день вперёд. "
|
||||
"Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, "
|
||||
"поднятое с нижних уровней; закрой cli.py escalation-ack <id>."),
|
||||
can_shell=True,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from datetime import datetime, timezone, timedelta
|
|||
from . import es
|
||||
from .levels import LEVELS
|
||||
from . import runtime
|
||||
from . import commands
|
||||
from .commands import _ORDER
|
||||
|
||||
# интервал демона
|
||||
POLL = 1.0
|
||||
|
|
@ -65,6 +67,11 @@ def find_task(level_cfg, model: dict):
|
|||
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):
|
||||
"""Сборка контекста тика: мандат + память уровня + выжимки нижних + задача."""
|
||||
parts = [level_cfg.mandate]
|
||||
|
|
@ -89,6 +96,27 @@ def build_context(level_cfg, model: dict, task=None):
|
|||
for d in digests[:6]:
|
||||
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-сообщения, ждущие ответа (в приоритете)
|
||||
inbox = [m for m in (model.get("inbox") or {}).values()
|
||||
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],
|
||||
"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]
|
||||
finally:
|
||||
st["busy"] = False
|
||||
|
|
|
|||
Loading…
Reference in a new issue