> ⚠ **ЭТО ХЕНДОФФ-ПРОМТ ПЛАТФОРМЕННОЙ СЕССИИ. Он живой и исполняется.** Прочитай `CLAUDE.md`, потом > этот файл целиком, потом — его карту чтения (§3) и больше ничего, пока не появится вопрос. > ⛔ **ПАК ПАРНЫЙ.** Твоя половина не лендится в одиночку: движковая делается параллельно другой > сессией, и обе принимаются вместе (`D39.234` п.2). Что это значит практически — §2. # Пак платформы: «ДВЕ ОСТАНОВКИ» ## 1. Какая проблема и что решит твой результат Пользователь, остановивший перевод книги, сегодня рвёт всё, что летит к провайдерам прямо сейчас: оплаченные генерации обрываются, движок списывает за них оценку и на продолжении делает их заново. Владелец 10.09 ратифицировал форму (`D39.234` п.1): остановок **две** — мягкая (новое не начинаем, начатое доигрываем, ничего не рвём) и жёсткая (сегодняшнее поведение). Первое нажатие — мягкая, второе — жёсткая. **Твоя зона держит три из четырёх вещей, без которых это не заработает**, и одну, которую сама же просила. ⚠ **Твой код уже НАПИСАН так, будто мягкая остановка есть.** `platform/internal/runner/runner.go:56-58` — «the engine … finishes the in-flight chunk before exiting»; `:163-164` — «SIGTERM to the engine alone so it can finish the chunk it is paying for». Сегодня это ЛОЖНО, движок рвёт. После пака станет правдой. ⇒ комментарии не «чинить под дерево», а сверить с тем, что реально построишь. **Носители в трекере (единый бэклог, `docs/BACKLOG.md`):** строки **381** (две остановки, с твоими адресами) и **382** (пометка «оценка» не едет по шву). Зонные строки заводи по своим правилам. ## 2. Зона и git Твоя зона — **`platform/`**. `backend/` правит движковая сессия, `docs/` — оркестратор. **Коммитит только оркестратор.** Итоги, вопросы и расхождения — ЗОННЫЙ журнал `platform/docs/platform-PROGRESS.md` (в `docs/PROGRESS.md` платформа не пишет). ⛔ **Пара.** Форма кадра событий **ЗАФИКСИРОВАНА нотой `D39.235` п.1 до начала работ** — именно затем, чтобы две зоны не решили по-разному. Ни ты, ни движковая сессия её не проектируете: обе читают ноту и делают РОВНО так. Что там: `Finished.Stop{mode, in_flight_finished, in_flight_cut, cut_estimated_micro_usd}` с правилом присутствия «есть, если остановку ЗАПРОСИЛИ, независимо от исхода»; `estimated{rows, micro_usd}` на ОБОИХ кадрах — на кумулятивном `spend` И в `Money` терминального; `Money` на `stopped` всегда, когда счётчики есть; минор словаря потока `1.3 → 1.4`. Считаешь форму неверной — вопрос оркестратору ДО кода. Читай также пинг №23 в своём журнале: там четыре адреса, снятые оркестратором, и они — вход в эту работу. ## 3. Карта чтения — ЗАКОН, дальше только по её ссылкам 1. `platform/docs/platform-PROGRESS.md` — **пинг оркестратора №23 (10.09)** сверху: четыре адреса и что с каждым делать. 2. `platform/internal/runner/runner.go` — целиком: `stopGrace` (~59), свойства юнита (~160–170), `Stop` (~194). 3. `platform/internal/runs/reconcile.go` — как остановка сводится с исходом: `StopRequestedAt` (~558, ~575, ~591), `finishStopped` (~678), `stoppedOnRequest` / `interruptedBySomeoneElse` (~1018, ~1112–1134), и комментарий ~1534. 4. `platform/internal/ingest/exit.go` — словарь кодов выхода, `ExitStopped = 5` (~31–34). 5. `docs/architecture/14-api-contract/` — `/runs/{runId}/stop` (`openapi.yaml:754+`) и его описание в README контракта. 6. ⛔ **Тело ноты `D39.235` п.1** — `docs/architecture/05-decisions-log.md`, греп `^## D39.235`: ФОРМА КАДРА, которую ты читаешь, а не проектируешь. Шестая позиция карты намеренно. Тела нот: `docs/architecture/05-decisions-log.md`, номер грепается `^## D<номер>`; статус одним хопом — `docs/architecture/05-decisions-index.md`. ⚠ ПОДНОМЕР (`D39.234` п.1) своего тела не имеет — это ПУНКТ внутри `## D39.234`. ## 4. Состав и разметка свободы ### 4.1 ⛔ ГРЕЙС МЕНЬШЕ ЗАКОННОГО ОЖИДАНИЯ — это первое, и без него пак вреден `platform/internal/runner/runner.go:59` — `stopGrace = 10 * time.Minute` (600 с), он же уезжает в `TimeoutStopSec` свойством юнита. Законное ожидание ОДНОГО вызова у движка — до `attempt_max_s` **1240 с** (`backend/configs/models.yaml`, deepseek), и владелец это ожидание ратифицировал (`D39.230` п.3; потолок подтверждён `D39.234` п.1б — «~20 минут приемлемо»). ⇒ **в ту секунду, когда SIGTERM станет мягким, systemd начнёт убивать ЗАКОННОЕ ожидание.** А SIGKILL не оставляет ни пометки `cancelled`, ни сеттла оценки: деньги у провайдера списаны, следа в леджере нет — то есть ровно та потеря, ради устранения которой пак и делается. **Делай РОВНО так:** грейс обязан покрывать потолок ожидания ПЛЮС время жёсткой фазы. ⛔ **Входы формулы — и здесь ОДНА ловушка, из-за которой я сам чуть не задал неверную.** Брать `max(attempt_max_s)` по провайдерам НЕЛЬЗЯ: ключ **инертен, когда не задан** (`backend/internal/llm/attemptcut.go`, греп `is inert when unset` и `p.AttemptMax > 0`), а в `backend/configs/models.yaml` его нет у **пяти провайдеров из восьми** (`zai` · `xai` · `openai` · `mistral` · `local`; есть у `deepseek` · `kimi` · `gemini`). У пятерых потолок ожидания даёт не ключ, а сама формула — `queue_slack_s + max_tokens/tok_s_floor`. ⇒ **потолок считай ПО КАТАЛОГУ, а не по одному ключу:** максимум по всем провайдерам от `DeadlineFor` (`backend/internal/llm/attemptcut.go`, греп `func (p RetryProfile) DeadlineFor`) на НАИБОЛЬШЕМ бюджете, который движок может попросить. Функция экспортирована ровно для такого вопроса — «сколько ждёт вызов такого размера у этого профиля», и её доккоммент это говорит. Сегодня наибольшая крышка — 1240 с (deepseek), у провайдеров без ключа значение выводится формулой. **Пере-считай своей рукой и назови число**; арифметика — в отчёт. ⚠ Валидатор каталога держит лишь абсолютную границу (`backend/internal/config/models.go`, греп `maxAttemptSeconds`) — она в часах и вопроса не решает. Дальше — время жёсткой фазы + запас. Само число выбери сам и **обоснуй его в отчёте арифметикой**, а не круглостью: откуда взял потолок, сколько заложил на жёсткую фазу, что будет, если провайдер сменит `attempt_max_s`. ⚠ Отдельно скажи, как число не протухнет: сегодня оно живёт константой в твоей зоне, а зависит от конфига в чужой. ### 4.2 `mode` у `/stop` — **делай РОВНО так по существу, форму согласуй** Сегодня у продукта один глагол: `POST /runs/{runId}/stop` (`docs/architecture/14-api-contract/openapi.yaml:754`), и он ведёт к `systemctl --user stop` (`runner.go:194`), то есть к ОДНОМУ SIGTERM. После пака один SIGTERM = мягкая остановка. Значит продукту нужен способ сказать «останови СЕЙЧАС». ⇒ у `/stop` появляется режим, и форму я называю сразу, чтобы не гонять её третий круг: **`mode: soft|hard`, дефолт `soft`.** Дефолт мягкий — потому что пользователь, который просто нажал «стоп», не должен терять деньги. ⚠ Контракт — ратифицированный носитель; правку контракта вносит оркестратор, не твоя рука: пришли точную формулировку описания в отчёте, я её ратифицирую минором. ### 4.2-бис ⛔ РЕЖИМ ОБЯЗАН ЖИТЬ В ЗАПИСИ НАМЕРЕНИЯ, а не только в запросе — **делай РОВНО так** `RequestStop` (`platform/internal/pgstore/runs.go`, греп `func (s *Store) RequestStop`) идемпотентен СХЛОПЫВАНИЕМ: «pressing stop twice is one intent with the FIRST timestamp». Пока остановка одна, это верно и красиво. С двумя — ломается: второе нажатие с `mode: hard` в базе неотличимо от первого, и платформа, перезапущенная между двумя нажатиями, не узнает, что должна послать второй сигнал. Довод — принцип этой же функции, записанный над ней: «the intent is COMMITTED before systemd is touched, so a platform that dies between the two still knows on its next sweep». Режим — часть намерения, значит и он коммитится до systemd. ⚠ Это схема ПЛЮС контракт: сегодня наружу едет булево `stop_requested` (`docs/architecture/14-api-contract/openapi.yaml`, греп `stop_requested`). Форму предложи в отчёте — ратифицирую минором; но САМ факт «режим переживает перезапуск платформы» не обсуждается. ### 4.3 Путь второго сигнала — **делай РОВНО так** Жёсткая остановка = ВТОРОЙ сигнал движку. `systemctl stop` для этого не годится: он уже потрачен на первый и запустит свой грейс. Форма, которую рекомендует консилиум: `systemctl kill --signal=SIGTERM` по тому же юниту (юнит не трогая), то есть просто ещё один SIGTERM процессу. Проверь это сам на своей машине — рекомендация не замер. ⛔ **Кто жмёт второй раз — решено, и в пак идёт ТОЛЬКО (а): пользователь, явным «останови сейчас».** ⚠ Испр. до выдачи: прежняя редакция выносила это сессии как развилку и приводила довод за (б) — автоэскалацию платформой. Довод снят и вот почему: (б) означает, что платформа САМА решает оборвать оплаченные вызовы, списать за них оценки и купить их заново на резюме — без слова пользователя. Это противоречит канону владельца («деньги видимы с первого вызова, явное согласие на пере-оплату») и его же слову «готов ждать» (`D39.230` п.3). Для живучести (б) и не нужна: мягкое ожидание движка само ограничено дедлайном попытки, а SIGKILL по грейсу — последний рубеж. Если ты считаешь иначе — это вопрос ВЛАДЕЛЬЦУ через оркестратора, а не решение сессии. ### 4.4 Читать счёт и сумму оценочных строк — **делай РОВНО так** ⭐ **Это то, что твоя же зона просила, и оно наконец приедет.** `platform/internal/pgstore/credits.go:235` — «publish the count and sum of estimated-price rows beside committed_usd, which is what would let this side say "at most Y"». Движок эти числа считает и печатает (`backend/internal/pipeline/status.go:321` — `estimated_rows` / `estimated_usd`), но по шву они не едут: в кадрах `backend/internal/runevents/runevents.go` слова `estimated` **0 хитов** (контроль: `committed` в том же файле — **6**; денежный кадр несёт один `committed_micro_usd`, ~244). С твоей стороны читателя тоже нет: в `platform/internal` вне тестов `estimated` — **2 хита, оба комментарии** (контроль: `committed_micro` — 1 хит; go-файлов вне тестов прибор прочёл **81**). Движковая половина везёт их в ОБА кадра — и в кумулятивный `spend`, и в `Money` терминального (`D39.235` п.1б). **Твоя — прочитать и довести до пользователя**, причём `spend` и есть тот канал, по которому «не больше Y» отвечается ВО ВРЕМЯ прогона, а не после него. ⛔ **И подними СВОЮ декларируемую версию декодера.** `platform/internal/ingest/events.go` держит `StreamVersion = "1.1"`, и его собственный комментарий объясняет, что это УТВЕРЖДЕНИЕ о том, что билд понимает, а не украшение («leaving it at 1.0 would have been a false one»). Читая поля минора `1.4`, ты обязан сказать об этом константой — иначе повторишь ровно ту ошибку, от которой предостерегает твой же комментарий. Зачем это нужно владельцу дословно: он разрешил списывать по ОЦЕНКЕ при УСЛОВИИ, что пометка стоит (`D39.230` п.1) — условие сегодня исполнено на одном канале из двух и потребителя не имеет. Пользователь должен видеть не только «списано X», но и «из них оценка Y». ### 4.5 Протухшая проза про движок — **почини, раз ты здесь** `platform/internal/runs/reconcile.go:1534` и `platform/internal/pgstore/runs.go:320` пишут «the engine catches SIGTERM and exits 1». Движок на пойманном сигнале отдаёт **5** (`backend/cmd/tmctl/main.go:100`), и твой собственный код это знает (`platform/internal/ingest/exit.go:34` = `ExitStopped = 5`, `reconcile.go:1134` сверяется именно с ним). Врут только комментарии — но их цитируют доки, и ложь уезжает дальше. Тот же текст — в `platform/internal/runs/control_test.go:201` (⚠ адрес испр. до выдачи: файла `platform/internal/runner/control_test.go` не существует). ### 4.7 ⛔ САМОЕ ДОРОГОЕ: `Stop` БЛОКИРУЕТСЯ НА ВЕСЬ ГРЕЙС — замерено исполнением `Runner.Stop` зовёт `systemctl --user stop` **без `--no-block`** (`platform/internal/runner/runner.go:195`), а `runCommand` собственного тайм-аута не имеет (`:106-112`). Пока стоп ЖЁСТКИЙ, юнит умирает за секунды и этого не видно. С мягким SIGTERM `stop` будет ждать столько, сколько живёт движок. **Замер (пробник на транзиентном юните, игнорирующем SIGTERM, `TimeoutStopSec=15`):** `systemctl --user stop` вернулся через **15,21 с** — то есть по SIGKILL; `stop --no-block` — через **0,019 с**, юнит в `deactivating`. ⇒ **два прямых следствия, оба ломают продукт в первый же день:** - в HTTP-глаголе `/stop` при `WriteTimeout: 30 s` (`platform/cmd/tmplatformd/main.go`, греп `WriteTimeout`) пользователь НЕ получает `202`, который контракт обещает (`docs/architecture/14-api-contract/openapi.yaml`, греп `The `202` does not mean the run has stopped`); - свип реконсайлера (`platform/internal/runs/reconcile.go`, вызовы `Stop`) встанет до двадцати минут на одном прогоне. **Делай РОВНО так:** `--no-block`. Реконсайлер и так пере-выдаёт стоп на следующем проходе — это написано в комментарии над его `Stop`. ⚠ И пере-сними мой замер своей рукой, прежде чем опираться: он снят не на вашем юните. ⭐ **Заодно закрыт вопрос второго сигнала (§4.3):** `systemctl kill --signal=…` на юните в состоянии `deactivating` ДОХОДИТ до процесса, состояние не меняется. Рекомендация — `--kill-whom=main`. ### 4.8 Ещё два места, названные ревью — **делай РОВНО так** **(а) Dev-супервизор.** `platform/internal/ingest/supervisor.go` шлёт SIGINT и держит `WaitDelay` **30 с** (греп `WaitDelay`). В dev-режиме мягкая остановка будет убита через полминуты. Либо поднять, либо явно объявить «dev не поддерживает мягкую остановку» — но не молчать. **(б) ЗАПАСНОЙ КАНАЛ для чисел.** У прогона, убитого по грейсу, кадра `finished` НЕТ ВООБЩЕ (его отсутствие само по себе значимо — см. шапку `Finished` в `backend/internal/runevents/runevents.go`). При этом `status --json` движка несёт `estimated_rows`/`estimated_usd` всегда. ⇒ платформа обязана читать ОБА канала, и это твоя половина, а не движковая. ### 4.6 Чего в паке НЕТ — **не делай** - **Движковая половина** — чужая зона. Ты не правишь `backend/` вообще, включая тесты. - **Коды выхода** — заморожены: оба стопа остаются **5**. Различие «мягко / оборвано» приезжает ЧИСЛАМИ в кадре, а не новым кодом и не новым значением `outcome` (довод — в комментарии к полю `Finished.Volume`, `backend/internal/runevents/runevents.go`, прочитай его прежде чем спорить). - **Правка контракта своей рукой** — через оркестратора (§4.2). - **Квоты и фри-тир** — их нет и не проектируется (`D39.176` п.1). ## 5. Мандат самопроверки ИСПОЛНЕНИЕМ — «перечитал сам» не считается Норма проекта и решение владельца. Самоотчёт «проверено» без исполнения здесь регулярно оказывался ложным. 1. **Baseline СВОИМ прогоном ДО первой правки** — зелень зоны целиком, скипы названы поимённо. 2. ⛔ **Отрицательный замер обязан ДОКАЗАТЬ, что спросил существующее:** рядом с нулём ПЕЧАТАЕТСЯ контрольная величина. Не «проверено с контролем», а число. 3. **Грейс проверяется ИСПОЛНЕНИЕМ, а не чтением конфига.** Сценарий, который обязан быть в отчёте: юнит с новым грейсом, процесс, который не выходит N секунд, — и что реально сделал systemd. ⚠ Это единственная часть пака, где ошибка молча уничтожает деньги пользователя. 4. **Второй сигнал — тоже исполнением:** что именно приходит процессу и в каком порядке. 5. **Адверсариальный проход по СВОЕЙ готовой работе**, отдельным заходом. Направление — что здесь уязвимо: (а) гонка «пользователь нажал второй раз, пока идёт первый»; (б) остановка юнита, который уже мёртв (у `Stop` для этого есть намеренная ветка — не сломай её); (в) реконсиляция: не читается ли мягко остановленный прогон как «упал» или как «дошёл до конца книги»; (г) идемпотентность `/stop`. ## 6. Записка-план ДО первой правки В зонный журнал: порядок работы и почему такой. Рекомендованный: §4.1 грейс (без него остальное вредно) → §4.3 второй сигнал → §4.2 режим → §4.4 чтение чисел → §4.5 проза. ## 7. Заявление = команда Любое «сделано / проверено» сопровождается КОМАНДОЙ и её выводом. Утверждение без команды не считается. ## 8. Эхо-протокол старта Первым сообщением верни: **(а)** одной фразой — что произойдёт, если заландить твою половину без движковой, и что если наоборот; **(б)** какое число ты считаешь самым опасным в этом паке; **(в)** что здесь неверно или недоказано — право сказать это у тебя есть. ## 9. Канал вопросов и право сказать «этого делать не надо» Вопрос — секцией в `platform/docs/platform-PROGRESS.md`. ⚠ Слово, живущее только в переписке, умирает вместе с сессией — этой смене это стоило трёх потерянных диспозиций (`D39.231` п.1). Всё, что должно пережить твою сессию, пишется в репозиторий. ## 10. Что НЕ удалось — обязательная секция отчёта Пустой она не бывает. ## 11. Критерий завершённости 1. Грейс покрывает законное ожидание движка, число обосновано арифметикой и проверено исполнением. 1-бис. Режим остановки переживает перезапуск платформы (§4.2-бис), и это показано, а не заявлено. 1-трис. Оба кадра читаются в форме `D39.235` п.1, декларируемая версия декодера поднята. 2. У продукта есть обе остановки (`mode: soft|hard`, дефолт `soft`); путь второго сигнала работает и показан исполнением. 2-бис. `Stop` не блокируется на грейс — показано исполнением, своим замером, а не моим (§4.7). 3. Мягко остановленный прогон не читается реконсиляцией ни как падение, ни как дочитанная книга. 4. Счёт и сумма оценочных строк доходят до пользователя. 5. Протухшая проза про движок исправлена. 6. Зелень зоны целиком, скипы названы; отчёт: что не удалось, что изменил против пака и почему. ## Деньги Пак **$0**: платных прогонов не требует и не разрешает. ## Отчёт `platform/docs/platform-PROGRESS.md`. Числа — командами. Ничего не коммить.