Compress the seam cold review to current findings only: verdicts, confirmed decisions, money gaps, emitter requirements, and the one owner fork

This commit is contained in:
heaven 2026-08-05 10:06:53 +03:00
parent d60cf31d97
commit 51e7d36755

View file

@ -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, деплоится несколько раз в
неделю; воркер обрабатывает одну задачу 430 часов, держит эксклюзивный лок на каталоге, тратит
деньги на платные 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. **Несовпадение времён жизни:** деплой платформы несколько раз в неделю против прогона 430 ч.
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. **Несовпадение времён жизни** — деплой несколько раз в неделю против задачи на 430 часов.
Выбирает супервизора раньше, чем вообще заходит речь о протоколе.
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`).