Причина: 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 зелёный.
329 lines
No EOL
19 KiB
Python
329 lines
No EOL
19 KiB
Python
"""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() |