textmachine/docs/PLATFORM_SOFT_STOP_SESSION_PROMPT.md

29 KiB
Raw Blame History

ЭТО ХЕНДОФФ-ПРОМТ ПЛАТФОРМЕННОЙ СЕССИИ. Он живой и исполняется. Прочитай 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.mddocs/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 п.1docs/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:59stopGrace = 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:321estimated_rows / estimated_usd), но по шву они не едут: в кадрах backend/internal/runevents/runevents.go слова estimated 0 хитов (контроль: committed в том же файле — 6; денежный кадр несёт один committed_micro_usd, ~244). С твоей стороны читателя тоже нет: в platform/internal вне тестов estimated2 хита, оба комментарии (контроль: 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, греп The 202 does 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. Мандат самопроверки ИСПОЛНЕНИЕМ — «перечитал сам» не считается

Норма проекта и решение владельца. Самоотчёт «проверено» без исполнения здесь регулярно оказывался ложным.

  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. Числа — командами. Ничего не коммить.