simbiot-harness/cli.py
Simbiot Harness 2f4e3d22a7 feat(scheduler): батч-обработка задач в тике — пачка ready-задач в бюджете контекста (t-260930101325-010ba3)
Причина: find_task возвращал ОДНУ ready-задачу за такт — при 5+ ready
очередь росла быстрее исполнения (1 задача/такт).

Теперь:
- select_task_batch: ВСЕ ready-задачи класса уровня, старейшая первой;
- кап пачки MAX_TASK_BATCH (min=3, hour=6, day=6; env SIMBIOT_BATCH_MAX_<LEVEL>);
- бюджет контекста: секция задач <= ctx_tokens*3*0.5 символов,
  мандат/выжимки/эскалации/инбокс остаются в тике; старейшая берётся всегда;
- run_level_tick: claim всего батча ДО воркера; TaskCompleted/TaskFailed
  ПО каждой отдельно (смерть воркера не теряет батч в running);
- build_context: секция 'ТЕКУЩАЯ РАБОТА — БАТЧ из N' с постановками,
  инструкция закрывать каждую задачу cli.py complete <id> -a;
- CLI complete: закрытие running-задачи с результатом
  (commands.complete_task_with_delivery — доставка в TG идемпотентна).

Тесты: tests/test_task_batch.py — 28 проверок (ALL PASS), набор 8/8 зелёный.
2026-09-30 14:13:30 +00:00

