308 lines
20 KiB
Python
308 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""ЖУРНАЛ АГЕНТ-СЕССИЙ ФАЗЫ Д. $0. Кап 80 (заказ владельца 10.08).
|
||
|
||
⚠ ЗАЧЕМ ОТДЕЛЬНЫЙ МОДУЛЬ, А НЕ СТРОКА В `judge.py`. В паке 23 счётчик существовал и не работал:
|
||
`log_run` звался из `--ingest`, то есть ПОСЛЕ того, как суб-агент отработал. На диске осталось
|
||
20 записей против 58 фактических сессий; при капе 80 такой счётчик пропустил бы ещё 60 — сторожить
|
||
кап можно ТОЛЬКО записью, сделанной ДО запуска. Здесь это и есть контракт: `claim()` пишет запись и возвращает имя судьи,
|
||
которое передаётся суб-агенту; не позвал `claim` — не получил имени, и разбор проставит `?`.
|
||
|
||
⚠ ВТОРОЙ ДЕФЕКТ, КОТОРЫЙ ЧИНИТ ЭТОТ ЖЕ ФАЙЛ. Карта «токен → судья» в паке 23 лежала одна на
|
||
ПРОХОД, а репликация судила ТЕ ЖЕ токены вторым жребием судей — и все её голоса получили имена
|
||
судей боевого прогона. Здесь запись помнит ПРОГОН и ВРЕМЯ, и `judge_of` разводит два жребия по
|
||
одним и тем же токенам ОКНОМ сессии `[at, done_at]`.
|
||
|
||
⚠ ТРЕТИЙ, И САМЫЙ ДОРОГОЙ. Атрибуция по времени работает, только пока сессии не перекрываются.
|
||
Пак 23 отдал пять единиц на пере-суживание ДВАЖДЫ, не дождавшись первого круга: оба круга писали
|
||
в одни файлы, `--ingest` прошёл между ними, и голоса разошлись с сырьём на 5 единицах из 32.
|
||
Поэтому `claim` ОТКАЗЫВАЕТ, если сессия с пересекающимися токенами ещё не закрыта `done`.
|
||
⚠ Первая редакция называла это «замком, который невозможно обойти механически» — неверно, и
|
||
поймано приёмкой другого модельного семейства: `claim` делал read-modify-write журнала БЕЗ
|
||
файлового лока, так что два параллельных вызова оба прочли бы леджер до записи друг друга,
|
||
оба прошли бы проверку пересечения и получили один и тот же номер. Тот же класс, что «неатомарная
|
||
проверка файла» в кассе эксп-22, стоившая ему границы пере-оплаты ≤$0.846103. Теперь журнал
|
||
берётся под атомарный `O_CREAT|O_EXCL`-замок, как `money.py`, и проверка пересечения идёт ВНУТРИ
|
||
критической секции.
|
||
|
||
Команды:
|
||
runs.py --claim <прогон> <проход> <ток> [<ток> …] [--note "…"] запись ДО запуска суб-агента
|
||
runs.py --done <n> закрыть сессию ПОСЛЕ ответа
|
||
runs.py --report сколько израсходовано
|
||
runs.py --selftest обязателен ДО первой сессии
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import datetime as dt
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
OUT = Path.home() / "books" / "dovodka"
|
||
LEDGER = OUT / "agent-runs.json"
|
||
# ⚠ КАП ПОДНЯТ 80 → 100 ПО СЛОВУ ВЛАДЕЛЬЦА 15.08, дословно: «Почему оставшиеся? Почему на все их
|
||
# не хватит? Ты же можешь наспамить под 90 агентов (я видел и такое)». Заказ 10.08 ставил 80;
|
||
# владелец снял это ограничение прямой репликой, но в КОДЕ оно осталось и 16.08 упёрлось в
|
||
# середину регистрации судейских сессий захода Д17 — шесть сессий английской пары не записались.
|
||
# Сессии бесплатны (агенты, не API), поэтому кап здесь охраняет не деньги, а внимание: он ловит
|
||
# «сессий стало больше, чем кто-либо отслеживает». 100 покрывает заход с запасом.
|
||
# ⚠ 20.08: кап поднят 100 → 200 по слову владельца («Можно поднять, но я не припомню что б ставил
|
||
# тебе жёсткие ограничения»). Уточнение к истории, которое стоит записать: число 80 пришло из
|
||
# ЗАКАЗА (`POLYGON_EXP2223_REDO_SESSION_PROMPT.md:25`), владелец снял его 15.08, а число 100 сюда
|
||
# поставил Я САМ — то есть на 20.08 в кап упиралось МОЁ ограничение, а не его. Сессии бесплатны
|
||
# (агенты, не API), и кап охраняет не деньги, а внимание: он ловит «сессий стало больше, чем
|
||
# кто-либо отслеживает». 200 покрывает переход на слепое чтение как ШТАТНЫЙ прибор — там одна
|
||
# сессия на главу, то есть счёт идёт десятками, а не единицами.
|
||
# ⚠ 30.08: кап поднят 200 → 215 по слову владельца («Да, давай подниму кап»), запрошенному прямо
|
||
# под ЭТО чтение: панель «однопроходка `luna` против однопроходки `deepseek` и против боевой
|
||
# связки» собирается на 15 главах из 16, свободно было 14 — то есть в кап упиралась ровно одна
|
||
# глава. Взято 215, а не 201: пятнадцать уходят на круг, остальное — на ВТОРОЙ круг той же панели,
|
||
# без которого конъюнкция (повтор во втором круге) недостижима, и панель нельзя будет закрыть.
|
||
# Кап по-прежнему охраняет не деньги, а внимание — сессии бесплатны.
|
||
# ⚠ 30.08, вторым шагом: 215 → 220 по слову владельца («Ладно, давай») на просьбу дать +5. Причина
|
||
# просьбы — МОЯ АРИФМЕТИЧЕСКАЯ ОШИБКА в записи выше: 215 оставляло 14 свободных при 15 нужных
|
||
# второму кругу, то есть «остальное на второй круг» не считалось. Резать панель до 14 глав, чтобы
|
||
# влезть в кап, запрещено — это подгонка прибора под возможности.
|
||
CAP = 220
|
||
FAR_FUTURE = 1e18
|
||
LOCK_TRIES = 600 # ×0.1 с — минута ожидания живого замка
|
||
|
||
|
||
def _now() -> str:
|
||
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="microseconds")
|
||
|
||
|
||
def _ts(iso: str | None) -> float:
|
||
return dt.datetime.fromisoformat(iso).timestamp() if iso else FAR_FUTURE
|
||
|
||
|
||
def used() -> list[dict]:
|
||
return json.loads(LEDGER.read_text(encoding="utf-8")) if LEDGER.exists() else []
|
||
|
||
|
||
def _write(rs: list[dict]) -> None:
|
||
"""⚠ Запись АТОМАРНА: `write_text` усекает файл и пишет заново, и падение между этими двумя
|
||
шагами оставляло журнал нечитаемым (поймано ревью исполнением: 2 прогона из 20). Пишем во
|
||
временный файл и подменяем `os.replace` — она атомарна в пределах ФС."""
|
||
OUT.mkdir(parents=True, exist_ok=True)
|
||
tmp = LEDGER.with_suffix(".tmp")
|
||
tmp.write_text(json.dumps(rs, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
os.replace(tmp, LEDGER)
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _locked():
|
||
"""Атомарный замок журнала. `O_CREAT|O_EXCL` — та же механика, что у кассы эксп-22.
|
||
|
||
⚠ Замок снимает ТОЛЬКО поставивший его процесс: в файл пишется PID, и протухшим замок
|
||
считается лишь тогда, когда процесса с этим PID больше нет (норма рига, промт фазы Д).
|
||
"""
|
||
OUT.mkdir(parents=True, exist_ok=True)
|
||
lock = LEDGER.with_suffix(".lock")
|
||
for _ in range(LOCK_TRIES):
|
||
try:
|
||
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||
os.write(fd, str(os.getpid()).encode())
|
||
os.close(fd)
|
||
break
|
||
except FileExistsError:
|
||
try:
|
||
pid = int(lock.read_text() or 0)
|
||
os.kill(pid, 0) # процесс жив — ждём
|
||
except (ValueError, OSError):
|
||
lock.unlink(missing_ok=True) # ставивший умер — снимаем
|
||
continue
|
||
time.sleep(0.1)
|
||
else:
|
||
raise SystemExit("⛔ журнал агент-сессий заперт дольше минуты — разберись, кто держит")
|
||
try:
|
||
yield
|
||
finally:
|
||
with contextlib.suppress(OSError):
|
||
if lock.exists() and lock.read_text().strip() == str(os.getpid()):
|
||
lock.unlink()
|
||
|
||
|
||
def claim(run: str, pass_: str, tokens: list[str], note: str = "") -> dict:
|
||
"""Занять агент-сессию ДО запуска суб-агента. Возвращает запись с именем судьи.
|
||
|
||
⚠ Кап проверяется ПЕРЕД записью, а не после: иначе 81-я сессия успевала бы отработать.
|
||
"""
|
||
with _locked():
|
||
return _claim_locked(run, pass_, tokens, note)
|
||
|
||
|
||
def _claim_locked(run: str, pass_: str, tokens: list[str], note: str) -> dict:
|
||
rs = used()
|
||
live = [r for r in rs if r.get("done_at") is None and set(r["tokens"]) & set(tokens)]
|
||
if live:
|
||
raise SystemExit(
|
||
"⛔ ГОНКА СЕССИЙ: токены уже отданы незакрытой сессии "
|
||
f"#{live[0]['n']} ({live[0]['judge']}, {sorted(set(live[0]['tokens']) & set(tokens))}).\n"
|
||
" Дождись её ответов и закрой `--done`, либо возьми другие токены. Ровно на этом\n"
|
||
" пак 23 потерял соответствие голосов и сырья на 5 единицах из 32.")
|
||
n = len(rs) + 1
|
||
if n > CAP:
|
||
raise SystemExit(f"⛔ КАП АГЕНТ-СЕССИЙ ФАЗЫ Д ПРОБИТ: {n} > {CAP} — СТОП и пинг владельцу")
|
||
rec = dict(n=n, at=_now(), done_at=None, run=run, pass_=pass_, judge=f"д-{n:02d}",
|
||
tokens=list(tokens), note=note)
|
||
rs.append(rec)
|
||
_write(rs)
|
||
return rec
|
||
|
||
|
||
def done(n: int) -> dict:
|
||
"""⚠ ТОЖЕ ПОД ЗАМКОМ. Первая редакция брала замок только в `claim`, а `done` делала свой
|
||
read-modify-write мимо него — и параллельные `claim`+`done` теряли запись claim в 19 прогонах
|
||
из 20: суб-агент запущен, имя судьи выдано, а в журнале его нет. Кап при этом недосчитывает,
|
||
`judge_of` отдаёт `?`, а замок пересечения токенов пускает на них вторую сессию. Нашло ревью
|
||
исполнением; селфтест этого не видел, потому что гонял только последовательные сценарии."""
|
||
with _locked():
|
||
return _done_locked(n)
|
||
|
||
|
||
def _done_locked(n: int) -> dict:
|
||
rs = used()
|
||
hit = [r for r in rs if r["n"] == n]
|
||
if not hit:
|
||
raise SystemExit(f"⛔ сессии #{n} в журнале нет")
|
||
hit[0]["done_at"] = _now()
|
||
_write(rs)
|
||
return hit[0]
|
||
|
||
|
||
def judge_of(run: str, token: str, answer_mtime: float) -> str:
|
||
"""Кто судил ЭТОТ ответ. Сессия владеет ответом, если он записан в её окне `[at, done_at]`.
|
||
|
||
Хвост допуска: ответ, пришедший ПОСЛЕ закрытия последней накрывающей сессии, отдаётся ей же —
|
||
закрытие ставится руками и может отстать от файловой системы на секунды. Ответ РАНЬШЕ первой
|
||
регистрации не отдаётся никому: он не мог быть ею произведён.
|
||
"""
|
||
cand = sorted((r for r in used() if r["run"] == run and token in r["tokens"]),
|
||
key=lambda r: _ts(r["at"]))
|
||
inwin = [r for r in cand if _ts(r["at"]) <= answer_mtime <= _ts(r.get("done_at")) + 5.0]
|
||
if inwin:
|
||
return inwin[-1]["judge"]
|
||
started = [r for r in cand if _ts(r["at"]) <= answer_mtime]
|
||
return started[-1]["judge"] if started else "?"
|
||
|
||
|
||
def report() -> None:
|
||
rs = used()
|
||
print(f"агент-сессий израсходовано {len(rs)} из {CAP}")
|
||
live = [r for r in rs if r.get("done_at") is None]
|
||
per: dict[str, int] = {}
|
||
for r in rs:
|
||
per[r["run"]] = per.get(r["run"], 0) + 1
|
||
for k, v in sorted(per.items()):
|
||
print(f" {k:28s} {v}")
|
||
if live:
|
||
print(f"⚠ НЕЗАКРЫТЫХ сессий {len(live)}: " + " ".join(f"#{r['n']}" for r in live))
|
||
|
||
|
||
def selftest() -> int:
|
||
"""Проверка ИСПОЛНЕНИЕМ, а не чтением: журнал подменяется временным, гоняются сценарии."""
|
||
global LEDGER, CAP # noqa: PLW0603
|
||
keep_l, keep_c = LEDGER, CAP
|
||
OUT.mkdir(parents=True, exist_ok=True)
|
||
LEDGER = OUT / "SELFTEST-agent-runs.json"
|
||
LEDGER.unlink(missing_ok=True)
|
||
bad = 0
|
||
|
||
def ck(name: str, ok: bool, detail: str = "") -> None:
|
||
nonlocal bad
|
||
bad += not ok
|
||
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + (f" {detail}" if detail else ""))
|
||
|
||
try:
|
||
# 1. запись появляется ДО того, как что-либо отработало
|
||
a = claim("проверка", "px", ["t1", "t2"], note="первый жребий")
|
||
ck("claim пишет запись сразу", LEDGER.exists() and len(used()) == 1, f"n={a['n']}")
|
||
ck("судья именован", a["judge"] == "д-01", a["judge"])
|
||
|
||
# 2. ЗАМОК ГОНКИ: те же токены нельзя отдать, пока сессия не закрыта
|
||
try:
|
||
claim("проверка", "px", ["t2"])
|
||
ck("гонка сессий по одному токену запрещена", False, "пропустил вторую сессию")
|
||
except SystemExit:
|
||
ck("гонка сессий по одному токену запрещена", True)
|
||
ck("отказавшая сессия НЕ записана", len(used()) == 1, f"{len(used())} записей")
|
||
|
||
# ⚠ ЗАМОК ПРОВЕРЯЕТСЯ ИСПОЛНЕНИЕМ, а не чтением. Прежний селфтест гонял только
|
||
# последовательные сценарии и дыру read-modify-write не видел — её нашла приёмка
|
||
# другого модельного семейства. Здесь замок ставится ЧУЖИМ живым PID и claim обязан
|
||
# ждать, а не пройти мимо.
|
||
global LOCK_TRIES # noqa: PLW0603
|
||
keep_tries, LOCK_TRIES = LOCK_TRIES, 5 # не ждать минуту в селфтесте
|
||
lock = LEDGER.with_suffix(".lock")
|
||
lock.write_text(str(os.getpid())) # живой PID = замок держится
|
||
t0 = time.time()
|
||
try:
|
||
claim("проверка", "px", ["t5"])
|
||
waited = time.time() - t0
|
||
ck("claim ЖДЁТ живой замок, а не проходит мимо", False,
|
||
f"прошёл за {waited:.2f} с при занятом замке")
|
||
except SystemExit:
|
||
ck("claim ЖДЁТ живой замок, а не проходит мимо", True, "упёрся в таймаут — верно")
|
||
finally:
|
||
lock.unlink(missing_ok=True)
|
||
LOCK_TRIES = keep_tries
|
||
lock.write_text("999999999") # мёртвый PID = замок протух
|
||
n_before = len(used())
|
||
claim("проверка", "px", ["t6"])
|
||
ck("протухший замок (мёртвый PID) снимается", len(used()) == n_before + 1)
|
||
ck("замок за собой убран", not lock.exists())
|
||
n_now = len(used())
|
||
ck("непересекающиеся токены разрешены", claim("проверка", "px", ["t7"])["n"] == n_now + 1)
|
||
|
||
# 3. ВТОРОЙ ЖРЕБИЙ ПО ТЕМ ЖЕ ТОКЕНАМ — тот самый дефект пака 23
|
||
t_first = _ts(a["at"]) + 0.001 # ответ пришёл в окне первой
|
||
done(a["n"])
|
||
b = claim("проверка", "px", ["t1", "t2"], note="второй жребий")
|
||
t_second = _ts(b["at"]) + 0.001
|
||
done(b["n"])
|
||
ck("ответ ПЕРВОГО жребия атрибутируется первому",
|
||
judge_of("проверка", "t1", t_first) == "д-01", judge_of("проверка", "t1", t_first))
|
||
ck("ответ ВТОРОГО жребия атрибутируется второму",
|
||
judge_of("проверка", "t1", t_second) == b["judge"],
|
||
f"{judge_of('проверка', 't1', t_second)} против {b['judge']}")
|
||
ck("ответ РАНЬШЕ первой регистрации не отдаётся никому",
|
||
judge_of("проверка", "t1", _ts(a["at"]) - 10) == "?")
|
||
ck("чужой прогон не атрибутируется", judge_of("другой", "t1", t_second) == "?")
|
||
ck("незарегистрированный токен даёт '?'", judge_of("проверка", "нет", t_second) == "?")
|
||
|
||
# 4. КАП ОБЯЗАН ОТКАЗАТЬ ДО ЗАПУСКА, а не после
|
||
CAP = len(used())
|
||
try:
|
||
claim("проверка", "px", ["t9"])
|
||
ck("кап отказывает при исчерпании", False, "пропустил лишнюю сессию")
|
||
except SystemExit:
|
||
ck("кап отказывает при исчерпании", True)
|
||
ck("сверх-капная сессия НЕ записана", len(used()) == CAP, f"{len(used())} записей")
|
||
finally:
|
||
LEDGER.unlink(missing_ok=True)
|
||
LEDGER, CAP = keep_l, keep_c
|
||
print(f"\n{'ЖУРНАЛ ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}")
|
||
return bad
|
||
|
||
|
||
if __name__ == "__main__":
|
||
a = sys.argv[1:] or ["--report"]
|
||
if a[0] == "--selftest":
|
||
sys.exit(1 if selftest() else 0)
|
||
elif a[0] == "--done":
|
||
r = done(int(a[1]))
|
||
print(f"сессия #{r['n']} ({r['judge']}) закрыта {r['done_at']}")
|
||
elif a[0] == "--claim":
|
||
note = ""
|
||
if "--note" in a:
|
||
i = a.index("--note")
|
||
note, a = (a[i + 1] if len(a) > i + 1 else ""), a[:i]
|
||
if len(a) < 4:
|
||
raise SystemExit("нужно: --claim <прогон> <проход> <ток> [<ток> …]")
|
||
r = claim(a[1], a[2], a[3:], note=note)
|
||
print(f"сессия #{r['n']}/{CAP} · судья {r['judge']} · токенов {len(r['tokens'])}")
|
||
else:
|
||
report()
|