diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index c2d294e5..9026e081 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -3,7 +3,7 @@ > **Единственный трекер проекта.** Здесь живут строки, на которые доки, промты и D-ноты ссылаются словами «строка N» / «строка бэклога N»: **ID строки стабилен навсегда**, не перенумеровывается и не переиспользуется (D39.80). Правки — только через оркестратора; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено. > ⚠ **Это НЕ бэклог зоны `docs/`, а бэклог ПРОЕКТА.** У зон свои, с другими неймспейсами ID, и единый их строк не принимает (D39.84): платформа — [../platform/BACKLOG.md](../platform/BACKLOG.md) (`П-N`) и её регистр дефектов `platform/docs/DEFECT_REGISTER.md` (`PD-N`); фронт — [../frontend/docs/BACKLOG.md](../frontend/docs/BACKLOG.md) (`Ф-N`). > ⚠ **Состояние проекта — не здесь.** Очередь, курс, CURRENT-STATE, состояние паков и живая хроника — шапка [PROGRESS.md](PROGRESS.md). Здесь только долг и его диспозиции. -> - **СЧЁТ ОЧЕРЕДИ на 10.09 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **288** строк · зона бэкенд **138** строго / **185** широко (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0** (236 закрыта D39.175; **371** — актом D39.233, **360** — актом D39.232, обе 10.09), платные прогоны разблокированы · «скоро» **137** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строка эксп-22: 153 (плюс 150 — руки владельца; ⚠ 55 из гейтов СНЯТА — D39.198 п.5); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). +> - **СЧЁТ ОЧЕРЕДИ на 10.09 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **288** строк · зона бэкенд **138** строго / **185** широко (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0** (236 закрыта D39.175; **371** — актом D39.233, **360** — актом D39.232, обе 10.09), платные прогоны разблокированы · «скоро» **136** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строка эксп-22: 153 (плюс 150 — руки владельца; ⚠ 55 из гейтов СНЯТА — D39.198 п.5); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). > - ⚠ **Переезд 06.09 (D39.218):** таблица вынесена из `PROGRESS.md` ДОСЛОВНО и ни один ID не сдвинут — **с единственным исключением: две ячейки (строки 220 и 306) тронуты РОВНО в номере якоря `docs/scripts/counts.py:566`→`:569` и `:592`→`:595`**, потому что цель сдвинул этот же коммит; текст ячеек не менялся, длины равны. В `PROGRESS.md` на прежнем месте оставлен заголовок-указатель, поэтому ссылки «секция „Бэклог“» доезжают в один хоп. ## Бэклог (ЕДИНЫЙ, собран 26.07, актуализация 04.08 D39.99/D39.101; правки — только через оркестратора) @@ -296,7 +296,7 @@ | 370 | **Anthropic-адаптер не несёт капа ретраев, который есть у общего транспорта.** `backend/internal/llm/provider_anthropic.go` возвращает `BilledDecodeError` с `retryable=true` без ограничителя `billedDecodeSeen`, который стоит в `httpllm.go` — то есть на этом адаптере оплаченный 2xx может повториться до `max_attempts`, а не один раз. ⚠ **Боевой цены сегодня НОЛЬ:** поверхность deprecated, Anthropic убран из стека 04.07, вызовов по этому пути в прогонах нет. ⇒ сознательно вынесено ИЗ пака «вызов, который оборвали мы» (строка 360) по доводу ревью: пак получил бы тесты и мутации на мёртвую поверхность. Чинить при воскрешении адаптера — тогда же и проверять, применим ли к нему признак доставки | бэкенд | когда-нибудь | адаптер получает тот же кап ЛИБО удалён вместе с поверхностью | ревью пака «вызов, который оборвали мы», 08.09 | | 372 | **СТРАЖ ОТМЕНЫ ГЛОТАЕТ НАСТОЯЩУЮ ДЕНЕЖНУЮ ОСТАНОВКУ, когда прогон отменил не человек, а упавший сосед.** Платформа получит `failed` вместо `paused`, а книга без денег упрётся в потолок на ПЕРВОЙ резервации следующего прогона. Сессия стража оставила и была права: ратифицированный комментарий ветки `waitAborted` требует ровно этого для ОБОИХ случаев. ⇒ предмет не движковый, а **продуктовое решение о шве**: должна ли платформа отличать «остановлено человеком» от «остановлено падением соседа» ⛔ **ЗАМЕР 10.09 (движковая сессия, ЧТЕНИЕМ; пере-снят оркестратором): в дереве ЖИВЁТ УТВЕРЖДЕНИЕ, которое дерево же опровергает.** Источников отменённого контекста **ДВА**, и по `ctx.Err()` они неотличимы: человеческий сигнал (`backend/cmd/tmctl/main.go`, греп `signal.NotifyContext`) и **собственная отмена волны** — `runWave` заводит производный контекст (`backend/internal/pipeline/waverun.go`, греп `context.WithCancel(parent)`) и отменяет его из ветвей инфра-ошибки соседа и паники. При этом ДВА не-тестовых комментария прямо декларируют, что источник один: `backend/internal/pipeline/bankdecisions.go`, греп `by nothing else`, и `backend/cmd/tmctl/main.go`, греп `a cancelled run is one somebody asked to stop`. ⇒ ни `exitCode`, ни `terminal()` двух причин не различают, а комментарии учат следующего, что различать нечего. ⚠ Полный инвентарь носителей отмены (4 источника, около двадцати читателей, с адресами по HEAD) — в секции движковой зоны `docs/PROGRESS.md`; он вход любого пака про корректность остановки | бэкенд | скоро | шов различает две отмены ЛИБО объявлено, что не различает, с ценой | сдача пака «вызов, который оборвали мы» 08.09 | | 373 | **НА HTTP/2 НАШ ДЕДЛАЙН ИНЕРТЕН, если тело запроса больше окна пира, а провайдер отвечает ранним 2xx, не дочитав его.** Замер движковой сессии: вызов длится столько, сколько держит сервер — **4 с и 8 с при дедлайне 700 мс**, — и всё уезжает в `decode_error`, ту самую дверь, которую пак 360 объявил закрытой. Механизм: запись паркуется в `awaitFlowControl` на `sync.Cond`, которую контекст НЕ будит. ⚠ Дыра ПРЕДШЕСТВУЕТ паку и требует пирa, отвечающего не дочитав тело (НЕ форма DeepSeek). Лечится `http2.Transport.WriteByteTimeout`, но его значение — НОВЫЙ порог, а пороги назначать нельзя | бэкенд | когда-нибудь | дедлайн действует и на записи тела ЛИБО класс объявлен недостижимым с замером | сдача пака «вызов, который оборвали мы» 08.09 | -| 375 | **ВЫГРУЗКА ЧИТАЕТ ОТМЕНЁННУЮ ПОЗИЦИЮ КАК ВЫПАВШИЙ КУСОК ТЕКСТА.** `memberDrops` считает строку `cancelled` выпавшим членом редакторской единицы — то есть выносит КОНТЕНТ-вердикт по факту нажатия кнопки: читателю сообщается, что из единицы пропал текст, тогда как пропала лишь незаконченная работа. Семантика экспорта, не транспорт. ⚠ Исчезает само вместе с классом `cancelled`, если денежная половина строки **360** будет урезана. Найдено критиком полноты седьмого круга; диспозиция оркестратора жила письмом и носителя не имела до `D39.231` ⚠ **ДОБАВКА 10.09 (замер движковой зоны по запросу оркестратора, подтверждён ключом БД).** Правка «применить `resolvedForResume` внутри `memberDrops`» ПОЛНА для этого пути (живой вызов один — `backend/internal/pipeline/export.go:307`) и попутно чинит артефакт порядка: внутренний цикл `status.go:538-543` оставляет ПОСЛЕДНЮЮ флагнутую черновую строку, а строки приходят `ORDER BY … stage`, поэтому при двух черновых стадиях `cancelled` может перебить контентную причину; фильтр возвращает контентную. Случай «сверху лёг `cancelled`» НЕВОЗМОЖЕН: строка апсертится по `(book_id, chapter, chunk_idx, stage)` (`backend/internal/store/chunkstatus.go:92`), а контентно флагнутая стадия не пере-выполняется (резюм-гейт `backend/internal/pipeline/stagerun.go:93`). ⛔ **НО правка меняет ЛОЖЬ на МОЛЧАНИЕ, и это не закрытие:** если единственный флаг члена — `cancelled`, после фильтра `DroppedMembers` = 0 и `DroppedReason` пуст (`export.go:320-322`), единица уходит `ok`, молча не досчитавшись текста члена. ⇒ закрывать надо ТРЕТЬИМ состоянием («единица неполна из-за остановки»), а не одним фильтром. Окно узкое: нужна комбинация «финальная строка от прежнего прогона + редрайв члена + стоп» (`export.go:174`) ⛔ **ВТОРАЯ ПОЛОВИНА, вытащенная 10.09 из отменённого пака, чтобы не умереть вместе с ним (ПОДОЗРЕНИЕ — выведено ЧТЕНИЕМ кода движковой сессией, фикстурой НЕ гонялось):** строка `chunk_status` не добавляется, а ЗАМЕЩАЕТСЯ — апсерт по ключу стадии с `DO UPDATE SET … final_hash = excluded.final_hash` (`backend/internal/store/chunkstatus.go`, греп `on conflict`), а запись отмены кладёт ПУСТОЙ `final_hash`. ⇒ прогон, который ПЕРЕ-атакует позицию с уже готовой строкой (редрайв · пере-пин с `--resnapshot` · правка исходника) и будет оборван, оставляет позицию с пустым хешем — выгрузка теряет текст, чекпойнт которого лежит на диске. То есть отмена способна стереть НАСТОЯЩИЙ вердикт, а не только пометить незаконченное. Проверяется фикстурой «готовая строка → редрайв → обрыв» | бэкенд | скоро | единица, неполная ИЗ-ЗА ОСТАНОВКИ, отличима и от выпавшего контента, и от полной: ни ложного контент-вердикта, ни молчания | седьмой круг ревью пака 360, 08.09; добавка — замер зоны 10.09 | +| 375 | **ВЫГРУЗКА ЧИТАЕТ ОТМЕНЁННУЮ ПОЗИЦИЮ КАК ВЫПАВШИЙ КУСОК ТЕКСТА.** `memberDrops` считает строку `cancelled` выпавшим членом редакторской единицы — то есть выносит КОНТЕНТ-вердикт по факту нажатия кнопки: читателю сообщается, что из единицы пропал текст, тогда как пропала лишь незаконченная работа. Семантика экспорта, не транспорт. ⚠ Исчезает само вместе с классом `cancelled`, если денежная половина строки **360** будет урезана. Найдено критиком полноты седьмого круга; диспозиция оркестратора жила письмом и носителя не имела до `D39.231` ⚠ **ДОБАВКА 10.09 (замер движковой зоны по запросу оркестратора, подтверждён ключом БД).** Правка «применить `resolvedForResume` внутри `memberDrops`» ПОЛНА для этого пути (живой вызов один — `backend/internal/pipeline/export.go:307`) и попутно чинит артефакт порядка: внутренний цикл `status.go:538-543` оставляет ПОСЛЕДНЮЮ флагнутую черновую строку, а строки приходят `ORDER BY … stage`, поэтому при двух черновых стадиях `cancelled` может перебить контентную причину; фильтр возвращает контентную. Случай «сверху лёг `cancelled`» НЕВОЗМОЖЕН: строка апсертится по `(book_id, chapter, chunk_idx, stage)` (`backend/internal/store/chunkstatus.go:92`), а контентно флагнутая стадия не пере-выполняется (резюм-гейт `backend/internal/pipeline/stagerun.go:93`). ⛔ **НО правка меняет ЛОЖЬ на МОЛЧАНИЕ, и это не закрытие:** если единственный флаг члена — `cancelled`, после фильтра `DroppedMembers` = 0 и `DroppedReason` пуст (`export.go:320-322`), единица уходит `ok`, молча не досчитавшись текста члена. ⇒ закрывать надо ТРЕТЬИМ состоянием («единица неполна из-за остановки»), а не одним фильтром. Окно узкое: нужна комбинация «финальная строка от прежнего прогона + редрайв члена + стоп» (`export.go:174`) ⛔ **ВТОРАЯ ПОЛОВИНА, вытащенная 10.09 из отменённого пака, чтобы не умереть вместе с ним (ПОДОЗРЕНИЕ — выведено ЧТЕНИЕМ кода движковой сессией, фикстурой НЕ гонялось):** строка `chunk_status` не добавляется, а ЗАМЕЩАЕТСЯ — апсерт по ключу стадии с `DO UPDATE SET … final_hash = excluded.final_hash` (`backend/internal/store/chunkstatus.go`, греп `on conflict`), а запись отмены кладёт ПУСТОЙ `final_hash`. ⇒ прогон, который ПЕРЕ-атакует позицию с уже готовой строкой (редрайв · пере-пин с `--resnapshot` · правка исходника) и будет оборван, оставляет позицию с пустым хешем — выгрузка теряет текст, чекпойнт которого лежит на диске. То есть отмена способна стереть НАСТОЯЩИЙ вердикт, а не только пометить незаконченное. Проверяется фикстурой «готовая строка → редрайв → обрыв» ⚠ **ИСПР. 11.09 — ОДИН ИЗ ТРЁХ МАРШРУТОВ ОПРОВЕРГНУТ ЧТЕНИЕМ, и опровергла его АВТОР прежней формулировки:** через РЕДРАЙВ сценарий НЕДОСТИЖИМ. `ResetChunkStages` (`backend/internal/store/chunkstatus.go`, греп `func (s *Store) ResetChunkStages`) в ОДНОЙ транзакции удаляет и `checkpoints`, и `chunk_status` сбрасываемых стадий — пере-снято мной: две `DELETE` между `BeginTx` и `Commit`; то же говорит доккоммент `backend/internal/pipeline/volume.go` (греп `DELETES the unit`). ⇒ к моменту пере-атаки замещать нечего, прежнего вердикта уже нет. **Остаются ДВА маршрута**, оба обходят быстрый путь резюма при ЖИВОЙ прежней строке: пере-пин `--resnapshot` (снапшот уехал, признак пере-пинуемости ложен) и правка исходника или промта (не сходится хеш содержимого). Их и предъявляет фикстура; исход «недостижимо» — такой же результат | бэкенд | скоро | единица, неполная ИЗ-ЗА ОСТАНОВКИ, отличима и от выпавшего контента, и от полной: ни ложного контент-вердикта, ни молчания | седьмой круг ревью пака 360, 08.09; добавка — замер зоны 10.09 | | 376 | **ДОСТАВЛЕННЫЙ ОБРЫВ ДО ЗАГОЛОВКОВ КНИЖИТСЯ НУЛЁМ — и мы НЕ ЗНАЕМ, у скольких провайдеров это срабатывает.** Деньги за оборванный нами вызов считаются по `answered && afterHeaders` (`backend/internal/llm/attemptcut.go`), то есть единственный оплачиваемый обрыв — пришедший через ошибку ЧТЕНИЯ ТЕЛА. Обрыв до первого байта ответа (наш дедлайн, стоп родителя, разрыв сокета) даёт **$0** и не виден даже в оценочной паре (`paidtail.go` пропускает `CostUSD <= 0`). ⚠ **Сужение СОЗНАТЕЛЬНОЕ и ратифицировано** (`D39.230`, разведение предикатов): довод замером — 3 из 25 отменённых в полёте сеттлили оценку за запрос, которого не видел ни один хендлер, то есть переплата ЧИТАТЕЛЯ, запрещённая `D39.196` п.2а. ⛔ **Открыт РАЗМЕР остатка, и он неизвестен по построению:** провайдеров **8** (замер по `configs/models.yaml`), ранний `200` задокументирован ровно у ОДНОГО (DeepSeek), а про поведение заголовков у остальных СЕМИ в репозитории не сказано НИЧЕГО — греп класса по странице квирков даёт 0 при 250 строках файла. ⇒ верное утверждение не «сломано у семи», а **«неизвестно для семи»**: если заголовки у них приходят рано, путь почти не срабатывает; если поздно — головной класс пака у них не закрыт вовсе. ⚠ Замерить без платных вызовов нельзя, и постфактум из телеметрии тоже: движок не пишет время до первого байта успешных вызовов. ⇒ первый шаг дешёвый и $0 — начать писать это время | бэкенд | скоро | время до первого байта пишется; размер остатка назван числом, а не «неизвестно» | приёмка пака «вызов, который оборвали мы» 10.09 | | 377 | **ОПЛАЧЕННЫЙ ОБРЫВ, ЗА КОТОРЫМ РЕТРАЙ УСПЕЛ, ИСЧЕЗАЕТ БЕССЛЕДНО: $0 и ни одного следа.** `backend/internal/llm/httpllm.go` — на успешном ретрае `if err == nil { return resp, nil }`, и накопленный `owedCut` ВЫБРАСЫВАЕТСЯ. Замер приёмки: сервер спрошен ДВАЖДЫ (первый вызов доставлен, подтверждён и оборван), вызывающий получает чистый успех и о первом не узнаёт ничего — ни строки леджера, ни оценки, ни пометки. ⚠ Это ровно предмет пака (платим дважды за одну генерацию), но пак его НЕ заказывал: §4.2 требовал деньги «на исчерпании», а здесь исчерпания нет — ретрай удался. Ни одна из девяти фикстур этого пути не покрывает: `TestABrokenConnectionAfterDeliveryIsRetriedOnceAndOnlyOnce` роняет ОБЕ попытки | бэкенд | скоро | оплаченный обрыв виден в леджере независимо от исхода ретрая | приёмка пака «вызов, который оборвали мы» 10.09 | | 378 | **ПИН «НЕ-2XX — НЕ ПОКУПКА» ЛОВИТ МУТАНТА НЕ ТЕМ АССЕРТОМ: денежные проверки в нём не исполняются ни разу.** Каталожная мутация `CUTCALL-a-non-2xx-becomes-a-purchase` (батарейная, цель — `backend/internal/llm/httpllm.go`, ветвь статуса `if resp.StatusCode < 200 || resp.StatusCode >= 300` → `if false`). Пере-снято ОРКЕСТРАТОРОМ 10.09 на копии: тест краснеет, но ТЕКСТОМ `cutcall_test.go:584: a provider failing every attempt must surface as an infra failure` — ассертом об исходе ПРОГОНА. `t.Fatal` останавливает тест ДО трёх проверок, ради которых пин написан (`committed`, `reserved`, `checkpoints`), и они не исполняются. Под мутацией движок сеттлит **$0.001056 как ОЦЕНКУ за 500-ку** — ровно ту утечку, которую доккоммент теста называет, — и текст падения об этом молчит. ⚠ Класс ратифицирован (`D39.217` п.2в, `D39.232` п.6в) — здесь открыт НОСИТЕЛЬ | бэкенд | скоро | мутант краснеет ассертом О ДЕНЬГАХ: посылка «прогон упал» проверяется так, чтобы не глотать денежные проверки | приёмка пака «вызов, который оборвали мы» 10.09 (С8) + пере-замер оркестратора | @@ -312,7 +312,7 @@ | 388 | **ДЕНЬГИ БАНКОВОГО ПАСА ИСЧЕЗАЮТ ИЗ ИТОГА ПРОГОНА, ЕСЛИ ПАС ОБОРВАЛИ.** `backend/internal/pipeline/mining.go`, греп `tres, err := r.runTerminologist`: на ЛЮБОЙ ошибке, включая отмену, идёт `return false, err` ДО присваивания `r.lastTerminology`; а единственный сумматор (`backend/internal/pipeline/waverun.go`, греп `t.CostUSD + t.ClassifyCostUSD`) прибавляет только когда поле не пусто. Партии при этом КУПЛЕНЫ и чекпойнтнуты. ⇒ прогон, оборванный внутри паса, недосчитывает свой расход ровно на этот пас — на той единственной строке, которую читает оператор. Найдено ЧТЕНИЕМ, обе половины | бэкенд | скоро | итог прогона включает купленное банковым пасом независимо от того, чем пас кончился | улов движковой сессии, 10.09 | | 389 | **ОСТАНОВЛЕННЫЙ ПРОГОН НЕ ПЕЧАТАЕТ НИЧЕГО О ТОМ, ЧТО ПРОИЗВЁЛ, и ПОЗИЦИЯ ЧИСЛИТСЯ `failed`.** Две половины одной беды — человек после остановки не понимает, в каком состоянии книга. **(а)** `backend/cmd/tmctl/main.go`, `translate()`: частичный результат рендерится только для подписного стопа и потолка; на отмене результата нет вовсе, видна одна строка ошибки. **(б)** `backend/internal/pipeline/stagerun.go`: ветвь прерванного ожидания и охранник после впуска резервации обе пишут `jobs.status = failed` за позицию, которую остановил ЧЕЛОВЕК. Ничто на `jobs.status` не гейтится (файл это и говорит) ⇒ это телеметрия, но слово читает человек. ⚠ Под инвариантом `D39.240` («правильно возобновить») это поверхность, по которой понимают, откуда продолжать | бэкенд | скоро | после остановки человек видит, что куплено и что доделано, и остановленная позиция не названа упавшей | улов движковой сессии, 10.09 | | 390 | **ПРОВАЛИВШИЙСЯ СЕТТЛ НАМЕРЕННО ОСТАВЛЯЕТ РЕЗЕРВАЦИЮ — И СЛЕДУЮЩИЙ ПОТОЛОЧНЫЙ СТОП ПРОСИТ ПОПОЛНИТЬ БОЛЬШЕ, ЧЕМ НУЖНО.** `backend/internal/pipeline/stagerun.go`, греп `THE RESERVATION IS DELIBERATELY NOT RELEASED HERE`: подметается только следующим пишущим `store.Open`. В окне между остановкой и этим открытием `reserved_usd` завышен, а нехватка для сообщения о потолке считается ИМЕННО от `reserved` ⇒ пользователю называют завышенную сумму пополнения. Решение оставить резервацию — осознанное и объявленное в коде; незаявлено ПОСЛЕДСТВИЕ | бэкенд | когда-нибудь | сумма пополнения, называемая пользователю, не зависит от того, была ли остановка между сеттлом и следующим открытием стора | улов движковой сессии, 10.09 | -| 391 | ⛔ **ИЗ ЗАВИСШЕГО СВОРАЧИВАНИЯ НЕТ ВЫХОДА, КРОМЕ SIGKILL, А ОН НЕ ОСТАВЛЯЕТ ТЕРМИНАЛЬНОГО КАДРА.** `backend/cmd/tmctl/main.go`, `signal.NotifyContext`: горутина выходит после ПЕРВОГО сигнала, регистрация снимается только отложенным `stop()`, канал ёмкостью один ⇒ второй сигнал падает в переполненный буфер, дефолтная диспозиция НЕ восстанавливается, процесс не умирает. ⚠ Складывается с замеренным `D39.238`: два сигнала в одном планировочном кванте процесс видит как ОДИН (0 из 20 при нулевом зазоре) ⇒ «нажать ещё раз» не является выходом даже теоретически. Цена: единственный способ прекратить зависшее сворачивание — убийство, после которого платформа не получает ни исхода, ни расчёта. Лечение дешёвое и от отменённого пака НЕ зависит: третья ступень (`signal.Stop` + `signal.Reset` ⇒ вернуть дефолт) — около двадцати строк | бэкенд | скоро | у зависшего сворачивания есть выход, оставляющий терминальный кадр | улов движковой сессии, 10.09; ⚠ строка заведена ВЗАМЕН прежнего смысла строки **381**, которая была про две остановки и умерла с паком | +| 391 | ⛔ **ИЗ ЗАВИСШЕГО СВОРАЧИВАНИЯ НЕТ ВЫХОДА, ОСТАВЛЯЮЩЕГО ТЕРМИНАЛЬНЫЙ КАДР.** `backend/cmd/tmctl/main.go`, греп `signal.NotifyContext`: горутина выходит после ПЕРВОГО сигнала, регистрация снимается только отложенным `stop()`, канал ёмкостью один ⇒ второй сигнал падает в переполненный буфер и дефолтная диспозиция НЕ восстанавливается. Складывается с `D39.238`: два сигнала в одном кванте процесс видит как ОДИН, значит «нажать ещё раз» не выход даже теоретически. Единственный способ прекратить зависшее сворачивание — убийство извне, после которого платформа не получает ни исхода, ни расчёта. ⚠ **ИСПР. 11.09 — ПРЕЖНЯЯ РЕДАКЦИЯ ЭТОЙ СТРОКИ ПРОТИВОРЕЧИЛА САМА СЕБЕ, и нашла это движковая сессия, а не я.** Она обещала лечение «около двадцати строк» (вернуть дефолтную диспозицию) при критерии приёмки «выход, ОСТАВЛЯЮЩИЙ терминальный кадр». Дешёвое лечение даёт РОВНО ОБРАТНОЕ: второй сигнал убивает процесс диспозицией по умолчанию — тот же SIGKILL, только вызванный вежливее, и кадра по-прежнему нет. ⇒ **дешёвого решения у этой строки НЕТ**: выход С кадром означает «на второй сигнал сами пишем терминальную строку и выходим», и это отдельный предмет с ценой, вплотную подходящий к отменённой лестнице (`D39.240`). Состояние стора после убийства при этом ЗАПИНЕНО и корректно (`TestKillMinus9LosesAtMostOneCall`), то есть строка про ВИДИМОСТЬ исхода для платформы, а не про консистентность | бэкенд | когда-нибудь | у зависшего сворачивания есть выход, после которого платформа получает исход и расчёт — либо признано, что его цена не стоит предмета | улов движковой сессии 10.09; противоречие критерия найдено ею же 11.09 | | 392 | ⛔ **ОБРАБОТЧИК `/stop` ШЛЁТ СИГНАЛ ПО ИМЕНИ ЮНИТА ИЗ СВОЕЙ ЖЕ ВЫПИСКИ — окно в один тик, и всё это время пользователь видит `202`, а прогон работает.** `platform/internal/runs/reconcile.go`, греп `func (s \*Service) Stop`: имя юнита приходит из `RequestStop` и уходит в `Runner.Stop`. Если между коммитом намерения и вызовом systemd свип успел РЕСТАРТОВАТЬ попытку, сигнал уходит СТАРОМУ юниту, а живёт новый. Самолечение есть — следующий проход увидит живой юнит с намерением и пере-выдаст стоп, — но **починка приходит от свипа, а не от обработчика**. ⚠ И самолечение слабее, чем кажется: пере-выдача ИНЕРТНА на юните в `deactivating` (замер `D39.237` п.2), то есть если первый сигнал не дошёл, свип этого не чинит вовсе — юнит стоит до убийства по грейсу, до десяти минут прогона, который ничего не делает, с зарезервированным холдом | платформа | скоро | остановка доходит до ЖИВОГО юнита в том же действии, которым записано намерение | улов платформенной сессии, 10.09 | | 393 | **РАСЧЁТ ПРИ ЗАВЕРШЕНИИ ИДЁТ ИЗ ДО-ДРЕНАЖНОЙ ВЫПИСКИ — класс «частично применённое состояние внутри одного прохода».** `platform/internal/runs/reconcile.go`, греп `this settlement is opportunistic`: расчёт получает ту выписку, которую проход прочитал ДО дренажа журнала; автор класс знал и закрыл ровно два поля, освежив их вручную. ⇒ любое число, которое дренаж ЭТОГО ЖЕ прохода записал в строку попытки, для расчёта невидимо. ⚠ **Честная граница, названная самой сессией: на сегодняшнем дереве это ЛАТЕНТНО, а не сломано** — расчёт не читает ни одного поля, которое пишет дренаж. Класс предъявлен исполнением только потому, что отменённый пак добавил такое поле (строка попытки видела 9 оценочных строк, выписка — 0). Пак уходит, поле уходит, **класс остаётся**. Родственник в том же файле уже стоил зоне дефекта (греп `freshRunState`) | платформа | когда-нибудь | у прохода одно состояние: расчёт не может читать выписку старше собственного дренажа | улов платформенной сессии, 10.09 | | 394 | **ПРОГОН, УБИТЫЙ ПО НАШЕМУ ЖЕ ГРЕЙСУ, ЧИТАЕТСЯ КАК ИНФРАСТРУКТУРНЫЙ СБОЙ.** Терминального кадра у него нет вовсе, маркер несёт `timeout/killed`. Разбор: исход сперва спрашивает намерение остановки и при записанном отдаёт `stopped`; БЕЗ намерения падает в причину отказа, где `timeout` даёт `service_error`. ⇒ у класса «мы сами его убили по своему сроку» нет отдельной пометки, и оператор видит поломку там, где сработала наша политика. ⚠ Прямо на предмете `D39.240`: остановка обязана быть отличима от аварии | платформа | скоро | убийство по грейсу отличимо от сбоя и названо своим словом | улов платформенной сессии, 10.09 |