#!/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 закрыть сессию ПОСЛЕ ответа 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()