textmachine/docs/BACKEND_SOFT_STOP_SESSION_PROMPT.md

431 lines
47 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 и §12.
# Пак бэкенда: «ДВЕ ОСТАНОВКИ»
## 1. Какая проблема и что решит твой результат
Человек, который остановил перевод книги, сегодня рвёт всё, что летит к провайдерам прямо сейчас.
Оплаченные генерации, которые провайдер уже начал, обрываются; движок честно списывает за них оценку,
помечает позиции и на продолжении делает их заново — то есть **платит второй раз за ту же работу**.
Владелец назвал это прямо: «нахрена обрывать соединение и запрос и посылать заново и терять до трети
стоимости».
10.09 он ратифицировал форму (`D39.234` п.1): **остановок должно быть две.**
- **МЯГКАЯ** — первое нажатие. Новых единиц работы не раздаём, **начатое доигрываем**, выходим сами.
Ничего не рвётся, поэтому ни одна позиция не получает пометки «остановлено» и никто не платит дважды.
- **ЖЁСТКАЯ** — второе нажатие. Ровно сегодняшнее поведение: отмена контекста, сеттл оценок, пометки
`cancelled`.
- Третье нажатие — дефолт процесса.
**Твой результат:** у человека появляются обе, они различимы машинно, и платформа их различает.
**Это не «добавить фичу», а привести дерево к тому, что мы уже обещали.** Контракт
`docs/architecture/14-api-contract/openapi.yaml:762` говорит «finished work is kept and not paid for
again», а платформа в собственном коде пишет, что движок «finishes the in-flight chunk before exiting»
(`platform/internal/runner/runner.go:56-58`). Оба утверждения сегодня ЛОЖНЫ. Ты делаешь их истинными.
**Носители в трекере:** строки **381** (две остановки), **382** (пометка «оценка» не едет по шву),
**377** (оплаченный обрыв, за которым ретрай успел, исчезает бесследно), **372** (кто отменил — человек
или упавший сосед), **375** (выгрузка читает остановленную позицию как выпавший текст), **379**(а) (пин
на стенных часах). Их тела — в `docs/BACKLOG.md`, грепом по номеру.
## 2. Зона и git
Твоя зона записи — **`backend/`**. Ничего вне неё не трогаешь: `platform/` делает своя сессия, `docs/`
— оркестратор. **Коммитит только оркестратор**: дерево готовишь и передаёшь на лендинг. Свои итоги,
вопросы и расхождения пиши в СВОЮ секцию `docs/PROGRESS.md` — это единственное исключение из «`docs/`
зона оркестратора».
**Пара.** Платформенная половина (`mode` у `/stop`, грейс, путь второго сигнала, чтение оценочных
строк) делается параллельно другой сессией. Ты её кода не пишешь и не правишь.
**ФОРМУ КАДРА НЕ ПРОЕКТИРУЕШЬ — она уже ЗАФИКСИРОВАНА нотой `D39.235` п.1**, до начала работ и именно
затем, чтобы две зоны не решили по-разному. Читаешь её и делаешь РОВНО так (§4.7). Если считаешь форму
неверной — это вопрос оркестратору ДО кода, а не правка.
## 3. Карта чтения — ЗАКОН, дальше только по её ссылкам
1. `backend/internal/pipeline/waverun.go` — целиком. Здесь живёт мягкий ГЛАГОЛ, который ты будешь
поднимать (`latched`, строки ~353363), жёсткий (`cancel()`, ~404 и ~414) и обе пост-обработки волн.
2. `backend/internal/pipeline/stagerun.go` — от `runStage` до конца денежного гейта: пер-вызовная
резервация `r.gate.admit(...)` (~570), ожидание `waitForSettle` (~607), хартбит «still waiting for
the provider» (~990).
3. `backend/internal/pipeline/reservegate.go` — тип `waitOutcome` и его исходы (~113128).
4. `backend/cmd/tmctl/main.go` — словарь кодов выхода (~96104), `signal.NotifyContext` (~207),
`exitCode` (~115130); и `backend/internal/pipeline/events.go``terminal()` (начало ~479, ветки исходов ~505530: их ПОРЯДОК там сам объявлен утверждением, читай с начала функции).
5. `backend/internal/runevents/runevents.go` — кадр `Finished` (~255+) и **комментарий к полю `Volume`**:
он объясняет, ПОЧЕМУ у объёмного потолка нет своего `outcome`, и это твой прецедент.
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 Типизированный сентинел остановки — **делай РОВНО так**
Мягкая остановка **контекст не отменяет**. Значит `errors.Is(err, context.Canceled)` на ней ЛОЖНО, и
без своего типа она провалится в ветку «ошибок нет» — то есть прогон с недоделанными единицами уедет
как **тихий успех**. Это ровно ловушка, которую `waverun.go` уже называет своими словами («a draft-only
run reporting empty translations as exit-0 success»).
⇒ заведи сентинел (имя твоё, `*StopRequested` — предложение) со **своим слотом** в `fail()` волны,
рядом с тем, что уже есть у `*CeilingHalt`. Ранжирование при столкновении — твоё решение, но объясни
его в коде: паника старше остановки, остановка старше обычной ошибки — или иначе, если у тебя есть
довод. ⚠ Один случай ранжирования решён за тебя и лежит в §4.15(г) — прочти его прежде, чем выбирать.
### 4.2 Где именно мягкая остановка отказывает — **делай РОВНО так**
**Точек отказа ДВЕ, и обе обязательны.****Исправлено по ревью до выдачи: одного гейта НЕДОСТАТОЧНО, и это замерено.**
**(1) Пер-вызовный денежный гейт** — `r.gate.admit(...)` (`backend/internal/pipeline/stagerun.go:570`). Он
отсекает НОВЫЕ вызовы: элемент волны — это вся последовательность стадий, то есть несколько платных
вызовов подряд, и без гейта рабочий, уже держащий элемент, доиграет всю.
**(2) Транспортная петля ретраев** — `retryLoop` (`backend/internal/llm/httpllm.go:187`). ⛔ Без неё
ратифицированные ~20 минут НЕ ПОЛУЧАЮТСЯ: резервация берётся ОДИН раз на `runAttempt`
(`stagerun.go:477`, `admit` на `:570`), а `client.Complete(ctx, …)` (`:699`) держит под ней ВСЮ цепочку
ретраев — это говорит собственный комментарий кода: «This timer wraps client.Complete — **the WHOLE
retry chain**» (`stagerun.go:984`). ⇒ 503 на первой попытке → бэкофф → вторая попытка ПОД ТОЙ ЖЕ
резервацией, гейта никто не спросит, и ожидание становится ЦЕПОЧКОЙ (~64 мин), а не попыткой (~20).
`retryLoop` сегодня между попытками смотрит только `ctx.Err()` (`httpllm.go:197`) — мягкий стоп он
увидеть не может.
**транспорт обязан видеть мягкую остановку МЕЖДУ попытками и в бэкоффе**: сигнал «новых попыток не
начинать», который читается только на границе попытки и НЕ трогает летящий запрос. Форма твоя (лишний
параметр профиля, поле клиента, значение в контексте — выбери и обоснуй), но ограничение обязано
стать ЗАПИНЕННЫМ, а не подразумеваемым.
**Владелец ратифицировал именно ~20 минут** (`D39.234` п.1б): «Приемлимо». Цепочку ретраев не
доигрываем — ретрай это НОВОЕ решение о покупке.
⚠ Фидер волны при этом тоже латчится (новых единиц не начинаем) — но это следствие, а не механизм.
### 4.3 Прогонный источник латча — **делай РОВНО так по форме, устройство твоё**
Мягкий глагол уже построен и работает: `latched` в `runWave`, дословно «a different verb from
cancel(): closing it makes the feeder stop handing out indices — nothing new begins — while every
worker already holding an item runs it to completion under a context nobody cancelled».
Но он **локален одному вызову** `runWave`, а вызовов два (черновая волна и редакторская). Кнопке нужен ПРОГОННЫЙ источник, который каждая волна и каждый гейт спрашивают.
Это подъём существующего на этаж выше, а НЕ новый механизм (⚠ испр. до выдачи: вызовов `runWave` ровно ДВА — `waverun.go:140` и `:285`; по главам волны НЕТ, прежняя редакция этого пункта учила неверной топологии) — если ты пришёл к выводу, что нужен новый,
напиши почему, прежде чем писать код.
### 4.4 Пост-обработка ОБЕИХ волн — по образцу потолка, не по образцу отмены — **делай РОВНО так**
Сегодня в `waverun.go` (~161173) ветка «ошибка волны» разделена надвое: `*CeilingHalt` уходит через
`r.reportEvicted(...)` + `r.moneyStoppedResult(...)`, любая другая ошибка — `return nil, err` мимо всего.
Мягкая остановка обязана идти по ПЕРВОМУ пути.
Причина названа там же в коде и она про деньги: черновая волна после остановки НЕ должна идти ни в
майнинг банка, ни в редакторскую волну, потому что редакторская находит незапущенного члена с нулевым
результатом и **платит за редактуру единицы с дырой**. Тот же класс уровнем выше: пере-сеять банк из
половины книги значит сдвинуть снапшот редакторской волны для всех последующих прогонов.
Редакторская волна — свой путь (`foldUnitOutcomes` + `noteMoneyStop`, ~299318 и ~928933).
### 4.5 Банковые проходы идут ВНЕ волны — **делай РОВНО так**
`runTerminologist` (`backend/internal/pipeline/terminologist.go:348`, зовётся из `mining.go:166`) и
`runBankRoleBatches` (`terminologist.go:907`, зовётся из `terminologist.go:465` и `:666`). ⚠ **Испр. до
выдачи: прежняя редакция утверждала, будто латч их не остановит, потому что они идут мимо волны —
это ЛОЖЬ.** Они идут через `r.runAttempt(...)` (`terminologist.go:819`), то есть через тот же гейт
`r.gate.admit`. Гейт их остановит — но ОШИБКОЙ посреди паса, а не усечением.
**решение, которое принимаю я, а не ты:** партии, уже купленные к моменту мягкой остановки, **НЕ
применяются**, пас выходит остановкой ДО применения. Довод тот же, что в §4.4: пере-сев банка из
половины двигает снапшот редакторской волны для всех последующих прогонов, а половина паса — это
половина банка. ⚠ И ничего не теряется: уже купленные партии лежат чекпойнтами и следующий прогон реплеит их за $0
(`backend/internal/pipeline/terminologist.go`, греп `Already-paid batches cost nothing`). Беречь
частичный результат применением — не бережливость, а тихий сдвиг снапшота.
У паса есть СВОЙ попартийный приём (`terminologist.go:940970`), где он уже умеет решать «сколько
партий беру» и говорить об этом вслух — это и есть место, где мягкая остановка говорит «новых партий
не начинаем».
⚠ Читай там комментарий целиком прежде, чем править: он объясняет, почему этот пас УСЕКАЕТ, а не
ОТКАЗЫВАЕТ, и почему отказ классом `Refusal` был бы враньём платформе про деньги.
### 4.6 Эскалация вторым сигналом — **делай РОВНО так**
**Сегодня второй Ctrl-C не делает НИЧЕГО, и это надо знать до того, как ты начнёшь.**
`signal.NotifyContext` (`main.go:207`) поднимает горутину, которая после ПЕРВОГО сигнала выходит;
регистрация снимается только `defer stop()` (~208); канал ёмкостью 1. ⇒ второй сигнал падает в
переполненный буфер и никем не читается, процесс не умирает. То есть «второе нажатие ужесточает» —
**новое поведение, которое ты пишешь**, а не восстановление дефолта.
Форма ратифицирована (`D39.234` п.1а): первый сигнал — мягкая, второй — жёсткая (сегодняшний путь),
третий — дефолт процесса. Устройство твоё; `NotifyContext` для этого мал, и это нормально.
**Платформа шлёт ровно ОДИН SIGTERM** (`platform/internal/runner/runner.go`, `systemctl stop`). Значит
её «остановить сейчас» — это второй сигнал, который она пошлёт отдельно. Твоя часть — чтобы второй
сигнал делал жёсткую остановку независимо от того, человек его послал или платформа.
### 4.7 Кадр `finished`: числа, а не новое слово — **делай РОВНО так**
**Коды выхода НЕ ТРОГАТЬ. Оба стопа — 5.** Полоса заморожена («a fresh code would be a word added to
a ratified dictionary»), и на 5 завязана логика платформы: `platform/internal/ingest/exit.go:34`
= `ExitStopped = 5`.
**И нового значения `Outcome` тоже НЕ заводить.** Прочитай комментарий к полю `Finished.Volume`
(`runevents.go:255+`) — там разобран ровно этот соблазн и объяснено, почему он неверен: словарь
`Outcome` читается из ОБОИХ каналов, каждое значение маппится в код выхода, и значение, живущее только
в потоке, заставило бы два канала сообщать РАЗНЫЙ исход одного прогона. **У чисел словаря нет, поэтому
факт едет числами** — присутствие поля и есть признак.
⇒ различие «мягко / оборвано» едет ПОЛЕМ С ЧИСЛАМИ, и **форма зафиксирована `D39.235` п.1 — делай РОВНО так:**
- `Finished.Stop{ mode: "soft"|"hard", in_flight_finished, in_flight_cut, cut_estimated_micro_usd }`.
**Правило присутствия — «есть, если остановку ЗАПРОСИЛИ», а НЕ «если исход stopped».** Прецедент
дословно у соседнего поля: `Money` present iff a ceiling was REACHED, not iff the outcome is ceiling.
Это важно ровно в случае §4.15(г): остановку запросили, а прогон уехал `failed` из-за соседа — поле
обязано БЫТЬ.
- `estimated{ rows, micro_usd }` едет **на ОБОИХ кадрах** — и на кумулятивном `spend`, и в `Money`
терминального. Довод: платформа отвечает пользователю «не больше Y» ВО ВРЕМЯ прогона, а число,
появляющееся только в конце, на этот вопрос не отвечает.
- `Money` на исходе `stopped` едет **всегда, когда счётчики есть**. Сегодня он `nil` не только без
потолка, но и когда волн ещё не было (`backend/internal/pipeline/events.go`, греп `moneyLedger`):
стоп до волн — ингест, сид — счётчиков не имеет, и это законная пустота, а не умолчание.
- **Минор словаря потока `1.3 → 1.4`** (`backend/internal/runevents/runevents.go`, греп `StreamVersion`).
⚠ Платформа поднимает СВОЮ константу сама — это её половина, не твоя.
### 4.8 Строка **377** — оплаченный обрыв, за которым ретрай УСПЕЛ, исчезает бесследно
`backend/internal/llm/httpllm.go`, ~189191: на успешном ретрае `if err == nil { return resp, nil }`, и
накопленный `owedCut` ВЫБРАСЫВАЕТСЯ. Провайдер спрошен дважды (первый вызов доставлен, подтверждён и
оборван), вызывающий получает чистый успех и о первом не узнаёт ничего — ни строки леджера, ни оценки,
ни пометки.
**Владелец велел делать её ЭТИМ ЖЕ заходом** (`D39.234` п.1в), и довод не в сумме. `D39.230` п.2
оставил открытым вопрос «биллит ли вендор оборванное» и держит его на сверке инвойса вендора с суммой
оценочных строк. Невидимая вторая генерация ломает эту сверку по построению: сверять нечем.
Форма починки твоя. ⚠ Ни одна из девяти фикстур этого пути не покрывает
(`TestABrokenConnectionAfterDeliveryIsRetriedOnceAndOnlyOnce` роняет ОБЕ попытки) — значит фикстуру
пишешь новую, и она обязана доказать, что второй запрос УСПЕЛ.
### 4.9 Строка **375** — третье состояние единицы — **делай РОВНО так**
Выгрузка сегодня читает строку `cancelled` как ВЫПАВШЕГО ЧЛЕНА редакторской единицы
(`memberDrops`, `backend/internal/pipeline/status.go:534`, живой вызов один —
`backend/internal/pipeline/export.go:307`) и сообщает читателю «из единицы пропал текст», тогда как
пропала лишь незаконченная работа.
**Простого фильтра НЕДОСТАТОЧНО, ЕСЛИ сценарий ниже достижим — и доказать это твоя работа.** ⚠ Испр. до выдачи: прежняя редакция называла это «замерено движковой зоной» — носителя такого замера НЕТ (наряд ставит по этой позиции «пинг», в `docs/PROGRESS.md` следов нет). Это рассуждение, а не замер, и доказать сценарий фикстурой — твоя работа. Если применить
`resolvedForResume` (`cutcall.go:253`) и на этом остановиться, единица уйдёт `ok`, МОЛЧА не досчитавшись
текста члена: `DroppedMembers` = 0, `DroppedReason` пуст. Это обмен ЛЖИ на МОЛЧАНИЕ, и верно ни то ни
другое. ⇒ нужен ТРЕТИЙ признак: «единица неполна ИЗ-ЗА ОСТАНОВКИ».
⭐ Попутно фильтр чинит артефакт порядка: внутренний цикл оставляет ПОСЛЕДНЮЮ флагнутую черновую строку,
строки приходят `ORDER BY … stage`, и при двух черновых стадиях `cancelled` может перебить содержательную
причину.
⛔ **И вот ЗДЕСЬ прежняя редакция ошибалась опаснее всего: случай «`cancelled` затёр настоящую причину»
ВОЗМОЖЕН.** Апсерт по `(book_id, chapter, chunk_idx, stage)` (`backend/internal/store/chunkstatus.go:93`)
не добавляет строку, а ЗАМЕНЯЕТ её. Сценарий (выведен по коду, фикстурой не гонялся — прогони сам):
единица c-lite, где член выпал по `length`, а редактор отработал `ok` по остатку → redrive черновика
этого члена (`ResetChunkStages` сбрасывает только флагнутую стадию, `ok`-стадии никогда) → мягкой
остановки над ним нет, а ЖЁСТКАЯ пишет `cancelled` поверх `length` → и фильтр §4.9 спрячет НАСТОЯЩИЙ
дроп. ⇒ починок ДВЕ: третий признак «неполна из-за остановки» И запрет `cancelled` затирать флагнутую
строку (колонка `first_flag_reason` для этого уже есть). Форма твоя; сценарий обязан быть предъявлен
фикстурой.
### 4.10 Строка **372** — четвёртая причина аборта ожидания — **делай РОВНО так**
`waitOutcome` (`reservegate.go`) сегодня разводит исходы ожидания, и его собственный комментарий
рассказывает, какая ошибка на этом месте уже была совершена: с `bool` «ничего не летит» и «контекст
умер» читались одинаково, и прогон, остановленный человеком, публиковал `ceiling`-событие с нехваткой —
то есть просил у платформы денег за остановку, к деньгам отношения не имевшую.
Мягкая остановка — ЕЩЁ ОДНА причина, и вместе с ней решается 372: «кто отменил — человек или упавший
сосед» превращается в «мягко / жёстко / сосед».
**Мелочь, которую заметь и почини заодно:** комментарий над типом говорит «THREE values», а констант
там четыре — `waitNotAllowed` добавили позже и текст не поправили. Ты добавляешь пятую.
### 4.11 Строка **382** — пометка «это оценка» не едет по шву — **делай РОВНО так по своей половине**
Владелец разрешил списывать по ОЦЕНКЕ при УСЛОВИИ, что пометка стоит (`D39.230` п.1). В движке она
есть: `backend/internal/pipeline/status.go:321``estimated_rows` / `estimated_usd` в `status --json`.
Дальше она обрывается: в кадрах `backend/internal/runevents/runevents.go` слова `estimated` **0 хитов**
(контроль: `committed` в том же файле — **6**; денежный кадр несёт один `committed_micro_usd`, ~244).
Платформа её не читает и прямо просит: `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"».
**твоя половина — довезти счёт и сумму оценочных строк до кадров.** Чтение — платформенная половина.
Форма — та же `D39.235` п.1, что в §4.7: `estimated{rows, micro_usd}` на `spend` И в `Money`. Ничего не
согласовывай отдельно: это один шов, и он уже описан нотой.
**ОДНО ОПРЕДЕЛЕНИЕ, ДВА ЧИТАТЕЛЯ — ЗАПИНЬ ИХ РАВЕНСТВО.** Величина «сколько из списанного — оценка»
поедет двумя каналами: кадром (эмиттер копит на лету) и `status --json`, который считает её ИЗ СТРОК
`request_log` (`backend/internal/pipeline/status.go`, греп `estimatedSpend`). Платформа по `D39.235` п.1(г)
обязана читать ОБА — и если на одном сторе они разойдутся, пользователю скажут два разных «не больше Y».
⇒ тест: на одном и том же сторе `Money.estimated` терминального кадра РАВЕН паре `estimated_rows`/
`estimated_usd` из `status --json`. Это ровно та дисциплина «одно определение», которой §4.9 требует от
`memberDrops`; здесь она нужна не меньше.
### 4.12 Строка **379**(а) — пин, решаемый окном стенных часов — **бери, раз всё равно трогаешь**
`backend/internal/pipeline/cutcall_test.go:402` — две строки таблицы девяти («after 2xx · cancelled by
the operator» и «before headers · cancelled by the operator») разводятся `<-srv.arrived` +
`time.Sleep(50 * time.Millisecond)` против `attempt_s: 1`. По какую сторону границы заголовков сел
`cancel()`, не утверждается ничем; на мутанте это давало **2 красных из 8**.
Ты в любом случае перестраиваешь семантику остановки, значит эти строки твои. Фикстура обязана строиться
так, чтобы предмет был НЕИЗБЕЖЕН, а не вероятен.
### 4.13 Оператору — сказать, чего он ждёт — **реши сам, но молчать нельзя**
**Испр. до выдачи: «не печатает НИЧЕГО» было буквально неверно** — при отмене пайплайн пишет WARN
(`backend/internal/pipeline/cutcall.go:214`), и `tmctl` печатает ошибку на выходе. Настоящих дыры две,
и вторая дороже: **(1)** в момент сигнала подтверждения нет — человек нажал и не понял, случилось ли
что-нибудь, а через двадцать секунд нажмёт снова и получит жёсткую, потеряв ровно те деньги, ради
которых мягкая заводилась; **(2)** ⛔ **на стоп-пути НЕ рендерится леджер**: частичный результат
`translate` показывает только для подписного стопа и потолка (`backend/cmd/tmctl/main.go`, греп
`WaveSignatureStop` и `CeilingHalt`), а результат остановленного прогона выбрасывается. Мягкая
остановка по §4.4 результат ПРОИЗВОДИТ (`moneyStoppedResult`) — и до оператора он не доедет.
⇒ при первом сигнале печатается строка вида «остановка запрошена: N вызовов в полёте, самый долгий
дедлайн Xs; ещё раз — оборвать их (спишется оценка)». Опора готова: хартбит «still waiting for the
provider on a call in flight» (`stagerun.go:990`) уже знает и число, и дедлайн.
### 4.15 Пять мест, которые ревью назвало «без них не сойдётся» — **делай РОВНО так**
**(а) ⛔ ГРАНИЦЫ ФАЗ, а не только волны.** §4.4 покрывает остановку, пришедшую ВО ВРЕМЯ волны. Пришедшая
МЕЖДУ фазами уводит прогон платить за редактуру: источник обязаны спрашивать переходы — после черновой
волны → перед майнинг-стопом → перед подписным стопом → перед редакторской волной, включая
авто-продолжение с пере-севом (`backend/internal/pipeline/mining.go`, греп `bank-mining/auto-continue`).
**(б) `res.Volume = nil` на мягкой остановке** — как это делает потолок. Плановые счётчики после
остановки посреди работы врут; довод дословно — в комментарии к полю `Finished.Volume`.
**(в) Статус джоба при отказе гейта.** Сегодня отказ даёт `failed` (`backend/internal/pipeline/stagerun.go`,
греп `mandatory`/статус после `admit`). Для мягкой остановки это НЕВЕРНОЕ слово: ничего не сломалось.
**(г) Столкновение мягкой остановки с падением соседа — решение приняло за тебя.** Инфра-ошибка соседнего
рабочего зовёт `cancel()` (`waverun.go`, ветка `fail`) и рвёт ровно те вызовы, которые мягкая остановка
берегла. **Это приемлемо** — провайдер сломан, беречь нечего, — но исход прогона тогда `failed` С
пометками `cancelled`, и так и должно быть. Не строй защиту от этого; запинь, что так и есть.
**(д) Пин на резюм после МЯГКОЙ остановки:** ноль платных вызовов по уже отвеченным стадиям, ничего не
пере-делывается. Тривиально по построению — и ровно поэтому должно быть проверено, а не подразумеваться.
### 4.14 Чего в паке НЕТ — **не делай**, и это ОБЪЯВЛЕННЫЕ сужения
- **Платформенная половина** — чужая зона и чужая сессия. Ты не правишь `platform/` вообще.
- **Коды выхода и словарь `Outcome`** — заморожены (§4.7).
- **Продуктовый вопрос «сколько ждать по умолчанию»** — решён владельцем (~20 мин, одна попытка). Не
пере-открывай; если у тебя есть довод против — это вопрос оркестратору, а не правка.
- **Строки 373 и 376** (инертный дедлайн на HTTP/2; доставленный обрыв до заголовков книжится нулём) —
в паке НЕТ. У 376 направление недосчёта ратифицировано (`D39.196` п.2а), у 373 нужен пир, не дренящий
тело.
- **Вердикт главы** — трогать НЕ надо: замером 10.09 установлено, что остановка его уже не портит
(`D39.233` п.2 — там замер; пин `TestAStoppedPositionIsNotADecidedUnit` зелён).
## 5. Мандат самопроверки ИСПОЛНЕНИЕМ — «перечитал сам» не считается
Это норма проекта и решение владельца, не пожелание. Самоотчёт «проверено» без исполнения здесь
регулярно оказывался ложным и стоил денег.
1. **Baseline СВОИМ прогоном ДО первой правки:** `make battery` — запиши MAKE-EXIT, число `ok`, число
FAIL, «no test files» и **число скипов поимённо**. ⛔ Прогон без `-v` прячет скипы: однажды 431 скип
проехали как зелень, и среди них были все пины предмета.
2. **`python3 docs/scripts/counts.py --check`** — до и после.
3. **`make mutations`** — снимается отдельно; число внеси в итоговый отчёт вместе с составом.
4. **Мутируй СВОИ новые тесты.** Линза качества трижды находила дыры в тестах, которые их автор считал
сильными.
5.**Мутация засчитывается по ТЕКСТУ падения, а не по факту красноты.** Читай текст: говорит ли он
про сломанное тобой. Правый вердикт по неправой причине — дыра, а не поимка.
6.**Пин, ФЛЕЙКОВЫЙ на мутанте, измеряет пустой сценарий.** Денежный пин гоняется не один раз (8
прогонов — рабочее число этой смены), и фикстура строится так, чтобы предмет был НЕИЗБЕЖЕН.
7.**Пин может удовлетворяться ЧУЖОЙ уликой, и это не флейк, а детерминированная пустота.** На
мутанте спрашивай не «покраснело ли», а ЧТО ИМЕННО удовлетворяло утверждение.
8.**Отрицательный замер обязан ДОКАЗАТЬ, что спросил существующее:** рядом с нулём ПЕЧАТАЕТСЯ
контрольная величина. Не «проверено с контролем», а число.
9.**Копия под мутацию защищается ПОСТРОЕНИЕМ** (`test -f go.mod` + сверка `pwd`), а не `set -e`:
10.09 `cd` в несозданный каталог провалился, `set -e` не удержал, и мутация ушла в НАСТОЯЩЕЕ дерево.
**Адверсариальный проход по СВОЕЙ готовой работе — отдельным заходом, свежим взглядом.** Что в ЭТОМ
паке уязвимо, направление даю:
- **Гонки.** Мягкая остановка — это состояние, которое читают несколько горутин. Латч в `runWave`
сделан КАНАЛОМ, а не флагом, и в коде объяснено почему (фидер БЛОКИРУЕТСЯ на отправке ровно в том
состоянии, в котором остановка и наступает). Твой прогонный источник обязан удержать то же свойство.
- **Тихий успех.** Главный риск пака (§4.1). Спроси у своей работы: может ли прогон с недоделанными
единицами уехать с кодом 0?
- **Двойной учёт.** Мягкая остановка не платит — значит ни одна пометка `cancelled`, ни один сеттл
оценки на ней возникать НЕ должны. Если возникают — ты построил жёсткую под мягким именем.
- **Тест, который измеряет время вместо предмета** (§4.12) — не повтори его в новых фикстурах.
## 6. Оси ревью — характер «Код», плюс деньги и шов
Ось вправе заменить с аргументом. По умолчанию: **деньги** (кто и когда платит), **шов** (что видит
платформа), **конкурентность** (латч и его читатели), **словарь исходов** (коды выхода и `Outcome`
неприкосновенны), **тесты** (не измеряют ли они соседнюю величину).
## 7. Записка-план ДО первой правки
Напиши в свою секцию `docs/PROGRESS.md` порядок работы и почему он такой, ДО того как тронешь код.
**И размети КАЖДЫЙ подпункт §4 одним из трёх слов: МЕХАНИЗМ · ПИН · ВТОРОЙ ЛЕНДИНГ.** Подпунктов
пятнадцать, но работ там не пятнадцать: часть — однострочники и пины. Разметка нужна не для порядка, а
чтобы ОТЧЁТ первого лендинга сверялся со СПИСКОМ, а не с памятью. В прошлом паке этой серии ровно такая
разметка наряда дала «36 позиций, исходов не из трёх — 0»; без неё сессия сдаёт то, что помнит.
Рекомендованный: §4.1 сентинел (без него ни один другой пункт не проверяется) → §4.3 источник → §4.2
точка отказа → §4.4/§4.5 пост-обработка → §4.6 эскалация → §4.7 кадр → дальше строки.
**ДВА ЛЕНДИНГА, линия объявлена заранее.** Пак большой: транспорт, пайплайн, `tmctl`, кадр событий плюс конкурентность. Резать его на две СЕССИИ нельзя — §4.2 и §4.8 правят одну функцию `retryLoop`. Поэтому одна сессия, но **два лендинга**: первый — §4.14.7, §4.10, §4.12, §4.13, §4.15 и кадр; второй — §4.8 (строка **377**) и §4.9 (строка **375**). ⚠ **Кончившийся контекст после первого лендинга — штатный исход, а не провал:** остаток уходит новым промтом новой сессии, и это заранее разрешено. Владелец велел делать 377 «этим же заходом» — речь была о ПОРЯДКЕ работ, а не о размере одного лендинга.
## 8. Заявление = команда
Любое «сделано / проверено / закрыто» в отчёте сопровождается КОМАНДОЙ, которой это можно воспроизвести,
и её выводом. Утверждение без команды не считается сделанным.
## 9. Эхо-протокол старта
Первым сообщением верни: **(а)** одной фразой — в чём разница между двумя остановками; **(б)** какой
пункт §4 ты считаешь самым опасным и почему; **(в)** что в этом паке ты считаешь неверным или
недоказанным (право сказать это у тебя есть и им пользуются).
## 10. Что НЕ удалось — обязательная секция отчёта
Пустой она не бывает. Если пуста — ты не искал.
## 11. Канал вопросов и право сказать «этого делать не надо»
Вопрос — секцией в свою часть `docs/PROGRESS.md`; оркестратор читает. ⚠ Слово, живущее только в
переписке, умирает вместе с сессией: этой смене это стоило трёх потерянных диспозиций (`D39.231` п.1).
Всё, что должно пережить твою сессию, пишется в репозиторий.
Если считаешь, что пункт пака неверен, — скажи, а не обходи молча. Два пункта прошлого пака оказались
неверны, и оба были ошибками оркестратора.
## 12. Критерий завершённости — по нему тебя примут
1. Две остановки существуют, различимы человеком и машиной, и мягкая **не создаёт ни одной пометки
`cancelled`** — это проверяется тестом, а не рассуждением.
2. Прогон с недоделанными единицами не может уехать как успех.
3. Ожидание мягкой остановки ограничено ОДНОЙ летящей попыткой, и это ограничение запинено.
4. Второй сигнал даёт сегодняшнюю жёсткую остановку, **третий — дефолт процесса** (проверяется, а не подразумевается).
5. Кадры несут числами, мягко или жёстко всё кончилось, и счёт с суммой оценочных строк — **в форме
`D39.235` п.1, буква в букву**, включая правило присутствия `Stop` и минор `1.3 → 1.4`.
6. Строки **377**, **375**, **372**, **379**(а) закрыты, каждая с предъявленной уликой (377 и 375 — вторым лендингом, §7).
6-бис. Резюм после мягкой остановки делает ноль платных вызовов по отвеченным стадиям — запинено (§4.15д).
7. Батарея зелёная целиком (все пакеты, скипы названы), `counts.py --check` зелёный, мутационный прогон
снят с числом.
8. Отчёт: что не удалось, что ты изменил против пака и почему, какие числа сняты какой командой.
## Деньги
Пак **$0**: платных вызовов не требует и не разрешает. Всё проверяется фикстурами. Если тебе кажется,
что нужен живой провайдер, — это вопрос оркестратору, а не решение сессии.
## Отчёт
Своя секция `docs/PROGRESS.md`. Числа — командами. Ничего не коммить.