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
This commit is contained in:
Simbiot Harness 2026-09-30 01:31:03 +00:00
parent e580b15653
commit 7f4db9f085
6 changed files with 291 additions and 6 deletions

View file

@ -20,9 +20,10 @@ Strands Harness SDK как исполнительное ядро, модели
`CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`, `CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`,
`TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`, `TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`,
`EscalationRaised`, `EscalationAcknowledged`, `TaskAwaitDelivery`, `DeliverySent`, `EscalationRaised`,
`BudgetSpent`, `CycleError`, `HarnessInit`, `PolicySet` — append-only, `EscalationAcknowledged`, `BudgetSpent`, `CycleError`, `HarnessInit`,
проекции пересобираются replay'ем, стейт = производное от событий. `PolicySet` — append-only, проекции пересобираются replay'ем, стейт =
производное от событий.
## Структура ## Структура
@ -32,13 +33,15 @@ Strands Harness SDK как исполнительное ядро, модели
- `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе - `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе
(глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`) (глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`)
- `harness/commands.py` — command side - `harness/commands.py` — command side
- `cli.py` — init/task/tasks/status/events/requeue - `cli.py` — init/task/task-await/tasks/status/events/requeue
## CLI ## CLI
```bash ```bash
./venv/bin/python cli.py init --identity "…" ./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 task "…" -m 240 # -m minutes-to-live estimate: S/M/L
./venv/bin/python cli.py task "…" -a # автодоставка результата в TG при готовности
./venv/bin/python cli.py task-await <task-id> # пометить существующую: доставить в TG
./venv/bin/python cli.py tasks ./venv/bin/python cli.py tasks
./venv/bin/python cli.py status ./venv/bin/python cli.py status
./venv/bin/python cli.py events -n 50 ./venv/bin/python cli.py events -n 50
@ -50,6 +53,16 @@ Strands Harness SDK как исполнительное ядро, модели
./venv/bin/python cli.py tg-reply <msg-id> "текст" # ответ на входящее TG ./venv/bin/python cli.py tg-reply <msg-id> "текст" # ответ на входящее TG
``` ```
## Автодоставка артефактов (t-260929205927)
Задача, заказанная из TG (`cli.py task -a` / `task-await`), помечается
`TaskAwaitDelivery`. При завершении (`TaskCompleted`) результат (answer)
уходит в чат сам — `DeliverySent`, без повторного запроса. Push в тике
(сразу после завершения) + pull-страховка (delivery-демон каждые 15с:
досылает недоставленное, если тик умер между завершением и отправкой).
Идемпотентно: один раз доставлено — повторно не шлётся; после requeue
доработка доставляется снова. Формат: «✅ Готово: <title>» + выжимка ≤1800с.
## Эскалации ## Эскалации
Важное с нижних уровней поднимается наверх событием `EscalationRaised` Важное с нижних уровней поднимается наверх событием `EscalationRaised`

16
cli.py
View file

