175 lines
7.7 KiB
Python
Executable file
175 lines
7.7 KiB
Python
Executable file
#!/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())
|