simbiot-harness/harness/commands.py
Simbiot Harness 7f4db9f085 feat(delivery): автодоставка готовых артефактов в TG + orphan-requeue сирот рестартов (t-260929205927-8c20ea)
- события TaskAwaitDelivery / DeliverySent: флаг 'результат доставить в чат'
  на задаче, доставка идемпотентна (один раз sent — повторов нет)
- commands.mark_awaiting_delivery / deliver_result (единственный путь отправки) /
  complete_task_with_delivery: завершение и отправка идут парой —
  «сделал и промолчал» больше не случается
- scheduler: push после TaskCompleted + delivery-демон (pull каждые 15с —
  досылает, если тик умер между завершением и отправкой); requeue сбрасывает
  доставку — доработка снова уйдёт в чат
- orphan_requeue: running-задачи с мёртвым воркером (рестарт демона убивает
  воркеров, TaskCompleted не пишется) возвращаются в очередь автоматически;
  корень эскалации e-260929234911: 'повторный таймаут часового воркера'
  был убийством живого воркера рестартом в 23:12/00:03, не таймаутом
  (трейс и BudgetSpent шли вплоть до секунды рестарта)
- cli: task -a (подать с доставкой) + task-await (пометить существующую);
  мин-уровень помечает задачи-просьбы из TG
- 18 тестов на копии БД: submit→await→complete→deliver→idempotency→
  requeue→re-deliver→pull→orphan→rebuild
2026-09-30 01:31:03 +00:00

241 lines
No EOL
12 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 — command side (CQRS): валидация + события.
Задачи подаются через CLI/Telegram/файл-инбокс; каждая команда
валидируется и только потом пишет событие.
"""
import os
import secrets
from datetime import datetime, timezone
from . import es
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"):
"""SubmitTask: класс по оценке длительности. Идемпотентно по id."""
task_id = f"t-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}-{secrets.token_hex(3)}"
size = size_class(minutes)
es.append_event("task", task_id, "TaskSubmitted", {
"id": task_id, "title": title, "details": details,
"size": size, "est_minutes": minutes, "source": source,
})
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/running/прерванную задачу в очередь (TaskPromoted → ready).
running тоже возвращаем: если тик-владелец умер (рестарт демона, таймаут),
TaskFailed/TaskCompleted не будет — иначе задача зависает в running навсегда.
"""
model = es.run_projections()
t = model["tasks"].get(task_id)
if t is None:
raise ValueError(f"задача {task_id} не найдена")
if t["status"] not in ("failed", "done", "running"):
raise ValueError(f"задача {task_id} в статусе {t['status']} — requeue не нужен")
es.append_event("task", task_id, "TaskPromoted",
{"to": t["size"], "from": t["size"], "reason": reason})
return task_id
def mark_awaiting_delivery(task_id: str, reason: str = "", reply_to: str = None):
"""TaskAwaitDelivery: задачу заказали из TG — результат уйдёт в чат сам.
Автодоставка артефактов (t-260929205927): когда задача, заказанная из
TG, завершается (TaskCompleted), результат (answer/результат_ref)
автоматически отправляется в чат — Alex не должен просить дважды.
Доставка идемпотентна: после DeliverySent повторная не отправляется.
Флаг живёт через requeue — доработка после неудачи доставляется тоже.
"""
model = es.run_projections()
t = model["tasks"].get(task_id)
if t is None:
raise ValueError(f"задача {task_id} не найдена")
if t.get("await_delivery"):
return task_id # уже помечена — идемпотентно
es.append_event("task", task_id, "TaskAwaitDelivery", {
"reply_to": reply_to, "reason": (reason or "заказано из TG")[:300],
})
return task_id
def complete_task_with_delivery(task_id: str, answer: str, level: str,
result_ref: str = None):
"""TaskCompleted + немедленная доставка результата в TG-чат.
Используется исполнителем (часовой/дневной тик) как атомарная замена
голому TaskCompleted для задач с await_delivery: завершение и отправка
идут парой, «сделал и промолчал» больше не случается. Для задач без
флага доставки — только TaskCompleted (поведение прежнее).
Возвращает (task_id, delivered: bool).
"""
model = es.run_projections()
t = model["tasks"].get(task_id)
if t is None:
raise ValueError(f"задача {task_id} не найдена")
if t["status"] != "running":
raise ValueError(f"задача {task_id} в статусе {t['status']} — завершать можно только running")
if (answer or "").strip() == "":
raise ValueError("пустой результат — доставлять нечего, заверши как failed/requeue")
# TaskCompleted — независимо от доставки (событие-истина о завершении)
es.append_event("task", task_id, "TaskCompleted", {
"result_ref": result_ref, "answer": answer[:2000],
})
delivered = deliver_result(task_id, answer)
return task_id, delivered
def deliver_result(task_id: str, answer: str) -> bool:
"""Доставка результата готовой задачи в TG-чат (единственный путь).
Идемпотентно: без await_delivery / уже sent — молча пропускает.
Три сценария использования, все через эту функцию:
1) push в тике сразу после TaskCompleted (scheduler.run_level_tick);
2) complete_task_with_delivery — то же для исполнителя вручную;
3) pull-страховка deliver_pending (демон): тик умер между
завершением и отправкой — досылает по read model.
Пустой answer доставляется как краткое уведомление о готовности
(задача завершена, а не отправка «ничего»).
Возвращает True, если отправка выполнена (событие DeliverySent).
"""
model = es.run_projections()
t = model["tasks"].get(task_id)
if not t or not t.get("await_delivery") or t.get("delivery_status") == "sent":
return False
if t.get("status") != "done":
return False # доставка только готового результата
from . import tg_inbox
text = _delivery_text(t, answer)
try:
tg_ok = tg_inbox.notify_chat(text)
except Exception:
tg_ok = None # TG недоступен — событие не пишем, pull повторит
if tg_ok is None:
return False
es.append_event("task", task_id, "DeliverySent", {
"task_id": task_id, "text": text[:2000], "tg_ok": bool(tg_ok),
})
return True # доставка зафиксирована событием (ok или not ok — повторов нет)
def _delivery_text(task: dict, answer: str) -> str:
"""Формат уведомления о готовности: что готово + выжимка результата."""
title = (task.get("title") or "задача").strip()
a = (answer or "").strip()
if not a:
return f"✅ Готово: «{title[:80]}» (завершено; детали — на дашборде)"
if len(a) > 1800:
a = a[:1800] + "…"
return f"✅ Готово: «{title[:80]}»\n\n{a}"
def set_policy(identity: str = None, cpu_alert_threshold: int = None):
"""PolicyChanged: идентичность + пороги. Мержим поверх текущей политики."""
model = es.run_projections()
current = dict(model.get("policy") or {})
patch = {}
if identity is not None:
patch["identity"] = identity
if cpu_alert_threshold is not None:
patch["cpu_alert_threshold"] = cpu_alert_threshold
current.update(patch)
es.append_event("harness", "policy", "PolicyChanged", current)
return current
def list_tasks():
model = es.run_projections()
out = []
for t in model["tasks"].values():
out.append(t)
return out
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:],
}