@ -18,6 +18,8 @@ def main():
p_task.add_argument("-d", "--details", default="") p_task.add_argument("-d", "--details", default="")
p_task.add_argument("-m", "--minutes", type=int, default=5, p_task.add_argument("-m", "--minutes", type=int, default=5,
help="оценка длительности (класс S≤5м M≤60м L≤8ч XL)") help="оценка длительности (класс S≤5м M≤60м L≤8ч XL)")
p_task.add_argument("-a", "--await-delivery", action="store_true",
help="результат доставить в TG-чат автоматически при готовности")
p_pol = sub.add_parser("policy", help="задать политику") p_pol = sub.add_parser("policy", help="задать политику")
p_pol.add_argument("--identity", default=None) p_pol.add_argument("--identity", default=None)
@ -35,6 +37,10 @@ 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_taw = sub.add_parser("task-await", help="пометить задачу: результат доставить в TG при готовности")
p_taw.add_argument("task_id")
p_taw.add_argument("-r", "--reason", default="заказано из TG")
p_tgt = sub.add_parser("tg-transcribe", help="расшифровать голосовое (m-NNN) вручную") p_tgt = sub.add_parser("tg-transcribe", help="расшифровать голосовое (m-NNN) вручную")
p_tgt.add_argument("msg_id", help="id входящего (m-NNN)") p_tgt.add_argument("msg_id", help="id входящего (m-NNN)")
p_tgt.add_argument("--force", action="store_true", help="перерасшифровать заново") p_tgt.add_argument("--force", action="store_true", help="перерасшифровать заново")
@ -78,7 +84,15 @@ def main():
print("OK: стор инициализирован, политика записана") print("OK: стор инициализирован, политика записана")
elif args.cmd == "task": elif args.cmd == "task":
tid, size = commands.submit_task(args.title, args.details, args.minutes) tid, size = commands.submit_task(args.title, args.details, args.minutes)
print(f"OK: задача {tid} [{size}] подана") if args.await_delivery:
commands.mark_awaiting_delivery(tid, reason="подана из TG с автодоставкой")
print(f"OK: задача {tid} [{size}] подана (результат уйдёт в TG при готовности)")
else:
print(f"OK: задача {tid} [{size}] подана")
elif args.cmd == "task-await":
es.init_db()
commands.mark_awaiting_delivery(args.task_id, reason=args.reason)
print(f"OK: {args.task_id} помечена — результат будет доставлен в TG при готовности")
elif args.cmd == "policy": elif args.cmd == "policy":
pol = commands.set_policy(identity=args.identity, cpu_alert_threshold=args.cpu_threshold) pol = commands.set_policy(identity=args.identity, cpu_alert_threshold=args.cpu_threshold)
print("OK:", json.dumps(pol, ensure_ascii=False)) print("OK:", json.dumps(pol, ensure_ascii=False))

View file

@ -113,6 +113,98 @@ def requeue_task(task_id: str, reason: str = "manual requeue"):
return task_id 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): def set_policy(identity: str = None, cpu_alert_threshold: int = None):
"""PolicyChanged: идентичность + пороги. Мержим поверх текущей политики.""" """PolicyChanged: идентичность + пороги. Мержим поверх текущей политики."""
model = es.run_projections() model = es.run_projections()

View file

