diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index bc1d5f77..0f327090 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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.1–2).** `python3 docs/scripts/counts.py --check` → «Литералы сходятся с пере-счётом (8 проверок)», exit 0.