Take the engine zone's inventory of where cancellation is created and read, and its own statement of what this shift proved and what it only read.

This commit is contained in:
heaven 2026-09-11 00:08:47 +03:00
parent 77b571f3df
commit ab7b3c44a4

View file

@ -185,7 +185,213 @@
## Бэкенд
#### Пак «ДВЕ ОСТАНОВКИ» (10.09, промт `docs/BACKEND_SOFT_STOP_SESSION_PROMPT.md`, вход HEAD `121c3c7`). ⏳ В РАБОТЕ — НЕ КОММИЧУ, жду лендинга оркестратором; пак ПАРНЫЙ и порознь не лендится (`D39.234` п.2)
#### Пак «ДВЕ ОСТАНОВКИ» (10.09, промт `docs/BACKEND_SOFT_STOP_SESSION_PROMPT.md`, вход HEAD `121c3c7`). ⛔ **ОТМЕНЁН ВЛАДЕЛЬЦЕМ 10.09, код зоны откатывается к HEAD**
> ⛔ **Слово владельца:** пак отменён не по качеству, а по РАЗМАХУ — восемнадцать файлов движка ради двух
> остановок он счёл несоразмерной ценой. **Вместо него инвариант значительно уже: остановка остаётся
> ЖЁСТКОЙ, деньги терять ДОПУСТИМО (сказано дословно), но остановка обязана быть КОРРЕКТНОЙ** — без гонок,
> без недоделанных состояний в сторе, чтобы ни движок, ни платформа не оставили неконсистентности и чтобы
> возобновление было правильным. Предмет переехал с «не сжечь деньги» на «не оставить мусор и правильно
> продолжить».
>
> ⚠ **Ниже сохранены ДВЕ вещи и по разным причинам.** Записка-план — как след того, что и почему строилось
> (пригодится, если к двум остановкам вернутся). **Улов о ДЕРЕВЕ (§«ЧТО ЗДЕСЬ НАЙДЕНО») — как знание,
> которое переживёт откат кода:** это то, что я УЗНАЛА про дерево, а не построила. Разметка провенанса
> — **ЧТЕНИЕМ · ИСПОЛНЕНИЕМ · ПОДОЗРЕНИЕ** — сохранена дословно по просьбе оркестратора: она половина
> ценности, потому что позволяет заводить строки с честным весом, а не с одинаковым. Строки трекера
> заводит оркестратор со ссылкой сюда (`D39.236` и далее); здесь — отчёт с провенансом, там — единицы
> работы. Это разделение носителей, а не дубль.
### ЧТО ЗДЕСЬ НАЙДЕНО — улов о дереве, снятый ДО отката
#### 1. Что НЕ консистентно уже сегодня, при ЖЁСТКОМ стопе
**1.1 Деньги банкового паса ИСЧЕЗАЮТ из итога прогона (ЧТЕНИЕМ, обе половины).**
`backend/internal/pipeline/mining.go`, греп `tres, err := r.runTerminologist`: на ЛЮБОЙ ошибке (в том
числе отмене) идёт `return false, err` ДО присваивания `r.lastTerminology = tres`. Единственный сумматор
`waverun.go`, греп `t.CostUSD + t.ClassifyCostUSD` — прибавляет только при `lastTerminology != nil`.
Партии при этом куплены и чекпойнтнуты. ⇒ прогон, оборванный внутри паса, недосчитывает СВОЙ расход ровно
на этот пас, на той единственной строке, которую читает оператор.
**1.2 Остановленный прогон не печатает НИЧЕГО о том, что произвёл (ЧТЕНИЕМ).**
`backend/cmd/tmctl/main.go`, `translate()`: частичный результат рендерится только для
`WaveSignatureStop` и `CeilingHalt`. На отмене `translateBookWaves` и так возвращает `(nil, err)`
результата нет вовсе. Под новым инвариантом это ровно та поверхность, по которой человек понимает, в
каком состоянии книга; сегодня он видит одну строку ошибки.
**1.3 ⛔ `cancelled`ЗАМЕНА строки целиком, и она способна стереть настоящий вердикт вместе с
`final_hash` (ПОДОЗРЕНИЕ, выведено по коду; фикстурой НЕ гонялось).**
`backend/internal/store/chunkstatus.go:93` — апсерт по `(book_id, chapter, chunk_idx, stage)` с
`DO UPDATE SET … final_hash = excluded.final_hash …`: строка не добавляется, а замещается.
`recordCancelledStage` (`backend/internal/pipeline/cutcall.go`) пишет `FinalHash: ""` и
`FlagReason: cancelled`. ⇒ прогон, который ПЕРЕ-атакует позицию с уже готовой строкой (редрайв,
пере-пин `--resnapshot`, правка исходника) и будет оборван, оставляет позицию с ПУСТЫМ `final_hash`
выгрузка теряет текст, чекпойнт которого лежит на диске. Это ⛔-половина строки **375**, жившая только в
отменённом паке; оркестратор переносит её в тело самой строки.
**1.4 Читающие модели называют остановленную позицию ПОТЕРЯННЫМ КУСКОМ ТЕКСТА (ЧТЕНИЕМ).**
`backend/internal/pipeline/status.go:534` (`memberDrops`) считает строку `cancelled` выпавшим членом
единицы; живой вызов один — `export.go:307`. Читателю сообщают, что из единицы пропал текст, тогда как
пропала незаконченная работа. Строка **375**, уже заведена.
**1.5 Таблица джобов говорит `failed` о позиции, которую остановил ЧЕЛОВЕК (ЧТЕНИЕМ).**
`backend/internal/pipeline/stagerun.go`: ветвь `waitAborted` и охранник `if ctx.Err() != nil` после
`admit`обе пишут `setJobStatus(ctx, job.ID, "failed")`. Ничто на `jobs.status` не гейтится (сам файл
это говорит), то есть это телеметрия — но это слово, которое читает человек.
**1.6 Провалившийся сеттл ОСТАВЛЯЕТ резервацию, намеренно (ЧТЕНИЕМ, объявлено в коде).**
`stagerun.go`, греп `THE RESERVATION IS DELIBERATELY NOT RELEASED HERE`: подметается только следующим
пишущим `store.Open`. В окне между стопом и этим открытием `reserved_usd` завышен, а
`shortfallMicroUSD` читает именно `reserved` ⇒ следующий потолочный стоп попросит пополнить БОЛЬШЕ, чем
нужно.
**1.7 Второй Ctrl-C не делает ничего (ЧТЕНИЕМ + поведение stdlib).**
`backend/cmd/tmctl/main.go`, `signal.NotifyContext`: горутина выходит после ПЕРВОГО сигнала, регистрация
снимается только `defer stop()`, канал ёмкостью 1 ⇒ второй сигнал падает в переполненный буфер,
дефолтная диспозиция НЕ восстанавливается, процесс не умирает. Следствие для нового инварианта: сегодня
нет способа завершить зависшее сворачивание, кроме SIGKILL, а SIGKILL не оставляет терминального кадра.
**1.8 Сигналы СЛИПАЮТСЯ (ИСПОЛНЕНИЕМ; замер платформенной сессии, пере-снят оркестратором).**
Два сигнала в одном планировочном кванте Go-процесс видит как ОДИН: в `runtime/sigqueue` на сигнал
заведён БИТ, а не счётчик, и размер буфера канала этого не чинит. 0 из 20 при зазоре 0 мс, 20 из 20 при
1 мс. ⇒ любое рассуждение о корректности, которое СЧИТАЕТ доставки сигналов, ложно; разделить их может
только наблюдаемый зазор.
**1.9 ⭐ А вот что КОНСИСТЕНТНО, и на это новый инвариант обязан опереться (ЧТЕНИЕМ).**
Каждая операция стора идёт на СВОЁМ контексте: `backend/internal/store/store.go:244`
`opContext() = context.WithTimeout(context.Background(), 10s)` — и ни один метод стора не привязан к
контексту прогона (`LogRequest` берёт `ctx` только ради trace-id и лога). ⇒ записи ПОСЛЕ отмены доходят:
и пометки, и сеттлы, и терминальный кадр. **Значит корректность жёсткого стопа зависит от ПОРЯДКА
записей, а не от того, переживут ли они отмену.** Нигде не записано, а держит очень много.
#### 2. Гонки — найденные и заподозренные
**2.1 ⛔ ИСПОЛНЕНИЕМ: инверсия порядка блокировок на шве «эмиттер ↔ стор». Ловушка ДЕРЕВА, не правки.**
Эмиттер держит `e.mu` ПОПЕРЁК записей в стор (`events.go`: `project()``journal.Append` +
`markAnnounced``store.MarkAnnounced`; `enqueue``store.EnqueueEvent`), а замыкание строки расхода,
отдаваемое в `SettleWithCheckpoint`, исполняется ВНУТРИ единственной пишущей транзакции стора. ⇒ любой
код, берущий `e.mu` из этого замыкания, замыкает цикл: сеттл держит пишущее соединение и ждёт `e.mu`,
эмиттер держит `e.mu` и ждёт соединение. **Снаружи это НЕ похоже на дедлок:** его разрывает
10-секундный `opTimeout` стора, и симптом — `pipeline: ensure job …: context deadline exceeded` в
**тринадцати тестах, не имеющих отношения к правке**. Наступила, продиагностировала, вылечила отдельным
мьютексом, который никогда не держится поперёк ввода-вывода, — и лечение уезжает вместе с откатом, то
есть мина остаётся заряженной. **Правило, которое надо записать: не брать `e.mu` из-под сеттла.**
**2.2 Всё, что зовут из обработчика сигналов, не должно брать `e.mu`** — по той же причине: он может быть
занят на время записи в стор.
**2.3 Горутина хартбита** (`stagerun.go`, `logWaitingForProvider`): останавливается ЯВНЫМ вызовом после
`client.Complete`, не дефером; на отмене выходит сама по `ctx.Done()`, так что отмена её не течёт.
Единственная дыра: паника внутри `client.Complete` пропускает явный стоп, и горутина живёт до смерти
контекста. ЧТЕНИЕМ, малая.
**2.4 Случайность выбора в `select` фидера** уже обработана и объяснена в `waverun.go` (неблокирующая
проба перед многосторонним `select`). Знать это обязан всякий, кто добавит ещё один латч: Go выбирает
РАВНОВЕРОЯТНО среди готовых веток, и «латч закрыт» без предварительной пробы даёт ещё по одному
стартовавшему элементу на каждого простаивающего рабочего.
**2.5 `lastTerminology` / `lastMinedCount` — однопишущие ПО ФАЗЕ** (объявлено в `runner.go`). Правда ровно
пока пас идёт между волнами; перенос паса внутрь волны ломает их молча.
**2.6 ПОДОЗРЕНИЕ, не проверено:** `recordCancelledStage` — ДЕФЕР внутри `runStage`, и он пишет в стор даже
под отменой (см. 1.9). Если процесс убьют между сеттлом и этим дефером (SIGKILL, или третье нажатие при
восстановленной дефолтной диспозиции), деньги на записи, а позиция без пометки. Окно маленькое, но это
ровно класс «не оставить мусор».
#### 3. Что из построенного служило бы НОВОМУ инварианту (код откатывается; сохраняю ОПИСАНИЕ)
- **Реестр летящих вызовов** (`inflight.go`, был): кто ещё в полёте, до какого срока, и что с ними стало.
Две точки подключения — `runAttempt` вокруг `client.Complete` и сеттл оборванного вызова. Это вопрос,
на который корректное завершение обязано отвечать: чего ждать, что оборвали, что об этом сказать.
- **Кадр `Stop{mode, in_flight_finished, in_flight_cut, cut_estimated_micro_usd}`** с правилом присутствия
«остановку ЗАПРОСИЛИ» (а не «исход stopped»). Без мягкой половины вырождается в «прогон остановил
человек, оборвано N вызовов, стоило X» — и это по-прежнему ответ на строку **372** («человек или
упавший сосед»): поле есть ⇒ за этим человек, нет ⇒ упал сосед.
- **Рендер частичного леджера на стоп-пути** — п. 1.2.
- **Третья ступень сигнальной лестницы** (`signal.Stop` + `signal.Reset` ⇒ дефолт процесса, ~20 строк,
от мягкой остановки не зависит): единственный существующий выход из зависшего сворачивания (п. 1.7).
Держится как КАНДИДАТ, не как заказ: цену решает владелец.
- **`estimated{rows, micro_usd}` на денежных кадрах с сидом ИЗ СТОРА** — строка **382**, вообще не про две
остановки. Диспозиция «сид из стора, дальше инкременты» ратифицирована `D39.236` п.1 и переживает откат;
замер рабочей сборки: `rows=1`, `7016` micro-USD, сходится с `estimated_rows`/`estimated_usd` из
`status --json` на одном сторе.
- **Методика ДЕТЕРМИНИРОВАННЫХ пинов остановки** (не на стенных часах): закрывать латч ИЗНУТРИ
обработчика запроса — тогда решение о ретрае физически не может случиться раньше стопа; и ИЗНУТРИ
логгера, в который петля пишет по дороге в бэкофф («will retry» пишется ПОСЛЕ проверки латча и ДО
`select`) — тогда сон уже начат и ветка `select` пинится точно, а не вероятно. Обе пробы были зелёными
с контролем: тот же 503 без стопа ретраится `MaxAttempts` раз.
#### 4. Мелочь для следующего пака
В `backend/cmd/tmmutate/mutations.json` две записи (`MS-draft-stop-does-not-edit`,
`MS-volume-ledger-on-a-money-stop`) держат `find` ДОСЛОВНЫМИ строками из `waverun.go`. Любой рефактор с
переименованием `moneyStoppedResult` / `noteMoneyStop` обязан обновить каталог, иначе мутации перестанут
сажаться — а «не посадилась» и «пережила» в отчёте выглядят одинаково.
#### 5. ИНВЕНТАРЬ НОСИТЕЛЕЙ ОТМЕНЫ — адреса по HEAD `121c3c7` (вход любого следующего пака; строка **372** ссылается сюда)
**Главное здесь не список, а вывод под ним.** Список — чтобы не собирать его заново; вывод — чтобы не
начать следующий пак с ложной посылки, которую дерево само же и декларирует.
**ИСТОЧНИКИ — кто создаёт отменяемый контекст. Их ЧЕТЫРЕ.**
1. `backend/cmd/tmctl/main.go:207``signal.NotifyContext(Background, SIGINT, SIGTERM)`. Единственный
ЧЕЛОВЕЧЕСКИЙ источник.
2. `backend/internal/pipeline/waverun.go:350``context.WithCancel(parent)` в `runWave`; отменяется из
`fail()` (инфра-ошибка СОСЕДА) и из `failPanic()`. ⇒ **второй источник, не человек.**
3. `backend/internal/llm/httpllm.go:568` и `backend/internal/llm/provider_anthropic.go:206`
`context.WithTimeout(ctx, deadline)` на ОДНУ попытку. Отличим только предикатом
`attemptCtx.Err()==DeadlineExceeded && ctx.Err()==nil`.
4. `backend/internal/store/store.go:150``opContext()`, ОТСОЕДИНЁННЫЙ от прогона (см. п. 1.9 выше).
**ЧИТАТЕЛИ — пайплайн:**
`bankdecisions.go:189`, `:239` (bank-apply) · `stagerun.go:627` (ветвь `waitAborted`, текст ошибки) ·
`stagerun.go:643`, `:646` (охранник «прогон кончается, а не деньги» ПЕРЕД возвратом `CeilingHalt`) ·
`stagerun.go:975` (горутина хартбита выходит по `ctx.Done()`) · `reservegate.go:169`
(`waitForSettle`: `<-ctx.Done()``waitAborted`) · `ratelimit.go:51`, `:52`, `:73`, `:75` (рейт-гард на
входе и в ожидании слота) · `waverun.go:448` (фидер волны при раздаче индексов) · **`waverun.go:497`
(`return parent.Err()` — ЕДИНСТВЕННОЕ место, где отмена становится ИСХОДОМ прогона, если ни один рабочий
не вернул ошибку).**
**ЧИТАТЕЛИ — транспорт:**
`httpllm.go:198``:199` (граница попытки: отмена побеждает, но денежная улика `owedCut` джойнится) ·
`httpllm.go:213`, `:228` (та же развилка ВНУТРИ бэкоффа) · `httpllm.go:601`, `:604` (`attempt()`:
отличает НАШ дедлайн от родительской отмены и этим решает `retryable`) · `httpllm.go:645` (то же на
чтении тела) · `provider_anthropic.go:219` (то же в нативном адаптере) · `attemptcut.go:198`, `:200`,
`:226` (классификация обрыва: родитель / наш дедлайн / соединение, и `cut.Parent = ctx.Err()`).
**ВЫВОД (ЧТЕНИЕМ): посылка «отменённый контекст ⇒ человек» ЛОЖНА уже сегодня.** Источников отмены ДВА
(1 и 2), по `ctx.Err()` они неотличимы, и ни `exitCode`, ни `terminal()` их не различают. При этом в
дереве живут ДВА не-тестовых комментария, прямо ДЕКЛАРИРУЮЩИХ обратное — «cancelled by
signal.NotifyContext and by nothing else» (`backend/cmd/tmctl/main.go:114`) и то же по смыслу в
`backend/internal/pipeline/bankdecisions.go:228`. То есть дерево учит следующего читателя, что различать
нечего, и опровергается соседним файлом. Это строка **372**, увиденная со стороны механизма.
#### 6. ЧТО ПРОВЕРЕНО ЧЕМ — границы этой смены, названные явно
- ⛔ **Мутационный прогон в эту смену НЕ ЗАПУСКАЛСЯ НИ РАЗУ.** `make mutations` отложен на конец пака и
не состоялся; ни одной мутации на путь остановки не сажалось. Любое утверждение «мутант выжил / не
выжил» на путях остановки в этой смене веса НЕ ИМЕЕТ — его никто не мерил.
- **Незапиненные утверждения, увиденные ЧТЕНИЕМ** (кандидаты «проверить прибором», не «починить»):
`res.Volume = nil` живёт единственным носителем `noteMoneyStop`, а каталожная запись
`MS-volume-ledger-on-a-money-stop` сама объявляет свой ПЕРВЫЙ пин вырожденным (фикстура без гранта) ⇒
проверять только с `--max-units` в силе · порядок ветвей `terminal()` держится комментарием, и файл сам
пишет «THIS BRANCH IS LAST AND IT WAS NOT, FOR AN HOUR» · `settleCannotHelp`: предикат запинен, ПРОВОДКА
`if` — нет, и это признано в `backend/docs/MONEYSTOP_REPORT.md` §25(г) словами «⚠ НЕ ЗАКРЫТА».
- **`terminal()` вызывается БЕЗ `defer`** (`bookrun.go`, `TranslateBook`: `res, err := r.translateBook(ctx)`
и следом `r.events.terminal(res, err)`). Следствие: паника, поднявшаяся ВНЕ рабочего волны (панику
рабочего `runWave` ловит и отдаёт ошибкой), пройдёт мимо — поток останется без терминальной строки, а
её отсутствие читатель толкует как «оборвано крахом», что в этом случае ПРАВДА. **Свойство, не дефект.**
ЧТЕНИЕМ, 11.09.
- **«Живых горутин после `TranslateBook` ноль» — НЕ ИЗМЕРЯЛОСЬ.** Ни `runtime.NumGoroutine`, ни `goleak`
не запускались. Известно только п. 2.3: горутина хартбита выходит по `ctx.Done()`, кроме случая паники
внутри `client.Complete`.
- ⚠ **И норма, ради которой это записано:** две из трёх «попутных находок движковой сессии», принесённые в
разбор через третьи руки, оказались НЕ моими. Разметка «замерено / прочитано / подозрение» принадлежит
тому, кто мерил, и переносу через пересказ не подлежит: спрашивать надо не «верно ли это», а «твоё ли
это и чем подтверждено».
### ЗАПИСКА-ПЛАН И BASELINE ОТМЕНЁННОГО ПАКА (след, не заказ)
**BASELINE НА ВХОДЕ, снят СВОИМ прогоном ДО первой правки (§5.12).**
`python3 docs/scripts/counts.py --check` → «Литералы сходятся с пере-счётом (8 проверок)», exit 0.