329 lines
No EOL
19 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 — CLI: таски, статус, ручные тики."""
import argparse
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from harness import commands, es
def main():
ap = argparse.ArgumentParser(prog="simbiot", description="Simbiot Harness CLI")
sub = ap.add_subparsers(dest="cmd", required=True)
p_task = sub.add_parser("task", help="подать задачу")
p_task.add_argument("title")
p_task.add_argument("-d", "--details", default="")
p_task.add_argument("-m", "--minutes", type=int, default=5,
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.add_argument("--identity", default=None)
p_pol.add_argument("--cpu-threshold", type=int, default=None)
p_rq = sub.add_parser("requeue", help="вернуть failed-задачу в очередь")
p_rq.add_argument("task_id")
p_rq.add_argument("-r", "--reason", default="manual requeue")
p_cmp = sub.add_parser("complete", help="закрыть running-задачу с результатом (батч-тик, t-260930101325)")
p_cmp.add_argument("task_id")
p_cmp.add_argument("-a", "--answer", required=True, help="результат задачи")
p_pr = sub.add_parser("promote", help="промоушен ready-задачи на уровень выше (S→M→L)")
p_pr.add_argument("task_id")
p_pr.add_argument("-r", "--reason", default="manual promote")
p_tgr = sub.add_parser("tg-reply", help="ответить на входящее TG-сообщение")
p_tgr.add_argument("msg_id", help="id входящего (m-NNN), для батча — несколько через запятую")
p_tgr.add_argument("text")
p_tgr.add_argument("-c", "--close", action="store_true",
help="батч-режим: один сводный ответ закрывает все указанные входящие "
"(t-260930064924)")
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.add_argument("msg_id", help="id входящего (m-NNN)")
p_tgt.add_argument("--force", action="store_true", help="перерасшифровать заново")
p_tgl = sub.add_parser("tg-list", help="список входящих TG-сообщений (inbox)")
p_tgl.add_argument("--new", action="store_true", help="только без ответа")
p_tgl.add_argument("-n", "--limit", type=int, default=25,
help="сколько последних показать")
p_tgl.add_argument("--all", action="store_true", help="без ограничения по количеству")
p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации")
p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)")
p_not.add_argument("text")
p_not.add_argument("-k", "--key", dest="key", default=None,
help="ключ идемпотентности: сообщение с таким ключом доставляется "
"только один раз (дубль/повтор — SKIP; t-260930083937)")
p_dfl = sub.add_parser("tg-deliver-file", help="отправить файл в чат (идемпотентно, t-260930083937)")
p_dfl.add_argument("file", help="путь к файлу")
p_dfl.add_argument("-c", "--caption", default="", help="подпись к файлу")
p_dfl.add_argument("-k", "--key", dest="key", default=None,
help="ключ идемпотентности (по умолчанию file:<абсолютный путь>)")
p_dfl.add_argument("--force", action="store_true",
help="отправить даже если уже доставлен (повторная доставка вручную)")
p_pro = sub.add_parser("proactive", help="инициативная реплика: ритм/тема/отправка (t-260930073419)")
p_pro.add_argument("--force", action="store_true",
help="отправить сейчас, игнорируя окно/интервал/тишину")
p_pro.add_argument("--plan", action="store_true", help="только состояние ритма, без отправки")
p_tgc = sub.add_parser("tg-commands", help="команды бота: setMyCommands + список")
p_tgc.add_argument("--register", action="store_true",
help="(re)зарегистрировать setMyCommands (кириллица, фолбэк — транслит)")
p_tgc.add_argument("--list", action="store_true", help="показать текущие команды бота")
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="хвост стора (последние N событий)")
p_ev.add_argument("-n", type=int, default=15)
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 — ритмичный агент кооператива")
p_rag = sub.add_parser("rag-query", help="семантический поиск по базе кооператива (вендор /root/rag)")
p_rag.add_argument("question")
p_rag.add_argument("-k", type=int, default=5, help="сколько чанков (<=5)")
p_rag.add_argument("--db", default=None, help="база RAG (по умолчанию rag.db; hermes.db — общая)")
p_rag.add_argument("--json", action="store_true", help="вывести машинно-читаемо")
args = ap.parse_args()
if args.cmd == "init":
es.init_db()
commands.set_policy(identity=args.identity)
print("OK: стор инициализирован, политика записана")
elif args.cmd == "task":
tid, size = commands.submit_task(args.title, args.details, args.minutes)
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":
pol = commands.set_policy(identity=args.identity, cpu_alert_threshold=args.cpu_threshold)
print("OK:", json.dumps(pol, ensure_ascii=False))
elif args.cmd == "requeue":
es.init_db()
tid = commands.requeue_task(args.task_id, args.reason)
print(f"OK: задача {tid} возвращена в очередь")
elif args.cmd == "complete":
# батч-тик (t-260930101325): исполнитель закрывает задачи пачки
# по отдельности; с await_delivery результат уходит в TG (идемпотентно)
es.init_db()
tid, delivered = commands.complete_task_with_delivery(
args.task_id, args.answer, level="manual")
print(f"OK: задача {tid} завершена"
+ (" (доставлено в TG)" if delivered else ""))
elif args.cmd == "promote":
es.init_db()
tid, new_size = commands.promote_task(args.task_id, args.reason)
print(f"OK: задача {tid} повышена до [{new_size}]")
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()
ids = [x.strip() for x in args.msg_id.split(",") if x.strip()]
if len(ids) > 1 or (args.close and len(ids) == 1):
if not args.close:
print("ERROR: несколько id без -c/--close — укажите флаг для сводного батч-ответа")
sys.exit(1)
mid = tg_inbox.send_batch_reply(ids, args.text)
print(f"OK: сводный батч-ответ отправлен (голова {mid}, закрыто {len(ids)})")
else:
mid = tg_inbox.send_reply(args.msg_id, args.text)
print(f"OK: ответ на {mid} отправлен")
elif args.cmd == "tg-transcribe":
es.init_db()
from harness import voice
from harness import tg_inbox
model = es.run_projections()
m = (model.get("inbox") or {}).get(args.msg_id)
if m is None:
print(f"ERROR: входящее {args.msg_id} не найдено")
sys.exit(1)
if args.force:
# сбрасываем прошлую расшифровку в read model (событие поверх)
# — transcribe_message идемпотентен по флагу transcribed
es.append_event("inbox", args.msg_id, "MessageTranscribed", {
"transcript": "", "engine": None, "reason": None, "reset": True,
})
print("(force: прошлый транскрипт сброшен)")
if m.get("transcribed") and not args.force:
print(f"Уже расшифровано ({m.get('engine')}): {m.get('transcript')}")
else:
text, engine = voice.transcribe_message(
args.msg_id, file_id=m.get("file_id") or "",
file_unique_id=m.get("file_unique_id") or "",
duration=m.get("duration"), kind=m.get("kind") or "voice")
if text:
print(f"OK ({engine}): {text}")
else:
reason = m.get("transcribe_reason")
print(f"ПУСТО (engine={engine})"
+ (f", причина: {reason}" if reason else "")
+ " — транскрипт записан в стор как пустой")
elif args.cmd == "tg-list":
es.init_db()
model = es.run_projections()
inbox = model.get("inbox") or {}
rows = sorted(inbox.items(),
key=lambda kv: int(kv[0].split("-")[1]) if kv[0].split("-")[1].isdigit() else 10**9)
total = len(rows)
fresh = [kv for kv in rows if not kv[1].get("replied") and not kv[1].get("ignored")]
print(f"всего: {total} | без ответа: {len(fresh)} | "
f"отвечено: {sum(1 for _, v in rows if v.get('replied'))} | "
f"ignored: {sum(1 for _, v in rows if v.get('ignored'))}")
shown = [kv for kv in rows if not args.new or (not kv[1].get("replied") and not kv[1].get("ignored"))]
if not args.all:
shown = shown[-args.limit:]
if not shown:
print("(нет сообщений по фильтру)")
for mid, v in shown:
who = v.get("username") or v.get("from_name") or v.get("chat_id") or "?"
st = ("replied" if v.get("replied") else "ignored" if v.get("ignored")
else "transcribed" if v.get("transcribed") else "new")
body = v.get("transcript") or v.get("text") or ""
print(f"{mid:12} {str(who)[:12]:12} {st:11} {str(v.get('kind') or 'text'):5} {str(body)[:100]}")
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
if args.key:
# идемпотентное уведомление (t-260930083937): ключ = артефакт-ид
aid = f"msg:{args.key}"
def _send():
return bool(tg_inbox.notify_chat(args.text))
ok = commands.deliver_artifact(aid, kind="message", send=_send,
owner="cli:tg-notify",
caption=args.text)
print("OK: уведомление отправлено" if ok
else "SKIP: уже доставлено (или некому отправить / чужой захват)")
else:
r = tg_inbox.notify_chat(args.text)
print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)")
elif args.cmd == "tg-deliver-file":
es.init_db()
from harness import tg_inbox
if not os.path.exists(args.file):
print(f"FAIL: файл не найден: {args.file}")
sys.exit(1)
aid = args.key or f"file:{os.path.abspath(args.file)}"
def _send():
return tg_inbox.send_document_to_chat(args.file, caption=args.caption)
ok = commands.deliver_artifact(aid, kind="file", send=_send,
owner="cli:tg-deliver-file",
caption=args.caption, force=args.force)
print(f"OK: файл доставлен ({aid})" if ok
else f"SKIP: уже доставлен ({aid}) — используй --force для повтора")
elif args.cmd == "proactive":
es.init_db()
from harness import proactive
if args.plan:
print(proactive.plan_state())
return
slot, why = proactive.speak(force=args.force)
print(f"OK: {why}" if slot else f"SKIP: {why}")
if not slot and not args.force:
print("\n" + proactive.plan_state())
elif args.cmd == "tg-commands":
es.init_db()
from harness import tg_commands
if args.register:
mode = tg_commands.register_bot_commands()
print(f"OK: команды бота зарегистрированы ({mode})" if mode
else "ERROR: setMyCommands не прошёл (нет токена / API недоступен)")
if args.list or not args.register:
cmds = tg_commands.get_my_commands()
if not cmds:
print("(у бота нет зарегистрированных команд)")
for name, desc in cmds:
print(f"/{name} — {desc}")
elif args.cmd == "rag-query":
# без init_db: RAG не пишет в read model, только трейс по уровню env
from harness import rag_lookup
chunks = rag_lookup.rag_query(args.question, k=args.k, db=args.db)
if args.json:
print(json.dumps(chunks, ensure_ascii=False))
elif not chunks:
print("(ничего не найдено или вендор недоступен)")
for c in chunks:
print(f"\n═══ {c['score']:.3f} | {c['source']} ═══")
print(c["text"][:rag_lookup.TIMEOUT * 100 or 900])
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))
elif args.cmd == "tasks":
es.init_db()
for t in commands.list_tasks():
print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}")
elif args.cmd == "events":
es.init_db()
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}")
if __name__ == "__main__":
main()