30 KiB
⛔⛔ ОТМЕНЁН ВЛАДЕЛЬЦЕМ 10.09, ДО ЛЕНДИНГА. Инструкции ниже НЕ исполняются. Причина — не качество пака и не ошибка сессии, а РАЗМАХ: «не хочу сильно рушить архитектуру ради этих двух изменений, фактически они не очень-то и нужны». Работа обеих сессий откачена, ничего не было закоммичено. ⚠ Предмет НЕ исчез, он ЗАМЕНЁН и сузился (
D39.240): остановка остаётся ЖЁСТКОЙ, деньги терять ДОПУСТИМО — но она обязана быть корректной: без гонок, без половинчатых состояний, чтобы ни движок, ни платформа не оставили неконсистентности и чтобы возобновление было правильным. ⭐ Что пак успел купить и что осталось навсегда: коалесценция двух SIGTERM в один (D39.238), блокирующийsystemctl stopбез--no-block(D39.237п.4), инертность повторного стопа наdeactivating(D39.237п.2), четыре ошибки в моих же паках, найденные сессиями (D39.236), и диагноз архитектуры: у вопроса «можно ли ещё покупать» нет единственного хозяина — строка 385.
⚠ ЭТО ХЕНДОФФ-ПРОМТ ПЛАТФОРМЕННОЙ СЕССИИ. Он живой и исполняется. Прочитай
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. Карта чтения — ЗАКОН, дальше только по её ссылкам
platform/docs/platform-PROGRESS.md— пинг оркестратора №23 (10.09) сверху: четыре адреса и что с каждым делать.platform/internal/runner/runner.go— целиком:stopGrace(~59), свойства юнита (~160–170),Stop(~194).platform/internal/runs/reconcile.go— как остановка сводится с исходом:StopRequestedAt(~558, ~575, ~591),finishStopped(~678),stoppedOnRequest/interruptedBySomeoneElse(~1018, ~1112–1134), и комментарий ~1534.platform/internal/ingest/exit.go— словарь кодов выхода,ExitStopped = 5(~31–34).docs/architecture/14-api-contract/—/runs/{runId}/stop(openapi.yaml:754+) и его описание в README контракта.- ⛔ Тело ноты
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), у провайдеров без ключа значение выводится формулой. Пере-считай своей рукой и назови
число; арифметика — в отчёт. ⛔ И запиши его как ЗАВИСИМОСТЬ, а не как свою константу (D39.236 п.6):
оно равно ожиданию после SIGTERM только если движковая половина посадит «мягкая остановка видна МЕЖДУ
попытками и в бэкоффе» (D39.235 п.2). Не сядет — ожидание снова становится цепочкой (~64 мин), и любой
грейс, выведенный из одной попытки, снова окажется ниже неё.
⚠ Валидатор каталога держит лишь абсолютную границу (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, ты
обязан сказать об этом константой — иначе повторишь ровно ту ошибку, от которой предостерегает твой же
комментарий. ⛔ И ТЕМ ЖЕ ДВИЖЕНИЕМ напиши у константы, каких полей миноров 1.2 и 1.3 ты НЕ читаешь и
почему (D39.236 п.4): декодер несёт Finished{Outcome} без Volume и Money и Ceiling{Halted, Scope}
без ShortfallMicroUSD. Из них Money пак и так заказывает; два оставшихся — решения зоны с записанным
доводом, и они должны быть названы, иначе 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, грепThe202does not mean the run has stopped); - ⚠ испр. 10.09 по возражению сессии (
D39.236п.5): свип НЕ встанет на двадцать минут —reconcileOneзаворачивает каждый прогон в СВОЙ бюджет (platform/internal/runs/reconcile.go, грепcontext.WithTimeout(ctx, s.runBudget())), и команда строится на нём. Ущерб тише и хуже: израсходованный бюджет САМ ПО СЕБЕ считается провалом реконсиляции (там же, грепSPENDING the budget counts as a failure) ⇒ каждый мягко останавливающийся прогон получаетReconcileFailures++, отсрочку и через несколько проходовStalledAfter— ЛОЖНУЮ тревогу оператору; плюс фаза прохода съедается одним таким прогоном.
Делай РОВНО так: --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. Мандат самопроверки ИСПОЛНЕНИЕМ — «перечитал сам» не считается
Норма проекта и решение владельца. Самоотчёт «проверено» без исполнения здесь регулярно оказывался ложным.
- Baseline СВОИМ прогоном ДО первой правки — зелень зоны целиком, скипы названы поимённо.
- ⛔ Отрицательный замер обязан ДОКАЗАТЬ, что спросил существующее: рядом с нулём ПЕЧАТАЕТСЯ контрольная величина. Не «проверено с контролем», а число.
- Грейс проверяется ИСПОЛНЕНИЕМ, а не чтением конфига. Сценарий, который обязан быть в отчёте: юнит с новым грейсом, процесс, который не выходит N секунд, — и что реально сделал systemd. ⚠ Это единственная часть пака, где ошибка молча уничтожает деньги пользователя.
- Второй сигнал — тоже исполнением: что именно приходит процессу и в каком порядке.
- Адверсариальный проход по СВОЕЙ готовой работе, отдельным заходом. Направление — что здесь
уязвимо: (а) гонка «пользователь нажал второй раз, пока идёт первый»; (б) остановка юнита, который
уже мёртв (у
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-бис. Режим остановки переживает перезапуск платформы (§4.2-бис), и это показано, а не заявлено.
1-трис. Оба кадра читаются в форме
D39.235п.1, декларируемая версия декодера поднята. - У продукта есть обе остановки (
mode: soft|hard, дефолтsoft); путь второго сигнала работает и показан исполнением. 2-бис.Stopне блокируется на грейс — показано исполнением, своим замером, а не моим (§4.7). - Мягко остановленный прогон не читается реконсиляцией ни как падение, ни как дочитанная книга.
- Счёт и сумма оценочных строк доходят до пользователя.
- Протухшая проза про движок исправлена.
- Зелень зоны целиком, скипы названы; отчёт: что не удалось, что изменил против пака и почему.
Деньги
Пак $0: платных прогонов не требует и не разрешает.
Отчёт
platform/docs/platform-PROGRESS.md. Числа — командами. Ничего не коммить.