agent-home-kit/bin/dispatch.py

175 lines
7.7 KiB
Python
Executable file
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.

#!/usr/bin/env python3
"""Диспетчер «одного окна» — минимальное ядро (agent-home-kit; исходник — bin/dispatch.py дома Дистилла).
Окно-координатор ставит задачу файлом ТЗ, диспетчер заносит её в реестр (state/dispatch/registry.jsonl),
запускает виток на узле пула (kit.json: nodes, runner), виток сдаёт через witness-log --done и пишет
итог координатору по шине; приёмка — accept.py по блоку «Приёмка:» ТЗ.
dispatch.py add <метка> <проект> <ТЗ> --reply <метка координатора> "<суть>" [--goal <блок>]
dispatch.py list [--open] задачи: метка · проект · узел · сдано · вердикт
dispatch.py launch <метка> [--host <узел>] запустить виток (runner из kit.json); без runner — печать команды
dispatch.py mail сдачи без вердикта приёмки (для ступени mail лестницы)
dispatch.py accept <метка> то же, что accept.py check <метка>
Формат реестра совместим с домом Дистилла: запись задачи без поля ev (at/label/project/tz/reply/what[/goal]),
события — с полем ev (launch, accept).
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import shlex
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from kit import CFG, HOME, AGENT # noqa: E402
import importlib.util as _iu # noqa: E402
_s = _iu.spec_from_file_location('accept_mod', str(HOME / 'bin/accept.py'))
acc = _iu.module_from_spec(_s); _s.loader.exec_module(acc)
ST = HOME / 'state/dispatch'
REG = ST / 'registry.jsonl'
RUNS = HOME / 'var/runs'
BODY = """Ты — {agent}. Сессия запущена диспетчером на узле {host}: headless.
Метка сессии: {label}
ТЗ: {tz}
Проект: {project}
Ответ: {reply}
Порядок:
1. Лестница пробуждения, по одной ступени за вызов, до строки «ЛЕСТНИЦА ПРОЙДЕНА»:
python3 {home}/bin/wake-ladder.py next {label} --project "{project}"
2. Прочитай ТЗ {home}/{tz} и исполняй его до результата: один предмет за ход, файлы по одному и по делу.
3. Сдача: python3 {home}/bin/witness-log.py --surface headless --session {label} --item <пункт> --done "<предмет → результат>"
Итог 3–5 строками координатору: python3 {home}/bin/session-bus.py send --to {reply} --session {label} "<итог>"
"""
def iso() -> str:
return dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
def rows() -> list[dict]:
return acc._jsonl(REG)
def put(rec: dict) -> None:
ST.mkdir(parents=True, exist_ok=True)
with REG.open('a', encoding='utf-8') as f:
f.write(json.dumps(rec, ensure_ascii=False) + '\n')
def tasks() -> dict[str, dict]:
"""Свёртка реестра по метке: задача + последний launch + последний accept + сдано ли."""
out: dict[str, dict] = {}
for r in rows():
lb = r.get('label')
if not lb:
continue
if 'ev' not in r:
out[lb] = {**r, 'host': '', 'verdict': ''}
elif lb in out and r['ev'] == 'launch':
out[lb]['host'] = r.get('host', '')
elif lb in out and r['ev'] == 'accept':
out[lb]['verdict'] = r.get('verdict', '')
done = {r.get('session') for r in acc._jsonl(acc.TURNS) if r.get('kind') == 'done'}
for lb, t in out.items():
t['done'] = lb in done
return out
def cmd_add(a) -> int:
if a.label in tasks():
print(f'{a.label}: уже в реестре'); return 1
ok, msg = acc.gate(a.tz)
if not ok:
print(msg); return 1
rec = {'at': iso(), 'label': a.label, 'project': a.project, 'tz': a.tz, 'reply': a.reply, 'what': a.what}
if a.goal:
rec['goal'] = a.goal
put(rec)
print(f'{a.label}: в реестре; {msg}')
return 0
def cmd_list(a) -> int:
ts = tasks()
n = 0
for lb, t in ts.items():
if a.open and t['verdict'] == 'ok':
continue
n += 1
print(f"{lb:<28} {t.get('project',''):<12} узел={t['host'] or '-':<8} сдано={'да' if t['done'] else 'нет':<3} вердикт={t['verdict'] or '-'} {t.get('what','')[:50]}")
if not n:
print('задач нет' if not a.open else 'открытых задач нет')
return 0
def pick_host(want: str) -> str:
nodes = CFG.get('nodes') or {}
if want:
if want not in nodes:
raise SystemExit(f'узла {want} нет в kit.json: nodes ({", ".join(nodes)})')
return want
busy: dict[str, int] = {}
for t in tasks().values():
if t['host'] and not t['done']:
busy[t['host']] = busy.get(t['host'], 0) + 1
for h, spec in nodes.items():
if busy.get(h, 0) < int(spec.get('max_runs', 1)):
return h
raise SystemExit('пул занят: все узлы kit.json на пределе max_runs')
def cmd_launch(a) -> int:
ts = tasks()
if a.label not in ts:
print(f'{a.label}: в реестре нет'); return 1
t = ts[a.label]
host = pick_host(a.host)
body = BODY.format(agent=AGENT, host=host, label=a.label, tz=t['tz'], project=t.get('project', ''),
reply=t.get('reply', ''), home=HOME)
RUNS.mkdir(parents=True, exist_ok=True)
bf = RUNS / f'{a.label}.prompt.md'
bf.write_text(body, encoding='utf-8')
runner = CFG.get('runner', '')
if not runner:
print(f'runner в kit.json не задан; промпт витка: {bf}\nпример: claude -p "$(cat {bf})"')
return 0
cmd = runner.format(prompt=bf, label=a.label, tz=t['tz'], home=HOME)
ssh = (CFG['nodes'].get(host) or {}).get('ssh', '')
argv = (['ssh', ssh, cmd] if ssh else shlex.split(cmd))
log = (RUNS / f'{a.label}.log').open('a', encoding='utf-8')
subprocess.Popen(argv, stdout=log, stderr=subprocess.STDOUT, cwd=str(HOME), start_new_session=True)
put({'ev': 'launch', 'label': a.label, 'host': host, 'at': iso()})
print(f'{a.label}: запущен на {host}; журнал {RUNS / (a.label + ".log")}')
return 0
def cmd_mail(a) -> int:
waiting = [lb for lb, t in tasks().items() if t['done'] and not t['verdict']]
print(f'диспетчер: сдано без приёмки {len(waiting)}' + (' → ' + ', '.join(waiting[:5]) + ' (dispatch.py accept <метка>)' if waiting else ''))
return 0
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
s = p.add_subparsers(dest='c', required=True)
x = s.add_parser('add'); x.add_argument('label'); x.add_argument('project'); x.add_argument('tz')
x.add_argument('what'); x.add_argument('--reply', required=True); x.add_argument('--goal', default='')
x.set_defaults(fn=cmd_add)
x = s.add_parser('list'); x.add_argument('--open', action='store_true'); x.set_defaults(fn=cmd_list)
x = s.add_parser('launch'); x.add_argument('label'); x.add_argument('--host', default=''); x.set_defaults(fn=cmd_launch)
s.add_parser('mail').set_defaults(fn=cmd_mail)
x = s.add_parser('accept'); x.add_argument('label'); x.set_defaults(fn=lambda a: acc.cmd_check(a.label))
a = p.parse_args()
return a.fn(a)
if __name__ == '__main__':
sys.exit(main())