textmachine/docs/PLATFORM_SOFT_STOP_SESSION_PROMPT.md

280 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

> ⚠ **ЭТО ХЕНДОФФ-ПРОМТ ПЛАТФОРМЕННОЙ СЕССИИ. Он живой и исполняется.** Прочитай `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), свойства юнита (~160170),
`Stop` (~194).
3. `platform/internal/runs/reconcile.go` — как остановка сводится с исходом: `StopRequestedAt`
(~558, ~575, ~591), `finishStopped` (~678), `stoppedOnRequest` / `interruptedBySomeoneElse`
(~1018, ~11121134), и комментарий ~1534.
4. `platform/internal/ingest/exit.go` — словарь кодов выхода, `ExitStopped = 5` (~3134).
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`. Числа — командами. Ничего не коммить.