280 lines
27 KiB
Markdown
280 lines
27 KiB
Markdown
> ⚠ **ЭТО ХЕНДОФФ-ПРОМТ ПЛАТФОРМЕННОЙ СЕССИИ. Он живой и исполняется.** Прочитай `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`. Числа — командами. Ничего не коммить.
|