From 7e9f8451160a17ff77462495ef38995e396ae445 Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 10 Sep 2026 23:46:15 +0300 Subject: [PATCH] Save what two sessions learned in the paths a rollback is about to erase: a store that outlives cancellation, a lock cycle that reads as a timeout, and a stop no second press can end. --- docs/BACKLOG.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 20aac3ff..d4b87733 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`; обновлять при каждом лендинге):** всего **271** строк · зона бэкенд **132** строго / **179** широко (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0** (236 закрыта D39.175; **371** — актом D39.233, **360** — актом D39.232, обе 10.09), платные прогоны разблокированы · «скоро» **125** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строка эксп-22: 153 (плюс 150 — руки владельца; ⚠ 55 из гейтов СНЯТА — D39.198 п.5); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). +> - **СЧЁТ ОЧЕРЕДИ на 10.09 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **283** строк · зона бэкенд **138** строго / **185** широко (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0** (236 закрыта D39.175; **371** — актом D39.233, **360** — актом D39.232, обе 10.09), платные прогоны разблокированы · «скоро» **134** (перечень — грепом по таблице, рукописный список снят 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,14 +296,26 @@ | 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` требует ровно этого для ОБОИХ случаев. ⇒ предмет не движковый, а **продуктовое решение о шве**: должна ли платформа отличать «остановлено человеком» от «остановлено падением соседа» | бэкенд | скоро | шов различает две отмены ЛИБО объявлено, что не различает, с ценой | сдача пака «вызов, который оборвали мы» 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`) | бэкенд | скоро | единица, неполная ИЗ-ЗА ОСТАНОВКИ, отличима и от выпавшего контента, и от полной: ни ложного контент-вердикта, ни молчания | седьмой круг ревью пака 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` · правка исходника) и будет оборван, оставляет позицию с пустым хешем — выгрузка теряет текст, чекпойнт которого лежит на диске. То есть отмена способна стереть НАСТОЯЩИЙ вердикт, а не только пометить незаконченное. Проверяется фикстурой «готовая строка → редрайв → обрыв» | бэкенд | скоро | единица, неполная ИЗ-ЗА ОСТАНОВКИ, отличима и от выпавшего контента, и от полной: ни ложного контент-вердикта, ни молчания | седьмой круг ревью пака 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) + пере-замер оркестратора | | 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** — отмена обгоняла разбор заголовков, и строка сходилась «ноль к нулю» (`D39.232` п.6а). **(б)** `backend/internal/llm/attemptcut_test.go` — `TestARefusalIsNotAPurchaseEvenWhenTheReplyOutrunsTheWrite` утверждает только `err != nil` и `!errors.As(err, &cut)`; что ответ ДЕЙСТВИТЕЛЬНО обогнал запись (шестимегабайтное тело не дописалось, `WroteRequest` не сработал), не проверяет ничто — тест зелен и на сценарии, где запись успела, то есть на другом предмете | бэкенд | скоро | каждый из двух пинов ПЕЧАТАЕТ величину, доказывающую пройденную границу, и падает, когда сценарий не состоялся | приёмка пака «вызов, который оборвали мы» 10.09 (С5, С6) | | 380 | **У ДЕНЕЖНОГО УТВЕРЖДЕНИЯ «ПЕРЕ-ДЕЛКА НЕ УДВАИВАЕТ БЮДЖЕТ» ОДИН НОСИТЕЛЬ, А НЕ ПЯТЬ.** Колонка `wantSameBudget` стоит в четырёх строках таблицы девяти (`backend/internal/pipeline/cutcall_test.go:333,340,355,361`), но осей не разводит: обжог случается ВНУТРИ `runAttempt`, а `maxTokens` считается в `runStage` ДО вызова, на итерации `attempt == 0`, где номер попытки и число удвоений равны по причине, к предмету отношения не имеющей. Пере-снято ОРКЕСТРАТОРОМ 10.09 на копии под каталожной мутацией `CUTCALL-the-redo-doubles-the-budget` (`maxTokensForAttempt(baseMaxTokens, escalations)` → `..., attempt`): таблица девяти — **0 красных из 8** (контроль: прибор запустил 10 подтестов, то есть спрашивал существующее), весь пакет `internal/pipeline` — ровно **ОДИН** красный тест, `TestABurnFollowedByARegenerationDoesNotOverBuy` (`cutcall_test.go:1435`). ⚠ Сам носитель ПОЧИНЕН паком (посылку теперь утверждает: «run 1 must leave exactly ONE checkpoint», «…is three checkpoints»), поэтому дыры сегодня нет — есть ЕДИНСТВЕННОСТЬ | бэкенд | когда-нибудь | утверждение держат ≥2 независимых носителя ЛИБО колонка `wantSameBudget` снята как ничего не меряющая, и это записано | приёмка пака «вызов, который оборвали мы» 10.09 (С3) + пере-замер оркестратора | -| 381 | ⛔ **ДВУХ ОСТАНОВОК НЕТ: у человека одна кнопка, и она ЖЁСТКАЯ.** Слово владельца 09–10.09 («должно быть два стопа, один жёсткий… и второй мягкий, который не обрывает всё сразу, чтоб деньги зазря не списывались») носителя не получило — ни ноты, ни строки, ни заказа в паке; жило перепиской и умерло вместе с сессией (тот же класс, что `D39.231` п.1). **Замерено 10.09 оркестратором и подтверждено движковой зоной:** единственная регистрация сигнала — `backend/cmd/tmctl/main.go:207` `signal.NotifyContext`, она отменяет контекст прогона ⇒ вызовы в полёте рвутся, доставленные книжатся по оценке и метятся `cancelled`. **Мягкий ГЛАГОЛ уже построен** — `latched` (`backend/internal/pipeline/waverun.go:353-363`, дословно «a different verb from cancel()»: фидер перестаёт раздавать индексы, начатое доигрывается под неотменённым контекстом), но дёргает его только денежный потолок (строка 277), и он ЛОКАЛЕН для одного `runWave` (живых вызовов два: `waverun.go:140`, `:285`) ⇒ кнопке нужен ПРОГОННЫЙ источник, который волна консультирует. **Что ещё названо замером зоны:** (а) мягкий стоп контекст не отменяет ⇒ `errors.Is(err, context.Canceled)` ложно и `exitCode` (`cmd/tmctl/main.go:117`) отдаёт 0/2 — «я остановил» машинно неотличимо от «книга кончилась»; форма для своего кода и поля есть у потолка (`CeilingHalt` → 4 → `events.go:423`), но прецедент против отдельного `outcome` тоже есть (`runevents.go:261-264`); (б) банковые проходы идут ВНЕ волны (`terminologist.go:348`, `:907`, зовутся из `mining.go:166`) и латчем не останавливаются — их естественная точка попартийный приём `terminologist.go:944-970`; (в) резюм уже верен и правки не требует (`waverun.go:449-452`); (г) `--max-units` слотов не тратит, но недоигранный грант должен публиковаться в `Finished.Volume`; (д) ⛔ **второй Ctrl-C сегодня НЕ ДЕЛАЕТ НИЧЕГО** — `stop()` стоит на `defer` (`main.go:208`), горутина `NotifyContext` после первого сигнала вышла, второй сигнал попадает в буфер и никем не читается ⇒ «второе нажатие ужесточает» — НОВОЕ поведение, а не восстановление дефолта. ⚠ **Продуктовая цена, которую владелец должен знать ДО решения:** мягкий стоп доигрывает начатое, а один вызов может держаться до `attempt_max_s` (~20 мин на deepseek) — оператор, не знающий этого, нажмёт второй раз и потеряет ровно те деньги, ради которых мягкий стоп заводился ⛔ **ДОБАВКА 10.09: два носителя УЖЕ обещают мягкий стоп, которого нет.** Контракт — `docs/architecture/14-api-contract/openapi.yaml:762` «The product "stop" action; finished work is kept and not paid for again»: жёсткий стоп это нарушает, летящая единица книжится по оценке и пере-делается на резюме (`backend/internal/pipeline/cutcall.go:208`), то есть покупается ДВАЖДЫ. Платформа — `platform/internal/runner/runner.go:56-58` («the engine … finishes the in-flight chunk before exiting») и `:163-164`: оба утверждения о движке ложны. И числовой конфликт: `stopGrace = 10 * time.Minute` (`runner.go:59`) МЕНЬШЕ потолка ожидания вызова (`attempt_max_s` 1240 с) ⇒ как только SIGTERM станет мягким, systemd будет убивать законное ожидание, а SIGKILL не оставляет ни пометки, ни сеттла. ⇒ пак движка ПАРНЫЙ платформенному, порознь не лендятся | бэкенд | скоро | у человека ДВЕ различимые остановки: мягкая (новое не начинаем, начатое доигрываем, пометок `cancelled` не возникает) и жёсткая; обе различимы машинно — кодом выхода и признаком в кадре `finished`, чтобы платформа не читала остановку как конец книги | слово владельца 09–10.09 + замер оркестратора и движковой зоны 10.09 | +| 381 | ⛔ **СНЯТА 10.09 ВМЕСТЕ С ОТМЕНОЙ ПАКА «ДВЕ ОСТАНОВКИ» (`D39.240`) — предмет строки был «у человека одна кнопка и она жёсткая», а владелец решил, что жёсткой и достаточно.** Тело не воспроизводится: замеры, ради которых строка стояла, ратифицированы нотами и живут там — блокирующий `systemctl stop` без `--no-block` и инертность повторного стопа на `deactivating` (`D39.237`), коалесценция двух сигналов в один (`D39.238`), диагноз «у вопроса о трате нет единственного хозяина» (строка **385**). ⚠ **Единственная часть прежнего тела, которая пережила отмену и живёт своей жизнью, — строка 391**: из зависшего сворачивания нет выхода, кроме убийства, и оно не оставляет терминального кадра. Строка сохранена надгробием, а не удалена, потому что на неё ссылаются `D39.233`–`D39.240` | бэкенд | когда-нибудь | ссылки на 381 ведут к нотам, а не в пустоту | `D39.240` | | 382 | ⛔ **ПОМЕТКА «ЭТО ОЦЕНКА» — УСЛОВИЕ СЛОВА ВЛАДЕЛЬЦА (`D39.230` п.1) — ПО ШВУ НЕ ЕДЕТ И ПОТРЕБИТЕЛЯ НЕ ИМЕЕТ.** Владелец разрешил списывать с читателя по ОЦЕНКЕ при условии, что пометка стоит. В движке она есть: `backend/internal/pipeline/status.go:321` — `estimated_rows` / `estimated_usd` в `status --json`. Дальше она обрывается. Замер 10.09 с контролем: в кадрах событий шва (`backend/internal/runevents/runevents.go`) слова `estimated` **0 хитов** (контроль: `committed` в том же файле — **6**; денежный кадр несёт один `committed_micro_usd`, `runevents.go:244`); в `platform/internal` вне тестов — **2 хита, оба КОММЕНТАРИИ**, причём `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"» (контроль: `committed_micro` — 1 хит; go-файлов вне тестов прибор прочёл **81**). ⇒ условие исполнено на ОДНОМ канале из двух, и читателя, ради которого пометка ставилась, у неё нет: платформа не может сказать пользователю «не больше Y». ⚠ Пара к строке **381**: и то и другое — признак, который обязан ехать в кадре `finished`/`spend`, а не только в `status --json` | бэкенд | скоро | платформа ЧИТАЕТ счёт и сумму оценочных строк из потока и может назвать пользователю верхнюю границу списания | акт `D39.233` п.7, замер оркестратора 10.09 по разбору чистого Fable 5 | | 383 | **ПОТОЛОК ОЖИДАНИЯ ЖИВЁТ КОНСТАНТОЙ В ОДНОЙ ЗОНЕ, А ВЫВОДИТСЯ ИЗ КОНФИГА ДРУГОЙ — ФОРМА, КОТОРАЯ ТУХНЕТ МОЛЧА.** Платформенный грейс остановки обязан покрывать законное ожидание движка. Сегодня это число платформа ВЫВОДИТ из движкового каталога и кладёт себе константой (пак «две остановки», `D39.236` п.6): любая правка `backend/configs/models.yaml` — новый провайдер, поднятый `edit_ceiling_out`, изменённый `tok_s_floor` — делает константу ниже правды, и никто об этом не узнает: превышение грейса даёт SIGKILL, а SIGKILL не оставляет ни пометки `cancelled`, ни сеттла оценки. ⛔ **И ловушка уже названа числом, а не гипотезой (замер платформенной сессии 10.09):** `attempt_max_s` инертен у ПЯТИ провайдеров из восьми, и у беcключевых `DeadlineFor` сверху не ограничена вовсе — она растёт с бюджетом линейно. Беcключевой `zai` перерастает сегодняшний максимум 1240 с (deepseek) при бюджете ≥ **43 400** токенов, то есть при `edit_ceiling_out` ≈ **9 900** против сегодняшних **8 000**. ⇒ запас по конфигу — меньше четверти. **Нетухнущая форма, которую надо построить:** движок ПЕЧАТАЕТ свой потолок ожидания сам (он и так умеет считать его `DeadlineFor` — функция экспортирована ровно для вопроса о конфигурации), платформа читает его НА БУТЕ и отказывается стартовать с грейсом ниже. ⚠ В паке «две остановки» этого НЕТ ни в одной половине — там принята сегодняшняя форма (пин на арифметику + строка реестра + комментарий с адресом), и она честно названа тухнущей | бэкенд | скоро | грейс платформы не может молча оказаться ниже законного ожидания движка: число выводится ИЗ ДВИЖКА в рантайме, а расхождение ловится на буте, а не SIGKILL-ом | эхо-протокол платформенной сессии по паку «две остановки», 10.09 (`D39.237` п.5) | | 384 | **ЛЕНДИНГА НЕТ — А ПРОДУКТ ПО СЛОВУ ВЛАДЕЛЬЦА СОСТОИТ ИЗ ДВУХ ПОВЕРХНОСТЕЙ.** 10.09 владелец назвал модель: «фронт — это скорее IDE для переводов; к нему нужна обёртка в виде ЛЕНДИНГА; модель такая же, как у любого ИИ-продукта — лендинг с продуктом, и IDE отдельно, с аккаунтами, оплатой и всем таким» (`D39.239` п.1). Сегодня в репозитории есть только вторая поверхность, и та наполовину: `frontend/` — две маршрутные точки и витрина компонентов (About · AddBook · Bank), зона ЗАМОРОЖЕНА словом владельца с 15.08 и разморозка — отдельным его словом. Лендинга нет ни кодом, ни требованием в `docs/product-requirements.md`, ни строкой до этой. ⚠ **Практическое следствие, из-за которого строка заведена СЕЙЧАС, а не когда дойдут руки:** когда владелец скажет размораживать фронт, надо будет знать, ЧТО размораживают — витрина относится к IDE, и склейка её с лендингом в один поток была бы ошибкой архитектуры, а не темпа | фронт | когда-нибудь | у продукта названы обе поверхности: что делает лендинг, что IDE, где проходит граница входа и оплаты — и это записано требованием, а не устной моделью | слово владельца 10.09, `D39.239` п.1 | | 385 | ⛔ **У ДВИЖКА НЕТ ЕДИНОГО МЕСТА, ОТВЕЧАЮЩЕГО НА ВОПРОС «МОЖНО ЛИ ЕЩЁ ПОКУПАТЬ» — их ЧЕТЫРЕ, и каждая новая причина остановки протягивается руками через все.** Замер 10.09 (вопрос владельца «почему остановка задела 18 файлов — нет ли архитектурного косяка»): причин перестать тратить деньги сегодня ТРИ, и каждая живёт своим набором носителей — денежный потолок `CeilingHalt` в **6** файлах (`internal/pipeline/{events,escalation,stagerun,waverun,reservegate}.go` + `cmd/tmctl/main.go`), объёмный грант `VolumeStop` в **4** (`internal/pipeline/{volume,bookrun,waverun}.go` + `cmd/tmctl/main.go`), остановка человеком — в **7**. **Пересечение несущее:** `waverun.go` и `cmd/tmctl/main.go` несут ВСЕ ТРИ, `stagerun.go` и `events.go` — две из трёх. Причина в том, что решение «тратить или нет» принимается в ЧЕТЫРЁХ независимых точках, каждая в своём словаре: фидер волны («выдать индекс?»), денежный гейт («впустить резервацию?»), петля ретраев («начинать попытку N+1?»), попартийный приём банка («брать ещё партию?»). ⚠ **Это НЕДО-абстракция, а не оверхед:** лишнего не построено, не хватает ОДНОЙ сущности — распорядителя допуска к трате, — и за её отсутствие платит каждая новая причина, протягиваясь четырежды. Денежный потолок заплатил ту же цену раньше, чем остановка вообще появилась. ⛔ **Чинить СЕЙЧАС не надо и это часть диспозиции:** введение единого распорядителя трогает ровно те пути, где живут деньги, ради выгоды, которая проявится только на ЧЕТВЁРТОЙ причине; риск больше пользы. Делать, когда эта область откроется по другому поводу или когда причина появится четвёртая | бэкенд | когда-нибудь | у вопроса «можно ли ещё покупать» ОДИН распорядитель, а причины (потолок · грант · остановка · будущие) — его входы, а не независимые протяжки; новая причина стоит одной реализации, а не четырёх | замер оркестратора 10.09 по вопросу владельца об инвазивности пака «две остановки» | +| 386 | ⭐ **НЕСУЩЕЕ СВОЙСТВО, НЕ ЗАПИСАННОЕ НИГДЕ: контекст стора НЕЗАВИСИМ от контекста прогона, поэтому записи ПОСЛЕ отмены доходят.** `backend/internal/store/store.go`, греп `opContext` — каждая операция стора идёт на своём `context.WithTimeout(context.Background(), 10s)`; ни один метод стора к контексту прогона не привязан (`LogRequest` берёт `ctx` только ради trace-id и лога). ⇒ **корректность жёсткой остановки зависит от ПОРЯДКА записей, а не от того, переживут ли они отмену**: пометки, сеттлы и терминальный кадр доезжают. Не дефект — свойство, на которое обязан опереться любой пак про корректное завершение (`D39.240`). ⚠ Заведено строкой именно потому, что не записано: без него следующий пак начнёт с ложной тревоги «а доедут ли записи», потратит круг и придёт к тому же ответу. Найдено движковой сессией ЧТЕНИЕМ | бэкенд | скоро | свойство записано носителем, на который можно сослаться — комментарием в `store.go` или нотой, и порядок записей при остановке проверен ПРОТИВ него | улов движковой сессии перед откатом пака «две остановки», 10.09 (`D39.240` п.6) | +| 387 | ⛔ **ИНВЕРСИЯ БЛОКИРОВОК «ЭМИТТЕР ↔ СТОР» — МИНА, КОТОРАЯ СНАРУЖИ НЕ ПОХОЖА НА ДЕДЛОК.** Замерено ИСПОЛНЕНИЕМ движковой сессией: эмиттер держит свой мьютекс ПОПЕРЁК записей в стор (`project()` → журнал + `MarkAnnounced`; `enqueue` → `EnqueueEvent`), а замыкание строки расхода, отдаваемое в `SettleWithCheckpoint`, исполняется ВНУТРИ единственной пишущей транзакции стора. ⇒ всякий код, берущий мьютекс эмиттера из этого замыкания, замыкает цикл: сеттл держит пишущее соединение и ждёт мьютекс, эмиттер держит мьютекс и ждёт соединение. **Снаружи это не дедлок:** его разрывает 10-секундный таймаут стора, и симптом — `pipeline: ensure job …: context deadline exceeded` в ТРИНАДЦАТИ тестах, к правке отношения не имеющих. Сессия наступила, продиагностировала и вылечила отдельным мьютексом, который не держится поперёк ввода-вывода, — **но лечение откатывается вместе с паком, и мина остаётся заряженной**. ⚠ Правило, которое надо записать в код: не брать мьютекс эмиттера из-под сеттла; всё, что зовут из обработчика сигналов, — тем более | бэкенд | скоро | правило названо в коде рядом с обоими замками, и есть проба, которая ловит цикл раньше, чем он вылезет чужим таймаутом | улов движковой сессии, замер исполнением, 10.09 | +| 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**, которая была про две остановки и умерла с паком | +| 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 | +| 395 | **DEV И ПРОД РАСХОДЯТСЯ В ДВАДЦАТЬ РАЗ ПО ВРЕМЕНИ НА СВОРАЧИВАНИЕ, И ЭТО НИГДЕ НЕ ОБЪЯВЛЕНО.** `platform/internal/ingest/supervisor.go`, греп `WaitDelay` — тридцать секунд на dev-пути; `platform/internal/runner/runner.go`, греп `stopGrace` — десять минут на боевом. Один и тот же движок против тех же платных провайдеров получает на свёртку полминуты или десять минут в зависимости от пути запуска. Расхождение само по себе может быть законным, но оно НЕ НАЗВАНО, и dev-замер «успевает свернуться» ничего не говорит о боевом (и наоборот) | платформа | когда-нибудь | оба числа названы в одном месте с доводом, почему они разные, либо сведены | улов платформенной сессии, 10.09 | +| 396 | ⛔ **ГЛАВНАЯ РАЗВИЛКА «ПРАВИЛЬНОГО ВОЗОБНОВЛЕНИЯ»: платформа ПЕРЕЗАПУСКАЕТ прогон, вышедший с кодом остановки БЕЗ записанного намерения.** `platform/internal/runs/reconcile.go`, греп `interruptedBySomeoneElse`. Решение осознанное и с записанным доводом (перезагрузка хоста против ручного `systemctl stop`, асимметрия цен), поэтому строка не о дефекте. ⚠ Но инвариант `D39.240` («правильно возобновить») обязан пересмотреть эту развилку ЯВНО, а не унаследовать: сегодня она означает, что прогон, остановленный мимо продуктовой двери, будет поднят заново и продолжит тратить. Названо платформенной сессией как то, что новый пак не должен взять по умолчанию | платформа | скоро | поведение при коде остановки без намерения ПЕРЕ-решено под инвариант корректного возобновления, а не унаследовано | улов платформенной сессии, 10.09 | +| 397 | **ПЯТЬ ОТКРЫТЫХ РЯДОВ РЕЕСТРА ПЛАТФОРМЫ ПРО ХОЛДЫ И ОСТАНОВКУ — ОДИН ПРЕДМЕТ, А НЕ ПЯТЬ РАЗРОЗНЕННЫХ.** Их печатает собственный гейт зоны как тревогу. Прямо по предмету остановки и возобновления: **PD-418** (у расчётной строки живого прогона нет ручки, а рантбук обещает оператору обратное) · **PD-244** (единственный выход расчёта, оставляющий холд открытым МОЛЧА) · **PD-162** (книга с удалённым каталогом принимает прогон и заклинивает его навсегда с открытым холдом) · **PD-465** (заявка на спавн коммитится ДО подъёма юнита, третий способ уехать под доказательством не ловится) · **PD-217** (книга с незакрытым холдом блокирует апгрейд движка бессрочно). ⇒ под инвариантом `D39.240` это готовый предметный список «не оставить мусор», уже приоритизированный реестром зоны | платформа | скоро | пять рядов закрыты ОДНИМ предметом либо явно разведены как разные | улов платформенной сессии, 10.09 |