@ -174,6 +174,32 @@ def _apply_event(model: dict, ev: dict):
t.update(size=data["to"]) t.update(size=data["to"])
t.update(status="ready", level=None, session_id=None, t.update(status="ready", level=None, session_id=None,
promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1) promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1)
# requeue сбрасывает факт доставки: работа будет сделана заново,
# новый результат должен снова уйти в чат (старый ≠ новый)
if t.get("await_delivery"):
t.update(delivery_status="await")
elif etype == "TaskAwaitDelivery":
# автодоставка (t-260929205927): задачу заказали из TG — результат
# должен уйти в чат сам, без повторного запроса. Флаг на задаче;
# scheduler-демон ловит TaskCompleted и рассылает DeliverySent.
t = model["tasks"].get(aid)
if t:
t.update(await_delivery=True, delivery_status="await",
delivery_reply_to=data.get("reply_to"),
delivery_reason=(data.get("reason") or "")[:300])
elif etype == "DeliverySent":
# доставлено в чат (результат готовой задачи); best-effort флаг:
# доставка идемпотентна — повторная не отправит (sent раз и навсегда)
t = model["tasks"].get(aid)
if t:
t.update(delivery_status="sent", delivery_sent_at=ev["timestamp"],
delivered_text=(data.get("text") or "")[:2000],
delivery_tg_ok=bool(data.get("tg_ok")))
else:
model.setdefault("deliveries", {})[aid] = {
"task_id": data.get("task_id"), "ts": ev["timestamp"],
"tg_ok": bool(data.get("tg_ok")),
}
elif etype == "DigestWritten": elif etype == "DigestWritten":
key = f"{data['level']}:{data['period']}" key = f"{data['level']}:{data['period']}"
model["digests"][key] = { model["digests"][key] = {

View file

@ -53,7 +53,9 @@ LEVELS = {
"isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. " "isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. "
"Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. " "Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. "
"Если просьба большая — подай её как задачу (cli.py task) и скажи это. " "Если просьба большая — подай её как задачу (cli.py task -a — флаг "
"автодоставки: результат сам придёт в чат при готовности, не проси "
"Alex спрашивать дважды) и скажи это. "
"Важное, что не по зубам уровню (падение тика, повторная ошибка, " "Важное, что не по зубам уровню (падение тика, повторная ошибка, "
"требуется фикс с большим контекстом) — эскалируй наверх: " "требуется фикс с большим контекстом) — эскалируй наверх: "
"cli.py escalate \"причина\" -c \"контекст\" -s critical|warn|info; " "cli.py escalate \"причина\" -c \"контекст\" -s critical|warn|info; "

View file

@ -229,6 +229,19 @@ def run_level_tick(level_cfg, seq: int, model: dict):
es.append_event("task", task_id, "TaskCompleted", { es.append_event("task", task_id, "TaskCompleted", {
"result_ref": trace_id, "answer": (text or "")[:2000], "result_ref": trace_id, "answer": (text or "")[:2000],
}) })
# автодоставка (t-260929205927): задачу заказали из TG (await_delivery)
# — результат уходит в чат сразу после завершения, без повторного запроса.
# Единственный путь доставки — commands.deliver_result (идемпотентен:
# без флага / уже sent — молча пропустит). Не отправилось здесь —
# дослает delivery-демон (deliver_pending) на следующем проходе.
try:
from . import commands as _cmd
if _cmd.deliver_result(task_id, text or ""):
_log(f"delivery: результат {task_id} доставлен в TG")
except Exception as e:
# доставка — best effort: не валить тик; DeliverySent допишет
# delivery-демон (см. deliver_pending) — задача не потеряется
_log(f"delivery: отправка {task_id} не удалась ({e}) — доставит delivery-демон")
return text, task_id return text, task_id
@ -297,6 +310,126 @@ def _reflex_tick(level_cfg, seq: int, model: dict):
return payload, None return payload, None
# ────────────── АВТОДОСТАВКА (t-260929205927) + СИРОТЫ-РЕСТАРТЫ ──────────────
DELIVERY_POLL = 15 # сек: проход delivery-демона (push уже сделал большую часть)
def orphan_requeue():
"""Сироты рестартов: задачи в running без живого воркера → очередь.
Раньше: тик-владелец задачи умирал (systemctl restart демона при деплое
убивает и воркеров), TaskFailed/TaskCompleted не пишется — и задача
висела в running навсегда (эскалация e-260929234911: cad7af завис дважды
за два часа; «повторный таймаут часового воркера» был на самом деле
убийством живого воркера рестартом). Мин-уровень вычищал руками.
Теперь демон при старте и каждые DELIVERY_POLL секунд:
- running-задача с мёртвым воркером (нет свежего следа сессии) →
TaskPromoted(ready) с reason 'orphan-requeue'; флаг await_delivery
сохраняется, доработка после requeue тоже уйдёт в чат.
"""
model = es.run_projections()
from datetime import timedelta
now = _now()
requeued = []
for tid, t in (model.get("tasks") or {}).items():
if t.get("status") != "running":
continue
sess = t.get("session_id") or ""
level = t.get("level") or "hour"
cfg = LEVELS.get(level)
# Порог молчания = таймаут убийства воркера (isolated_runner:
# max(tick_timeout, every*0.8)) + запас. Пока воркер жив, он пишет
# след (BudgetSpent/Tool*) каждые секунды-минуты; тишина дольше
# его же таймаута означает: воркер уже убит раннером (TimeoutExpired
# НЕ пишет TaskFailed — задача и висела в running) или умер с демоном.
timeout = (cfg.tick_timeout if cfg else 300) or 300
if cfg and cfg.uses_llm:
timeout = max(timeout, int(cfg.every * 0.8))
timeout += 60
# свежесть следа: трейс сессии (aggregate_id 'hour-497423:96')
# или BudgetSpent с её trace_id. session 's-hour-497423' →
# префикс 'hour-497423' безопасен: след. символ ':' или '"'.
prefix = sess[2:] if sess.startswith("s-") else sess
db = es._conn()
try:
row = db.execute(
"SELECT MAX(timestamp) AS ts FROM events "
"WHERE (aggregate='trace' AND aggregate_id LIKE ?) "
" OR (aggregate='harness' AND event_type='BudgetSpent' AND data LIKE ?)",
(prefix + "%", "%" + prefix + "%"),
).fetchone()
last = row["ts"] if row else None
finally:
db.close()
if last:
try:
last_dt = datetime.fromisoformat(last)
if (now - last_dt) < timedelta(seconds=timeout + 60):
continue # воркер жив/недавно работал — не трогаем
except ValueError:
pass
es.append_event("task", tid, "TaskPromoted",
{"to": t.get("size") or "M", "from": t.get("size") or "M",
"reason": "orphan-requeue: воркер тика-владельца мёртв "
"(рестарт/таймаут), задача возвращена в очередь"})
requeued.append(tid)
_log(f"orphan-requeue: {tid} (сессия {sess}) → ready")
return requeued
def deliver_pending():
"""Доставка готовых задач с await_delivery, не получивших push.
Push (в run_level_tick) закрывает 99% кейсов немедленно; этот pull —
страховка: тик умер после TaskCompleted, но до DeliverySent; флаг
поставлен позже завершения (старый тик) — досылаем по факту.
Идемпотентно: delivery_status=='sent' не отправляет повторно.
"""
from . import commands as _cmd
model = es.run_projections()
out = []
for tid, t in (model.get("tasks") or {}).items():
if not (t.get("await_delivery") and t.get("status") == "done"
and t.get("delivery_status") != "sent"):
continue
# ответ задачи: TaskCompleted.answer (проекция не хранит — берём из стора)
db = es._conn()
try:
row = db.execute(
"SELECT data FROM events WHERE aggregate='task' AND aggregate_id=? "
"AND event_type='TaskCompleted' ORDER BY id DESC LIMIT 1",
(tid,),
).fetchone()
finally:
db.close()
if not row:
continue
answer = (json.loads(row["data"]).get("answer") or "").strip()
try:
if _cmd.deliver_result(tid, answer):
out.append(tid)
_log(f"delivery-pull: {tid} доставлен")
except Exception as e:
_log(f"delivery-pull: {tid} ошибка {e} — повторим на след. проходе")
return out
def _delivery_and_orphans_loop():
"""Фоновый поток демона: доставка + сироты, не мешает тикам уровней."""
import threading
_log("delivery-демон запущен (orphan-requeue + автодоставка)")
# первый проход сразу: чистим сирот рестарта, доим недоставленное
while True:
try:
orphan_requeue()
deliver_pending()
except Exception:
pass # демон не должен умирать: следующий проход попробует снова
time.sleep(DELIVERY_POLL)
def main_loop(): def main_loop():
"""Демон: уровни тикают независимо, каждый в своём потоке. """Демон: уровни тикают независимо, каждый в своём потоке.
LLM-тик одного уровня больше не блокирует остальные LLM-тик одного уровня больше не блокирует остальные
@ -305,6 +438,11 @@ def main_loop():
es.init_db() es.init_db()
_log("Simbiot Harness запущен; уровни: " + ", ".join(ACTIVE)) _log("Simbiot Harness запущен; уровни: " + ", ".join(ACTIVE))
# delivery-демон: сироты рестартов + автодоставка TG (t-260929205927).
# Первый проход сразу — чистка сирот до первого тика уровней.
dt = threading.Thread(target=_delivery_and_orphans_loop, daemon=True,
name="delivery")
dt.start()
last_seq = {} last_seq = {}
threads = {} threads = {}
while True: while True: