diff --git a/docs/research/25-seam-cold-review.md b/docs/research/25-seam-cold-review.md index 93bb3800..65c05cc6 100644 --- a/docs/research/25-seam-cold-review.md +++ b/docs/research/25-seam-cold-review.md @@ -1,223 +1,112 @@ -# 25 · Холодное ревью шва движок↔платформа: независимые панели +# 25 · Холодное ревью шва движок↔платформа и топологии сервисов -> **Что это.** Шов, ратифицированный D39.85 (`research/23`), вынесен на независимую проверку: -> абстрактная формулировка задачи отдана панелям чистых агентов, ничего не знающих о проекте. -> Вопрос был не «оставить stdout или переехать», а «вот две задачи — как их поженить», без -> подсказки пространства решений. Заказ владельца 05.08: «сформулировать максимально абстрактно и -> посмотреть, какой механизм предложат сами». +> **Метод.** Задача шва (control plane + многочасовой воркер, деньги, standalone-режим) отдана +> панелям изолированных агентов в переодетом домене (сборка генома), без доступа к репозиторию. +> Три чистых плеча: базовое (`wf_6adb39d7-870`, 5 агентов) · с добавленным фактом «воркер дёшево +> резюмится» (`wf_2b45042d-115`, 5) · топологическое «сколько вообще нужно разворачиваемых единиц», +> без постулата разделения, с вебом (`wf_4ac43daf-ec5`, 6, три модели, включая seat-адвоката +> монолита). Изоляция проверена поагентно по журналам: ноль файловых вызовов, ноль следов +> репозитория. Первый прогон (`wf_b2b954de-e83`) загрязнён чтением репозитория и ОТБРОШЕН; +> правило для будущих панелей: запрещать файловые инструменты явно и проверять постфактум. > -> **Статус:** фактура. Ничего не ратифицирует и не отменяет; D39.85 в силе. Служит входом для -> решения по строке 103 единого бэклога и по П-1/П-3. -> -> **Кто делал:** оркестратор №14, 05.08.2026. Прогоны — воркфлоу, транскрипты в сессионном -> каталоге (`wf_b2b954de-e83` грязный, `wf_6adb39d7-870` чистый, `wf_2b45042d-115` чекпойнт-плечо). +> **Статус:** фактура. D39.85 в силе; служит входом для строки 103 единого бэклога, П-1/П-3 и +> решения по транспорту. Оркестратор №14, 05.08.2026. -## ⚠ Первый прогон ЗАГРЯЗНЁН — не цитировать +## Вердикты панелей -Восемь агентов первого прогона получили абстрактную формулировку, но остались с файловыми -инструментами в рабочем каталоге: **все восемь** сходили `Bash`/`Read` по репозиторию, узнали -проект и прочитали `research/23`, D-решения, `backend/`, `pricing.go`. Запрет сети трое соблюли; -запрет файлов я поставить забыл — ошибка постановки, не агентов. - -Поэтому вывод «независимая панель сошлась на нашей схеме шва» из первого прогона **стоит ноль**: -они её прочитали. Единственное, что от него остаётся, — большинство проголосовало ПРОТИВ -ратифицированного дизайна, а загрязнение толкает к согласию с прочитанным, не против. - -Урок для следующих панелей: запрещать **все** инструменты явно и **проверять постфактум** по -`journal.jsonl` (счётчик `tool_use` поагентно). В чистом прогоне проверка дала ноль обращений к -файлам и сети и ноль следов репозитория — только тогда результат брался в работу. - -## Метод чистого прогона - -Домен переодет (сборка генома, не перевод), числа изменены, ни одного совпадения с репозиторием. -Пять изолированных архитекторов, пять разных линз: холодный архитектор · «отгрузить за три недели -вдвоём» · «проектируй от отказов» · «сначала защити самый развязанный вариант, потом самый -связанный, потом выбери» · «ты это уже строил, и вышло плохо». Схема ответа требовала перечислить -ВСЕ взвешенные механизмы, включая отвергнутые, — census важнее вердикта. - -Сценарий: control plane со счетами, кредитами, очередью и HTTP для SPA, деплоится несколько раз в -неделю; воркер обрабатывает одну задачу 4–30 часов, держит эксклюзивный лок на каталоге, тратит -деньги на платные API, обязан оставаться запускаемым в одиночку на ноутбуке; одна VM, оркестратора -нет. - -## Расклад чистой панели - -**Родитель-ребёнок с потоком на stdout или унаследованном дескрипторе — 0 выбрали, 5 отвергли.** -Формулировка повторяется почти дословно: *время жизни воркера не должно быть подмножеством времени -жизни control plane*. Названный убийца — `KillMode=control-group`: рестарт control plane убивает -весь его cgroup вместе с 22-часовой задачей. Один: «залатать это `setsid` и двойным форком можно, -но в этот момент ты руками написал systemd, только хуже». - -| Механизм | выбрали | второй | отвергли | -|---|---|---|---| -| Процесс-на-задачу под systemd (`Restart=no`, длинный `TimeoutStopSec`, стоп SIGTERM) | **5** | — | 0 | -| Append-only журнал в каталоге задачи, control plane тейлит по курсору | **3** | 1 | 1 | -| Push из воркера в control plane по unix-сокету (HTTP/JSON) | **2** | 2 | 1 | -| Передача конфигурации задачи файлом в каталоге (нет файла = режим ноутбука) | **5** | — | 0 | -| SSE в браузер | **5** | — | 0 | -| Воркер как библиотека внутри control plane | 0 | 0 | **5** | -| Воркер как ребёнок control plane (любой IPC) | 0 | 0 | **5** | -| Чтение stdout/journald как источника событий | 0 | 0 | **5** | -| gRPC · брокер · общая БД · чтение приватной БД воркера · синхронный запрос авторизации на каждый платный вызов | 0 | 0 | **5** каждый | - -Отдельно: journald отвергнут как контракт всеми пятью (он **молча роняет** под всплеском и ротирует -по своему расписанию), но всеми пятью оставлен как человеческий канал отладки. - -## Что совпало с нами вхолодную - -Семь решений панель назвала единогласно, не зная о нас ничего: - -| Назвали 5/5 | У нас | +| Вопрос | Результат | |---|---| -| Предавторизованный потолок, который воркер держит САМ; синхронной авторизации нет | холд + потолок движку (D39.84) | -| Hold → capture → возврат остатка | `Hold`/`Settle`/`Release` | -| Идемпотентное применение по `(job_id, seq)` | `(engine_run_id, seq)` | -| Control plane НИКОГДА не открывает приватную БД воркера | прямой запрет D39.85 §4 | -| Standalone — тот же путь кода, без ветки «есть ли control plane» | так и есть | -| `flock` вместо pid-файлов | так и есть | -| Целочисленные деньги | на платформе да; ⚠ в движке `float64` | -| Запись намерения ДО платного вызова, расчёт после | Р7, есть | +| Сколько разворачиваемых единиц | **2 — единогласно (6/6)**, включая seat-адвоката монолита; за монолит 0 | +| Платформа — родитель процесса движка + поток на stdout/fd | **0 выбрали, 10 отвергли** (оба плеча) | +| Дешёвый резюм воркера меняет расклад? | **Нет.** 5/5: «факт снял ограничения, но выбор не сделал»; при дорогом прерывании ушли бы в ту же сторону сильнее | +| Канал воркер→платформа | Append-only журнал в каталоге прогона, тейл по курсору — 8/10; unix-сокет-push упал в обоих плечах по недолговечности через рестарты платформы | +| Супервизор воркера | systemd, юнит-на-задачу, `Restart=no`, стоп SIGTERM — 10/10 | +| gRPC · брокер · общая БД · чтение приватной БД воркера · stdout/journald как источник событий · синхронная авторизация на каждый платный вызов | отвергнуты почти единогласно (чтение приватной БД: 1 голос «за» из 11 — единственное расхождение с D39.85 §4 за все плечи) | -Это, а не вердикт по транспорту, — главный результат: несущие решения шва воспроизводятся -независимо. Транспорт же, по их же словам, меняется при переезде на вторую машину, а перечисленное -переживает без правки. +## Подтверждено вхолодную (совпало с нашими решениями) -## Что назвали обязательным, а у нас нет +Названо 5/5 агентами, не знавшими о проекте: предавторизованный потолок, который воркер держит сам +(= холд + потолок движку, D39.84) · hold→capture→возврат остатка (`Hold`/`Settle`/`Release`) · +идемпотентное применение по `(run_id, seq)` · запрет читать приватную БД воркера (D39.85 §4) · +standalone тем же путём кода · `flock` вместо pid-файлов · запись намерения ДО платного вызова (Р7) +· целочисленные деньги (⚠ у нас — на платформе; в движок float64 входит раньше, см. «Деньги»). -1. **Реконсилятор мёртвых воркеров** — компонент, не удобство: на старте control plane добрать - хвост журнала, пометить прогон прерванным, вернуть остаток холда. Один добавил: «если оставить - один тест, оставьте SIGKILL реального воркера, иначе этот код сгниёт». -2. **Состояние `uncertain` для окна «упали между платным вызовом и записью расчёта».** Назвали все - пятеро, лечат одинаково: смещение в сторону списания, запись по оценке, флаг человеку, - **никогда молчаливая бесплатная работа**. У нас такого состояния нет. -3. **Сверка с данными провайдера** (3 из 5, ночным прогоном). Совпадает с находкой по коду: - платформа считает целочисленно и точно, но на вход арифметики приходит `float64`, посчитанный по - руками поддерживаемой таблице цен, и у незнакомого слага есть ненулевой дефолтный якорь - (`backend/internal/ledger/pricing.go:27-52`, `CostUSD` на `float64`). Якорь — осознанное решение - с хорошим обоснованием ($0 ослепил бы потолки), но следствие остаётся: при незнакомом слаге сумма - есть оценка. Спорить о ±1 микродолларе поверх числа с процентной систематикой — инвертированный - приоритет. -4. **Пиннинг задачи к версионированному пути бинаря на старте** (4 из 5): иначе апгрейд воркера - меняет поведение между попытками одной задачи. Прямо в цель «детерминизм» (канон §6). +**Два модуля подтверждены, но несущие доводы другие, чем в наших доках.** Всё «процессное» +(пиннинг версии, cgroups, независимый рестарт, пер-задачные журналы) даёт и один бинарь, +exec-ающий сам себя транзиентным юнитом. До «двух ПОСТАВОК» доживают ровно два довода: +**граница как ошибка компиляции** (модульный гард D39.85 — ровно это) и **независимые релизные +поезда** (фикс движка не ждёт CI платформы). Лучшая карта монолита — «одна транзакция решает +деньги» — фальшива: приватная БД задачи + глобальный леджер + провайдер = **три хранилища при +любом числе процессов**, outbox и сверка нужны в любом исходе. -## Чего у них не было — и естественный эксперимент на этом (плечо 2) +## Настоящие драйверы против «платформа — родитель» (не зависят от стоимости прерывания) -Панель отвергла родителя-ребёнка на посылке «редеплой уничтожает часы оплаченной работы». У нас эта -посылка слабее: движок чекпойнтится на уровне chunk×stage и резюмится -(`backend/internal/pipeline/resume.go`), поэтому остановка платформы стоит одного чанка, а не -прогона. Оркестратор выдвинул это как контраргумент — и проверил его чужими руками, а не оставил -своим мнением. +1. **Несовпадение времён жизни:** деплой платформы несколько раз в неделю против прогона 4–30 ч. +2. **Плановая недоступность платформы делает долговечность требованием к каналу:** любой push-канал + требует буфера/ретраев/дедупа — а этот буфер и есть журнал. +3. **Атомарность денег:** «юнит сделан» и «юнит стоил $X» — одна транзакция в хранилище воркера. +4. **Радиус поражения:** леджер и аутентификация не делят адресное пространство и OOM-цель с + часто меняющимся воркером. +5. **Standalone не должен быть режимом:** непрогоняемая ветка `--no-server` гниёт. -**Плечо 2 (`wf_2b45042d-115`, тоже чистое — проверено поагентно):** та же формулировка, тот же -переодетый домен, та же изоляция, **изменена ровно одна переменная** — в сценарий добавлено, что -воркер чекпойнтится ежеминутно и резюмится, а прерывание стоит минут и центов, никогда не часов. +**Наше смягчение, которого у панелей не было:** при остановке платформы движок останавливается +тоже (сигнал группе; на stdout — SIGPIPE), окна «пишет без слушателя» нет, события не теряются. +Довод №2 бьёт по нам не потерей событий, а тем, что **каждый деплой платформы прерывает все идущие +переводы** ценой одного чанка каждый. -**Расклад не сдвинулся.** +## Развилка владельцу -| | плечо 1 (без факта) | плечо 2 (с фактом) | -|---|---|---| -| Родитель-ребёнок + поток на stdout/дескрипторе | 0 выбрали, 5 отвергли | **0 выбрали, 5 отвергли** | -| systemd, юнит-на-задачу | 5 | **5** | -| Append-only журнал с курсором | 3 (+1 вариантом) | **5** (4 файлом, 1 таблицей внутри БД воркера) | -| Push по unix-сокету | 2 (+2 вторым) | **0** — упал по причине, к новому факту не относящейся | +**Должен ли идущий перевод переживать деплой платформы?** +- **Да** → журнал в каталоге прогона + движок отдельным юнитом; платформа перестаёт быть родителем. +- **Нет** → нынешняя форма (родитель + stdout) законна, но фиксируется как сознательная ставка с + явной ценой; формулировка «переезд не окупается» опровергнута. -Поле «сколько весил дешёвый резюм» отвечали все пятеро, и все пятеро сказали одно: **факт снял -ограничения, но выбор не сделал.** «Весил много, но почти целиком в сторону, обратную очевидной» · -«повлиял на жизненный цикл и почти не повлиял на механизм» · «изменил мою уверенность и число -строк куда сильнее, чем выбор». Один прямо опознал постановку: *«свойство вставлено в бриф как -приманка к выводу „прерывания дешёвы, значит просто слинкуйте воркер в control plane“»*. +Формат событий (NDJSON, hello-хендшейк, `(run_id, seq)`) не меняется ни в одном исходе — меняется +только кто спавнит и откуда читает. Решение владельца 05.08: одна машина пока что; при второй +машине умирают файловая шина/сокет/`systemctl start`, переживают outbox, денежная модель, +идемпотентность, SSE. Цена переезда мала, если платформа тейлит журнал, и велика, если она родитель. -Контрфактическую сторону тоже проверили: при ДОРОГОМ прерывании они ушли бы в ту же сторону -сильнее, а не назад. То есть переменная не является шарниром ни в одну сторону. +## Деньги: обязательные механизмы, которых у нас нет -**Что она реально изменила** — объём механизма при той же форме: не нужен протокол drain/quiesce, -не нужно рукопожатие «можно ли тебя останавливать», не нужен интерлок деплоя, `systemctl stop` -принимается как полный глагол остановки. Один оценил: «примерно половина механизма, который иначе -понадобился бы, этим свойством удаляется». И ещё: факт **сменил основание отказа** от варианта -«воркер внутри control plane» — аргумент «деплой убьёт задачу» перестал работать, и отказ переехал -на радиус поражения (леджер и аутентификация не должны делить адресное пространство и OOM-цель с -еженедельно меняющимся воркером). Вердикт тот же, обоснование другое. +1. **Реконсилятор мёртвых воркеров** (компонент, не удобство): на старте платформы добрать хвост, + пометить прогон прерванным, вернуть остаток холда. Тестировать SIGKILL'ом реального воркера. +2. **Состояние `uncertain`** для окна «упали между платным вызовом и записью расчёта»: смещение в + сторону списания, запись по оценке, флаг человеку; молчаливой бесплатной работы не бывает. +3. **Сверка с данными/инвойсом провайдера** (ночной прогон). Приоритетнее споров об округлении: + в движке суммы считаются в float64 по ручной таблице цен с ненулевым дефолтным якорем для + незнакомого слага (`backend/internal/ledger/pricing.go:27-52`) — систематика процентная, а не + микродолларовая. +4. **Гейт на каждый платный вызов, а не на юнит работы** — «перерасход ≤ один юнит» без этого + утверждение, а не механизм. (Наши вызовы синхронные — оценка перерасхода держится, но гейт + всё равно пер-вызовный.) -**Вывод: контраргумент про chunk-резюм не работает.** Настоящие драйверы, названные независимо от -стоимости прерывания: +## Проектные требования к эмиттеру потока (задать до постройки, строка 103) -1. **Несовпадение времён жизни** — деплой несколько раз в неделю против задачи на 4–30 часов. - Выбирает супервизора раньше, чем вообще заходит речь о протоколе. -2. **Плановая недоступность control plane делает ДОЛГОВЕЧНОСТЬ требованием к каналу.** Он лежит - несколько раз в неделю ПО ЗАМЫСЛУ, значит любой push-канал нуждается в буфере, ретраях и - дедупликации — а буфер, который вы построите, и есть журнал. Это и убивает сокеты, пайпы, - journald и localhost-HTTP как ОСНОВНОЙ канал. -3. **Атомарность денег** — «юнит сделан» и «юнит стоил $X» обязаны быть одной транзакцией в - собственном хранилище воркера. -4. **Радиус поражения** — см. выше. -5. **Standalone не должен быть режимом** — ветка `--no-server`, которую никто не гоняет, гниёт. +- **Outbox правильно:** строка расхода — в БД задачи, в той же транзакции, что чекпойнт; + `events.jsonl` ПРОИЗВОДИТСЯ из неё релеем (иначе двойная запись — ловушка, в которую попали + 3 из 6 агентов, призывая сам паттерн). +- **`events` ≠ `status`:** долговечный денежный журнал (fsync, seq) отдельно от косметического + снапшота живости (atomic rename, ~2 с, из него никогда не биллить). +- **Перекос версий:** прогон может резюмить более новый движок, чем начал. Штамповать версию в + каталоге, резюм чужой версией — только по явному флагу. (В канон детерминизма, рядом с + `brief_hash`.) +- **Живость = растущий `seq`**, а не удерживаемый лок. +- **Сброс буфера на всех путях выхода:** `os.Exit`/`log.Fatal` пропускают defer и теряют финальные + события. +- **Обратное давление:** пайп/канал ограничен; политика на переполнение (блокировать воркер или + ронять с маркером `events_dropped`) выбирается явно, молчаливой третьей нет. -**Оговорка, которой у панели не было и которая касается только нас.** У нас при остановке платформы -движок останавливается ТОЖЕ (сигнал группе, а на stdout ещё и SIGPIPE), поэтому окна «движок пишет, -слушателя нет» не возникает и события не теряются. Аргумент №2 бьёт по нам не потерей событий, а -тем, что **каждый деплой платформы прерывает все идущие переводы**, ценой одного чанка каждый. -То есть вопрос сводится к продуктовому: **должен ли четырёхчасовой перевод переживать деплой -платформы?** Если да — журнал и отдельный юнит. Если нет — пайп законен, и цена названа явно. +## Прочее найденное -## Что панель 2 добавила сверх первой - -- **Разделять долговечный денежный канал и косметический канал живости:** `events.jsonl` - (fsync, append-only, с номерами) отдельно от `status.json` (атомарное переименование, ~2 с, явно - НЕдолговечный, из него никогда не биллить). 5/5. -- **Рамка outbox:** строка журнала — это ПРОЕКЦИЯ уже закоммиченной строки в БД воркера, - побайтово переиздаваемая после краха. Это превращает файл из «второй записи» в производный - артефакт и снимает половину возражений про fsync-дисциплину. -- **Перекос версий на длинной задаче:** воркер выкатывается еженедельно, задача идёт 30 часов — - значит задачу может ПРОДОЛЖИТЬ более новый воркер, чем её начал. Подняли 4 из 5; двое штампуют - версию воркера в каталоге задачи и отказываются резюмиться без явного - `--allow-version-change`. Для нас это прямо в канон §6 (детерминизм) и рядом с `brief_hash`. -- Один из пяти выбрал вариант «control plane читает приватную БД воркера» (в плече 1 его отвергали - 5/5) — единственное расхождение с D39.85 §4 за три панели. Меньшинство, но зафиксировано. - -## Развилка: одна машина - -Все пять независимо назвали ОДИН и тот же переворачивающий факт: **будет ли воркер когда-нибудь -работать на машине, отличной от control plane.** Решение владельца 05.08: **одна машина пока что.** - -Растащить при этом можно, и домен этому не мешает: лок и каталог — пер-книжные, а не пер-системные, -поэтому книга пиннится к хосту (это уже записано в `platform/BACKLOG.md`, П-3: сериализация по -`book_id`, пиннинг книги к хосту, лизы `book_leases`). Через границу машины не проезжают ровно три -вещи, и все три сегодня в конструкции есть: платформа как **родитель** процесса; чтение его потока -из пайпа или файла в его каталоге; запуск `tmctl status --json` платформой. Шаг на вторую машину — -это «на каждый хост ставится агент», который спавнит движок, читает поток и толкает события -платформе; идемпотентность, холд, леджер и SSE переживают без правки. - -**Разница не в «можно или нельзя», а в цене шага:** - -- платформа **родитель** движка → переезд переписывает супервизор: родство процессов, пайп, сигналы - группе, вызов `status --json`, плюс разваливается cgroup-аргумент, которым закрыт PD-13; -- платформа **тейлит журнал по курсору** → переезд подменяет источник, код приёма не меняется. - -## Открытое - -- **Вопрос владельцу, к которому всё свелось: должен ли идущий перевод переживать деплой - платформы?** Три панели, пятнадцать архитекторов, ноль голосов за нынешнюю форму — но их довод - упирается именно в это, а у нас есть смягчение (движок останавливается вместе с платформой, и - резюм стоит одного чанка). «Да» ⇒ журнал + отдельный юнит, движок перестаёт быть ребёнком. - «Нет» ⇒ нынешняя форма законна, и цена записана явно: каждый деплой прерывает все переводы. -- Строка 103 единого бэклога: транспорт потока. В ЛЮБОМ исходе ставка должна быть записана как - СОЗНАТЕЛЬНАЯ, с названной ценой, а не как «переезд не окупается» — эта формулировка опровергнута - плечом 2. -- Три денежные дыры выше (реконсилятор · `uncertain` · сверка с провайдером) — строками единого - бэклога, зона платформы их одна не закроет: половина работы в движке. -- Пиннинг к версионированному бинарю и **отказ резюмиться воркером другой версии** — туда же. -- Разделение `events` (долговечный, денежный) и `status` (косметический, не для биллинга) — принять - или отклонить при проектировании эмиттера, до его постройки. - -## Что из этого следует методически - -Три панели дали три разных урока о самой процедуре, и они дороже любого вердикта: - -1. **Абстрактная формулировка обязана быть изолированной по-настоящему.** Запрет сети без запрета - файлов — не изоляция. Проверять постфактум, поагентно. -2. **Слабая аргументация ≠ неверная позиция.** Оркестратор сперва принял предложение зоны по её - ссылкам, потом отклонил его же, открыв ссылки и увидев, что они не держат. Обе итерации спорили - о качестве ссылок, а не о существе. -3. **Свой контраргумент проверять чужими руками.** Довод «chunk-резюм делает нынешнюю форму - нормальной» звучал убедительно, был предъявлен как решающий — и не пережил панели, которой этот - факт дали прямым текстом. Один из пятерых даже опознал его как приманку в постановке. +- **Диск не ограничен нигде:** `ENOSPC` на ФС леджера ломает денежные инварианты разом. Леджер на + своей ФС, квота на задачу, проверка места при допуске. (В зону платформы при деплой-слое.) +- **Состояние systemd — не истина:** транзиентный юнит выгружается, VM перезагружается; источник + истины на старте = каталоги задач + БД платформы. +- **Ключи идемпотентности провайдеров протухают** (порядок суток) — replay как оптимизация ретрая, + не гарантия. +- **SPA из бинаря платформы** (один origin, нет перекоса версий) — 6/6 у панели; у нас не решено, + вопрос к П-1/фронту. +- SIGPIPE зависит от номера дескриптора (fd 1/2 — смерть процесса, прочие — EPIPE; замерено): + нынешний stdout даёт бесплатную остановку осиротевшего движка, переезд на fd 3 её ломает. +- Защита stdout от посторонних записей, если канал остаётся: dup-перехват на уровне дескриптора в + `main` движка, только в области потоковой команды (безусловный сломал бы `tmctl status --json`).