feat(tg): TG-visibility — уведомления о занятости уровней + срез лестницы по запросу (t-260929170039-aad557)

- tg_inbox.notify_chat(): отправка в чат харнеса без входящего-родителя
  (чат = последний MessageReceived от Alex)
- tg_inbox.ladder_snapshot(): срез лестницы — занятость/тики/ошибки уровней,
  готово/в работе, открытые эскалации; read-only через run_projections()
- scheduler._notify_busy(): уровень сообщает в TG о взятии задачи
  (анти-спам 15м на уровень) — TaskClaimed в scheduler.run_level_tick
- cli.py: команда ladder + tg-notify; /лестница|/ladder|/статус|/status
  в tg-reply заменяется на срез лестницы — запрос из TG самодостаточен
- guard в __main__ tg_inbox: единственный легитимный поллер — systemd-сервис
  (раньше python -m harness.tg_inbox с любыми аргументами поднимал
  конкурирующего поллера → 409 Conflict в легитимном)
This commit is contained in:
simbiot-harness 2026-09-29 21:04:31 +00:00
parent 935c75d078
commit 78ba888bb9
2 changed files with 73 additions and 0 deletions

17
cli.py
View file

@ -35,6 +35,10 @@ def main():
p_tgr.add_argument("msg_id", help="id входящего (m-NNN)")
p_tgr.add_argument("text")
p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации")
p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)")
p_not.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"],
@ -85,8 +89,21 @@ def main():
elif args.cmd == "tg-reply":
es.init_db()
from harness import tg_inbox
low = args.text.strip().lower()
if low in ("/лестница", "/ladder", "/статус", "/status", "лестница", "status"):
# срез лестницы по запросу из TG — видимостью управляет сам харнес
args.text = "📊 " + tg_inbox.ladder_snapshot()
mid = tg_inbox.send_reply(args.msg_id, args.text)
print(f"OK: ответ на {mid} отправлен")
elif args.cmd == "ladder":
es.init_db()
from harness import tg_inbox
print(tg_inbox.ladder_snapshot())
elif args.cmd == "tg-notify":
es.init_db()
from harness import tg_inbox
r = tg_inbox.notify_chat(args.text)
print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)")
elif args.cmd == "escalate":
es.init_db()
eid, to_level = commands.raise_escalation(

View file

@ -9,6 +9,7 @@ getUpdates offset=last+1. Идемпотентность: update_id → aggregat
"""
import json
import os
import sys
import time
import urllib.request
import urllib.parse
@ -153,5 +154,60 @@ def poll_loop():
time.sleep(5)
def notify_chat(text: str):
"""Отправить сообщение в чат харнеса (без входящего-родителя — уведомления).
Чат берём из последнего входящего MessageReceived (это личка Алекса);
если входящих ещё не было — тихо пропускаем (некому показывать).
"""
_load_env()
if not TG_TOKEN:
return None
model = es.run_projections()
inbox = [m for m in (model.get("inbox") or {}).values()
if m.get("chat_id") and not m.get("ignored")]
if not inbox:
return None # некому: чат ещё не известен
chat_id = sorted(inbox, key=lambda m: m.get("ts") or "")[-1]["chat_id"]
try:
return send_message(chat_id, text)
except Exception:
return None # уведомление — best effort, не валить тик
def ladder_snapshot() -> str:
"""Срез лестницы для Telegram: уровни, занятость, задачи, эскалации."""
model = es.run_projections()
cs = model.get("cycle_state") or {}
order = ["sec", "min", "hour", "day"]
lines = []
for name in order:
st = cs.get(name) or {}
busy = "🔧 занят" if st.get("busy") else "ожидает"
lines.append(f"{'🔹' if name != 'sec' else '⚡'} {name}: {busy}, тиков {st.get('ticks', 0)}"
+ (f", ошибок {st['errors']}" if st.get("errors") else ""))
tasks = list((model.get("tasks") or {}).values())
tasks.sort(key=lambda t: t.get("created") or "", reverse=True)
board = [t for t in tasks if t.get("status") in ("ready", "running")]
lines.append(f"📋 готово/в работе: {sum(1 for t in tasks if t['status'] == 'ready')}/"
f"{sum(1 for t in tasks if t['status'] == 'running')}")
for t in board[:8]:
lines.append(f" [{t['size']}] {t['status']}: {t['title'][:60]}")
open_esc = [e for e in (model.get("escalations") or {}).values()
if isinstance(e, dict) and e.get("status") == "open"]
lines.append(f"🚨 открытых эскалаций: {len(open_esc)}")
for e in open_esc[:5]:
lines.append(f" [{e.get('severity', 'info')}] {e.get('reason', '')[:80]}")
return "\n".join(lines)
if __name__ == "__main__":
# Единственный легитимный поллер — systemd-сервис simbiot-harness-tg.
# `python -m harness.tg_inbox` с любыми аргументами (--help, list, …)
# раньше поднимал второго поллера → 409 Conflict в легитимном.
if len(sys.argv) > 1:
print("usage: python -m harness.tg_inbox # long-poll поллер (systemd)")
print(" cli.py ladder # срез лестницы (текст)")
print(" cli.py tg-notify <text> # отправка в чат харнеса")
sys.exit(0)
poll_loop()