Land platform pack P12 with contract minor 0.9.0: the bank-stop bypass is gone, a chapter counts by the pass current for it, and the boundary now judges prose too
This commit is contained in:
parent
8e475fa2eb
commit
0a680a3d7f
49 changed files with 2700 additions and 334 deletions
File diff suppressed because one or more lines are too long
|
|
@ -23,7 +23,7 @@
|
|||
|---|---|---|
|
||||
| Оркестратор | [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) | роль и нормы; счётчик роли — CURRENT-STATE |
|
||||
| Бэкенд | [BACKEND_COLDRUN_V16_SESSION_PROMPT.md](BACKEND_COLDRUN_V16_SESSION_PROMPT.md) | **ВЫДАН 31.08** (**D39.179**): холодный прогон движка на схеме v16 — ПЕРВЫЙ ПЛАТНЫЙ сквозной прогон на текущем HEAD. Санкция владельца: ≈$0.60, потолок $0.80, десять глав ПОЛНОЙ цепью включая редакторскую волну; прежняя «≈$0.05» снята как протухшая. Гейт строк **16·154·157·160·198·202·216**. Оба рубежа пройдены: механическая сверка 12 блоков + опровергатель в четыре линзы — **53 находки, 8 блокеров**, все применены. Прежний пак «писатель книги» ПРИНЯТ И ЗАЛЕНДЖЕН 30.08 (**D39.175**, `8adcb86`): `tmctl build` — EPUB 3 + чистый txt, шесть состояний честности, отказ exit 16 / `--partial`. Открыто строками **238/239**; вопросы владельцу (лестница D25.1 · лейбл К-3 · RTL) — CURRENT-STATE |
|
||||
| Платформа | [PLATFORM_P12_SESSION_PROMPT.md](PLATFORM_P12_SESSION_PROMPT.md) | **ВЫДАН 30.08** сессии `textmachine-main-b5` (эхо принято; имя не переживает рестарт — адрес сверять `ListAgents`). Пак P12 «долги под ногами»: 7 открытых major (PD-425 деньги · PD-402/405 ложь экрана · PD-403/404 повторная продажа глав — решение владельца D39.165 §2 релеем · PD-424 замороженный холд) + снятие обхода `--verify-bank` (обязательство D39.158) + PD-369/431/432 + гигиена регистра. Оба рубежа пройдены: опровергатель (3 линзы + верификаторы) дал **21 удержанную находку**, все применены. Прежний пак `sqlc` — D39.172, промт в архиве: 40 запросов на типизированный слой, пин 1.31.1, `sqlc diff` в `make check`. ⚠ Решение принято НЕ доводом покрытия (3 из 42), а шестью ВЫЖИВШИМИ мутациями: `sqlgate` не видит Go-сторону вызова. Промт отработан — `archive/prompts/`. Открыто строками: `Touch` без проверки затронутых строк · `observe.go` неконвертируем |
|
||||
| Платформа | активного НЕТ | пак **P12 «долги под ногами» ПРИНЯТ И ЗАЛЕНДЖЕН 31.08** (**D39.180**) вместе с контрактным минором **0.9.0**. Приёмка: 5 линз, 6 major / 16 minor / 0 блокеров, 8 дофиксов исполнены; числа пере-ранены оркестратором при полном условии хоста. Обязательство D39.158 (снятие обхода `--verify-bank`) закрыто целиком, живой пробой на двух ярусах. Регистр: открытых 97 против 107, major 2 против 7. Промт отработан — `archive/prompts/`. Открыто: `PD-424` сужена до терминальной ручки · новые `PD-433`/`PD-434`/`PD-435` |
|
||||
| Полигон | [POLYGON_EXP2223_REDO_SESSION_PROMPT.md](POLYGON_EXP2223_REDO_SESSION_PROMPT.md) (отложенный — [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md), строка 85) | фаза Д ИДЁТ; ⚠ живой носитель курса — в `eval/dovodka/`, какой именно называет зона (⚠ [POLYGON_PHASE_D_HANDOFF.md](POLYGON_PHASE_D_HANDOFF.md) — перекрытый снимок, читать не как курс) |
|
||||
| Фронт | активного НЕТ | **ЗОНА ЗАМОРОЖЕНА** (D39.136 п.2 + D39.147: разморозка отдельным словом владельца, не привязана к P7); перечень первого касания — в зонном журнале |
|
||||
| Контракт | активного НЕТ | минор **0.8.0** ПРИНЯТ и заленджен 29.08 (**D39.169**) вместе с паком P11: кадр `session_ended` — отзыв сессии гасит открытый поток и НАЗЫВАЕТ причину; кадр СОЕДИНЕНИЯ, своего номера не потребляет. До него в тот же день — **0.7.0** (D39.166, пере-проход как член `RunRequest.re_pass`), **0.6.0** (D39.162/163) и **0.5.0** (D39.161). ⚠ Названная цена 0.7.0 СНЯТА 29.08: строка 231 закрыта паком «деньги» (D39.170) — читающий путь движка сворачивает банк, поэтому смета доезжает до покупателя ДО покупки |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Реестр D-нот — карта актуальности v2 (D1–D39.179;
|
||||
# Реестр D-нот — карта актуальности v2 (D1–D39.180;
|
||||
|
||||
> ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА
|
||||
> у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк
|
||||
|
|
@ -241,3 +241,4 @@
|
|||
| D39.177 | 31.08 | **Передача роли оркестратора №20 → №21.** Смена приняла пак «писатель книги» (D39.175) и закрыла продуктовый лист платформы (D39.176), выдала промт P12 и ратифицировала минор 0.9.0 по пингу сессии. Три урока: опровергатель промта окупился в третий и четвёртый раз (62 находки; самая дорогая — «копия книги» через симлинк мигрировала бы ОРИГИНАЛ) · долг без носителя переживает приёмки (две находки ревью P9 прожили двое суток без строки) · якорь по номеру в файл живой сессии заведомо мёртв. Четыре ошибки названы; ТРИ из них поймал ВЛАДЕЛЕЦ вопросами «ты уверен / ты дочитал / почему не закрыл». Аудит всех живых доков: 57 находок, 55 применены. | жив | ЖИВОЕ: очередь №21 в CURRENT-STATE; пять обязательств переходят; незакоммиченное дерево пака P12 — норма, лендить при приёмке | передача процесс нормы аудит |
|
||||
| D39.178 | 30.08 (записано 31.08) | **Слово владельца о двери выдачи**: дверь `createExport` ВСЕГДА строит файл, отказ пользователю не отдаётся — неполная книга уходит с пометкой (`tmctl build --partial`), отказ exit 16 остаётся операторской ручкой CLI; D29.1(б) fail-closed читать как «никогда МОЛЧА»; дверь сверяет `BuildReport` — предупреждение оператору, не читателю. Ратификация жила в CURRENT-STATE без грепаемого носителя — пробел закрыт аудитом доков. | жив | ЖИВОЕ: лестница D25.1 (ступень копии без пометки) не решена, ждёт владельца перед дверью | контракт продукт выдача |
|
||||
| D39.179 | 31.08 | **Слово владельца о цене холодного прогона**: ≈$0.60, потолок $0.80, десять глав ПОЛНОЙ цепью включая редакторскую волну; прежняя санкция «≈$0.05» снята как протухшая (пере-пин DeepSeek, множитель 4.47). Промт `BACKEND_COLDRUN_V16_SESSION_PROMPT.md` выдан — 53 находки двух рубежей применены. Норма формы указателей заострена: стабильна не ЗОНА, а ЧИСТЫЙ ФАЙЛ. `arch-3` закрыт: 51 якорь пере-проверен, 34 уехали, сверяемых по содержимому 89→127 | жив | ЖИВОЕ: прогон не начат; строки 16·154·157·160·198·202·216 гейчены им | деньги прогон доки якоря |
|
||||
| D39.180 | 31.08 | **Пак платформы P12 «долги под ногами» ПРИНЯТ И ЗАЛЕНДЖЕН** вместе с контрактным минором **0.9.0**. Приёмка — 5 линз, 6 major / 16 minor / 0 блокеров, 8 дофиксов исполнены. Обязательство D39.158 (снятие обхода `--verify-bank`) закрыто целиком, живой пробой на двух ярусах. Регистр: открытых 97 против 107, major 2 против 7. Три урока: тихо-зелёное с заголовком против тела (D39.171); **граница контракта судит ПРОЗУ, а не только поля** — непрозрачность поля не спасает; строка, закрытая несуществующим пином | жив | ЖИВОЕ: PD-424 сужена до терминальной ручки · новые PD-433/434/435 · строка бэклога 244 | платформа контракт приёмка |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Журнал решений оркестратора — контракт D1–D39.179 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
# Журнал решений оркестратора — контракт D1–D39.180 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
|
||||
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто:
|
||||
> ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 живёт ниже в этом файле (голова D39.106+).
|
||||
|
|
@ -1673,3 +1673,33 @@ head-1 это семнадцать бюджетируемых операций,
|
|||
4. **Норма формы указателей заострена (уточняет D39.177 §2в): стабильна не ЗОНА, а ЧИСТЫЙ ФАЙЛ.** Номер строки ставится только туда, где `git status <цель>` показывает файл чистым; файл с незакоммиченной правкой — чей угодно, включая `docs/` — получает греп-указатель без номера. Ось «зона» была НЕВЕРНА и поймана опровергателем на моей же правке: `14-api-contract/openapi.yaml` лежит в зоне оркестратора и при этом двигается паком контракта 0.9.0. Носитель нормы — шапка `15-money-path.md`.
|
||||
|
||||
5. **`arch-3` (major остатка аудита, строка 243) ЗАКРЫТ, и он был вдвое больше заявленного.** Аудит 30–31.08 называл «13 якорей, 9 не в тот код»; пере-проверка панелью нашла **51 указатель: 34 уехали, 1 мёртв, 16 верны**. Дороже адресов оказались КЛЕЙМЫ: дословная цитата контракта, которой в контракте нет; потерянный третий обязательный член `Usage`; живой К-13 при закрытом D39.132; метод `Settle`, которого у движкового `Store` нет; «смена потолка не двигает снапшот» без оговорки про `edit_ceiling_out`. **Корень механический:** 9 якорей из 12 РОДИЛИСЬ верными (`085dbb9`) и сгнили дрейфом, а гейт молчал по построению — по содержимому сверялись 89 якорей из ~818. Поэтому чинилась ФОРМА, не номера: сверяемых по содержимому стало **127**.
|
||||
|
||||
## D39.180 — ПАК ПЛАТФОРМЫ P12 «ДОЛГИ ПОД НОГАМИ» ПРИНЯТ И ЗАЛЕНДЖЕН вместе с контрактным минором 0.9.0 (31.08, оркестратор №21). ✅
|
||||
|
||||
**Приёмка:** пять линз (слепая к отчёту · охотник вне карты · исполнением · контракт · регистр) — **6 major, 16 minor, блокеров НЕТ**; восемь дофиксов заказаны и исполнены зоной, дерево на время прохода морожено дважды (D39.172). Механика пака — его отчёт в `platform/docs/platform-PROGRESS.md`, здесь не дублируется.
|
||||
|
||||
**Числа пере-ранены оркестратором, не приняты со слов:** `make check` при полном условии хоста (DSN живого Postgres + движковый бинарь) — 18 пакетов, EXIT=0, 0 FAIL, линтер 0 issues, `sqlc diff` чист; `counts.py --lint` 0, `--check` сходится; регистр 435 строк, открытых 97 против 107 на входе, major **2** против 7.
|
||||
|
||||
### 1. Что закрыто
|
||||
|
||||
`PD-425` (детач пост-verb записи) · `PD-402` (порядок `lastRun` одной константой, 11 носителей) · `PD-405` · `PD-411` · `PD-369` · `PD-431`/`PD-432` · `PD-367`/`PD-213` · гигиена регистра. **Обязательство D39.158 исполнено целиком:** обход `--verify-bank` снят вместе с колонкой `bank_released` (миграция 00029), живой пробой предъявлен на обоих ярусах — движковом (exit 3 → повтор exit 0) и сквозном через демон.
|
||||
|
||||
### 2. Три находки приёмки, стоящие переноса
|
||||
|
||||
**(а) Тихо-зелёное там, где заголовок теста спорит со своим телом.** Выведенный первый сегмент полосы накрывал только момент стояния у стопа ⇒ всю ЧЕРНОВУЮ волну прогона с подписью пер-главные счётчики показывали НОЛЬ готовых глав. Три носителя утверждали обратное — два комментария и заголовок пина, чьё тело пинило ровно этот ноль. Класс D39.171. Лечение зоны сильнее заказанного: правило написано ОДИН раз константой `chapterUnitsDone` и читается обоими читателями, книго-широкая копия удалена. **Пере-проверено СОБСТВЕННЫМИ посадками оркестратора вне списка автора: три мутации — снятие терма `r.verify_bank`, всегда-черновик, подмена колонки — каждая даёт РОВНО ОДИН красный тест, новый пин.** ⚠ Терм `r.verify_bank`, который прежняя редакция объявляла непроверяемым батареей, теперь ловится.
|
||||
|
||||
**(б) Граница контракта судит ПРОЗУ, а не только поля.** Минор 0.9.0 ратифицирован №20 как непрозрачный счётчик — и суждение верно, — но проза `ShapeEpoch` называла форму конвейера открытым текстом («the shape of the pipeline … an editing pass added or removed on the deployment», дважды). Канон запрещает это своими словами: «no phase or stage names… **The rule binds the PROSE too — every description here is compiled into the generated client's source**». Переформулировано формо-нейтрально оркестратором тем же лендингом. **Норма на будущее: непрозрачность ПОЛЯ не спасает, если проза рядом прозрачна.** Попутно снята битая ссылка ратифицированного канона на схему `BookStage`, которой в контракте нет вовсе, и подняты три примера `contract_version`, застрявшие на `0.8.0`. Провенанс минора — `14-api-contract/README.md` §2.22.
|
||||
|
||||
**(в) Строка закрывалась пином, которого нет.** `PD-403` ссылалась на `TestARunOnADeploymentThatDroppedTheEditorCanReachOne` — греп по зоне пуст: имя пережило откат редакции. Зона оставила его ЭРРАТОЙ, а не удалила молча, — это дороже удаления, потому что останавливает следующую сессию.
|
||||
|
||||
### 3. Что зона нашла в себе САМА, и это главное
|
||||
|
||||
Адверсариальный проход сессии по СВОЕЙ ГОТОВОЙ работе дал три регрессии, включая прямое отступление от того, что она сама обещала пингом: полоса ПРОГОНА была переведена на эпоху, а эпоха присваивается и ходит в обе стороны — замерено на живом Postgres (4/4, затем 2/2 на двух попытках одного прогона при неизменной `structure_version`). Откачено, цена подписана строкой `PD-435`. **Без мандата промта на проход ИСПОЛНЕНИЕМ находка уехала бы в приёмку зелёной, с пингом зоны в качестве алиби** — формулировка самой сессии. Ещё дважды тот же класс поймал её на дофиксах: правя украденные док-комментарии, она украла ещё один, а правкой рантбука убила два якоря регистра — оба записаны в отчёт, а не только исправлены.
|
||||
|
||||
### 4. Открыто и почему (подписано, не замолчано)
|
||||
|
||||
`PD-424` сужена до терминальной РУЧКИ (новая разрушительная операторская поверхность над деньгами — дизайн своего размера) · `PD-410` пере-диспозиционирована направлением D39.165 §1 · `PD-420` — одна точка против «1 из 4» в самой строке · `PD-423` — диагноз не установлен. Новые строки пака: **PD-433** (непокрытая гоночная ветвь), **PD-434** (ресинк не материализует прогресс), **PD-435** (остаток `PD-403`). Вес `PD-435` оставлен `minor` с доводом В САМОЙ строке: денег он не двигает — врёт дробь и подпись, а не оплата; диспозиция оркестратора — принять довод, переоценка не меняет работу.
|
||||
|
||||
### 5. Строка бэклога 244 — свойство гейта, найденное приёмкой
|
||||
|
||||
Гейт якорей на пре-коммите судит ЗАКОММИЧЕННЫЙ док против РАБОЧЕГО кода, поэтому ломается всякий раз, когда зона правит код и свой реестр одним незакоммиченным деревом. Замерено: хук оркестратора напечатал 13 битых якорей, `--lint` зоны на том же дереве — 0; права зона. ⚠ Обратная сторона опаснее и НЕ замерена: по-настоящему битый якорь тем же механизмом может оказаться зелёным.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
батч 0.3.0 — D39.138, 16.08.2026, оркестратор №17;
|
||||
синк с платформой 0.4.0 — 20.08.2026, контрактная сессия; РАТИФИЦИРОВАН D39.152, 20.08.2026, оркестратор №18)
|
||||
|
||||
СТАТУС: РАТИФИЦИРОВАН как контракт API v0 по 0.8.0 включительно (D39.169; перечень миноров и их
|
||||
СТАТУС: РАТИФИЦИРОВАН как контракт API v0 по 0.9.0 включительно (D39.169; перечень миноров и их
|
||||
провенанс — ниже по файлу, здесь НЕ дублируется). Нормативная поверхность — openapi.yaml РЯДОМ.
|
||||
⚠ Зонная копия frontend/docs/api-contract/ ВРЕМЕННО ОТСТАЁТ (0.2.3 при каноне
|
||||
0.8.0) — ратифицировано D39.142 п.5 на время фриза фронта; синк байт-в-байт + перегенерация типов =
|
||||
|
|
@ -52,7 +52,7 @@ cmp-сверка обязательна (D39.138 п.3).
|
|||
> сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML;
|
||||
> при вопросе «почему так» — этот файл.
|
||||
>
|
||||
> **Статус: РАТИФИЦИРОВАН по 0.8.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) ·
|
||||
> **Статус: РАТИФИЦИРОВАН по 0.9.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) ·
|
||||
> 0.2.2 (D39.129, 10.08) · 0.2.3 (D39.135, 15.08) · **0.3.0 (D39.138, 16.08) — ломающий минор по
|
||||
> целостному ревью research/28** · **0.4.0 (D39.152, 20.08) — синк с платформой, §6в** ·
|
||||
> **0.5.0 (D39.161, 27.08) — снос отменённой пер-термной модели подписи (PD-370) + дверь правок
|
||||
|
|
@ -65,7 +65,10 @@ cmp-сверка обязательна (D39.138 п.3).
|
|||
> **0.8.0 (D39.169, 29.08) — кадр `session_ended`: отзыв сессии гасит открытый поток и НАЗЫВАЕТ
|
||||
> причину, вместо молчаливого обрыва, который клиент отвечает переподключением в `401`. Кадр
|
||||
> СОЕДИНЕНИЯ: несёт id последнего исторического кадра и своего номера не потребляет, поэтому вход
|
||||
> заново продолжает ровно с места остановки. Приехал ОДНИМ лендингом с платформенным паком P11**.
|
||||
> заново продолжает ровно с места остановки. Приехал ОДНИМ лендингом с платформенным паком P11** ·
|
||||
> **0.9.0 (ратифицирован №20 31.08 по пингу зоны ДО стройки, заленджен №21 с паком P12) — ВТОРАЯ
|
||||
> граница пересчёта `chapters_done` и непрозрачный `Book.shape_epoch`, которым она себя называет;
|
||||
> провенанс — §2.22**.
|
||||
> Дом канона — этот каталог;
|
||||
> `frontend/docs/api-contract/openapi.yaml` — байт-зеркало.
|
||||
>
|
||||
|
|
@ -922,6 +925,47 @@ the original request was never applied». Каждый `POST /books` созда
|
|||
|
||||
---
|
||||
|
||||
|
||||
### 2.22. Вторая граница пересчёта и `shape_epoch` (0.9.0) — ✓ выведено из построенного сервера
|
||||
|
||||
**Что.** У `chapters_done` было ОДНО событие, легитимно пересчитывающее счёт, — пере-нарезка книги, и
|
||||
она называла себя `structure_version`. Событий оказалось ДВА: счёт пересчитывается и когда деплой
|
||||
меняет, что для главы значит «готово». Второе событие получило свою координату — `Book.shape_epoch`,
|
||||
целое, растущее.
|
||||
|
||||
**Почему координата, а не молчание.** Без неё клиент видит скачок `chapters_done` в обе стороны и не
|
||||
может отличить легитимный пересчёт от ошибки сервера: «никогда не движется назад» переставало быть
|
||||
правдой, а замены ему не было. Инвариант переписан точно: счёт не движется назад **внутри одной пары**
|
||||
(`structure_version`, `shape_epoch`); через любую из двух границ — пересчитывается, и клиент
|
||||
пере-читает, а не считает это ошибкой.
|
||||
|
||||
**Почему это НЕ третье исключение границы (D39.163).** Поле НЕПРОЗРАЧНО по построению: целое, которое
|
||||
сравнивают с предыдущим, и оно сообщает только ЧТО поколение сменилось. Оно не отвечает на вопрос
|
||||
«как переводится книга» — ни имени фазы, ни стадии, ни модели. Ратификация №20 (31.08) стоит именно на
|
||||
этом различении, и он честно записал его как СВОЁ суждение, а не как факт.
|
||||
|
||||
⚠ **И на приёмке это суждение чуть не было пробито ПРОЗОЙ.** Первая редакция минора описывала событие
|
||||
границы словами «the shape of the pipeline … an editing pass added or removed on the deployment» — то
|
||||
есть называла фазу конвейера открытым текстом, дважды. Граница запрещает это прямо и своими же
|
||||
словами: «no phase or stage names… **The rule binds the PROSE too — every description here is compiled
|
||||
into the generated client's source**» (§Boundaries). Поймано линзой контракта при приёмке P12,
|
||||
переформулировано формо-нейтрально («когда деплой меняет, что для главы значит „готово“») оркестратором
|
||||
№21 ТЕМ ЖЕ лендингом. **Урок для следующего минора: непрозрачность поля не спасает, если проза рядом
|
||||
прозрачна** — граница судит и описания, потому что они компилируются в исходник клиента.
|
||||
|
||||
⚠ Попутно тем же проходом снята битая ссылка ратифицированного канона: проза `ShapeEpoch` ссылалась на
|
||||
схему `BookStage`, которой в контракте НЕТ ВОВСЕ (`grep -c BookStage` = 0 после правки; до неё
|
||||
единственным вхождением была сама ссылка). Заменена на указатель формы, уже принятой в файле — «см.
|
||||
**Boundaries**». И три примера `contract_version`, оставшиеся на `0.8.0`, подняты до `0.9.0`.
|
||||
|
||||
**Механика — в зоне платформы:** пара колонок `books.shape_epoch` + `books.epoch_editor` (миграция
|
||||
`00030_shape_epoch.sql`, бэкфилл `epoch_editor = edit_wave`, поэтому первая граница — только реальная
|
||||
смена формы). ⚠ **Полоса ПРОГОНА на эпоху НЕ переведена:** зона попробовала и откатила своим же
|
||||
адверсариальным проходом — эпоха присваивается и ходит в обе стороны, отчего полоса переставала быть
|
||||
монотонной (замерено: один прогон читал 4/4, потом 2/2 на своих же двух попытках при неизменной
|
||||
`structure_version`). Через эпоху считается ТОЛЬКО пожизненный счёт книги; остаток — открытая строка
|
||||
регистра платформы `PD-435`.
|
||||
|
||||
## 3. Зависимости: чтение → источник → строка бэклога
|
||||
|
||||
**Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ openapi: 3.1.0
|
|||
|
||||
info:
|
||||
title: TextMachine API
|
||||
version: 0.8.0
|
||||
version: 0.9.0
|
||||
summary: Ratified contract between the frontend and the TextMachine platform.
|
||||
description: |
|
||||
**RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`;
|
||||
|
|
@ -1239,6 +1239,28 @@ components:
|
|||
anchor to that would restart them constantly.
|
||||
examples: [3]
|
||||
|
||||
ShapeEpoch:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: |
|
||||
Generation of the book's COUNT. It moves when the deployment changes what "finished" means
|
||||
for a chapter — the one event besides re-cutting that legitimately recomputes
|
||||
`chapters_done` and `chapter_count`'s remainder.
|
||||
|
||||
OPAQUE, and a client must treat it as such: an integer to compare with the last one it saw,
|
||||
never a fact to interpret. It says only THAT the generation changed, never how the book is
|
||||
translated — what a deployment does to a chapter is not part of this contract, and a member
|
||||
that leaked it would be a third exception to a boundary that has two (see **Boundaries**:
|
||||
there are two, and there is no third). A larger value is not a breaking change and needs no
|
||||
version of its own.
|
||||
|
||||
What a client MUST do when it sees a new value: re-read the book's counters. A jump in
|
||||
`chapters_done` across a shape epoch is the count being recomputed on a legitimate boundary,
|
||||
not a server walking a counter backwards — the same relationship `structure_version` has with
|
||||
a re-cut. Distinct from `StructureVersion`, which is about which chapters EXIST: a shape epoch
|
||||
moves no chapter boundary, so it invalidates no cursor, no pair identifier and no term window.
|
||||
examples: [1]
|
||||
|
||||
NextCursor:
|
||||
type: [string, 'null']
|
||||
description: |
|
||||
|
|
@ -1280,7 +1302,7 @@ components:
|
|||
The version this deployment serves — the only place a non-streaming client learns it. A
|
||||
client generated against a different one REFUSES to work and says so: while the major is
|
||||
`0` a differing minor carries breaking changes by design.
|
||||
examples: ['0.8.0']
|
||||
examples: ['0.9.0']
|
||||
language_pairs:
|
||||
type: array
|
||||
description: |
|
||||
|
|
@ -1521,6 +1543,7 @@ components:
|
|||
- character_count
|
||||
- added_at
|
||||
- note_count
|
||||
- shape_epoch
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Id' }
|
||||
revision:
|
||||
|
|
@ -1544,6 +1567,7 @@ components:
|
|||
Why the book was rejected; meaningful only while `status` is `rejected`, and `null`
|
||||
everywhere else — including on a rejected book whose reason the service cannot name.
|
||||
structure_version: { $ref: '#/components/schemas/StructureVersion' }
|
||||
shape_epoch: { $ref: '#/components/schemas/ShapeEpoch' }
|
||||
chapter_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
|
@ -1554,9 +1578,13 @@ components:
|
|||
description: |
|
||||
Chapters fully translated — "finished" as `Progress` defines it, the last pass this
|
||||
deployment gives a chapter. Against `chapter_count` this is the book's own progress —
|
||||
what a library row shows — and it never moves backwards WITHIN one `structure_version`;
|
||||
cutting the book again recomputes both numbers. The bar of a RUNNING run is
|
||||
`Run.progress`, which measures what that run bought.
|
||||
what a library row shows — and it never moves backwards WITHIN one pair of
|
||||
(`structure_version`, `shape_epoch`). TWO events recompute both numbers, and each has its
|
||||
own coordinate to announce itself by: cutting the book again, and the deployment
|
||||
changing what "finished" means for a chapter. Across
|
||||
either boundary the count is recomputed and may jump in either direction; a client
|
||||
re-reads rather than treating the jump as an error. Within one pair it only grows. The bar
|
||||
of a RUNNING run is `Run.progress`, which measures what that run bought.
|
||||
|
||||
⚠ It is also the number a service reads to decide **how much of the book is left to
|
||||
buy**, so a count that cannot reach `chapter_count` keeps offering work already finished.
|
||||
|
|
@ -2642,8 +2670,8 @@ components:
|
|||
properties:
|
||||
contract:
|
||||
type: string
|
||||
description: Contract version this deployment serves, e.g. `0.8.0`.
|
||||
examples: ['0.8.0']
|
||||
description: Contract version this deployment serves, e.g. `0.9.0`.
|
||||
examples: ['0.9.0']
|
||||
|
||||
EventStatus:
|
||||
allOf:
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
| П-9 | **Загрузка книги `POST /books` (multipart)** — единственная ручка контракта 0.2.0, которую пак раннера НЕ взял. Причина названа, а не умолчана: это не «ещё один хендлер», а хранилище (куда лёг файл, кто его чистит при отказе), разбор (статусы `uploading`/`parsing`/`rejected` существуют в схеме и ни одним писателем не заполняются), каталог проекта движка (кто пишет `book.yaml` — платформа его НЕ правит, D39.110) и пер-маршрутный потолок тела вместе с тестом, которого ждёт PD-72. Пока её нет, книги заводятся дев-инструментом `tmplatformctl book add`, и библиотека читает их так же, как читала бы загруженные: read-model один. ⚠ PD-72 закрывается ВМЕСТЕ с этой ручкой, не раньше — **ИСПОЛНЕНО P5 (11.08), с одной НАЗВАННОЙ половиной на ратификации:** ручка построена потоково (`r.MultipartReader`, пер-маршрутный потолок тела, свой дедлайн чтения), хранилище — каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` получили писателей, разбор зовёт `tmctl manifest`, PD-72 закрыт своим тестом. НЕ построено и вынесено вопросом в журнал: кто пишет стартовый `book.yaml` при интейке (D39.110 §2b) — шов `books.ErrNotProvisioned` назван, без конфигурации книга отказывает разбором **→ развилка РЕШЕНА D39.130: бета = форма Б (стройка = П-14); движковая форма В (`tmctl init`) = строка 170 единого** | Ф3, следующий пак | сессия P4 (границей промта D39.119 §5а), исполнено P5 |
|
||||
| П-10 | **Честная оценка «$/глава» от ДВИЖКА.** Сейчас ставка — константа платформы ($0.03, провенанс exp08 v2 через D30.4, `STACK_DECISIONS` §20), и это осознанная бета-мера: движковой поверхности оценки не существует, а выдумывать её запрещено. Ставка решает только ДЛИНУ шкалы (деньги защищены холдом и потолком движка), но на книге, которая заметно дороже или дешевле средней, шкала врёт пользователю о том, сколько глав он покупает. Нужна оценка от движка по конкретной книге — запрос уходит строкой ЕДИНОГО бэклога через оркестратора, не сюда | когда-нибудь (до первого платящего) | сессия P4 |
|
||||
| П-11 | **Наблюдаемость раннера.** Метрик и трейсинга в зоне нет вовсе (грепнуто: ни prometheus, ни otel, ни expvar, ни pprof), а у раннера появились величины, которые без них не видны: глубина очереди, возраст незакрытых холдов, число прогонов в карантине, отставание тейлера, длительность свипа. Сегодня всё это читается только глазами по логам и SQL. Связано с PD-115 (у зоны нет внешнего эталона ни по одной оси, кроме безопасности) — **ИСПОЛНЕНО P5 (11.08):** `prometheus/client_golang` v1.24.1 на отдельном слушателе `TM_PLATFORM_METRICS_ADDR`; глубина очереди · возраст самого старого открытого холда · карантины · отставание тейлера · книги в интейке · длительность свипа и счётчик недоведённых проходов · запросы и задержки по паттерну маршрута. Ось наблюдаемости получила внешний эталон (практики именования Prometheus + золотые сигналы, `ENGINEERING_STANDARDS` §2) — половина PD-115 | Ф3 | сессия P4, исполнено P5 |
|
||||
| П-12 | **Квота интейка и ретеншен отклонённых книг.** `POST /books` даёт аутентифицированному аккаунту писать на диск оператора: один аплоад ограничен (64 МиБ), число аплоадов — ничем. Отклонённая по вине источника книга каталог теряет, отклонённая по вине деплоя — сохраняет намеренно, и не чистит их никто. Нужны лимит книг на аккаунт (сколько книг входит во фри-тир — продуктовая политика, не инженерная) и свип ретеншена. Строка регистра — PD-175 | до первого чужого пользователя | сессия P5 |
|
||||
| П-12 | **Квота интейка и ретеншен отклонённых книг.** `POST /books` даёт аутентифицированному аккаунту писать на диск оператора: один аплоад ограничен (64 МиБ), число аплоадов — ничем. Отклонённая по вине источника книга каталог теряет, отклонённая по вине деплоя — сохраняет намеренно, и не чистит их никто. Строка регистра — PD-175 ⚠ **ПРОДУКТОВАЯ ПОЛОВИНА СНЯТА ЦЕЛИКОМ — D39.176 п.1 (слово владельца 30.08), диспозиция записана паком P12 31.08:** продуктовых КВОТ НЕТ и не будет, фри-тир-лимиты не проектируются, живём на покупке API и зачислениях из админки. То есть «сколько книг входит во фри-тир» — больше НЕ развилка и НЕ ждёт владельца; вопроса нет. Остаётся ИНЖЕНЕРНАЯ гигиена, решаемая зоной без чьего-либо слова: ретеншен отклонённых (`rejected`) книг · свип каталогов-сирот · потолок диска. Гейт у неё один и он не продуктовый — открытая регистрация, которой в закрытой бете нет. Диспозиция дописана СЮДА и в `PD-175`, потому что доставлена она была пингом, а пинг уезжает в архив | инженерная гигиена, до открытой регистрации | сессия P5; продуктовая половина снята D39.176 |
|
||||
| П-13 | **Оценка размера книги в символах — у ДВИЖКА.** `character_count` контракта платформа считает потоково на приёме (байты, не являющиеся продолжением UTF-8), что точно для UTF-8 и приблизительно для GB18030/UTF-16, которые движок принимает и декодирует сам. Манифест несёт `source_bytes` и `encoding`, но не число символов. Запрос уходит строкой ЕДИНОГО бэклога через оркестратора; строка регистра — PD-177 **→ строка 171 единого заведена 14.08** | когда-нибудь | сессия P5 |
|
||||
| П-15 | **ИСПОЛНЕНО P6 (14.08).** Платформенная половина шва эмиттера (движковая залендена D39.131, `9cfe080`; события уже пишутся — StreamVersion 1.1):** (а) маппинг exit-кодов движка: 4 = потолок (`paused`, не `failed`), 5 = graceful stop, 10–19 = полоса отказов — сегодня outcome() знает только 0/2/3 и читает `paused_reason` из stale-снапшота ДО drain (ceiling-стоп материализуется `failed`, Resume 409), а `refusedTheSource()` не смотрит на код вовсе (опечатка `book.yaml`/лок ведут к удалению загрузки — PD-196 у потребителя не закрыт); (б) фолд `unit_done` ПРИСВАИВАНИЕМ по тройке (chapter, unit, wave), не инкрементом — ратифицировано D39.131 п.2г (at-least-once; ⚠ уточнение дофикса P6: присваивание самолечит ПОВТОРНУЮ доставку, а не пропущенную — недодрейненный хвост новая попытка не перечитывает, её курсор начинается с размера журнала на допуске; строка PD-219); (в) принять `Ceiling.Scope` (book\|day — диагностика PD-157; resume дневного потолка не гонять в цикл) и outcome `ceiling\|stopped`; (г) dev-супервизор `outcomeOf` стейл (0/2/3); (д) порядок деплоя против деадлока v15 — строка 174 единого (решение ДО деплоя эмиттер-бинаря); watch: foreign-hello adoption при пре-существующем журнале в workdir | ИСПОЛНЕНО P6: (а) коды 4/5/10–19 через `ingest.OutcomeOf`, потолок = `paused` двумя каналами (PD-113 закрыт), интейк судит по коду (PD-196 закрыт), exit 5 без намерения = прерывание и перезапуск (PD-152 закрыт); (б) фолд присваиванием по `unit_resolutions` (миграция 00015); (в) StreamVersion 1.1, `Ceiling.Scope`, внутренняя причина `daily_ceiling`, резюм дневного потолка = 409 (PD-157 половина); (г) dev-супервизор читает ту же таблицу; watch воспроизведён и закрыт (PD-200); порядок деплоя — `deploy/README.md` + `tmplatformctl books --migratable` | приёмка D39.131 |
|
||||
| П-14 | **ИСПОЛНЕНО P6 (14.08).** Стройка интейка формы Б (ратификация D39.130): при создании книги платформа рендерит стартовый `book.yaml` из деплой-шаблона (шов `books.ErrNotProvisioned` уже назван P5); требования к полям — читать `backend/internal/config/book.go` как справочник, шаблон — деплой-артефакт оператора, не код; при приходе формы В (строка 170 единого, `tmctl init`) рендер заменяется вызовом движка | ИСПОЛНЕНО P6: рендер в ОДНОМ месте (`books.provision`), `TM_PLATFORM_BOOK_TEMPLATE`, работа с YAML-узлом (комментарии и незнакомые ключи оператора живы, значения — строки), `O_EXCL` (никогда не перезаписываем), битый шаблон = класс, который ЖДЁТ. Проверено настоящим `tmctl manifest` (3 главы) | ратификация D39.130 (приёмка P5) |
|
||||
|
|
|
|||
|
|
@ -126,11 +126,22 @@ Prometheus; на контрактную поверхность они не вы
|
|||
| `internal/pricing`, `internal/money` | шкала глав и целые микро-доллары |
|
||||
| `internal/metrics`, `internal/reqid`, `internal/jobs`, `internal/config`, `internal/gates` | телеметрия, id запроса, очередь, конфигурация, гейты тулчейна |
|
||||
|
||||
Каналов движка ПЯТЬ, и других нет (⚠ до 29.08 фраза начиналась «три канала, и других нет» и тут же
|
||||
перечисляла пять — счёт правился, а слово нет): `tmctl manifest --json` (структура),
|
||||
`tmctl export --json --pairs` (ТЕКСТ пар — единственный носитель), `<project_db>.bank.json` (банк),
|
||||
`tmctl status --json` (канал ремонта; с лендингом `6ec9f8a` он ещё и оценивает пере-проход ДО
|
||||
покупки) и `events.jsonl` (поток). Живой SQLite движка не читается никогда (D39.85).
|
||||
Каналы движка, которыми зона пользуется, — СЕМЬ (⚠ счёт в этой фразе отставал от списка ТРИЖДЫ: до
|
||||
29.08 она начиналась «три канала», перечисляя пять; на 30.08 говорила «пять» при шести; 31.08 —
|
||||
«шесть» при семи, забыв `bank-apply`. Поэтому здесь больше нет слов «и других нет»: список ниже
|
||||
СЧИТАЕТСЯ при правке, а притязание на исчерпывающесть снято как трижды не оправдавшееся —
|
||||
исчерпывающий перечень с атомарностью живёт в `docs/STACK_DECISIONS.md`, «Инвентарь каналов движка»,
|
||||
и правится вместе с ним): `tmctl manifest --json` (структура), `tmctl export --json --pairs`
|
||||
(ТЕКСТ пар — единственный носитель), `<project_db>.bank.json` (банк), `tmctl status --json` (канал
|
||||
ремонта; с лендингом `6ec9f8a` он ещё и оценивает пере-проход ДО покупки), `events.jsonl` (поток) и —
|
||||
с лендингом пака «писатель книги» (D39.175) — `tmctl build` с файловым сайдкаром
|
||||
`<project_db>.book.<fmt>`, чьи пути публикуются в `StatusArtifacts.book_files`. Шестой канал ещё никем
|
||||
в зоне не потребляется: его будет читать дверь выдачи `createExport`/`getExport`, и она обязана
|
||||
СТРОИТЬ (звать `tmctl build`), а не подбирать лежащий рядом файл — там копия прежней сборки
|
||||
(D39.175 п.2). Седьмой — `tmctl bank-apply`: документ решений уходит движку файлом, отчёт
|
||||
`tm-bank-report-v1` приходит обратно на stdout; это ЕДИНСТВЕННЫЙ канал, по которому зона ПИШЕТ в
|
||||
проект движка, и потому у него своя дверь (`POST /books/{bookId}/bank/corrections`) и свой класс
|
||||
отказов. Живой SQLite движка не читается никогда (D39.85).
|
||||
|
||||
## Чего здесь НЕ будет
|
||||
|
||||
|
|
|
|||
|
|
@ -196,6 +196,17 @@ Read-only команды движка отказывают файлу проек
|
|||
он неотличим от осторожности. Порядок ниже действует; читать его как ДЕЙСТВУЮЩИЙ, а не как «когда
|
||||
приедет».
|
||||
|
||||
⚠⚠ **ВТОРАЯ, НЕЗАВИСИМАЯ причина не выкатывать движок раньше платформы, дописана 31.08 (дофикс приёмки
|
||||
P12): подъём ФОРМЫ МАНИФЕСТА движка требует платформенного билда, иначе выбывает КАЖДАЯ новая книга.**
|
||||
Платформа с P12 сверяет `manifest_version` с известной ей формой (`ingest.KnownManifestVersion`,
|
||||
зеркало `manifestVersion` движка) и на незнакомую отвечает НЕ-деструктивным классом
|
||||
`parser_unavailable` — файл пользователя цел, и это осознанный выбор: без сверки переименованный ключ
|
||||
декодируется в нули, а ноль глав интейк читает как «источник прочли, книги нет», то есть УДАЛЯЕТ
|
||||
аплоад (`PD-213`). Но класс всё равно ТЕРМИНАЛЕН по бюджету попыток: движок впереди платформы ⇒ каждая
|
||||
загруженная книга уходит в `rejected` после пяти попыток. Симптом — интейк массово отклоняет при
|
||||
здоровом на вид движке; лечение — выкатить платформенный билд, знающий новую форму. Правило то же, что
|
||||
у схемы хранилища: **сначала платформа, потом движок**, и оба конца этого правила теперь записаны.
|
||||
|
||||
Порядок (действующий). ⚠ Ключевое: **`migrate` гоняется НОВЫМ бинарём** — старый уводит
|
||||
файл в свою же схему, то есть не делает ничего, и деадлок остаётся. Поэтому бинарь кладётся ДО
|
||||
миграции, а `TM_PLATFORM_ENGINE_BIN` переключается ПОСЛЕ.
|
||||
|
|
@ -285,6 +296,17 @@ tmplatformctl run abandon --run <id> --reason "почему" [--release-hold]
|
|||
ПОСЛЕ действия оператора, и `tm_platform_oldest_open_hold_seconds` продолжал расти. Читалось это как
|
||||
«я сделал, не помогло».
|
||||
|
||||
⚠ **Оговорка, дописанная паком P12 30.08 (`PD-418`): ветвление `run abandon` идёт по
|
||||
`runs.finished_at`, а не по наличию осиротевшей попытки.** Значит команда лечит `settling`-строку
|
||||
только у прогона, который УЖЕ кончился. Живой прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой ушёл бы в
|
||||
живую ветку: осиротевший холд команда не тронет, а ответит про процесс. Сегодня это состояние
|
||||
недостижимо — единственный не-тестовый путь ко второй открытой резервации, `reopen`, отказывается
|
||||
стартовать следующую попытку, пока холд предыдущей открыт, — и пак P12 его достижимее НЕ сделал
|
||||
(блокированная расплата теперь считается неудачей и видна, но рестарт по-прежнему не происходит,
|
||||
`PD-424`). Оговорка стоит здесь, потому что документ иначе обещает оператору то, чего код не делает,
|
||||
и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Долговечное лечение — ветвить по
|
||||
НАЛИЧИЮ осиротевшей попытки вместо `finished_at`, как уже делает settling-ветвь `StalledRuns`.
|
||||
|
||||
**Строка `PHASE = settling` — та же команда, другой исход.** Прогон уже кончился, свипа, который
|
||||
довёл бы его расчёт, не будет никогда, поэтому `run abandon` закрывает деньги В СВОЕЙ транзакции
|
||||
независимо от флага: резервация закрывается, холд возвращается ЦЕЛИКОМ, прогон помечается
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -339,14 +339,54 @@
|
|||
материализацию, начатую раньше. Каждый писатель этой колонки обязан идти через сверку метки —
|
||||
безусловная запись возвращает любой из трёх.
|
||||
|
||||
35. **«Есть ли у пайплайна редактор» — свойство КНИГИ (`books.edit_wave`), монотонное, от объявления
|
||||
движка.** Движок называет форму сам: пайплайн без редактора даёт волне `edit` знаменатель ноль
|
||||
(`beginWaves`). Читать это с последнего прогона НЕЛЬЗЯ — последний прогон самый новый, а только
|
||||
что допущенный ещё ничего не объявил, и `chapters_done` падал в ноль в момент допуска. Флаг
|
||||
только растёт: книга, прошедшая редактирующий пайплайн, остаётся такой, иначе полу-сделанные
|
||||
главы начали бы считаться сделанными. От этого же флага зависит БАЗЛАЙН полосы прогона: базлайн
|
||||
и числитель обязаны считать один проход, оба конца этого правила — в `StartRun` и в снятии стопа
|
||||
подписи.
|
||||
35. **«Есть ли у пайплайна редактор» — свойство КНИГИ, от объявления движка; но фактов ДВА, и в этом
|
||||
вся правка (пере-подписано паком P12, 30.08, по решению владельца D39.165 §2; прежняя редакция
|
||||
ниже).** Движок называет форму сам: пайплайн без редактора даёт волне `edit` знаменатель ноль
|
||||
(`beginWaves`). Читать это с последнего ПРОГОНА по-прежнему НЕЛЬЗЯ — последний прогон самый
|
||||
новый, а только что допущенный ещё ничего не объявил, и `chapters_done` падал в ноль в момент
|
||||
допуска (это часть правила не менялась).
|
||||
- `books.edit_wave` — ИСТОРИЧЕСКИЙ факт, монотонный, пин D39.153 §4б стоит и не отменён: книга,
|
||||
прошедшая редактирующий пайплайн, остаётся такой. Он больше НЕ авторитет для счёта.
|
||||
- `books.epoch_editor` + `books.shape_epoch` (миграция 00030) — ЭПОХА: форма как она стоит
|
||||
СЕЙЧАС (присваивается, не накапливается) и счётчик пересечений границы. Через эпоху считается
|
||||
ПОЖИЗНЕННЫЙ СЧЁТ КНИГИ — и ТОЛЬКО он.
|
||||
- **Полоса ПРОГОНА остаётся на монотонном `books.edit_wave`.** Это не оговорка, а замеренное
|
||||
ограничение: `epoch_editor` присваивается и ходит в обе стороны, поэтому полоса, читающая
|
||||
эпоху, перестаёт быть монотонной — один прогон читал **4/4, затем 2/2** на своих же двух
|
||||
попытках при неизменной `structure_version`. Канон держит полосу одной монотонной дробью
|
||||
(строка 200), так что читать её через эпоху НЕЛЬЗЯ, как бы соблазнительно ни выглядела
|
||||
симметрия со счётом книги. Цена ограничения названа и открыта строкой `PD-435`: на деплое, где
|
||||
редактора убрали, полоса тарифицирует edit-волну, которой не будет, и до единицы не доходит.
|
||||
Правильный носитель для неё — форма, под которой работает ЭТОТ прогон, записанная на самом
|
||||
прогоне; в `StartRun` она ещё не известна (движок объявляет её первым progress-событием — это
|
||||
ровно `PD-401`), поэтому это отдельная работа, а не хвост этой.
|
||||
- Пер-главный счётчик (`units_done` на проводе) — третий читатель и НЕ выводится ни из того, ни
|
||||
из другого целиком: правило `chapterUnitsDone` написано ОДИН раз и вычисляется ПО ГЛАВЕ (для
|
||||
прогона с подписью глава, которой не касался редактор, считается по черновику). Книго-широкий
|
||||
предикат здесь запрещён: кадры глав шлются поглавно, и он менял бы ответ про главы, которых не
|
||||
касалось ни одно событие.
|
||||
**Почему разделено:** один факт отвечал на два вопроса и мог быть прав только для одного.
|
||||
Прочитанный как авторитет счёта, монотонный флаг неверен в ОБЕ стороны — вниз он не ходит:
|
||||
редактора убрали ⇒ счёт мёрзнет против edit-колонки, которую больше никто не заполнит, полоса
|
||||
ни одного прогона не доходит до единицы, а шкала покупки снова продаёт переведённое (`PD-403`,
|
||||
это деньги); редактора добавили ⇒ флаг переворачивается на первом progress-событии и книга
|
||||
7/10 показывает 0/10 назад внутри одной `structure_version`, что канон запрещал прямо
|
||||
(`PD-404`). Решение владельца 28.08: смена формы конвейера — СОБЫТИЕ КНИГИ, как пере-нарезка, и
|
||||
счёт легально пересчитывается на границе; `shape_epoch` — то, чем клиент отличает законный
|
||||
пересчёт от хода назад (канон **0.9.0**, поле непрозрачное, саму форму на провод не выносим).
|
||||
⚠ **Прежняя редакция ЭТОГО абзаца (30.08) утверждала обратное — «полосу прогона это НЕ ломает,
|
||||
эпоха всегда объявление её собственного прогона» — и была НЕВЕРНА; заменена 31.08 по замеру.**
|
||||
Довод звучал убедительно и не выдержал исполнения: у одного прогона две попытки, и вторая
|
||||
объявляет СВОЮ форму, поэтому эпоха между ними ходит вниз. `PD-401` при этом действительно был
|
||||
не про то, какой флаг выбирает волну, а про парность числителя и базлайна своей колонки — она не
|
||||
тронута, базлайны по-прежнему берутся в `StartRun` и не пере-снимаются. Но парности мало:
|
||||
монотонность полосы держит именно МОНОТОННЫЙ флаг.
|
||||
> Прежняя формулировка (до 30.08), оставлена с датой и причиной: «свойство КНИГИ
|
||||
> (`books.edit_wave`), монотонное… Флаг только растёт: книга, прошедшая редактирующий пайплайн,
|
||||
> остаётся такой, иначе полу-сделанные главы начали бы считаться сделанными. От этого же флага
|
||||
> зависит БАЗЛАЙН полосы прогона». Причина замены: правило было верно для СОХРАННОСТИ счёта и
|
||||
> ложно для его ПРАВИЛЬНОСТИ — монотонность защищала направление «редактора добавили» и морозила
|
||||
> «редактора убрали», и обе стороны стояли открытыми строками регистра.
|
||||
|
||||
36. **Утверждение про АТОМАРНОСТЬ пишется через `xmin`.** Пин, проверяющий конечное состояние, не
|
||||
видит выноса записи из транзакции во второй оператор — состояние то же, меняется окно. `xmin`
|
||||
|
|
@ -373,6 +413,8 @@
|
|||
| `<project_db>.auto-bank.yaml` | `pipeline/mining.go` `os.WriteFile` | **НЕ атомарно** | читателя нет | ✅ то же правило |
|
||||
| `mined_delta` (путь из `book.yaml`) | **писателя в движке НЕТ** — только читатель `loadMinedDelta`; формат `seed.File` (`terms:`), грузится `membank.LoadGlossarySeed`, `Source` пере-штампуется на `"mined"` | — | **писателя НЕТ и у платформы** | ❌ **разрыв — это строка 199(а) единого бэклога**, развилка ждёт ратификации |
|
||||
| `mined_rejects` | читатель `loadMinedRejects`; формат `rejects: [{src, note}]` — это ФИЛЬТР ПРЕДЛОЖЕНИЙ, в банк не входит | — | писателя нет | ❌ тот же разрыв |
|
||||
| `tmctl bank-apply` + документ решений | зона ПИШЕТ (`internal/runs/bank.go` `decisionsFile` → файл во временном каталоге), движок отвечает отчётом `tm-bank-report-v1` на stdout | документ пишется целиком до вызова; отчёт — одноразовая выдача на вызов | `internal/runner/bankapply.go` → `ingest.DecodeBankReport` → `internal/runs/bank.go` `bankVerdict` | ⚠ **СЕДЬМОЙ канал, дописан 31.08 дофиксом приёмки P12** (в первой редакции этой таблицы его не было, и `platform/README.md` трижды подряд объявлял список исчерпывающим при неполном счёте). ЕДИНСТВЕННЫЙ канал, по которому платформа ПИШЕТ в проект движка, — отсюда и своя дверь (`POST /books/{bookId}/bank/corrections`), и свой класс отказов, и класс `write_incomplete` = exit 15. ⚠ Пост-verb факт этого канала (`bank_moved_at`) пишется на ОТДЕЛЬНОМ, отцепленном контексте — иначе обрыв клиента теряет его навсегда (`PD-425`) |
|
||||
| `tmctl build` + сайдкар `<project_db>.book.<fmt>` | движок (`pipeline`, пак «писатель книги», D39.175) | файл пишется целиком до публикации пути; ПУТИ публикуются в `StatusArtifacts.book_files` (`status --json` / `manifest --json`), stdout — конверт `tm-build-v1` | **читателя НЕТ** (грепом по зоне — ноль вхождений) | ⚠ **ШЕСТОЙ канал, дописан паком P12 31.08 по пингу аудита доков.** Его будет читать дверь выдачи `createExport`/`getExport`, и она обязана СТРОИТЬ — звать `tmctl build` — а НЕ подбирать файл, лежащий рядом с БД: там копия ПРЕЖНЕЙ сборки (D39.175 п.2, слово владельца). Сверять `BuildReport` (`config_drift`/`stale_unknown`). ⚠ Новый класс отказа движка: exit **16** `book_incomplete` — книга с дырами без `--partial`; раскладка на провод — при постройке двери. ⚠ И ловушка на будущее: интейк на exit **11** действует ДЕСТРУКТИВНО, а `tmctl build` книги из нуля юнитов выходит именно 11 — сегодня недостижимо (интейк зовёт `manifest`, не `build`), учесть при подключении `build` к автоматике |
|
||||
| `tmctl export --json --pairs` | движок (`pipeline/export.go`) | — (одноразовая выдача на вызов) | `internal/runner/engine.go` `ExportArgs`/`Export` → `ingest.DecodeExport` → `internal/readmodel` | ✅ ⚠ **Единственный канал, несущий ТЕКСТ пары** — исходник и перевод; манифест несёт только структуру. Пропущен в первой редакции этой таблицы (найдено аудитом доков в тот же день) |
|
||||
| `events.jsonl` (NDJSON эмиттера) | движок, StreamVersion 1.1 | append-only | `internal/ingest/tail.go` + `pgstore.RunSink` | ✅ |
|
||||
| exit-коды `tmctl` | контракт движка | — | `ingest.OutcomeOf`, `internal/runs/reconcile.go` `outcome` | ✅ |
|
||||
|
|
@ -415,14 +457,35 @@ sed -e 's#^pipeline: ../configs/#pipeline: <repo>/backend/configs/#' \
|
|||
скипнутых: `TM_PLATFORM_TEST_DSN` (Postgres) · пара `TM_PLATFORM_TEST_ENGINE_BIN` +
|
||||
`TM_PLATFORM_TEST_BOOK_TEMPLATE` (живой рендер конфигурации и живой прогон движка) · ДОСТИЖИМЫЙ
|
||||
пользовательский менеджер systemd (`/run/user/<uid>`; без него три теста `internal/runner`
|
||||
скипаются молча — `PD-374`) ⚠ **и ЧЕТВЁРТОЕ условие, которого здесь не было до 29.08: вызывающий
|
||||
процесс обязан жить ВНУТРИ `user@<uid>.service`.** Проверка одной командой:
|
||||
`cut -d: -f3 /proc/self/cgroup` не должен давать `/init.scope`. Оболочка, поднятая вне
|
||||
пользовательского входа (под WSL — обычный случай), проходит проверку «менеджер достижим» и всё
|
||||
равно не получает лимитов: `systemd-run --user` заводит юнит в модели менеджера, а процесс остаётся
|
||||
в исходном cgroup, поэтому `MemoryMax` не применяется НИ К КОМУ и молча. Симптом —
|
||||
`TestARunIsBoundedByItsOwnCgroup` красный при зелёном всём остальном (`PD-423`). Ожидание при всех трёх: 18 пакетов, exit 0, **скипов 0**, линтер
|
||||
«0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD
|
||||
скипаются молча — `PD-374`) ⚠ **и ЧЕТВЁРТОЕ условие, которого здесь не было до 29.08: хост обязан
|
||||
РЕАЛЬНО применять `MemoryMax` к транзиентному юниту.**
|
||||
|
||||
⚠⚠ **Движковый бинарь второго гейта обязан быть СОБРАН ИЗ ТЕКУЩЕГО `backend/`, а не переиспользован
|
||||
со стенда** (`cd <repo>/backend && go build -o $W/tmctl ./cmd/tmctl`) — дописано паком P12 30.08 по
|
||||
строке `PD-432`. Цена пропуска названа замером: стендовый `tmctl` от 24.08 против сегодняшнего
|
||||
`backend/configs/models.yaml` дал ТРИ красных теста в `internal/books` и `internal/runner` с
|
||||
сообщением `tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field
|
||||
system_messages not found in type config.CapabilitiesConfig`. Диагноз стоит времени именно потому,
|
||||
что выглядит как дефект зоны: падает платформенный тест, а лжёт бинарь движка, собранный до того,
|
||||
как в конфиг движка приехало поле. То же правило и той же причины — для КОНФИГОВ стенда, если они
|
||||
скопированы рядом с бинарём: пак P12 нашёл стендовую копию `backend/configs` без `langpacks/ru`,
|
||||
появившегося позже.
|
||||
|
||||
⛔ **Команда-проверка четвёртого условия СНЯТА и не подлежит восстановлению без нового диагноза.**
|
||||
Прежняя редакция предлагала одну строку: `cut -d: -f3 /proc/self/cgroup` не должен давать
|
||||
`/init.scope`. Она **ОПРОВЕРГНУТА** — второй точкой `PD-423` (29.08: тест зелен 5 из 5 в изоляции
|
||||
при `/init.scope`, прямая проба `systemd-run --user --scope` показала, что процесс ВСЁ-ТАКИ попадает
|
||||
внутрь `user@<uid>.service`) и третьей точкой пака P12 (30.08: у оболочки `cut -d: -f3
|
||||
/proc/self/cgroup` = `/`, и `TestARunIsBoundedByItsOwnCgroup` при этом зелен в полной батарее со
|
||||
скипами 0). То есть cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает — команда даёт ложный
|
||||
отрицательный, и сессия, честно исполнившая её, объявит гейт невыполненным на хосте, где он выполнен.
|
||||
**Судить по самому тесту:** `TestARunIsBoundedByItsOwnCgroup` зелен при 0 скипов ⇒ условие есть.
|
||||
Красный — сначала проверять СРЕДУ (`cgroup.subtree_control` целевого среза, рецепт в «граблях»
|
||||
ниже), и только потом искать дефект в своём диффе. Кандидат на замену команды —
|
||||
состояние `cgroup.subtree_control` среза `tm-runs.slice` в момент прогона — назван КАНДИДАТОМ и
|
||||
только: диагноз `PD-423` не установлен, и вносить его в рецепт как проверку нельзя.
|
||||
|
||||
Ожидание при всех трёх: 18 пакетов, exit 0, **скипов 0**, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD
|
||||
`fbe6cf3`**, **304 на дереве пака P11** (пак добавил 17 пинов, гейченных тем же DSN). Число зависит
|
||||
от дерева, и переносить его между ними нельзя.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@
|
|||
> вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор
|
||||
> читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).
|
||||
|
||||
## ПИНГ оркестратора №20 → зоне платформы (30.08, аудит доков: три места твоей зоны врут после пака «писатель книги»)
|
||||
|
||||
Инвентарь всех живых доков против дерева (5 аудиторов + верификатор на находку) дал по твоей зоне три расхождения. Правку вношу НЕ я — твоя зона; прошу одним заходом, удобнее всего с паком P12:
|
||||
1. **`platform/README.md`, «Каналов движка ПЯТЬ, и других нет»** — фраза объявлена исчерпывающей и уже правилась по счёту 29.08. После D39.175 канал ШЕСТОЙ: глагол `tmctl build` и его файловый сайдкар `<project_db>.book.<fmt>`, чьи пути публикуются в `artifacts.book_files`. Именно этот канал будет читать дверь выдачи — молчание списка обойдётся дороже прочих.
|
||||
2. **`platform/docs/STACK_DECISIONS.md`, «Инвентарь каналов движка»** — тот же пропуск: нет ни строки `tmctl build`, ни сайдкара книги. К этой таблице зона ходит за атомарностью сайдкаров, поэтому пропуск читается как «канала нет».
|
||||
3. **`platform/BACKLOG.md` П-12 (и `PD-175`)** — формулируют хвост как ПРОДУКТОВУЮ развилку, ждущую слова владельца («сколько книг во фри-тир»). D39.176 п.1 снял её целиком: продуктовых квот НЕТ и не будет, фри-тир-лимиты не проектируются; остаётся ИНЖЕНЕРНАЯ гигиена (ретеншен `rejected`, свип сирот, потолок диска), решаемая зоной без слова владельца. Новость доставлена пингом выше, но долговечной диспозиции нет ни в бэклоге, ни в регистре — а пинг в журнале уедет в архив.
|
||||
|
||||
⚠ Попутно: `platform/docs/DEFECT_REGISTER.md` держит пять якорей в код, уехавших под правками пака P12 (гейт `counts.py --lint` их называет). Это твоя зона — чинит она; оркестратор чужой регистр не правит.
|
||||
|
||||
## ПИНГ оркестратора №20 → зоне платформы (30.08, слова владельца по продуктовому листу — D39.176)
|
||||
|
||||
Ревизия пака P9 собрала твой продуктовый лист и вынесла владельцу; ответы получены, ратификация — **D39.176**:
|
||||
|
|
@ -21,6 +30,344 @@
|
|||
- ⚠ Находка приёмки: интейк на exit **11** действует ДЕСТРУКТИВНО (удаляет аплоад), а `tmctl build` книги из нуля юнитов выходит именно 11 (`sourceHasNoContent`). Сегодня недостижимо — интейк зовёт `manifest`, не `build`; учесть при подключении `build` к автоматике.
|
||||
- `tm-export-v1` расширен АДДИТИВНО: `heading` · `ghost_units` (отдельное верхнеуровневое поле — в `chunks` ghost-строки НЕ попадают) · `text_modified`; `DecodeExport` проверен приёмкой: новые поля переживает, `unitState` не задет.
|
||||
|
||||
## ДОФИКСЫ ПРИЁМКИ P12 ИСПОЛНЕНЫ — восемь пунктов, из них один про поведение; батарея зелёная (сессия `textmachine-main-b5`, 31.08)
|
||||
|
||||
Приёмка оркестратора №21: 6 major, 16 minor, блокеров нет. Ниже — что сделано по каждому пункту.
|
||||
|
||||
**1 (major, код) — пер-главный счётчик читал нули всю черновую волну.** Диагноз приёмки верен и это моя
|
||||
регрессия: снятая мной вторая редакция предиката накрывала только момент стояния у стопа. Взял вариант
|
||||
**(а)**, пер-главный, но НЕ ровно кандидатом приёмки: правило написано ОДИН раз константой
|
||||
`chapterUnitsDone` и читается ОБОИМИ читателями — кадром главы в sink'е и `ListChapters`. Причина, по
|
||||
которой не двумя выражениями: гейт `TestEverySQLStatementParsesAgainstTheMigratedSchema` требует
|
||||
компайл-тайм-константы и функцию-помощник отверг — правильно; а две копии одного правила это ровно то,
|
||||
как страница начинает спорить с кадром, который клиенту только что толкнули. Следствие: `ListChapters`
|
||||
теперь джойнит книгу и её текущий прогон, и `scope.wave` — вторая копия правила, книго-широкая —
|
||||
удалена совсем. Три состояния пинит `TestAChapterOfASigningRunCountsByThePassCurrentForThatChapter`
|
||||
(черновая волна · своя-глава-против-чужой · переход на edit-колонку по СВОЕЙ главе), и он ловит ОБЕ
|
||||
откаченные редакции: посадка **M20** (сузить до момента у стопа) и **M21** (сделать терм книго-широким)
|
||||
— обе красные адресно. **Цена джойна замерена, а не оценена:** страница 100 глав — **0.617 мс против
|
||||
0.496 мс** без джойна (`BenchmarkChapterPage`, тот же вакуумированный корпус, что у `LibraryPage`;
|
||||
бенчмарк добавлен тем же деревом). +24% и +0.12 мс абсолютных за одно правило вместо двух.
|
||||
|
||||
**2 (major, док) — §35 агитировал за откаченную регрессию.** Абзац переписан: через эпоху считается
|
||||
ТОЛЬКО пожизненный счёт книги; полоса прогона остаётся на монотонном `edit_wave` с замеренной причиной
|
||||
(4/4 → 2/2 на двух попытках одного прогона) и ссылкой на `PD-435`; довод «полосу это НЕ ломает» помечен
|
||||
как прежняя редакция, неверная, с датой. Добавлен третий читатель — пер-главное правило и запрет
|
||||
книго-широкого предиката в нём.
|
||||
|
||||
**3 (major, регистр) — `PD-403` ссылалась на пин, которого нет.** Имя снято; в строке прямо написано,
|
||||
что теста с таким именем в зоне нет ни одного и что свойство несёт открытая `PD-435`, а не пин.
|
||||
|
||||
**4 (minor) — шапка против тела.** Старый тест заменён целиком (п.1), противоречия не осталось. ⚠ Но
|
||||
моя же вставка при этом УКРАЛА док-комментарий у `TestTheBarIsOneMonotonicFractionThroughTheSigningStop`
|
||||
— тот же класс, что п.6; возвращён на место.
|
||||
|
||||
**5 (minor, обязательно сейчас) — миграция 00030 неверно цитировала D39.163.** Ссылка была на «словарь
|
||||
стадий закрыт, третьего исключения нет»; D39.163 про то, что ВТОРОЕ исключение выдано под двумя
|
||||
условиями. Переписано по существу: границу канона (§Boundaries) непрозрачному счётчику поколения
|
||||
исключение не нужно вовсе, потому он им и является. Хеш в `migrations.sha256` пере-записан.
|
||||
|
||||
**6 (minor) — четыре украденных док-комментария.** Все четыре возвращены своим функциям:
|
||||
`ingest.Manifest.Whole()`, `pgstore.TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`,
|
||||
`books.TestAManifestThatContradictsItselfNeverCostsTheUpload` и (мой собственный, п.4)
|
||||
`pgstore.TestTheBarIsOneMonotonicFractionThroughTheSigningStop`.
|
||||
|
||||
**7 (minor) — шесть номерных якорей в `bank.go`.** Переведены на символы и грепы (`writeCtx`,
|
||||
`settleCtx`, `runner.BankApply`, греп `bank_moved_at =`, `if book.BankMoved`, `pgstore.StartRun`);
|
||||
номерных якорей в файле теперь **ноль**. В сам комментарий вписано, почему по символам.
|
||||
|
||||
**8 (minor, три места).** `readmodel.go` «small and bounded» ушло вместе со старым комментарием при
|
||||
п.1. `platform/README.md`: добавлен СЕДЬМОЙ канал (`tmctl bank-apply` — единственный, которым зона
|
||||
ПИШЕТ в проект движка), и **притязание «и других нет» снято** как трижды не оправдавшееся, со ссылкой
|
||||
на исчерпывающий инвентарь в `STACK_DECISIONS`; туда же добавлена строка `bank-apply`.
|
||||
`deploy/README.md`: вторая, независимая причина порядка «сначала платформа, потом движок» — подъём
|
||||
формы манифеста без платформенного билда уводит КАЖДУЮ книгу в `parser_unavailable` и по бюджету
|
||||
попыток в `rejected`, при здоровом на вид движке.
|
||||
|
||||
**Диспозиция по `PD-435`: оставляю `minor`, довод в самой строке.** Мажорной `PD-403` делали ДЕНЬГИ —
|
||||
шкала продавала переведённое; эта половина закрыта, и `PD-410` остаётся `major` именно поэтому. Здесь
|
||||
не двигается ни один микро-доллар: прогон делает всю купленную работу, закрывается `ready`, леджер
|
||||
сходится, остаток считается верно. Врёт дробь и подпись — контрактно видимо и потому не `info`, но это
|
||||
отчёт о работе, а не её оплата; плюс нужна смена формы деплоя под уже отредактированной книгой, тогда
|
||||
как `PD-410` кусает на каждой покупке. Сочтёшь довод слабым — поднимай, работа от веса не меняется.
|
||||
|
||||
### Числа дофиксов (каждое — командой)
|
||||
|
||||
- `make check` при трёх гейтах + условии хоста: **18 пакетов, EXIT=0, скипов 0**, линтер «0 issues»,
|
||||
`sqlc diff` чист.
|
||||
- `counts.py --check`: битая форма пусто, хвост вне словаря пусто; открытых **97** (major 2 · minor 32 ·
|
||||
info 63).
|
||||
- `counts.py --lint`: **0 проблемных якорей в 101 живом доке.** ⚠ По дороге пришлось пере-нацелить ещё
|
||||
ДВА — их убила моя же правка `deploy/README.md` из п.8; правило «якорь, убитый твоим переездом,
|
||||
чинишь ты» сработало на мне в тот же заход.
|
||||
- Новых посадок: **M20**, **M21** (обе на правило п.1), обе красные адресно.
|
||||
- Новый бенчмарк `BenchmarkChapterPage`: 0.617 мс против 0.496 мс до джойна.
|
||||
- Тестов после дофиксов: **+12 новых, 3 пере-подписаны** (дифф `^func Test` исполнением + новые файлы).
|
||||
|
||||
## ПАК P12 ОТРАБОТАН — семь major разобраны, обход `--verify-bank` снят и пробит живьём, канон 0.9.0; адверсариальный проход нашёл в СВОЕЙ работе три регрессии и они откачены (сессия `textmachine-main-b5`, 31.08)
|
||||
|
||||
Дерево передаётся оркестратору `textmachine-main-5c`. Не коммичу. ⚠ В дереве лежат ТВОИ незакоммиченные
|
||||
правки — `docs/PROGRESS.md`, `docs/architecture/05-decisions-log.md` (D39.178), `05-decisions-index.md`;
|
||||
я их не трогал и в свой состав не включаю.
|
||||
|
||||
### Таблица комплектности против §3 (пункт → сделано → чем ДОКАЗАНО)
|
||||
|
||||
| § | Взято? | Чем доказано |
|
||||
|---|---|---|
|
||||
| 3.1 `PD-425` | **да** | детач РОВНО записи (`WithoutCancel`+`recordBudget`); пин `TestTheBankMoveFactSurvivesAClientThatHungUp`, посадка M1 красная адресно |
|
||||
| 3.2 `PD-403` | **половина** | счёт книги — да (эпоха, миграция 00030, канон 0.9.0); полоса прогона — НЕТ, откачено, новая строка `PD-435` |
|
||||
| 3.2 `PD-404` | **да** | тот же носитель; пин `TestAddingTheEditorMovesTheEpochWithTheCountItRecomputes`, посадка M9 |
|
||||
| 3.2 `PD-405` | **да** | отказ старта до дерева, ДО холда; пины ×2, посадки M10/M19 |
|
||||
| 3.2 `PD-411` | **да** | миграция 00031, оба писателя сняты; вскрытый гап заведён строкой `PD-434` |
|
||||
| 3.3 `PD-402` | **да** | константа `newestRun` в 10 склейках + `LatestRun`; 3 пина по местам, посадки M6/M7 |
|
||||
| 3.4 `PD-424` | **половина** | счёт неудач и видимость — да (пин + M13); терминальная РУЧКА — нет, диспозиция подписана |
|
||||
| 3.4 `PD-418` | **дешёвая половина** | оговорка в рантбуке; долговечное лечение — диспозицией, состояние по-прежнему недостижимо |
|
||||
| 3.5 `PD-369` | **да** | `ErrKeyContended` оборачивает `ErrKeyInFlight`; серия **80/80 PASS, 0 FAIL, 0 SKIP** |
|
||||
| 3.5 `PD-420` | **пере-замерена** | 3 параллельные полные батареи в чистых копиях: 18/18 каждая, 0 красных. Одна точка, не опровержение — строка открыта |
|
||||
| 3.6 `PD-431` | **да** | сквозной пин через `auth.Authenticator` с ЖИВЫМ стором; посадка M12 — ИМЕННО та, которой строка заведена |
|
||||
| 3.6 `PD-432` | **да** | рецепт дописан; опровергнутая команда `/proc/self/cgroup` снята (моя точка — третья) |
|
||||
| 3.7 обход | **да, весь состав (а)+(б)+(в)** | миграция 00029; **живой пробой на стенде**, см. ниже |
|
||||
| 3.7 строка 240 | **да** | и потом ПЕРЕ-починена: адверсариальный проход нашёл, что гард читал устаревший снапшот |
|
||||
| 3.8 `PD-367` | **да** | симметричный пол; фикстуры расширены (`wholeManifest`), не обойдены; посадка M15 |
|
||||
| 3.8 `PD-213` | **да** | форменный гейт версии манифеста; посадка M14 |
|
||||
| 3.9 гигиена | **да** | 13 строк закрыто, 15 перенесено в секцию эры P12, 4 новые строки, 4 пере-диспозиции |
|
||||
|
||||
### Живой пробой §3.7 — сделан НАСТОЯЩИЙ, и вот чем он отличается от достижимой половины
|
||||
|
||||
Промт предупреждал, что пробой может упереться в данные стенда, и он уперся: движок падает ГРОМКО, если
|
||||
`--verify-bank` передан книге, которая не умеет майнить (`backend/internal/pipeline/mining.go:57-67`), а у
|
||||
стендовой книги не было ни `langpack_root`, ни секции `mining:`. Достроил стенд НАСТОЯЩИМ артефактом, а не
|
||||
выдумкой: контраст — `jieba_dict_general_zh.txt`, sha256 сошёлся с пином `docs/experiments/16-bank-mining.md:183`.
|
||||
|
||||
**Что доказано исполнением, в двух ярусах.**
|
||||
1. Движковый: `tmctl translate --verify-bank` ⇒ **EXIT 3**, стоп, `bank_stop_presented` = 2 поверхности
|
||||
(方源, 青茅山). Тот же вызов ВТОРОЙ раз ⇒ **EXIT 0**, журнал движка: «`--verify-bank` is raised and the
|
||||
delta is non-empty, but every cluster in it was already presented by an earlier stop — continuing».
|
||||
2. Сквозной через платформу: свежий `tmplatformd` (демона №19 убил ПО PID), интейк → `POST /runs`
|
||||
`stop_for_signing:true` → **`awaiting_bank` 2/4 stage=editing** → `POST /resume` → **`ready` 4/4**.
|
||||
Прямая улика, что флаг ДОЕХАЛ на возобновлённой попытке: тот самый WARN движка в journald за окно
|
||||
резюма (он эмитится ТОЛЬКО при поднятом флаге) плюс записанный `auto-bank.yaml`.
|
||||
|
||||
То есть «стоп банка доезжает флажком движка, а не обходом» — не мнение и не половина.
|
||||
|
||||
### ⚠ АДВЕРСАРИАЛЬНЫЙ ПРОХОД ПО СВОЕЙ ГОТОВОЙ РАБОТЕ НАШЁЛ ТРИ РЕГРЕССИИ, ВНЕСЁННЫЕ ЭТИМ ЖЕ ПАКОМ
|
||||
|
||||
Дерево на время прохода было заморожено. 5 линз → 19 находок → адверсариальная верификация каждой
|
||||
(рефутер по умолчанию опровергает): **14 подтверждено, 5 опровергнуто**. Существенны три, и все три — мои.
|
||||
|
||||
**1. Полоса прогона перестала быть монотонной, и я обещал обратное.** Я перевёл `runDone`/`runTotal`/
|
||||
`runStage` на эпоху — а эпоха ПРИСВАИВАЕТСЯ и ходит в обе стороны. Замерено на живом Postgres через
|
||||
продовые пути: один прогон читал **4/4, затем 2/2** на своих же двух попытках при неизменной
|
||||
`structure_version`. Канон держит полосу одной монотонной дробью (строка 200). **Откачено:** полоса
|
||||
вернулась на монотонный `books.edit_wave`, на эпоху уехал только пожизненный счёт книги — ровно то, что я
|
||||
писал тебе в пинге и в записке-плане и от чего отступил по ходу. Цена названа: вторая половина `PD-403`
|
||||
(полоса не доходит до единицы на деплое без редактора) НЕ закрыта, заведена `PD-435` с разбором, почему это
|
||||
не однострочник. Обратная сторона запинена: `TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`.
|
||||
|
||||
**2. Гард строки 240 читал УСТАРЕВШИЙ снапшот, то есть не работал в главном случае.** `LiveRun` — копия по
|
||||
значению ДО дрейна журнала, а событие банк-стопа приходит именно в этом дрейне. Замерено: после дрейна
|
||||
строка в БД читает `awaiting_bank`, снапшот всё ещё `translating`, и оплаченный стоп рестартился насквозь.
|
||||
Плюс вторая дыра: ветвь `interruptedBySomeoneElse` (внешний SIGTERM, exit 5) отвечает РАНЬШЕ `outcome`, где
|
||||
лежало моё правило. **Починено:** статус пере-читывается после дрейна вместе с ceiling-причиной
|
||||
(`freshRunState`), и ветвь внешнего сигнала исключает `awaiting_bank`. Пин на ОБА пути —
|
||||
`TestABankStopArrivingInThisSweepsOwnDrainIsNotRestartedPast`, посадки M16/M17 красные адресно.
|
||||
|
||||
**3. Производный сегмент полосы флипался НЕОБЪЯВЛЕННО.** Мой предикат имел второй терм `draftBar < draftWork`
|
||||
— книго-широкий, а кадры глав шлются ПОГЛАВНО: он переворачивался на draft-юните ЧУЖОЙ главы, молча меняя то,
|
||||
что пере-чтение говорит о главах, которых не касалось ни одно событие. Замерено на настоящем sink'е.
|
||||
**Починено:** терм снят, остался `r.verify_bank and r.status = 'awaiting_bank'` — а статус двигается только
|
||||
на банк-стопе и на резюме, и оба бампают ревизию и шлют кадр, то есть каждый флип объявлен.
|
||||
|
||||
**Плюс восемь честностей, каждая исправлена:** тавтологическое утверждение в моём же пине PD-402
|
||||
(`sc.bookID` копируется из аргумента — не могло упасть никогда; заменено на `wave`, посадка M6b теперь
|
||||
красная) · SHAPE-утверждение в пере-нацеленном тесте ресинка выполнялось СВОЕЙ ЖЕ фикстурой (поток уже
|
||||
записал ту же форму; развёл формы, посадка M18 красная) · заголовок пина PD-405 обещал посадку, которую
|
||||
ничто не ловило (завёл недостающий случай, M19) · комментарий в `runs_test` утверждал, что эпоха делает
|
||||
дубль-половину кусачей — не делает, поправлено · `&& emptyBook(m)` стал недостижимо-ложным за новым полом —
|
||||
конъюнкт снят вместе с осиротевшим хелпером · док-комментарий `ApplyStatus` всё ещё обещал четыре снесённые
|
||||
колонки · `shape_epoch` попал в `properties` канона мимо `required`, хотя сервер шлёт его всегда · книга со
|
||||
списанным долгом поверхности стала НЕзапускаемой — ручка существует (`tmplatformctl book refresh`) и теперь
|
||||
названа прямо в комментарии гарда и в строке регистра.
|
||||
|
||||
### Числа сдачи (каждое — командой)
|
||||
|
||||
- **`make check` при ТРЁХ гейтах + условии хоста: 18 пакетов, EXIT=0, скипов 0, линтер «0 issues», `sqlc diff` чист.**
|
||||
⚠ Четвёртое условие сужу по САМОМУ тесту (`TestARunIsBoundedByItsOwnCgroup` зелен при 0 скипов), а не по
|
||||
опровергнутой команде: у моей оболочки `cut -d: -f3 /proc/self/cgroup` = `/`, и это ТРЕТЬЯ точка против неё.
|
||||
- ⚠ **`sqlc` на машине НЕ БЫЛО**, а `make check` держит `sqlc diff` пререквизитом — то есть батарея зоны
|
||||
была недостижима, пока я не поставил пин `v1.31.1`. Стоит знать при следующем подъёме окружения.
|
||||
- Три параллельные полные батареи в чистых копиях: 18/18 каждая, 0 красных (замер для `PD-420`).
|
||||
- `PD-369`, серия: `-count=80` ⇒ **80 PASS / 0 FAIL / 0 SKIP** (счёт по `^--- PASS`, не по коду выхода).
|
||||
- Тесты: **+12 новых, 3 пере-подписаны** (дифф `^func Test` исполнением + новые файлы).
|
||||
- Мутационных посадок: **19**, каждая в СВОЕЙ копии дерева ВМЕСТЕ с каноном, вердикт по ДЕЛЬТЕ против
|
||||
чистой базы ТОЙ ЖЕ копии. Все 19 красные адресно.
|
||||
- Миграции: 00029 (снос `bank_released`), 00030 (эпоха), 00031 (снос четырёх мёртвых колонок). Down-путь
|
||||
гоняется существующим `TestARollbackSurvivesTheDataTheNewVocabulariesWrote` (DownTo(5)+Up) — зелен.
|
||||
- Регистр: 435 строк, открытых **97** (major 2, minor 32, info 63) против 107 на входе; `counts.py --check`
|
||||
чист по форме и словарю.
|
||||
|
||||
### Пере-подписанные пины — поимённо (D39.121: заказанная смена контракта, не подгонка)
|
||||
|
||||
1. `TestAResumedRunIsSpawnedWithoutTheSigningStop` → **`...WithTheFlagItStartedWith`**, утверждение
|
||||
ИНВЕРТИРОВАНО. Заказано §3.7 промта; прежнее было верно для движка ДО памяти v16.
|
||||
2. `TestTheReconcilerDoesNotLiftABankStopNobodySigned` → **`...DoesNotRestartPastABankStopNobodySigned`**.
|
||||
Пинившийся гард (`LiftBankStop`) с памятью v16 не защищал НИЧЕГО; защита переехала на уровень выше.
|
||||
3. `TestTheResyncMaterializesThePhaseSplitAndNeverLowersACounter` →
|
||||
**`TestTheResyncRecordsFreshnessAndShapeAndNoProgress`**. Предмет исчез вместе с колонками; утверждение
|
||||
СУЖЕНО до истинного, а потерянное свойство заведено строкой `PD-434`, а не замолчано.
|
||||
4. Фикстуры интейка (`books_test.newFixture`, два манифеста `render_test`) — расширены `wholeManifest`.
|
||||
5. Фикстуры runs (`newFixture`, `secondBook`) — получили дерево глав.
|
||||
6. `TestTheBarIsOneMonotonicFractionThroughTheSigningStop`, `TestADraftOnlyDeploymentCountsItsOneWaveOnce`,
|
||||
`TestLiftingTheSigningStopLeavesTheBarWhereItStood` — снято поле `LiftBankStop`, утверждения не тронуты.
|
||||
|
||||
⚠ Чужой пин НЕ правил: `seam_test.TestAnEndingIsNeverDecidedFromAReadThatFailed` матчится на текст ошибки,
|
||||
который задел мой рефактор — сохранил формулировку валидной, а не переписал чужое утверждение.
|
||||
|
||||
### Диспозиции по норме §3 п.8 (греп ОТКРЫТЫХ строк по СВОИМ файлам ПОЛНЫМИ путями)
|
||||
|
||||
Совпадений **57** на 38 моих файлах. Из них: **13 закрыто** этим паком · **4 пере-диспозиционировано**
|
||||
(`PD-410` — направление D39.165 §1 есть, механика отдельным паком; `PD-420` — пере-замерена; `PD-423` —
|
||||
третья точка, команда снята из рецепта, диагноз по-прежнему не установлен; `PD-418` — оговорка написана) ·
|
||||
**2 сужены** (`PD-403` до половины, `PD-424` до ручки) · остальные **38 — со-локация по файлу, не
|
||||
взаимодействие**: их якоря я проверил, ни один мой переезд их не убил.
|
||||
|
||||
### Obstacle — что НЕ удалось и что осталось НЕПРОВЕРЕННЫМ
|
||||
|
||||
- **Не проверено:** сколько ещё раз параллельные батареи должны пройти чисто, чтобы снять вторую точку
|
||||
`PD-420`. Три чистых прогона при заявленной частоте «1 из 4» ничего не исключают. Строка открыта честно.
|
||||
- **Не проверено:** диагноз `PD-423`. Я добавил третью точку и снял ложную команду из рецепта, но что
|
||||
РАЗЛИЧАЕТ точки — по-прежнему не установлено. Кандидат (`cgroup.subtree_control` целевого среза) назван
|
||||
кандидатом и в рецепт не внесён.
|
||||
- **Не сделано, подписано:** терминальная ручка `PD-424` (`run abandon` над живым прогоном) — новая
|
||||
разрушительная операторская поверхность над деньгами, дизайн своего размера.
|
||||
- **Не сделано, подписано:** долговечное лечение `PD-418` (ветвление по осиротевшей попытке).
|
||||
- **Не сделано, заведено строкой:** `PD-434` (ресинк не материализует прогресс) и `PD-435` (полоса на
|
||||
деплое без редактора). Обе вскрыты этим паком и обе НЕ лечатся тем, что пак делал.
|
||||
- **Подтверждено:** остаточное окно `PD-425` — смерть ПРОЦЕССА между verb и записью теряет факт навсегда.
|
||||
`WithoutCancel` переживает клиента, не процесс; свипа быть не может (у платформы нет свидетеля
|
||||
свёрнутой коррекции). Названо в строке при закрытии.
|
||||
- **Стенд остался в рабочем виде и изменён:** конфиги пере-собраны из текущего `backend/` (не хватало
|
||||
`langpacks/ru`), `tmctl` пере-собран, база `tmstand` ПЕРЕ-СОЗДАНА (миграции сносят колонки), демон —
|
||||
мой свежий на 8080. Добавлены `pipeline-zero-mining.yaml`, `book-template-zero-mining.yaml` и книга
|
||||
`probe-verify-bank`. ⚠ `TM_PLATFORM_BOOK_TEMPLATE` демона сейчас указывает на майнящий шаблон.
|
||||
|
||||
### Пинг аудита доков (три места зоны) — ИСПОЛНЕН тем же деревом
|
||||
|
||||
1. `platform/README.md`: «каналов ПЯТЬ» → **ШЕСТЬ**, шестой назван (`tmctl build` + сайдкар
|
||||
`<project_db>.book.<fmt>`, пути в `StatusArtifacts.book_files`) вместе с правилом, что дверь выдачи обязана
|
||||
СТРОИТЬ, а не подбирать лежащий рядом файл. ⚠ Счёт в этой фразе отставал от списка ДВАЖДЫ, поэтому в неё
|
||||
вписано, что список считается, а не запоминается.
|
||||
2. `platform/docs/STACK_DECISIONS.md`, «Инвентарь каналов движка»: строка `tmctl build` добавлена с
|
||||
атомарностью, отсутствующим читателем, exit **16** `book_incomplete` и ловушкой exit 11 у интейка.
|
||||
3. `platform/BACKLOG.md` П-12 **и** `PD-175`: продуктовая половина снята целиком (D39.176 п.1), осталась
|
||||
инженерная гигиена без чьего-либо слова. Записано в ОБА носителя, а не только пингом — пинг уезжает в архив.
|
||||
|
||||
⚠ **Плюс попутное из того же пинга: 13 битых якорей регистра пере-нацелены** (не пять — гейт `counts.py --lint`
|
||||
на моём дереве называл тринадцать; часть убил мой же переезд, часть жила раньше). Все тринадцать наведены
|
||||
ПО ТОКЕНУ, а не по номеру строки; двусмысленный (`case overran:` — два кандидата) разрешён вручную в пользу
|
||||
`settleOne`, о котором строка. `--lint`: **0 проблемных якорей в 101 живом доке.**
|
||||
|
||||
### Вопросы оркестратору
|
||||
|
||||
1. **`PD-435` — чей пак?** Носитель ясен (форма прогона, записанная на первом объявлении ЭТОГО прогона), но
|
||||
он требует решения о том, что делать со второй попыткой одного прогона, объявившей другую форму. Это
|
||||
продолжение §3.2, не его хвост.
|
||||
2. **`PD-434`** (ресинк не чинит полосу): лечить его — значит дать ресинку писать в `chapters` и разобрать,
|
||||
как он не спорит с потоком, который те же строки пишет из `unit_resolutions`. Тоже отдельно.
|
||||
3. Канон 0.9.0 у меня в дереве; баннер-компаньон `14-api-contract/README.md` по-прежнему говорит «по 0.8.0
|
||||
включительно» — по его же правилу это ТВОЙ акт лендинга, я не трогал.
|
||||
|
||||
## ЗАПИСКА-ПЛАН: пак P12 «долги под ногами» — что беру, чего не беру, чем докажу (сессия `textmachine-main-b5`, 30.08)
|
||||
|
||||
Пишется ДО первой правки кода, как требует §6 промта. Все числа ниже — МОИ, получены командами на
|
||||
сегодняшнем дереве (HEAD `4bb3404`, чисто), не унаследованы из промта.
|
||||
|
||||
### Синхронизация с деревом (числа — командами)
|
||||
|
||||
| Что | Команда | Замер 30.08 |
|
||||
|---|---|---|
|
||||
| Базовая батарея | `make check` при ТРЁХ гейтах | **18 пакетов, EXIT=0, скипов 0**, линтер «0 issues», `sqlc diff` чист |
|
||||
| Открытых строк регистра | парсер таблицы по колонке статуса | **107** (major 7 · minor 35 · info 65) |
|
||||
| Мест склейки `lastRun` | `grep -n 'lastRun' internal/pgstore/*.go` без тестов | **10** — промт прав; **плюс ОДИННАДЦАТЫЙ носитель того же порядка**, `LatestRun` (`internal/pgstore/runs.go:985`), где `order by started_at desc, id desc` выписан РУКОЙ, мимо константы |
|
||||
| Тулчейн | `go version` · `golangci-lint --version` · `sqlc version` | Go **1.26.7** · линтер **2.12.2** · sqlc **v1.31.1** |
|
||||
| Стенд | `sha256sum ~/.local/bin/tmctl` против свежей сборки | **РАСХОДЯТСЯ** — `PD-432` живая: стендовый `tmctl` от 29.08 13:12, с тех пор в `backend/` 4 коммита |
|
||||
|
||||
⚠ **Расхождение с записью подъёма окружения, называю сразу:** `sqlc` на машине НЕ БЫЛО (`which sqlc`
|
||||
пусто), а `make check` держит `sqlc-check` пререквизитом — то есть батарея зоны на этом хосте была
|
||||
недостижима до того, как я поставил пин `v1.31.1` (`go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1`).
|
||||
Гейтов у батареи по-прежнему три + условие хоста; четвёртое условие я НЕ вывожу из команды
|
||||
`/proc/self/cgroup` — см. §3.6 ниже, у меня она даёт **третью** точку против себя.
|
||||
|
||||
### Что беру (в порядке ранжирования промта)
|
||||
|
||||
**Первый эшелон.**
|
||||
|
||||
1. **§3.1 `PD-425` — детач пост-verb записи.** Детачю РОВНО `RecordBankMove`
|
||||
(`internal/runs/bank.go:182`), не дверь и не verb. Довод против более широкого детача — свой, не
|
||||
унаследованный: (а) детач на `httpapi/bank.go:149` снёс бы два намеренно построенных свойства —
|
||||
«мёртвый клиент покидает очередь книги» (`runs/bank.go:96-99`) и бюджет verb'а; (б) детач самого
|
||||
verb'а не нужен, потому что ранний обрыв клиента даёт SIGTERM → движок проверяет контекст ДО
|
||||
своих записей → exit 5 → `ErrBankUnavailable`, и НИЧЕГО не потеряно; потеря требует отмены ПОСЛЕ
|
||||
последней проверки контекста движком. Единственный ctx-связанный ввод-вывод после verb — эта одна
|
||||
запись. Таймаут — `recordBudget` (10 с, `runs/reconcile.go:370`, тот же пакет): она идёт ПОД
|
||||
блокировкой книги, и 30-секундный `writeBudget` интейка утроил бы удержание.
|
||||
Пин: посадка «контекст отменён между verb и записью» через гейт `entered`/`release` фикстуры.
|
||||
2. **§3.7 снятие обхода `--verify-bank` — весь состав пинга №21, включая (в).** Порядок односторонний:
|
||||
пере-сборка `tmctl` → `tmctl migrate` по стендовой книге → код. Колонку `bank_released` СНОСИТЬ
|
||||
НОВОЙ миграцией (00016 не трогаю) и сегмент полосы пере-вывести предикатом внутри латерали —
|
||||
оставить писателя снятым, а колонку живой значило бы заморозить `false` и врать полосой.
|
||||
Пере-подписываемые пины перечислю поимённо в отчёте.
|
||||
3. **§3.3 `PD-402` read-половина.** Одна константа порядка `(finished_at is null) desc, started_at desc,
|
||||
id desc` вместо `started_at desc` — и та же константа в `LatestRun`, иначе экран пойдёт за живым
|
||||
прогоном, а `Resume` откажет ему как «не последнему»: PD-402 перевернётся, а не закроется.
|
||||
Судится СВОИМИ тестами по каждому из 10 мест, не зеленью батареи.
|
||||
|
||||
**Второй эшелон.**
|
||||
|
||||
4. **§3.2 эпоха формы конвейера (`PD-403`/`PD-404`/`PD-411`/`PD-405`).** Форма выбрана — новая пара
|
||||
колонок `books.shape_epoch` + `books.epoch_editor`; `edit_wave` остаётся и остаётся монотонным
|
||||
(пин D39.153 §4б не трогаю), с живого флага уезжает только ПОЖИЗНЕННЫЙ счёт. ⚠ Эта форма требует
|
||||
правки ФРАЗЫ канона (`openapi.yaml:1554-1559` — вторая граница пересчёта рядом с пере-нарезкой),
|
||||
поэтому **пинг оркестратору отправлен ДО стройки**, как велит промт; пункт не строится до ответа.
|
||||
Отказ ратификации ⇒ строки уезжают следующим паком ДИСПОЗИЦИЕЙ, а не молчаливой отсрочкой.
|
||||
`PD-411` (мёртвые четыре колонки) беру ЭТИМ паком: оба её писателя стоят ровно в тех двух
|
||||
функциях, которые эпоха и правит, и оставить мёртвый носитель рядом с новым живым — способ
|
||||
заставить следующую сессию взять неверный.
|
||||
5. **§3.6 `PD-431` + `PD-432`** и **§3.9 гигиена регистра** — дёшевы, тем же деревом.
|
||||
По `PD-431` поведение НЕ меняю (слово оркестратора): пин — сквозной тест через `auth.Authenticator`
|
||||
с ЖИВЫМ `*pgstore.Store`. ⚠ Направление импорта решает, где он живёт: `pgstore` импортирует `auth`,
|
||||
обратно нельзя, значит тест в пакете `pgstore`.
|
||||
6. **§3.4 `PD-424`+`PD-418`, §3.5 `PD-369`+`PD-420`, §3.8 `PD-367`+`PD-213`** — беру по остатку
|
||||
ресурса, в этом порядке. Приор зоны по `PD-424` (`deferred` ⇒ неудача владеющей фазы) принимаю;
|
||||
по `PD-369` выбираю форму `ErrKeyInFlight`, а не границу по времени — довод в отчёте.
|
||||
|
||||
### Чего НЕ беру (и это решение, а не пропуск)
|
||||
|
||||
Дверь выдачи `createExport`/`getExport` · `PD-410` · эскроу П-18 · `PD-412`/`PD-413` · `PD-422`
|
||||
(гейчена проводкой `--max-units`) · `PD-421` (снят замером оркестратора 30.08, эррата 30.08-а).
|
||||
`backend/` не правлю — единственная разрешённая операция вне зоны — `tmctl migrate` по стендовой книге.
|
||||
|
||||
### Открытые оси (форма решится исполнением; затык = пинг, не интерпретация)
|
||||
|
||||
- Живой пробой §3.7 упирается в ДАННЫЕ стенда, а не в код: движок падает громко, если `--verify-bank`
|
||||
передан книге, которая не умеет майнить, а у стендовой книги нет ни `langpack_root`, ни секции
|
||||
`mining:` в `pipeline-zero.yaml`. Проверю это исполнением ПЕРВЫМ делом; если подтвердится — либо
|
||||
до-оснащу стендовую книгу, либо назову в отчёте, что полный пробой оплаченного стопа требует
|
||||
данных стенда, которых нет, и НЕ выдам достижимую половину за полную.
|
||||
- Строка бэклога 240 (молчащий стоп подписи) закрывается ТОЙ ЖЕ работой: снять гард вместе с обходом
|
||||
ЛИБО доказать недостижимость памятью `bank_stop_presented` (v16). Что из двух — решу исполнением.
|
||||
- `PD-405`: между «отказ старта до дерева» и «полоса из progress-событий» склоняюсь к первому
|
||||
(меньше кода, отказ ДО взятия холда), но второй тронул бы `nothingIsRunning`, общий с `ReadStream`
|
||||
и лизинг-дисциплиной §34 — цена названа, выбор подпишу замером.
|
||||
|
||||
### Чем докажу (DoD §3, поимённо)
|
||||
|
||||
`make check` целиком при трёх гейтах + условии хоста, скипы вслух · мутационные посадки в КОПИИ дерева
|
||||
ВМЕСТЕ с каноном (`cp -a --parents platform docs/architecture/14-api-contract`), вердикт по ДЕЛЬТЕ
|
||||
против чистой базы ТОЙ ЖЕ копии · греп ОТКРЫТЫХ строк регистра по СВОИМ файлам ПОЛНЫМИ путями,
|
||||
каждое совпадение — с диспозицией · дифф `^func Test` исполнением · адверсариальный проход по своей
|
||||
готовой работе ПЕРЕД сдачей, дерево на это время заморожено.
|
||||
|
||||
## ЗАПИСКА-ПЛАН: пак `sqlc` (П-19) — что беру, чего не беру, чем докажу (сессия `textmachine-1b`, 29.08)
|
||||
|
||||
Пишется ДО стройки, как требует §6 промта. Числа ниже — мои, получены на сегодняшнем дереве
|
||||
|
|
|
|||
|
|
@ -133,14 +133,36 @@ type fixture struct {
|
|||
now time.Time
|
||||
}
|
||||
|
||||
// wholeManifest is a manifest that DESCRIBES ITSELF: every count agrees with what the document
|
||||
// actually carries, and every chapter and pair has an identity.
|
||||
//
|
||||
// ⚠ It exists because the intake's floor became symmetric with the materialiser's (register row
|
||||
// PD-367), and the fixtures were EXTENDED to satisfy it rather than the floor relaxed to admit them.
|
||||
// The old fixture declared 500 chapters and carried none — the very document the materialiser has
|
||||
// always refused and the intake accepted, founding a book with `chapter_count = 500` and an empty
|
||||
// tree over which a run could be started and PAID FOR. A fixture that models an impossible document
|
||||
// proves nothing about the possible ones.
|
||||
func wholeManifest(chapters, unitsPerChapter int) ingest.Manifest {
|
||||
m := ingest.Manifest{
|
||||
Version: "tm-manifest-v2", ChaptersTotal: chapters, UnitsTotal: chapters * unitsPerChapter,
|
||||
SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07",
|
||||
}
|
||||
for i := 1; i <= chapters; i++ {
|
||||
c := ingest.ManifestChapter{ID: "c" + strconv.Itoa(i), Number: i, UnitsTotal: unitsPerChapter}
|
||||
for u := range unitsPerChapter {
|
||||
c.Units = append(c.Units, ingest.ManifestUnit{
|
||||
ID: "c" + strconv.Itoa(i) + ":cut:" + strconv.Itoa(u), FirstChunkIdx: u})
|
||||
}
|
||||
m.Chapters = append(m.Chapters, c)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
store, ctx := intakeDB(t)
|
||||
root := t.TempDir()
|
||||
eng := &fakeEngine{manifest: ingest.Manifest{
|
||||
Version: "tm-manifest-v2", ChaptersTotal: 500, UnitsTotal: 950,
|
||||
SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07",
|
||||
}}
|
||||
eng := &fakeEngine{manifest: wholeManifest(500, 2)}
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
f := &fixture{store: store, engine: eng, ctx: ctx, root: root, now: now}
|
||||
f.svc = &Service{
|
||||
|
|
@ -1285,13 +1307,119 @@ func TestAPassTooShortForAParseStartsNoneAtAll(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// THE PD-367 DOCUMENT: counts that describe a book, and no book. `{chapters_total: 120,
|
||||
// units_total: 400}` with an empty chapter list is exactly what the MATERIALISER has always refused
|
||||
// — and the intake accepted it, founded a book with `chapter_count = 120` over an empty tree, and let
|
||||
// a run be STARTED AND PAID FOR against a count nothing could ever fill.
|
||||
//
|
||||
// The floor is symmetric now, and symmetric is the whole property: one document gets ONE answer from
|
||||
// both ends of the intake. Its class is the seam's — unknown or inconsistent is non-destructive and
|
||||
// loud — so the upload survives and the book is never founded.
|
||||
//
|
||||
// ⚠ This is a COMMISSIONED change to a pinned intake contract (P12 §3.8), not a test bent to a green:
|
||||
// the intake's own battery rode documents with no chapter list, and those fixtures were EXTENDED
|
||||
// (`wholeManifest`) rather than the floor relaxed to admit them.
|
||||
//
|
||||
// Mutation caught: moving the floor below the `ChaptersTotal < 1` branches, or dropping Whole() from
|
||||
// Readable.
|
||||
func TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
book := f.accept(t, "book.txt", "первая глава")
|
||||
dir := f.provision(t, book)
|
||||
// Counts that describe 120 chapters and 400 pairs; a document carrying neither.
|
||||
f.engine.set(ingest.Manifest{Version: "tm-manifest-v2", ChaptersTotal: 120, UnitsTotal: 400,
|
||||
SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07"}, nil)
|
||||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := f.card(t, book.ID)
|
||||
if got.Status == "not_started" {
|
||||
t.Fatalf("the intake founded a book on a document its own materialiser refuses: status %q,"+
|
||||
" chapter_count %d — a run over it can be started and PAID FOR against a count nothing fills",
|
||||
got.Status, got.ChapterCount)
|
||||
}
|
||||
if got.ChapterCount != 0 {
|
||||
t.Errorf("chapter_count was written as %d from a document that carries no chapters", got.ChapterCount)
|
||||
}
|
||||
// NON-DESTRUCTIVE, through the whole budget: a document this build cannot read says nothing about
|
||||
// the user's text.
|
||||
for range parseAttempts + 1 {
|
||||
f.now = f.now.Add(claimGrace + time.Minute)
|
||||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := f.card(t, book.ID); got.Status != "rejected" {
|
||||
t.Errorf("the book is %q after its whole budget; the budget still bounds a broken deployment", got.Status)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, SourceName+".txt")); err != nil {
|
||||
t.Fatalf("a document this build could not read cost the user their upload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PD-213, the LATENT MINE: an engine whose manifest shape moved must never look like a book with
|
||||
// nothing in it.
|
||||
//
|
||||
// The mechanism, and it is one field rename away: `DecodeManifest` does not gate the version,
|
||||
// `json.Unmarshal` ignores unknown fields and leaves missing ones zero — so a v3 manifest that
|
||||
// renamed `chapters_total` decodes into a valid Manifest reading zero chapters and zero units. Zero
|
||||
// and zero is exactly what the intake reads as «the engine read the source and there is no book in
|
||||
// it», which is the ONE verdict that DELETES the user's file after five attempts. The engine would
|
||||
// have parsed the book perfectly.
|
||||
//
|
||||
// The version gate makes that unreachable by turning the same document into `parser_unavailable`,
|
||||
// which keeps the file. It is a SHAPE gate, not a value pin — the counts and identities below it are
|
||||
// stable, and pinning values everywhere would make every engine release a platform release.
|
||||
//
|
||||
// Mutation caught: dropping the version check from Readable, which restores the deletion path.
|
||||
func TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
book := f.accept(t, "book.txt", "первая глава")
|
||||
dir := f.provision(t, book)
|
||||
// The engine of tomorrow: a version this build has never heard of, and — because the keys moved
|
||||
// under the allowlist — every count decoding to zero. Indistinguishable from an empty book to a
|
||||
// reader that does not ask which shape it is holding.
|
||||
f.engine.set(ingest.Manifest{Version: "tm-manifest-v3"}, nil)
|
||||
for range parseAttempts + 1 {
|
||||
f.now = f.now.Add(claimGrace + time.Minute)
|
||||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := f.card(t, book.ID); got.Status != "rejected" {
|
||||
t.Errorf("the book is %q; the budget still bounds a deployment this build cannot read", got.Status)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, SourceName+".txt")); err != nil {
|
||||
t.Fatalf("an engine upgrade deleted the user's upload: %v", err)
|
||||
}
|
||||
// And the KNOWN shape, whose counts genuinely agree on nothing, still ends the way it must: that
|
||||
// verdict is a statement about the user's text, and it is only reachable past the gate.
|
||||
empty := f.accept(t, "empty.txt", " ")
|
||||
emptyDir := f.provision(t, empty)
|
||||
f.engine.set(ingest.Manifest{Version: ingest.KnownManifestVersion}, nil)
|
||||
for range parseAttempts + 1 {
|
||||
f.now = f.now.Add(claimGrace + time.Minute)
|
||||
if err := f.svc.Parse(f.ctx, empty.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(emptyDir); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("a source with no book in it kept its directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest that counts no chapters and still counts UNITS is not an empty book — it is a document
|
||||
// this build is reading wrong, most likely because a field moved under it. The difference decides
|
||||
// whether the user's upload is deleted, so the two must not share a verdict.
|
||||
//
|
||||
// The engine's manifest is not version-gated on purpose (the value would make every engine release a
|
||||
// platform release), which leaves exactly this gap: the destructive verdict was guarded by a JSON
|
||||
// field name. Found by the seam lens of the dofix review.
|
||||
// ⚠ Its FIRST case now dies one line earlier than it used to, and the test is kept rather than
|
||||
// retired because the property it states is unchanged: `tm-manifest-v9` is refused by the version
|
||||
// gate `Readable` added (PD-213) before the count branches are reached at all. The count branch it
|
||||
// was written against is still there and still reachable — by a KNOWN shape whose counts contradict
|
||||
// each other — which is what the second half of this test exercises. The sentence that used to close
|
||||
// this comment ("the engine's manifest is not version-gated on purpose, which leaves exactly this
|
||||
// gap") describes the state before P12: the gate exists now, and it is a SHAPE gate rather than the
|
||||
// value pin that sentence was arguing against.
|
||||
//
|
||||
// Mutation caught: reading `ChaptersTotal < 1` alone as the source being unreadable.
|
||||
func TestAManifestThatContradictsItselfNeverCostsTheUpload(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -126,19 +126,46 @@ func (s *Service) Parse(ctx context.Context, bookID string) error {
|
|||
}
|
||||
m, err := s.manifest(ctx, claim)
|
||||
if err == nil {
|
||||
if m.ChaptersTotal < 1 && emptyBook(m) {
|
||||
// The engine SUCCEEDED and reported a book with no chapters in it. Same verdict as exit 11
|
||||
// and the same budget: a manifest is not a place a deployment fault can hide.
|
||||
return s.defer_(ctx, claim, ReasonSourceUnreadable)
|
||||
// THE FLOOR, and it is FIRST for a reason that is the whole of it: below this line a document
|
||||
// that could not be read correctly is indistinguishable from a book with nothing in it, and
|
||||
// that reading DELETES the user's upload after the attempt budget.
|
||||
//
|
||||
// It asks two things at once because both have the same answer (ingest.Readable): is this the
|
||||
// manifest shape this build reads, and does the document describe its own contents. The first
|
||||
// is the mine register row PD-213 names — a renamed key decodes to zeroes and the zeroes read
|
||||
// as an empty book. The second is PD-367: the MATERIALISER has always refused a document whose
|
||||
// counts and contents disagree, and the intake accepted the same document, founded a book on
|
||||
// its counts and let a run be started and PAID FOR over an empty tree. One document must not
|
||||
// get two answers from two ends of the same intake.
|
||||
//
|
||||
// `parser_unavailable` and never `source_unreadable`: a count read from the wrong key is not
|
||||
// evidence about the user's text, and the class that keeps the file is the only honest one for
|
||||
// «this build cannot read what the engine sent». It still spends the attempt budget, so a
|
||||
// deployment that is genuinely broken stops rather than retries forever.
|
||||
if rerr := m.Readable(); rerr != nil {
|
||||
s.log().ErrorContext(ctx, "the manifest does not describe itself, so it is not being read correctly",
|
||||
"chapters", m.ChaptersTotal, "units", m.UnitsTotal, "manifest_version", m.Version,
|
||||
"reason", ReasonParserUnavailable, "err", rerr)
|
||||
return s.defer_(ctx, claim, ReasonParserUnavailable)
|
||||
}
|
||||
if m.ChaptersTotal < 1 {
|
||||
// No chapters, but the same document counts units or chunks — which no book has and no
|
||||
// engine produces. It is a manifest this build cannot read (a field that moved, most
|
||||
// likely), and the difference matters because the branch above is the one that DELETES the
|
||||
// upload: a count read from the wrong key is not evidence that the source is empty.
|
||||
s.log().ErrorContext(ctx, "the manifest counts no chapters and still counts work: it is not being read correctly",
|
||||
"units", m.UnitsTotal, "manifest_version", m.Version, "reason", ReasonParserUnavailable)
|
||||
return s.defer_(ctx, claim, ReasonParserUnavailable)
|
||||
// The engine SUCCEEDED and reported a book with no chapters in it. Same verdict as exit 11
|
||||
// and the same budget: a manifest is not a place a deployment fault can hide.
|
||||
//
|
||||
// Reachable only for a document that PASSED the floor above — the version this build reads,
|
||||
// and every count agreeing with its own contents — which is what makes «there is no book in
|
||||
// these bytes» a statement about the user's text rather than about our reader.
|
||||
//
|
||||
// ⚠ The second conjunct this branch used to carry (`&& emptyBook(m)`, i.e. `UnitsTotal < 1`)
|
||||
// is GONE rather than kept for safety, because past the floor it can no longer be false and
|
||||
// a condition nothing can falsify is a condition no test can defend. Whole() forces
|
||||
// `ChaptersTotal == len(Chapters)` and `UnitsTotal == sum(len(c.Units))`, so zero chapters
|
||||
// implies zero units by arithmetic. What the conjunct used to guard — a document counting
|
||||
// units while counting no chapters, the shape of a key that moved — is refused ABOVE now,
|
||||
// non-destructively, which is the same verdict it used to produce and one branch earlier.
|
||||
// Removed on the finding of this pack's own adversarial pass, which showed it surviving
|
||||
// deletion against the whole battery.
|
||||
return s.defer_(ctx, claim, ReasonSourceUnreadable)
|
||||
}
|
||||
// Counts and versions, no book id: the same rule as everywhere else on this side of the log
|
||||
// (ENGINEERING_STANDARDS §Наблюдаемость). What an operator needs per book — including why a
|
||||
|
|
@ -312,13 +339,6 @@ func (s *Service) manifest(ctx context.Context, claim pgstore.ParseClaim) (inges
|
|||
return s.Engine.Manifest(ctx, s.Cfg.EngineBinary, workdir)
|
||||
}
|
||||
|
||||
// emptyBook reports a manifest that agrees with itself about there being nothing to translate.
|
||||
//
|
||||
// The counts are read TOGETHER because only one of them is destructive on its own: a document whose
|
||||
// chapter count is zero while its unit count is not is not an empty book, it is a document being
|
||||
// read wrong, and the two must not share a verdict (see Parse).
|
||||
func emptyBook(m ingest.Manifest) bool { return m.UnitsTotal < 1 }
|
||||
|
||||
// intakeReason turns what the engine ANSWERED into this platform's own word for it.
|
||||
//
|
||||
// ⚠ This is the consumer half of PD-196, and the whole defect lived in the sentence this function
|
||||
|
|
|
|||
|
|
@ -250,8 +250,7 @@ func TestARenderedConfigurationIsNeverRewritten(t *testing.T) {
|
|||
if err := os.Remove(tpl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.engine.set(ingest.Manifest{Version: "tm-manifest-v2", ChaptersTotal: 3,
|
||||
SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07"}, nil)
|
||||
f.engine.set(wholeManifest(3, 1), nil)
|
||||
// Another walk of the same book — a re-parse after the claim went stale.
|
||||
f.now = f.now.Add(claimGrace + time.Minute)
|
||||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||||
|
|
@ -314,8 +313,7 @@ func TestABrokenTemplateStopsNoIntakeAndSpendsNoBudget(t *testing.T) {
|
|||
if err := os.WriteFile(tpl, []byte(templateYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.engine.set(ingest.Manifest{Version: "tm-manifest-v2", ChaptersTotal: 3,
|
||||
SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07"}, nil)
|
||||
f.engine.set(wholeManifest(3, 1), nil)
|
||||
f.now = f.now.Add(claimGrace + time.Minute)
|
||||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import "net/http"
|
|||
// client generated against another one refuses to work and says so — which is why this must be
|
||||
// raised in the same commit as the code that implements a new minor, and never as a courtesy
|
||||
// afterwards.
|
||||
const ContractVersion = "0.8.0"
|
||||
const ContractVersion = "0.9.0"
|
||||
|
||||
// Capabilities is what this deployment can do: one flat document, the same for every account.
|
||||
type Capabilities struct {
|
||||
|
|
|
|||
|
|
@ -94,9 +94,20 @@ func (h *v0) beginIdempotent(w http.ResponseWriter, r *http.Request, user string
|
|||
case errors.Is(err, pgstore.ErrKeyReused):
|
||||
FailCause(w, r, CodeIdempotencyConflict, CauseKeyReused)
|
||||
return nil, false
|
||||
case errors.Is(err, pgstore.ErrKeyContended):
|
||||
// The claim loop gave up: this key was taken and handed back under us as fast as the racers
|
||||
// could fail. The ANSWER is the same as an ordinary in-flight repeat, because the caller's
|
||||
// remedy is the same one — wait and present it again — and it must not be a 500: nothing was
|
||||
// wrong with the request and there is nothing its client could fix (PD-369). Ordered BEFORE
|
||||
// the case below because it wraps it.
|
||||
//
|
||||
// Loud on OUR side and quiet on the wire, which is the whole mitigation for "the fix hides a
|
||||
// storm": repeated contention on one key is an operator's signal, never a caller's fault.
|
||||
h.log.WarnContext(r.Context(), "an idempotency key is under repeated contention", "err", err)
|
||||
h.keyInFlight(w, r)
|
||||
return nil, false
|
||||
case errors.Is(err, pgstore.ErrKeyInFlight):
|
||||
WriteProblem(w, r, Problem{Code: CodeIdempotencyConflict,
|
||||
Cause: &Cause{Code: CauseKeyInFlight}, RetryAfter: keyInFlightRetry})
|
||||
h.keyInFlight(w, r)
|
||||
return nil, false
|
||||
case err != nil:
|
||||
h.log.ErrorContext(r.Context(), "the idempotency key could not be claimed", "err", err)
|
||||
|
|
@ -129,6 +140,14 @@ func (h *v0) replay(w http.ResponseWriter, r *http.Request, stored *pgstore.Idem
|
|||
}
|
||||
}
|
||||
|
||||
// keyInFlight is the contract's answer to "this key is busy, come back": ONE writer, because the two
|
||||
// paths that reach it — a first attempt still running, and a claim that lost the race too often —
|
||||
// must not drift into two different words for one remedy.
|
||||
func (h *v0) keyInFlight(w http.ResponseWriter, r *http.Request) {
|
||||
WriteProblem(w, r, Problem{Code: CodeIdempotencyConflict,
|
||||
Cause: &Cause{Code: CauseKeyInFlight}, RetryAfter: keyInFlightRetry})
|
||||
}
|
||||
|
||||
// replayIfIdentical answers a repeat whose identity is its BODY: the stored answer when the bytes
|
||||
// digest to what the first attempt accepted, and `key_reused` when they do not — which is what the
|
||||
// canon calls a different request under the same key.
|
||||
|
|
|
|||
|
|
@ -368,6 +368,25 @@ func TestTheClaimsFourAnswersReachTheClient(t *testing.T) {
|
|||
t.Error("no Retry-After on the answer that asks the client to wait")
|
||||
}
|
||||
})
|
||||
t.Run("a claim that lost the race too often is told to wait, not answered 500", func(t *testing.T) {
|
||||
// PD-369. Contention on one key is a legitimate request meeting other legitimate requests, and
|
||||
// the answer must be one of the contract's own — 409 `key_in_flight` with a Retry-After — not
|
||||
// an internal error the caller can do nothing with. The two paths share ONE writer
|
||||
// (`keyInFlight`), and the case order matters: ErrKeyContended WRAPS ErrKeyInFlight, so the
|
||||
// narrower case has to come first or this test reads the same as the one above it.
|
||||
keys := newFakeKeys()
|
||||
keys.err = pgstore.ErrKeyContended
|
||||
w := idemPost(t, idemServer(t, keys, &fakeIntake{}), "one-per-gesture", "AAAABBBBCCCC", nil)
|
||||
if w.Code != http.StatusConflict || !contains(w.Body.String(), `"key_in_flight"`) {
|
||||
t.Errorf("a contended key answered %d %s, want 409 key_in_flight", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Header().Get("Retry-After") == "" {
|
||||
t.Error("no Retry-After on the answer that asks the client to wait")
|
||||
}
|
||||
if contains(w.Body.String(), `"internal_error"`) {
|
||||
t.Error("a legitimate request under contention was answered with an internal error")
|
||||
}
|
||||
})
|
||||
t.Run("an attempt that did not complete gives the key back", func(t *testing.T) {
|
||||
keys := newFakeKeys()
|
||||
h := idemServer(t, keys, &fakeIntake{err: errors.New("the storage is gone")})
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ func projectBook(b pgstore.Book) wireBook {
|
|||
SourceLang: b.SourceLang, TargetLang: b.TargetLang,
|
||||
Status: b.Status,
|
||||
StructureVersion: b.StructureVersion,
|
||||
ShapeEpoch: b.ShapeEpoch,
|
||||
ChapterCount: b.ChapterCount,
|
||||
ChaptersDone: b.ChaptersDone,
|
||||
AddedAt: b.AddedAt,
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ type wireBook struct {
|
|||
Status string `json:"status"`
|
||||
RejectReason *string `json:"reject_reason"`
|
||||
StructureVersion int `json:"structure_version"`
|
||||
ShapeEpoch int `json:"shape_epoch"`
|
||||
ChapterCount int `json:"chapter_count"`
|
||||
ChaptersDone int `json:"chapters_done"`
|
||||
CharacterCount *int64 `json:"character_count"`
|
||||
|
|
|
|||
|
|
@ -78,6 +78,44 @@ type ManifestUnit struct {
|
|||
FirstChunkIdx int `json:"first_chunk_idx"`
|
||||
}
|
||||
|
||||
// KnownManifestVersion is the shape of the engine's manifest this build was written against. It
|
||||
// MIRRORS `manifestVersion` in backend/internal/pipeline/manifest.go and moves when that moves.
|
||||
//
|
||||
// It is a shape gate and not a value pin, which is the distinction the Version field's own comment
|
||||
// draws: the counts and identities below are stable, so pinning the value everywhere would make
|
||||
// every engine release a platform release. What it guards is the ONE place where reading a manifest
|
||||
// wrong is destructive — see Readable.
|
||||
const KnownManifestVersion = "tm-manifest-v2"
|
||||
|
||||
// Readable is the floor the INTAKE stands on: is this a manifest this build can read at all.
|
||||
//
|
||||
// Two questions, and they are one floor because the answer to both is the same non-destructive class.
|
||||
//
|
||||
// The version, which is register row PD-213 and a latent mine: `DecodeManifest` does not gate the
|
||||
// value, `json.Unmarshal` silently ignores unknown fields and leaves missing ones zero — so a shape
|
||||
// change on the engine's side (`chapters_total` renamed, say) decodes into a perfectly valid Manifest
|
||||
// with ChaptersTotal 0 and UnitsTotal 0. Zero chapters and zero units is exactly what the intake
|
||||
// reads as "the engine read the source and there is no book in it", which is the ONE verdict that
|
||||
// DELETES the user's file after the attempt budget. The engine would have parsed the book perfectly.
|
||||
//
|
||||
// The self-consistency, which is register row PD-367: `Whole()` already guarded the materialiser, and
|
||||
// the intake accepted the very documents the materialiser would refuse — `{chapters_total: 120,
|
||||
// units_total: 400}` with an empty chapter list founded a book with `chapter_count = 120` and an
|
||||
// empty tree, over which a run could be STARTED AND PAID FOR. The floor is symmetric now: one
|
||||
// document, one answer, on both sides of the intake.
|
||||
//
|
||||
// The class is the law of the seam's: unknown or inconsistent is answered NON-DESTRUCTIVELY and
|
||||
// LOUDLY. The caller maps this to `parser_unavailable`, which spends the attempt budget and keeps the
|
||||
// file — never `source_unreadable`, which is a statement about the USER's text and is the one that
|
||||
// deletes it.
|
||||
func (m Manifest) Readable() error {
|
||||
if m.Version != KnownManifestVersion {
|
||||
return fmt.Errorf("ingest: the manifest identifies as %q and this build reads %s",
|
||||
m.Version, KnownManifestVersion)
|
||||
}
|
||||
return m.Whole()
|
||||
}
|
||||
|
||||
// Whole reports whether the document DESCRIBES ITSELF: every list is as long as the count printed
|
||||
// beside it.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -39,7 +39,12 @@ type Book struct {
|
|||
// projection of it (httpapi.contractRejectReason).
|
||||
RejectReason string
|
||||
StructureVersion int
|
||||
ChapterCount int
|
||||
// ShapeEpoch is the generation of the book's COUNT: it moves when the pipeline's shape changes
|
||||
// under the book (an editor added or removed), which is the one other event besides a re-cut that
|
||||
// legitimately recomputes `ChaptersDone`. A coordinate, like StructureVersion — the shape itself
|
||||
// is deliberately not published (canon 0.9.0).
|
||||
ShapeEpoch int
|
||||
ChapterCount int
|
||||
// ChaptersDone is the book's own lifetime progress — chapters fully translated — as opposed to
|
||||
// the bar of a run, which measures what that run bought.
|
||||
ChaptersDone int
|
||||
|
|
@ -991,6 +996,17 @@ type BookRunContext struct {
|
|||
// fact costs one harmless --resnapshot (the engine reads the flag only when a snapshot actually
|
||||
// moved), a falsely-retired one kills the next run on the guard after its hold.
|
||||
BankMoved bool
|
||||
// HasTree is whether the book's chapter tree has been MATERIALISED — the `chapters` rows the whole
|
||||
// read model counts over — as opposed to merely declared by `chapter_count`.
|
||||
//
|
||||
// The two are separate facts with separate writers, and the window between them is ordinary: the
|
||||
// intake commits `not_started` in one transaction and materialises the tree afterwards, outside
|
||||
// it. On the healthy path the window is milliseconds; when the materialisation FAILED or was
|
||||
// deferred it is unbounded, and a run admitted inside it is a run whose bar can never move — every
|
||||
// counter the screen shows is a count over `chapters`, and there are no rows to count (PD-405).
|
||||
// Worse, the tree's debt is frozen for the run's whole life, because the sweep that would pay it
|
||||
// skips a book with a live run. So the run is refused at admission instead, before its hold.
|
||||
HasTree bool
|
||||
}
|
||||
|
||||
// ReadBookForRun gathers the facts a run start is judged on.
|
||||
|
|
@ -1004,11 +1020,12 @@ func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (Book
|
|||
exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null),
|
||||
exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null
|
||||
and lr.status = 'awaiting_bank'),
|
||||
b.bank_moved_at is not null
|
||||
b.bank_moved_at is not null,
|
||||
exists (select 1 from chapters c where c.book_id = b.id)
|
||||
from books b ` + lastRun + ` where b.id = $1 and b.owner_id = $2`
|
||||
var out BookRunContext
|
||||
err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.Status, &out.ChaptersLeft,
|
||||
&out.ChapterCount, &out.HasLiveRun, &out.LiveRunAwaitingBank, &out.BankMoved)
|
||||
&out.ChapterCount, &out.HasLiveRun, &out.LiveRunAwaitingBank, &out.BankMoved, &out.HasTree)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return BookRunContext{}, ErrNoBook
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,16 +77,43 @@ func NewClaimToken() ClaimToken { return ClaimToken(newID("clm")) }
|
|||
// to anybody: the key is simply free again, and the claim is taken from the top.
|
||||
var errKeyVanished = errors.New("pgstore: the idempotency key was released mid-claim")
|
||||
|
||||
// claimRounds bounds that retry. Two attempts of one key can hand it back and forth at most as fast
|
||||
// as they can fail, and a caller that loses three rounds is meeting something other than a race.
|
||||
// claimRounds bounds that retry: an unbounded spin over a key that keeps being handed back would
|
||||
// hold a pool connection for as long as the contention lasts.
|
||||
//
|
||||
// ⚠ Its predecessor's justification — "a caller that loses three rounds is meeting something other
|
||||
// than a race" — was FALSE and it is corrected rather than inherited: eight racers on one key, each
|
||||
// giving it straight back as any 4xx does, take the race from the same loser three times running
|
||||
// often enough to be measured (1–2 failures per ~80 runs of this package's own race test, which is
|
||||
// why that test was itself flaky — PD-369). Losing the bound IS a race. What changed is not the
|
||||
// number but the ANSWER: exhausting it is now a contractual reply, so the bound decides how long a
|
||||
// caller spins, never whether it gets a 500.
|
||||
const claimRounds = 3
|
||||
|
||||
// ErrKeyContended is the retry loop giving up: the key was taken and handed back under this attempt
|
||||
// `claimRounds` times running.
|
||||
//
|
||||
// It WRAPS ErrKeyInFlight, and that is the whole design rather than a convenience. The caller's
|
||||
// remedy is identical — wait Retry-After and present the key again — and contention on a key is
|
||||
// precisely what the contract's `key_in_flight` describes, so no new vocabulary is minted and no
|
||||
// version moves. It keeps an identity of its own so the HTTP layer can say so in a log: repeated
|
||||
// contention on one key is worth an operator seeing, an ordinary in-flight repeat is not.
|
||||
//
|
||||
// ⚠ What it replaces is a bare error that mapped to 500. A legitimate request — the right key, the
|
||||
// right body, arriving into a burst of its own retries — was answered with an internal error, which
|
||||
// is neither true nor actionable: nothing was wrong with it, and there was nothing for its client to
|
||||
// fix. That is the whole of PD-369 (=П-21).
|
||||
var ErrKeyContended = fmt.Errorf(
|
||||
"pgstore: the idempotency key was claimed and released under us %d times: %w", claimRounds, ErrKeyInFlight)
|
||||
|
||||
// ClaimIdempotency takes the right to perform a request once.
|
||||
//
|
||||
// It answers one of four things, and the four are the whole semantics: a token to go ahead with, a
|
||||
// stored response to replay, ErrKeyInFlight while the first attempt is still running, ErrKeyReused
|
||||
// when the key was used for a different request. The token is what the caller must present to
|
||||
// complete or release: it is the proof that the claim is still this attempt's.
|
||||
//
|
||||
// Losing the retry bound folds into the THIRD of those four — ErrKeyContended wraps ErrKeyInFlight —
|
||||
// so there is no fifth answer and no path out of here that a client cannot act on.
|
||||
func (s *Store) ClaimIdempotency(ctx context.Context, k IdempotencyKey, now time.Time) (*IdempotentResponse, ClaimToken, error) {
|
||||
// ⚠ Retried, because losing a race is not one of the four answers. Two attempts arrive together;
|
||||
// one wins the insert and then FAILS FAST — a 4xx gives the key straight back — and the loser's
|
||||
|
|
@ -100,7 +127,7 @@ func (s *Store) ClaimIdempotency(ctx context.Context, k IdempotencyKey, now time
|
|||
}
|
||||
return out, token, err
|
||||
}
|
||||
return nil, "", fmt.Errorf("pgstore: the idempotency key was claimed and released under us %d times", claimRounds)
|
||||
return nil, "", ErrKeyContended
|
||||
}
|
||||
|
||||
func (s *Store) claim(ctx context.Context, k IdempotencyKey, now time.Time) (*IdempotentResponse, ClaimToken, error) {
|
||||
|
|
|
|||
|
|
@ -256,6 +256,21 @@ func TestTheSweepForgetsOnlyWhatIsPastTheWindow(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Giving up on a contended key is a CONTRACTUAL answer, and this one line is what the HTTP layer's
|
||||
// whole mapping rests on: `ErrKeyContended` wraps `ErrKeyInFlight`, so a caller that only knows the
|
||||
// four documented answers still recognises it and still has a remedy (PD-369).
|
||||
//
|
||||
// Cheap, no database, no flake — and it is the property, not a restatement of it: unwrap the sentinel
|
||||
// and the door answers 500 on a legitimate request again.
|
||||
//
|
||||
// Mutation caught: declaring ErrKeyContended with errors.New instead of wrapping.
|
||||
func TestGivingUpOnAContendedKeyIsOneOfTheContractsOwnAnswers(t *testing.T) {
|
||||
if !errors.Is(ErrKeyContended, ErrKeyInFlight) {
|
||||
t.Fatal("ErrKeyContended does not answer as ErrKeyInFlight: a claim that lost the race too" +
|
||||
" often reaches the wire as an internal error, on a request that was never wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// A claim can lose a RACE rather than a conflict, and losing a race is not one of the four answers.
|
||||
// Two attempts arrive together; one wins the insert and then fails fast — a 4xx gives the key back
|
||||
// immediately — and the loser's re-read, on a fresh READ COMMITTED snapshot, finds nothing at all.
|
||||
|
|
|
|||
228
platform/internal/pgstore/lastrun_live_priority_test.go
Normal file
228
platform/internal/pgstore/lastrun_live_priority_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package pgstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"textmachine/platform/internal/money"
|
||||
)
|
||||
|
||||
// resumedBehindAFinishedRun builds the shape PD-402 is about, and it is a shape the product reaches
|
||||
// on an ordinary afternoon: a run stops, ANOTHER run of the same book legitimately runs and finishes
|
||||
// while it is stopped, and then the user presses «continue» on the first one.
|
||||
//
|
||||
// The trap is that `RestartRun` does NOT re-stamp `started_at` — deliberately, because the run's
|
||||
// baselines and its bar are the ones it began with — so the live run is now OLDER than the finished
|
||||
// one. Every book-scoped read resolves the book to one run through the `lastRun` join, and ordered by
|
||||
// `started_at` alone that join hands them the FINISHED one.
|
||||
//
|
||||
// Returns the book and the id of the LIVE (older, resumed) run.
|
||||
func resumedBehindAFinishedRun(t *testing.T, s *Store, at time.Time) (book, live string) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
book = readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 1,
|
||||
Ceiling: money.MicroUSD(100_000), Now: at}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.FinishRun(ctx, RunEnding{RunID: first.ID, AttemptID: first.AttemptID,
|
||||
Status: "stopped", Now: at.Add(time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The OTHER run: started later, and finished. This is the row that must not win.
|
||||
second, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 1,
|
||||
Ceiling: money.MicroUSD(100_000), Now: at.Add(2 * time.Minute)}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.FinishRun(ctx, RunEnding{RunID: second.ID, AttemptID: second.AttemptID,
|
||||
Status: "ready", Now: at.Add(3 * time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// …and the user resumes the FIRST one. Its `started_at` stays where it was.
|
||||
if _, err := s.RestartRun(ctx, RestartInput{RunID: first.ID, AttemptID: first.AttemptID,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000),
|
||||
Now: at.Add(4 * time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var started time.Time
|
||||
if err := s.pool.QueryRow(ctx, `select started_at from runs where id = $1`, first.ID).
|
||||
Scan(&started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !started.Equal(at) {
|
||||
t.Fatalf("the resumed run's started_at moved to %s: this fixture cannot observe PD-402, because"+
|
||||
" the live run is no longer the older one", started)
|
||||
}
|
||||
return book, first.ID
|
||||
}
|
||||
|
||||
// A LIVE run outranks a finished one on EVERY book-scoped surface, and the money is why: while the
|
||||
// screen quotes somebody else's finished run, the live one is spending.
|
||||
//
|
||||
// This is PD-402's read half, judged per SPLICE SITE rather than by the colour of the battery — the
|
||||
// ordering is a fragment concatenated into ten queries, and a fragment is exactly the kind of thing
|
||||
// that can be right in the one place a test happens to look.
|
||||
//
|
||||
// Mutation caught: dropping the `(finished_at is null) desc` term from `newestRun`.
|
||||
func TestALiveRunOutranksAFinishedOneOnEveryBookScopedRead(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
at := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Hour)
|
||||
book, live := resumedBehindAFinishedRun(t, s, at)
|
||||
|
||||
// SPLICE books.go:925 + :949 — the card and its Run object.
|
||||
card, run, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run == nil || run.ID != live {
|
||||
t.Errorf("the card quotes run %+v, want the LIVE %s: the user is shown a finished run's state"+
|
||||
" while their own is spending", run, live)
|
||||
}
|
||||
if card.Status != "translating" {
|
||||
t.Errorf("the card reads %q while a run of this book is live, want translating", card.Status)
|
||||
}
|
||||
|
||||
// SPLICE books.go:860 — the library row.
|
||||
lib, err := s.ListBooks(ctx, "u1", 10, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lib.Books) != 1 {
|
||||
t.Fatalf("%d books in the library, want the one", len(lib.Books))
|
||||
}
|
||||
if lib.Books[0].Status != "translating" {
|
||||
t.Errorf("the library row reads %q, want translating", lib.Books[0].Status)
|
||||
}
|
||||
|
||||
// SPLICE runs.go:985 — the WRITE gate, spliced from the same constant. If it disagreed with the
|
||||
// reads above, PD-402 would be inverted rather than closed: the screen would follow the live run
|
||||
// and the resume would refuse it as «not the latest».
|
||||
latest, isLive, err := s.LatestRun(ctx, book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest != live || !isLive {
|
||||
t.Errorf("the resume gate resolves the book to (%s, live=%v), want (%s, true)", latest, isLive, live)
|
||||
}
|
||||
}
|
||||
|
||||
// The ORDINARY case is untouched: with a finished run and a newer LIVE one, the newer still wins, and
|
||||
// with two finished runs the newest-started still wins. A fix that made the live term decide
|
||||
// everything would have re-ordered the history of every book that has one.
|
||||
//
|
||||
// Mutation caught: dropping the `started_at desc` term from `newestRun`.
|
||||
func TestWithNoLiveRunTheBookStillResolvesToTheNewestStarted(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
at := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Hour)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
older, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 1,
|
||||
Ceiling: money.MicroUSD(100_000), Now: at}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.FinishRun(ctx, RunEnding{RunID: older.ID, AttemptID: older.AttemptID,
|
||||
Status: "ready", Now: at.Add(time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newer, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 1,
|
||||
Ceiling: money.MicroUSD(100_000), Now: at.Add(2 * time.Minute)}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.FinishRun(ctx, RunEnding{RunID: newer.ID, AttemptID: newer.AttemptID,
|
||||
Status: "stopped", Now: at.Add(3 * time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, run, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run == nil || run.ID != newer.ID {
|
||||
t.Errorf("with both runs finished the card quotes %+v, want the newest-started %s", run, newer.ID)
|
||||
}
|
||||
latest, isLive, err := s.LatestRun(ctx, book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest != newer.ID || isLive {
|
||||
t.Errorf("the resume gate resolves to (%s, live=%v), want (%s, false)", latest, isLive, newer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// The FRAMES a watching client is pushed follow the live run too, which is the half of PD-402 that
|
||||
// costs money rather than trust: the bar and the caption are what a user watches while a run spends,
|
||||
// and both are read through the same join as the card.
|
||||
//
|
||||
// Mutation caught: dropping the `(finished_at is null) desc` term — the status frame then says
|
||||
// `ready` and the progress frame quotes the finished run's ceiling, on a book whose live run is going.
|
||||
func TestTheStreamsFramesFollowTheLiveRunAndNotTheFinishedOne(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
at := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Hour)
|
||||
book, live := resumedBehindAFinishedRun(t, s, at)
|
||||
|
||||
// SPLICE readmodel.go:507 — the status frame. Written by every materialisation; the resume above
|
||||
// emitted one, so the newest frame of this book is the answer.
|
||||
var status string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
select data->>'status' from book_events
|
||||
where book_id = $1 and event = $2 order by position desc limit 1`,
|
||||
book, FrameStatus).Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "translating" {
|
||||
t.Errorf("the status frame says %q while a run of this book is live, want translating: the"+
|
||||
" screen is quoting a finished run's ending", status)
|
||||
}
|
||||
|
||||
// SPLICE bookScope, which decides WHICH WAVE's column a chapter read reports. Resolved to the
|
||||
// wrong run it reports the wrong pass's text state for the whole page.
|
||||
//
|
||||
// ⚠ The assertion is on `wave`, and the first edition of this test asserted `sc.bookID` instead —
|
||||
// which `bookScope` copies from its own argument before it queries anything, so it was true for
|
||||
// every possible implementation and every ordering constant. Caught by this pack's adversarial
|
||||
// pass, which showed the mutant ordering leaving that line green while the rest of the test went
|
||||
// red. `wave` is the field the resolved run actually decides.
|
||||
scopeSigning := func() bool {
|
||||
t.Helper()
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
sc, err := bookScope(ctx, tx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sc.signing
|
||||
}
|
||||
// The LIVE run asked the engine to stop for the bank; the finished run that would otherwise win
|
||||
// never did. Which of the two the scope resolves to decides which pass every chapter of the page
|
||||
// counts by, so it is the run-level fact this splice is worth asserting on.
|
||||
exec(t, s, ctx, `update runs set verify_bank = true where id = $1`, live)
|
||||
exec(t, s, ctx, `update books set edit_wave = true, epoch_editor = true where id = $1`, book)
|
||||
if !scopeSigning() {
|
||||
t.Error("a chapter read of this book carries the FINISHED run's facts, not the live one's:" +
|
||||
" every chapter of the page then counts by the wrong pass")
|
||||
}
|
||||
|
||||
// SPLICE sink.go:299 — the progress frame. Its denominator is the resolved run's ceiling, so the
|
||||
// bar of the LIVE run is what a client sees, not the finished run's completed one.
|
||||
var runID string
|
||||
var done, total int
|
||||
if err := s.pool.QueryRow(ctx, `select r.id, `+runDone+`, `+runTotal+`
|
||||
from books b `+lastRun+` where b.id = $1`, book).Scan(&runID, &done, &total); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runID != live {
|
||||
t.Errorf("the bar is computed over run %s, want the live %s: the user watches a frozen bar"+
|
||||
" while their own run spends", runID, live)
|
||||
}
|
||||
}
|
||||
|
|
@ -44,3 +44,6 @@ c21877113b5966bc8a200ba69ce752d4ac295bdfd34e681afbd887523edb6ceb 00019_read_mod
|
|||
14de53c75746c2b9ab872dcf9c9e0c4bd1ea2eb9e9778fe6a6e2cbe390d8961d 00026_run_draft_baseline.sql
|
||||
6b0f226e2b5acb90e930337f6548ba777f985deb4472a9f900709acb504ab99f 00027_bank_move.sql
|
||||
9546ad2c636a57b631391aeb9aab10dcbd06adc677f9bd55f0850296346e141b 00028_settling_attempts.sql
|
||||
5db5672a3f94866cf23a98520f35491b94b880b85461eac1a333d0bb8364ffe2 00029_drop_bank_released.sql
|
||||
976d9d22e3543a29e85aef5698b9e304e8d69ba7f432b3f2d62f27024cb9b1f5 00030_shape_epoch.sql
|
||||
60d31ea77deaae32026a841ab614b11c15ca596fdb19eac5c692af01c6579b9e 00031_drop_run_wave_counters.sql
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
-- +goose Up
|
||||
|
||||
-- `runs.bank_released` goes, and with it the last of the platform's stand-in for a decision that now
|
||||
-- belongs to the engine. The column was one half of the `--verify-bank` workaround (00016): the
|
||||
-- platform masked the flag on a resumed attempt because the engine of that era re-halted on
|
||||
-- EVERYTHING still undecided, so re-passing it put the run straight back into the stop it had just
|
||||
-- been released from. The engine grew a presented memory instead (its storage schema v16, D39.158):
|
||||
-- a stop now fires only on a cluster no earlier stop has shown, so the flag can ride every attempt
|
||||
-- and the boundary is decided once, by the side that owns it (law of the seam, п.1). Ping #21 named
|
||||
-- the removal and its ORDER — engine landed, every book migrated, and only then this.
|
||||
--
|
||||
-- The column had a SECOND reader that the workaround's removal does not by itself retire: the bar's
|
||||
-- segment, which asks "does a chapter of this run count when its DRAFT is done, or when the last
|
||||
-- pass is". That question is answered by derivation now (`readmodel.firstSegment`), and the swap is
|
||||
-- a repair rather than a port. The bit only ever moved on a USER's resume, so a run that raised the
|
||||
-- flag and was never stopped — an empty delta, or a map the engine's memory had already shown, which
|
||||
-- after D39.158 is the ORDINARY case — carried `false` for its whole life and counted the draft
|
||||
-- column through the entire edit wave. The screen called a chapter finished that had only been
|
||||
-- drafted, for half a run.
|
||||
--
|
||||
-- 00016 is NOT edited to do this: released migrations are append-only, and goose keys on the number.
|
||||
alter table runs drop column bank_released;
|
||||
|
||||
-- +goose Down
|
||||
alter table runs add column bank_released boolean not null default false;
|
||||
51
platform/internal/pgstore/migrations/00030_shape_epoch.sql
Normal file
51
platform/internal/pgstore/migrations/00030_shape_epoch.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- +goose Up
|
||||
|
||||
-- The book's pipeline SHAPE — does its work go through an editor — becomes two facts instead of one,
|
||||
-- because the single fact was answering two different questions and could only be right for one.
|
||||
--
|
||||
-- `books.edit_wave` (00024) stays exactly as it is, monotone, and keeps its pin (D39.153 §4б): it is
|
||||
-- the HISTORICAL fact, "has this book ever been through an editing pipeline", and monotonicity is what
|
||||
-- stops a half-edited chapter counting as finished. What it is NOT, and was being used as, is the
|
||||
-- authority on how the book's LIFETIME count is computed. Read that way it is wrong in both
|
||||
-- directions, and both were open register rows:
|
||||
--
|
||||
-- PD-403, editor REMOVED — the flag cannot come back down, so the count stays pinned to an edit
|
||||
-- column no run will ever fill: the book freezes half-done, the run closes `ready` at 50%, and the
|
||||
-- purchase scale goes on offering chapters that are already translated. The user is sold work twice.
|
||||
--
|
||||
-- PD-404, editor ADDED — the flag flips true on the first progress event of the run, the count moves
|
||||
-- to the edit column mid-transaction, and a book showing 7/10 shows 0/10, backwards, inside one
|
||||
-- `structure_version`, which the canon forbids in as many words.
|
||||
--
|
||||
-- The owner ruled on 28.08 (D39.165 §2): a change of pipeline shape is an EVENT OF THE BOOK, like
|
||||
-- cutting it again, and the count is legitimately RECOMPUTED when the boundary is crossed. So the
|
||||
-- boundary needs a carrier, and this is it.
|
||||
--
|
||||
-- `epoch_editor` — the shape of the CURRENT epoch, ASSIGNED rather than accumulated. NULL until the
|
||||
-- engine has announced a shape at all; from then on it is what the engine last said. This is what
|
||||
-- the lifetime count reads.
|
||||
-- `shape_epoch` — how many boundaries this book has crossed. It is a COORDINATE, like
|
||||
-- `structure_version`: a client tells "the count was legitimately recomputed" from "the count moved
|
||||
-- backwards illegally" by seeing it change. It is the only half of this pair that goes on the wire,
|
||||
-- and it goes as an OPAQUE counter (canon 0.9.0): the shape itself — whether this book gets an
|
||||
-- editing pass — is NOT published, because the canon's boundary forbids the wire everything about
|
||||
-- HOW a book is translated (§Boundaries: names of models, phases and stages, internal vocabularies,
|
||||
-- sums of money). D39.163 granted that boundary its SECOND exception, `Progress.stage`, under two
|
||||
-- stated conditions; an opaque generation counter needs no exception at all, which is why this
|
||||
-- column is one.
|
||||
--
|
||||
-- Backfill: every existing book gets epoch 0 and its CURRENT flag as the epoch's shape, so nothing
|
||||
-- moves on deployment — the first announcement that DIFFERS is the first boundary. Books that never
|
||||
-- announced (edit_wave IS NULL) stay NULL and keep falling back to the flag's own default.
|
||||
alter table books
|
||||
add column shape_epoch integer not null default 0,
|
||||
add column epoch_editor boolean;
|
||||
|
||||
update books set epoch_editor = edit_wave where edit_wave is not null;
|
||||
|
||||
-- ⚠ 00024's header comment describes the frozen rule this migration supersedes, and it is IMMUTABLE
|
||||
-- (released migrations are append-only, TestReleasedMigrationsAreUnchanged). It is corrected here
|
||||
-- rather than there: the monotone flag it introduced is still correct for the question IT answers.
|
||||
|
||||
-- +goose Down
|
||||
alter table books drop column epoch_editor, drop column shape_epoch;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
-- +goose Up
|
||||
|
||||
-- `runs.draft_done / draft_total / edit_done / edit_total` go: a carrier with TWO writers and ZERO
|
||||
-- readers (register row PD-411). They are written on EVERY progress event, inside the transaction
|
||||
-- that holds the book row's lock, by two DIFFERENT disciplines — the stream assigns, the resync takes
|
||||
-- `greatest` — and not one SELECT in the zone reads any of them. The bar the screen actually shows is
|
||||
-- derived from `chapters` (readmodel.runDone / runTotal), and it always was.
|
||||
--
|
||||
-- The cost of leaving them was not the four columns: it was that the code around them argued for a
|
||||
-- bar that does not exist there. The `greatest()` rationale defended the monotonicity of a counter
|
||||
-- nobody reads, and `sink.unitDone`'s comment told the next reader that "the counters the screen reads
|
||||
-- today come from the progress event" — so a session looking for where the bar lives would find this,
|
||||
-- and be wrong.
|
||||
--
|
||||
-- ⚠ WHAT THE REMOVAL EXPOSES, and it is a real gap rather than a side effect of this migration: the
|
||||
-- RESYNC — the repair channel for a run whose stream is quarantined or never moved — wrote progress
|
||||
-- into these four columns and nowhere else. It touches neither `unit_resolutions` nor `chapters`, so
|
||||
-- it materialises nothing the screen reads, and a quarantined run's bar stands still for the whole of
|
||||
-- its life however faithfully `tmctl status` answers. The dead columns HID that: the code looked as
|
||||
-- though the repair channel repaired something. Filed as its own register row rather than repaired
|
||||
-- here — this migration removes a lie, it does not get to invent the cure.
|
||||
alter table runs
|
||||
drop column draft_done,
|
||||
drop column draft_total,
|
||||
drop column edit_done,
|
||||
drop column edit_total;
|
||||
|
||||
-- +goose Down
|
||||
alter table runs
|
||||
add column draft_done integer not null default 0,
|
||||
add column draft_total integer not null default 0,
|
||||
add column edit_done integer not null default 0,
|
||||
add column edit_total integer not null default 0;
|
||||
|
|
@ -60,3 +60,20 @@ func BenchmarkLibraryPage(b *testing.B) {
|
|||
}
|
||||
b.ReportMetric(float64(time.Since(start).Milliseconds())/float64(b.N), "ms/page")
|
||||
}
|
||||
|
||||
// BenchmarkChapterPage measures the read this pack changed: the chapter listing now joins the book
|
||||
// and its current run so it can splice the SAME `chapterUnitsDone` text the sink's chapter frame uses
|
||||
// (one rule, one text — the alternative was a second copy free to disagree with the frame a client
|
||||
// was just pushed).
|
||||
//
|
||||
// The join is what needs a number rather than an argument, so here it is. Same corpus and same world
|
||||
// as the library page above: vacuumed, 500 chapters in the book being paged.
|
||||
func BenchmarkChapterPage(b *testing.B) {
|
||||
s, ctx := testDB(b)
|
||||
corpus(b, s, 1, 500, 10)
|
||||
for b.Loop() {
|
||||
if _, err := s.ListChapters(ctx, "u1", "bk000", 100, ""); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -366,11 +366,58 @@ const derivedStatus = `case when b.status in ('uploading', 'parsing', 'not_start
|
|||
// once because a second copy is how a projection drifts: the intake's 201, the library page and the
|
||||
// card all answer the same fields or the client sees a book change shape by the route it came from.
|
||||
const bookColumns = `b.id, b.title, b.source_lang, b.target_lang, ` + derivedStatus + `, b.reject_reason,
|
||||
b.structure_version, b.chapter_count, ` + chaptersDone + `, b.character_count, ` + noteCount + `,
|
||||
b.structure_version, b.shape_epoch, b.chapter_count, ` + chaptersDone + `, b.character_count, ` + noteCount + `,
|
||||
b.added_at, b.revision`
|
||||
|
||||
// editWave is whether the pipeline this book's work goes through has an EDITOR, over a `books b`.
|
||||
// Unknown reads as "it has one": a chapter only half-done must not count as finished.
|
||||
// epochWave is whether the pipeline this book's work goes through has an EDITOR — the shape of its
|
||||
// CURRENT epoch, as the engine last announced it, over a `books b`. Three fallbacks in order: the
|
||||
// epoch's own answer; the historical monotone flag, for a book whose last announcement predates the
|
||||
// epoch columns; and finally "it has one", because a chapter only half-done must not count as
|
||||
// finished.
|
||||
//
|
||||
// ⚠ It reads the EPOCH and not the monotone `edit_wave`, and the difference is two register rows.
|
||||
// Read through the flag the count is wrong in BOTH directions, because the flag cannot come down:
|
||||
// with the editor REMOVED the count freezes against an edit column no run will ever fill — the book
|
||||
// stays half-done, no run's bar can reach one, and the purchase scale goes on offering chapters
|
||||
// already translated, which is the user paying twice (PD-403); with the editor ADDED the flag flips
|
||||
// on the run's first progress event and a book showing 7/10 shows 0/10, backwards, inside one
|
||||
// `structure_version`, which the canon forbade in as many words (PD-404).
|
||||
//
|
||||
// The owner ruled the cure on 28.08 (D39.165 §2): a change of pipeline shape is an EVENT of the book,
|
||||
// like cutting it again, and the count is legitimately recomputed at the boundary. `books.shape_epoch`
|
||||
// is what makes that recomputation legible rather than indistinguishable from a server walking a
|
||||
// counter down (canon 0.9.0).
|
||||
//
|
||||
// ⚠ `books.edit_wave` is NOT repealed and is NOT dead: it is still written, still monotone, still
|
||||
// pinned (D39.153 §4б), and it is the second fallback above — the answer for every book whose shape
|
||||
// was recorded before this epoch existed. What changed is that it stopped being the AUTHORITY.
|
||||
//
|
||||
// ⚠ Safe for the RUN's bar as well as the book's count, and that is worth stating because the bar is
|
||||
// where PD-401 lived. The epoch moves only when a progress event announces a shape, and progress
|
||||
// events come from the book's own live run — one per book by construction — so the epoch a bar reads
|
||||
// is always that run's own announcement. PD-401 was never about WHICH flag chose the wave: it was
|
||||
// about pairing each numerator with the baseline captured on ITS OWN column, and that pairing is
|
||||
// untouched here.
|
||||
const epochWave = `coalesce(b.epoch_editor, b.edit_wave, true)`
|
||||
|
||||
// editWave is the HISTORICAL, monotone answer to the same question, and it is what the RUN's bar
|
||||
// reads. Unknown reads as "it has one", for the same reason.
|
||||
//
|
||||
// ⚠ The two are separated HERE and the separation is load-bearing, so it is worth being exact about
|
||||
// which fact belongs to which reader. `epoch_editor` is ASSIGNED on every announcement, so it moves
|
||||
// in BOTH directions; `edit_wave` only ever grows. That is right for the BOOK, whose lifetime count
|
||||
// the owner ruled may be recomputed at a shape boundary (D39.165 §2) with `shape_epoch` announcing
|
||||
// it — and WRONG for the run's bar, which the canon holds to one monotonic fraction over the run's
|
||||
// whole work (row 200). An adversarial pass of this same pack landed the mistake and measured it:
|
||||
// with the bar on the epoch, one run whose second attempt announced a draft-only shape read 4/4 and
|
||||
// then 2/2 — the same row, the same `structure_version`, the fraction walking backwards. The bar
|
||||
// stays on the flag.
|
||||
//
|
||||
// ⚠ Consequence, named rather than hidden: on a deployment where the editor was REMOVED, a run's bar
|
||||
// still tariffs an edit wave that will not happen and cannot reach one. That is the second half of
|
||||
// register row PD-403, and it stays OPEN — closing it needs a per-RUN record of the shape that run is
|
||||
// actually working under, which is a decision of its own size (the shape is not known at StartRun —
|
||||
// the engine announces it with the run's first progress event, which is exactly what PD-401 found).
|
||||
const editWave = `coalesce(b.edit_wave, true)`
|
||||
|
||||
// finishedUnits is how many of a chapter's units are FINISHED: resolved by the LAST pass the book
|
||||
|
|
@ -385,13 +432,51 @@ const editWave = `coalesce(b.edit_wave, true)`
|
|||
// moved BACKWARDS: the latest run is the newest, and a run just admitted has announced nothing, so
|
||||
// every book on a deployment with no editor fell back to zero chapters done until that run's first
|
||||
// progress event.
|
||||
const finishedUnits = `(case when ` + editWave + ` then c.units_edit_done else c.units_draft_done end)`
|
||||
const finishedUnits = `(case when ` + epochWave + ` then c.units_edit_done else c.units_draft_done end)`
|
||||
|
||||
// segmentUnits is the same count for the current SEGMENT of a run, which is the work between two
|
||||
// stops: in the first segment of a run that stops for signing a chapter counts when the DRAFT is
|
||||
// done with it, and otherwise when the last pass is.
|
||||
const segmentUnits = `(case when r.verify_bank and not r.bank_released
|
||||
then c.units_draft_done else ` + finishedUnits + ` end)`
|
||||
// chapterUnitsDone is how many pairs of ONE chapter the CURRENT PASS has finished — the number the
|
||||
// wire calls `units_done` — written once here and bound by each reader to the facts it has at hand.
|
||||
//
|
||||
// It is a rule about a chapter and it is evaluated PER CHAPTER, which is the property that matters
|
||||
// most and the one two earlier editions of it lost. `signing` is the run-level fact (this run asked
|
||||
// the engine to stop for the bank); everything else is columns of `c`. Nothing in it is book-wide, so
|
||||
// a unit event on one chapter cannot change what a re-read says about another — and every chapter
|
||||
// frame the sink pushes is announced by an event of that same chapter.
|
||||
//
|
||||
// The three arms, in order:
|
||||
// - A run that stops for signing, over a chapter the editor has not touched: the DRAFT pass is the
|
||||
// current one for that chapter, and its progress is the only honest number there is. This arm
|
||||
// covers the whole draft wave AND the stop itself, which is the point — a user watching a signing
|
||||
// run is watching the draft pass for most of it.
|
||||
// - Otherwise the book's epoch decides which pass is the last one, exactly as `finishedUnits` does
|
||||
// for the book's lifetime count.
|
||||
//
|
||||
// ⚠ The stored `bank_released` bit this replaces went out with the `--verify-bank` workaround
|
||||
// (D39.158, ping #21), and the replacement is deliberately NOT a like-for-like port — the bit was
|
||||
// wrong in a way that outlived every run: it moved only on a USER's resume, so a run that raised the
|
||||
// flag and was never stopped by the engine (an empty delta, or a map the engine's presented memory
|
||||
// had already shown — after D39.158 the ORDINARY case) kept it false for its whole life and counted
|
||||
// DRAFTS as finished through the entire edit wave.
|
||||
//
|
||||
// ⚠ Two editions of this rule were landed and withdrawn inside one pack, and the reasons are worth
|
||||
// keeping because they are the two ways to get it wrong. The first asked whether the run still owed
|
||||
// draft passes — BOOK-wide, so it flipped on a draft unit of some OTHER chapter and silently changed
|
||||
// chapters no event had touched. The second asked only whether the run was standing AT the stop,
|
||||
// which is announced and per-run but far too narrow: it read the edit column — zeroes — through the
|
||||
// whole draft wave, so a signing run showed nothing done for most of its life. Neither survived a
|
||||
// measurement; this one is per-chapter and covers the wave.
|
||||
// ⚠ ONE constant and not a function of its inputs, and that is the battery's rule rather than taste:
|
||||
// every SQL of this package must fold to a compile-time constant so
|
||||
// `TestEverySQLStatementParsesAgainstTheMigratedSchema` can plan it against the migrated schema. A
|
||||
// helper taking its facts as arguments produced a non-constant expression at both call sites and the
|
||||
// gate refused it — correctly. So the two readers share the TEXT: both join the book and its current
|
||||
// run, which is what the sink's chapter frame already did and what the chapter listing now does too.
|
||||
const chapterUnitsDone = `(case when r.verify_bank and c.units_edit_done = 0 then c.units_draft_done
|
||||
when ` + epochWave + ` then c.units_edit_done
|
||||
else c.units_draft_done end)`
|
||||
|
||||
// segmentUnits is the name the sink reads it by.
|
||||
const segmentUnits = chapterUnitsDone
|
||||
|
||||
// chaptersDone is the BOOK's own progress: chapters fully translated, against chapter_count. It is a
|
||||
// different question from the bar of a run — which measures what that run bought.
|
||||
|
|
@ -410,8 +495,8 @@ const noteCount = `(select coalesce(sum(c.note_count), 0) from chapters c where
|
|||
func scanBook(row pgx.Row) (Book, error) {
|
||||
var b Book
|
||||
err := row.Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Status, &b.RejectReason,
|
||||
&b.StructureVersion, &b.ChapterCount, &b.ChaptersDone, &b.CharacterCount, &b.NoteCount,
|
||||
&b.AddedAt, &b.Revision)
|
||||
&b.StructureVersion, &b.ShapeEpoch, &b.ChapterCount, &b.ChaptersDone, &b.CharacterCount,
|
||||
&b.NoteCount, &b.AddedAt, &b.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Book{}, ErrNoBook
|
||||
}
|
||||
|
|
@ -493,11 +578,29 @@ const (
|
|||
then 'editing' else 'drafting' end)`
|
||||
)
|
||||
|
||||
// newestRun is the order in which a book RESOLVES to one of its runs: the LIVE one first, however the
|
||||
// clocks fell, and only then the newest-started.
|
||||
//
|
||||
// ⚠ The live term is not a tie-break, it is the whole point (PD-402). `RestartRun` re-opens a run
|
||||
// WITHOUT re-stamping `started_at` — deliberately, because the run's baselines and its bar are the
|
||||
// ones it started with — so a resumed run is older than the finished run that legitimately ran
|
||||
// between its stop and its resume. Ordered by `started_at` alone the book then resolves to the
|
||||
// FINISHED one: the card says `ready`, the bar stands frozen at somebody else's work, and the live
|
||||
// run spends money invisibly behind them. The index `runs_one_live_per_book` (00002:66, unique on
|
||||
// `book_id` where `finished_at is null`) is what makes this a TOTAL order and not a new ambiguity —
|
||||
// at most one row can win the first term.
|
||||
//
|
||||
// It lives in one constant because the read model is not its only user: the resume gate reads the
|
||||
// same question through `LatestRun`, and the two answering differently would INVERT this defect
|
||||
// rather than fix it — the screen would follow the live run while the resume refused it as "not the
|
||||
// latest one".
|
||||
const newestRun = ` order by (finished_at is null) desc, started_at desc, id desc limit 1`
|
||||
|
||||
// lastRun is the join every book-scoped read uses: the current or last run of the book.
|
||||
const lastRun = `left join lateral (
|
||||
select id, status, paused_reason, failure_reason, verify_bank, bank_released, ceiling_chapters,
|
||||
select id, status, paused_reason, failure_reason, verify_bank, ceiling_chapters,
|
||||
chapters_before, draft_before, eta_seconds, started_at, finished_at, stop_requested_at
|
||||
from runs where book_id = b.id order by started_at desc, id desc limit 1) r on true`
|
||||
from runs r where book_id = b.id` + newestRun + `) r on true`
|
||||
|
||||
func readBookStatusTx(ctx context.Context, tx pgx.Tx, bookID string) (bookStatus, error) {
|
||||
var st bookStatus
|
||||
|
|
@ -581,13 +684,17 @@ func (s *Store) ListChapters(ctx context.Context, userID, bookID string, limit i
|
|||
return err
|
||||
}
|
||||
out.Page = Page{Revision: scope.revision, StructureVersion: scope.structure}
|
||||
// The SAME TEXT the sink's chapter frame is computed from, which is why this query joins the
|
||||
// book and its current run at all: a page that answered a different number from the frame it
|
||||
// was pushed a moment ago is a screen disagreeing with itself, and two copies of one rule is
|
||||
// how that happens.
|
||||
rows, err := tx.Query(ctx, `
|
||||
select c.id, c.number, c.units_total,
|
||||
case when $4 = 'draft' then c.units_draft_done else c.units_edit_done end,
|
||||
`+segmentUnits+`,
|
||||
c.note_count
|
||||
from chapters c
|
||||
from chapters c join books b on b.id = c.book_id `+lastRun+`
|
||||
where c.book_id = $1 and ($2::integer is null or c.number > $2::integer)
|
||||
order by c.number limit $3`, bookID, after, limit+1, scope.wave)
|
||||
order by c.number limit $3`, bookID, after, limit+1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgstore: list chapters: %w", err)
|
||||
}
|
||||
|
|
@ -939,11 +1046,12 @@ type scope struct {
|
|||
structure int
|
||||
structureReset int64
|
||||
bankReset int64
|
||||
// wave is which pass the chapter counters are read from — the platform's own split, which the
|
||||
// wire never carries. The FIRST segment of a run that stops for signing is the draft pass; so is
|
||||
// every pass on a deployment whose pipeline has no editor (finishedUnits). Everything else counts
|
||||
// a chapter when its work is finished end to end.
|
||||
wave string
|
||||
// signing is whether the book's current run asked the engine to stop for the bank — a FACT, never
|
||||
// a pre-decided wave. Which pass a chapter counts by is `chapterUnitsDone`, written once and
|
||||
// evaluated per chapter; a scope that answered "draft" or "edit" by itself was a SECOND copy of
|
||||
// that rule — book-wide, applied to every chapter of a page, and free to disagree with the frame
|
||||
// the sink had just pushed for one of them.
|
||||
signing bool
|
||||
}
|
||||
|
||||
// The BOOK is in the tag: without it a cursor minted for one book was accepted on another at the
|
||||
|
|
@ -954,26 +1062,21 @@ func (s scope) tag(collection string) string {
|
|||
|
||||
func bookScope(ctx context.Context, tx pgx.Tx, userID, bookID string) (scope, error) {
|
||||
out := scope{bookID: bookID}
|
||||
var stopForSigning, released *bool
|
||||
var edits bool
|
||||
var signing *bool
|
||||
err := tx.QueryRow(ctx, `
|
||||
select b.revision, b.structure_version, b.structure_reset_revision, b.bank_reset_revision,
|
||||
r.verify_bank, r.bank_released, `+editWave+`
|
||||
r.verify_bank
|
||||
from books b `+lastRun+`
|
||||
where b.id = $1 and b.owner_id = $2`, bookID, userID).
|
||||
Scan(&out.revision, &out.structure, &out.structureReset, &out.bankReset,
|
||||
&stopForSigning, &released, &edits)
|
||||
Scan(&out.revision, &out.structure, &out.structureReset, &out.bankReset, &signing)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return scope{}, ErrNoBook
|
||||
}
|
||||
if err != nil {
|
||||
return scope{}, fmt.Errorf("pgstore: read book scope: %w", err)
|
||||
}
|
||||
out.wave = "edit"
|
||||
signing := stopForSigning != nil && *stopForSigning && released != nil && !*released
|
||||
if signing || !edits {
|
||||
out.wave = "draft"
|
||||
}
|
||||
// A book with no run at all has no signing fact; that is not "signing".
|
||||
out.signing = signing != nil && *signing
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -836,13 +836,85 @@ func TestASecondRunOnADraftOnlyDeploymentStillOpensAtZero(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// A chapter of a signing run counts by the pass that is CURRENT FOR THAT CHAPTER, and the rule is
|
||||
// evaluated per chapter — which is the whole of it, and what two withdrawn editions got wrong.
|
||||
//
|
||||
// Why the draft arm exists at all: under an editor pipeline `units_edit_done` is zero for the whole
|
||||
// draft wave, so a counter read only off the last pass shows a user nothing done for most of a run
|
||||
// they are paying for. A run that stops for signing is exactly the run whose user is asked to LOOK at
|
||||
// that half, so it is the one that must show it.
|
||||
//
|
||||
// Why it is per chapter: the frames the sink pushes are per chapter, announced by an event of that
|
||||
// same chapter. A book-wide term flips on a unit of some OTHER chapter and silently changes what a
|
||||
// re-read says about chapters nothing touched — measured on the real sink, and the reason the first
|
||||
// edition of this rule was withdrawn. The second edition asked only whether the run was standing AT
|
||||
// the stop: announced and per-run, but it read zeroes through the entire draft wave, which is the
|
||||
// defect the acceptance found. This one moves only on the chapter's own column.
|
||||
//
|
||||
// Mutation caught: dropping the `c.units_edit_done = 0` arm (the draft wave shows nothing again);
|
||||
// replacing it with a run-level or book-wide term (the flip stops being the chapter's own).
|
||||
func TestAChapterOfASigningRunCountsByThePassCurrentForThatChapter(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Now().UTC().Truncate(time.Microsecond)
|
||||
if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true,
|
||||
CeilingChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec(t, s, ctx, `update books set edit_wave = true, epoch_editor = true where id = $1`, book)
|
||||
units := func(number int) int {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`select `+segmentUnits+` from chapters c join books b on b.id = c.book_id `+lastRun+`
|
||||
where c.book_id = $1 and c.number = $2`, book, number).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
// THE DRAFT WAVE, which is most of the run: chapter 1 is half drafted and nothing is edited.
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = 1 where book_id = $1 and number = 1`, book)
|
||||
if got := units(1); got != 1 {
|
||||
t.Errorf("mid-draft the chapter counts %d, want its draft progress 1: a user watching the half"+
|
||||
" they are asked to sign off is shown nothing done", got)
|
||||
}
|
||||
// …and it stays the draft pass when that chapter's draft finishes. The whole wave, not a moment.
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1 and number = 1`, book)
|
||||
if got := units(1); got != 2 {
|
||||
t.Errorf("with its draft finished the chapter counts %d, want 2", got)
|
||||
}
|
||||
// PER CHAPTER: chapter 2 has not been drafted, and finishing chapter 1 said nothing about it.
|
||||
if got := units(2); got != 0 {
|
||||
t.Errorf("chapter 2 counts %d after work on chapter 1 alone: the rule is not the chapter's own,"+
|
||||
" and a re-read now disagrees with a frame nothing announced", got)
|
||||
}
|
||||
// THE EDIT PASS begins for chapter 1: its counter follows the pass that is current for IT, while
|
||||
// chapter 2 — still untouched by the editor — goes on reporting its own draft progress.
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book)
|
||||
exec(t, s, ctx, `update chapters set units_edit_done = 1 where book_id = $1 and number = 1`, book)
|
||||
if got := units(1); got != 1 {
|
||||
t.Errorf("with one unit edited the chapter counts %d, want the edit pass's 1", got)
|
||||
}
|
||||
if got := units(2); got != 1 {
|
||||
t.Errorf("chapter 2 counts %d, want its own draft progress 1: chapter 1's editor did not reach it", got)
|
||||
}
|
||||
// A run that never asked for the stop is untouched by the draft arm: it counts by the last pass,
|
||||
// exactly as it did before this rule existed.
|
||||
exec(t, s, ctx, `update runs set verify_bank = false where book_id = $1`, book)
|
||||
if got := units(2); got != 0 {
|
||||
t.Errorf("an ordinary editing run counts %d for an unedited chapter, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// THE rule of the through-bar (owner's word of 20.08, row 200): the bar is ONE monotonic fraction
|
||||
// over the run's whole work, so lifting the signing stop MOVES NOTHING — the re-basing that used to
|
||||
// restart it from zero is gone, and «100%, then zero» with it. Through the stop the draft half
|
||||
// stands, the edit half continues, and the caption follows the counters.
|
||||
//
|
||||
// Mutation caught: re-taking either baseline in RestartRun; switching the numerator's wave on
|
||||
// `bank_released` (the old segment counting).
|
||||
// Mutation caught: re-taking either baseline in RestartRun.
|
||||
func TestTheBarIsOneMonotonicFractionThroughTheSigningStop(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
|
|
@ -870,7 +942,7 @@ func TestTheBarIsOneMonotonicFractionThroughTheSigningStop(t *testing.T) {
|
|||
t.Errorf("with the draft half done the stage is %q, want editing", first.Progress.Stage)
|
||||
}
|
||||
if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000),
|
||||
Now: at.Add(time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -997,7 +1069,7 @@ func TestADraftOnlyDeploymentCountsItsOneWaveOnce(t *testing.T) {
|
|||
t.Fatalf("a drafted draft-only purchase reads %d/%d, want 2/2", first.Progress.Done, first.Progress.Total)
|
||||
}
|
||||
if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000),
|
||||
Now: at.Add(time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ func TestLiftingTheSigningStopLeavesTheBarWhereItStood(t *testing.T) {
|
|||
}
|
||||
// Through the path a resume actually takes: the stop is lifted by the re-open itself.
|
||||
if _, err := s.RestartRun(ctx, RestartInput{RunID: started.ID, AttemptID: started.AttemptID,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000),
|
||||
Now: time.Now().UTC()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,9 +208,6 @@ type LiveRun struct {
|
|||
Position Position
|
||||
Quarantined bool
|
||||
VerifyBank bool
|
||||
// BankReleased is whether this run's signing stop has already been lifted. It decides whether the
|
||||
// next attempt is spawned WITH the engine's `--verify-bank`: resume means the stop is over.
|
||||
BankReleased bool
|
||||
// Resnapshot/AcceptRebill are the run's re-pass consents (P10): read by every spawn so the argv
|
||||
// is the admission's decision, never a sweep's re-derivation. AcceptRebill == 0 means no consent
|
||||
// was given (the flag is not passed); a consent is always a concrete sum.
|
||||
|
|
@ -826,7 +823,7 @@ func abandonSettlement(ctx context.Context, tx pgx.Tx, runID, reason string, now
|
|||
// runColumns is the reconciler's view of a run. Written once because the two queries that use it
|
||||
// differ only in which runs they select, and a scan list copied twice is a scan list that drifts.
|
||||
const runColumns = `
|
||||
select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.bank_released, r.resnapshot,
|
||||
select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.resnapshot,
|
||||
r.accept_rebill_micro, r.ceiling_chapters,
|
||||
coalesce(r.paused_reason, ''), r.started_at, r.status, r.stop_requested_at,
|
||||
a.id, a.attempt_no, coalesce(a.unit_name, ''), coalesce(a.engine_run_id, ''),
|
||||
|
|
@ -847,7 +844,7 @@ func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]Live
|
|||
var l LiveRun
|
||||
var ceiling, ceilingArg, acceptRebill int64
|
||||
var baseline *int64
|
||||
if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.BankReleased, &l.Resnapshot,
|
||||
if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.Resnapshot,
|
||||
&acceptRebill, &l.CeilingChapters,
|
||||
&l.PausedReason, &l.StartedAt, &l.Status, &l.StopRequestedAt,
|
||||
&l.AttemptID, &l.AttemptNo, &l.UnitName, &l.EngineRunID,
|
||||
|
|
@ -974,15 +971,21 @@ func (s *Store) whyNotLive(ctx context.Context, userID, runID string) error {
|
|||
// The reconciler's own list is deliberately not reusable here: it selects the attempt that has not
|
||||
// ended, and every run this call is about has ended. What resume needs is the state the run stopped
|
||||
// in and the attempt whose money and journal position it stopped at.
|
||||
// LatestRun answers which run of the book every book-scoped read resolves — the newest by the
|
||||
// same ordering `lastRun` uses — and whether that row is still live. The resume gate compares
|
||||
// against it: re-opening any OTHER run leaves the card and every progress frame quoting the wrong
|
||||
// row (PD-402 keeps the read half), and the two reasons carry different words — a LIVE newer run
|
||||
// is «the book is being translated», a finished one is «resume the latest».
|
||||
// LatestRun answers which run of the book every book-scoped read resolves — a LIVE run first and
|
||||
// then the newest-started, the `newestRun` order the read model's `lastRun` splices — and whether
|
||||
// that row is still live. The resume gate compares against it: re-opening any OTHER run leaves the
|
||||
// card and every progress frame quoting the wrong row, and the two reasons carry different words —
|
||||
// a LIVE newer run is «the book is being translated», a finished one is «resume the latest».
|
||||
//
|
||||
// ⚠ The order is SPLICED and not written out again, and that is the half of PD-402 the read fix
|
||||
// could not do alone: this query used to hand-write `order by started_at desc` while the read model
|
||||
// spliced a constant, and the two are the same question. Answered differently they invert the
|
||||
// defect instead of closing it — every surface would follow the live run while this gate refused to
|
||||
// resume it, calling it "not the latest".
|
||||
func (s *Store) LatestRun(ctx context.Context, bookID string) (id string, live bool, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
select id, finished_at is null from runs
|
||||
where book_id = $1 order by started_at desc, id desc limit 1`,
|
||||
where book_id = $1`+newestRun,
|
||||
bookID).Scan(&id, &live)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, ErrNoRun
|
||||
|
|
@ -1066,11 +1069,7 @@ type RestartInput struct {
|
|||
// already been told it ended — and the run the user sees finished starts spending again. A resume
|
||||
// does exactly that on purpose, which is why the guard is the caller's to ask for.
|
||||
OnlyIfLive bool
|
||||
// LiftBankStop clears the signing stop as part of THIS transaction. In its own statement the pair
|
||||
// could half-land: the run re-opens, the write fails, and the bar then counts the draft pass
|
||||
// against a ceiling nothing will ever reach, with no channel that repairs it.
|
||||
LiftBankStop bool
|
||||
Now time.Time
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// ErrRunFinished is a restart refused because another pass has already ended the run. Not an error of
|
||||
|
|
@ -1177,12 +1176,11 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error
|
|||
if _, err := tx.Exec(ctx, `
|
||||
update runs r set status = 'translating', paused_reason = null, finished_at = null,
|
||||
settled_at = null, stop_requested_at = null,
|
||||
bank_released = bank_released or $2,
|
||||
resnapshot = r.resnapshot or $3,
|
||||
accept_rebill_micro = greatest(r.accept_rebill_micro, $4),
|
||||
resnapshot = r.resnapshot or $2,
|
||||
accept_rebill_micro = greatest(r.accept_rebill_micro, $3),
|
||||
revision = b.revision + 1
|
||||
from books b
|
||||
where r.id = $1 and b.id = r.book_id`, in.RunID, in.LiftBankStop,
|
||||
where r.id = $1 and b.id = r.book_id`, in.RunID,
|
||||
in.Resnapshot, int64(in.AcceptRebill)); err != nil {
|
||||
// Clearing finished_at puts the run back under the one-live-run-per-book index, and the
|
||||
// book may already have a NEWER live run — nothing stops an account starting one after it
|
||||
|
|
@ -1215,11 +1213,11 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error
|
|||
return LiveRun{}, err
|
||||
}
|
||||
// The fields the caller needs to spawn but this transaction did not read.
|
||||
const q = `select b.workdir, r.verify_bank, r.bank_released, r.resnapshot, r.accept_rebill_micro,
|
||||
const q = `select b.workdir, r.verify_bank, r.resnapshot, r.accept_rebill_micro,
|
||||
r.ceiling_chapters from runs r
|
||||
join books b on b.id = r.book_id where r.id = $1`
|
||||
var acceptRebill int64
|
||||
if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank, &out.BankReleased,
|
||||
if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank,
|
||||
&out.Resnapshot, &acceptRebill, &out.CeilingChapters); err != nil {
|
||||
return LiveRun{}, fmt.Errorf("pgstore: read restarted run: %w", err)
|
||||
}
|
||||
|
|
@ -1253,26 +1251,26 @@ func (s *Store) AttemptReservationOpen(ctx context.Context, runID string, attemp
|
|||
return open, nil
|
||||
}
|
||||
|
||||
// RunPausedReason reads what the run's projection currently says about a ceiling.
|
||||
// RunPausedReason reads what the run's projection currently says: its STATUS and its ceiling reason.
|
||||
//
|
||||
// It exists because the reconciler's snapshot is taken BEFORE the journal is drained, and the
|
||||
// ceiling event that decides whether an ending is `paused` or `failed` can arrive in that very
|
||||
// drain. Judging the ending from the snapshot answered `failed` for a run whose own stream had just
|
||||
// It exists because the reconciler's snapshot is taken BEFORE the journal is drained, and the events
|
||||
// that decide how an ending is classified can arrive in that very drain — the ceiling event, and
|
||||
// (since the bank-stop protection of unified-backlog row 240) the bank-stop event, which writes
|
||||
// `awaiting_bank` on the row and nothing the snapshot carries. Judging the ending from the snapshot answered `failed` for a run whose own stream had just
|
||||
// said `ceiling` — one sweep of staleness, on the one branch where being wrong is contractually
|
||||
// visible (acceptance of D39.131, п.3).
|
||||
//
|
||||
// An empty string is "nothing said so", not an error: most runs never pause.
|
||||
func (s *Store) RunPausedReason(ctx context.Context, runID string) (string, error) {
|
||||
var reason string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`select coalesce(paused_reason, '') from runs where id = $1`, runID).Scan(&reason)
|
||||
func (s *Store) RunPausedReason(ctx context.Context, runID string) (status, reason string, err error) {
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`select status, coalesce(paused_reason, '') from runs where id = $1`, runID).Scan(&status, &reason)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil // the run is gone; the caller's next write refuses on its own
|
||||
return "", "", nil // the run is gone; the caller's next write refuses on its own
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pgstore: read paused reason: %w", err)
|
||||
return "", "", fmt.Errorf("pgstore: read run state: %w", err)
|
||||
}
|
||||
return reason, nil
|
||||
return status, reason, nil
|
||||
}
|
||||
|
||||
// PauseRun records a run that cannot go on, without pretending it failed.
|
||||
|
|
|
|||
|
|
@ -218,31 +218,49 @@ func TestAnEventAndItsCursorMoveTogetherAndADuplicateChangesNothing(t *testing.T
|
|||
t.Fatal(err)
|
||||
}
|
||||
var lastSeq, offset int64
|
||||
var draftDone, editDone int
|
||||
var epoch int
|
||||
var editor *bool
|
||||
var eta *int
|
||||
// ⚠ The observable is the ETA and the announced SHAPE, not the per-wave counters this line also
|
||||
// used to write: those columns had two writers and no reader and were dropped with PD-411, so an
|
||||
// assertion over them said only that a fold ran. These two are what a progress line still leaves
|
||||
// in the read model.
|
||||
//
|
||||
// ⚠ What the DUPLICATE half of this test proves is the CURSOR, and only the cursor — said plainly
|
||||
// because the first edition of this comment claimed the epoch made it bite, and this pack's
|
||||
// adversarial pass showed it cannot: a re-delivery carries the same shape, so the epoch's
|
||||
// `epoch_editor <> $2` guard is false by construction and the counter would not move even with the
|
||||
// dedup guard deleted. The idempotence of the FOLD is pinned where it can be observed, on the
|
||||
// counters a duplicate would actually double: TestAUnitAnnouncedTwiceIsCountedOnce.
|
||||
read := func() {
|
||||
t.Helper()
|
||||
if err := s.pool.QueryRow(ctx, `select last_seq, last_offset from run_attempts where id = $1`,
|
||||
run.AttemptID).Scan(&lastSeq, &offset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `select draft_done, edit_done, eta_seconds from runs where id = $1`,
|
||||
run.ID).Scan(&draftDone, &editDone, &eta); err != nil {
|
||||
if err := s.pool.QueryRow(ctx, `select eta_seconds from runs where id = $1`,
|
||||
run.ID).Scan(&eta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `select epoch_editor, shape_epoch from books where id = 'bk1'`).
|
||||
Scan(&editor, &epoch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
read()
|
||||
if lastSeq != 2 || offset != 128 || draftDone != 3 || editDone != 1 || eta == nil || *eta != 42 {
|
||||
t.Fatalf("after one event: seq=%d offset=%d draft=%d edit=%d eta=%v", lastSeq, offset, draftDone, editDone, eta)
|
||||
if lastSeq != 2 || offset != 128 || eta == nil || *eta != 42 {
|
||||
t.Fatalf("after one event: seq=%d offset=%d eta=%v", lastSeq, offset, eta)
|
||||
}
|
||||
if editor == nil || !*editor || epoch != 0 {
|
||||
t.Fatalf("the line announced an edit total of 10: shape folded to editor=%v epoch=%d", editor, epoch)
|
||||
}
|
||||
// The same line again — the ordinary consequence of a reader restart.
|
||||
if err := sink.Apply(ctx, ev, cur); err != nil {
|
||||
t.Fatalf("a redelivered event was refused: %v", err)
|
||||
}
|
||||
before := draftDone
|
||||
read()
|
||||
if draftDone != before || lastSeq != 2 {
|
||||
t.Errorf("a duplicate moved something: draft %d→%d, seq %d", before, draftDone, lastSeq)
|
||||
if epoch != 0 || lastSeq != 2 {
|
||||
t.Errorf("a duplicate moved something: epoch %d, seq %d", epoch, lastSeq)
|
||||
}
|
||||
// The handshake's chunker version is what tells the platform its chapter numbering was produced
|
||||
// by a different chunker (row 100).
|
||||
|
|
|
|||
123
platform/internal/pgstore/session_slide_seam_test.go
Normal file
123
platform/internal/pgstore/session_slide_seam_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package pgstore
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"textmachine/platform/internal/auth"
|
||||
)
|
||||
|
||||
// The idle window SLIDES, through the real middleware, against a live store — and nothing pinned
|
||||
// that until this test (register row PD-431).
|
||||
//
|
||||
// What kept it unpinned is a shape that coverage cannot see: `Touch` throws away the command tag, so
|
||||
// an UPDATE that matches NO ROW is indistinguishable from one that slid the window. Measured by the
|
||||
// `sqlc` pack's adversarial pass on 29.08: `where token_sha256 = $1` → `where 1 = 0 and
|
||||
// token_sha256 = $1` left eighteen packages green and zero tests red, while the control mutation on
|
||||
// the same stand went red twice — so the harness worked and the silence belonged to `Touch`. The two
|
||||
// tests that call it are both decided by predicates of the SQL itself and stay true when it does
|
||||
// nothing at all.
|
||||
//
|
||||
// ⚠ The behaviour is NOT changed and must not be: zero rows is a legitimate race with a revocation
|
||||
// arriving between the Lookup and the Touch (the orchestrator's word, and the reason the `sqlc` pack
|
||||
// deliberately left it alone). What was missing was the CHECK, so this is the check.
|
||||
//
|
||||
// Two things make it honest rather than decorative. It asserts through a LATER Lookup rather than
|
||||
// through the response, because a failed Touch is logged and swallowed — the 200 proves nothing. And
|
||||
// it runs the live `*pgstore.Store` as the middleware's `auth.SessionStore`, which no test did: the
|
||||
// interface is satisfied already, so nothing in production had to change for this to be possible,
|
||||
// only somebody had to connect the two ends. It lives in package `pgstore` because `pgstore` imports
|
||||
// `auth` and not the other way round — the same test in package `auth` is an import cycle.
|
||||
//
|
||||
// Mutation caught: `where 1 = 0 and token_sha256 = $1` in TouchSession — the update that touches
|
||||
// nothing; and swapping the two deadlines, which slides the wrong clock.
|
||||
func TestTheIdleWindowSlidesThroughTheAuthenticatorAgainstALiveStore(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
seedUser(t, s, ctx, "u_slide")
|
||||
|
||||
const idleTTL, maxAge = time.Hour, 24 * time.Hour
|
||||
t0 := time.Now().UTC().Truncate(time.Millisecond)
|
||||
token := auth.NewToken()
|
||||
digest := auth.Digest(token)
|
||||
if err := s.CreateSession(ctx, digest, "u_slide", t0, idleTTL, maxAge); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A request in the window's SECOND half — which is the only time the middleware slides at all —
|
||||
// and far from the absolute ceiling, which is its other condition.
|
||||
at := t0.Add(40 * time.Minute)
|
||||
served := false
|
||||
a := &auth.Authenticator{
|
||||
Sessions: s, // ⚠ THE POINT: the live store, as the middleware's own dependency.
|
||||
IdleTTL: idleTTL,
|
||||
Now: func() time.Time { return at },
|
||||
Deny: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
t.Error("a live session was denied")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}),
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "/v0/books", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { served = true })).
|
||||
ServeHTTP(httptest.NewRecorder(), r)
|
||||
if !served {
|
||||
t.Fatal("the request never reached the handler: this fixture cannot observe the slide")
|
||||
}
|
||||
|
||||
// PAST the original deadline. A window that did not slide is a session that no longer exists.
|
||||
after := t0.Add(90 * time.Minute)
|
||||
got, err := s.Lookup(ctx, digest, after)
|
||||
if err != nil {
|
||||
t.Fatalf("the session is gone at t0+90m (%v): the request at t0+40m did not slide the idle"+
|
||||
" window, so an active user is signed out on the idle TTL counted from LOGIN", err)
|
||||
}
|
||||
if got.UserID != "u_slide" {
|
||||
t.Fatalf("the slid session belongs to %q", got.UserID)
|
||||
}
|
||||
|
||||
// And it slid the RIGHT clock. A Touch that moved the absolute ceiling instead would pass the
|
||||
// assertion above and quietly turn the session's hard limit into a sliding one.
|
||||
if want := at.Add(idleTTL); !got.IdleExpiresAt.Equal(want) {
|
||||
t.Errorf("the idle deadline is %s, want %s (the request's own time plus the TTL)",
|
||||
got.IdleExpiresAt, want)
|
||||
}
|
||||
if want := t0.Add(maxAge); !got.AbsoluteExpiresAt.Equal(want) {
|
||||
t.Errorf("the absolute deadline moved to %s, want it fixed at %s: the whole point of two"+
|
||||
" clocks is that this one does not slide", got.AbsoluteExpiresAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The other end of the same seam: a REVOKED session is denied through the live store, and the
|
||||
// handler never runs. Cheap, and it exercises the Lookup failure path that only fakes had reached.
|
||||
func TestARevokedSessionIsDeniedThroughTheAuthenticatorAgainstALiveStore(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
seedUser(t, s, ctx, "u_revoked")
|
||||
t0 := time.Now().UTC().Truncate(time.Millisecond)
|
||||
token := auth.NewToken()
|
||||
if err := s.CreateSession(ctx, auth.Digest(token), "u_revoked", t0, time.Hour, 24*time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RevokeSession(ctx, auth.Digest(token), t0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := false
|
||||
a := &auth.Authenticator{
|
||||
Sessions: s,
|
||||
IdleTTL: time.Hour,
|
||||
Now: func() time.Time { return t0.Add(time.Minute) },
|
||||
Deny: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
denied = true
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}),
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "/v0/books", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("a revoked session reached the handler")
|
||||
})).ServeHTTP(httptest.NewRecorder(), r)
|
||||
if !denied {
|
||||
t.Error("a revoked session was not denied")
|
||||
}
|
||||
}
|
||||
246
platform/internal/pgstore/shape_epoch_test.go
Normal file
246
platform/internal/pgstore/shape_epoch_test.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package pgstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"textmachine/platform/internal/money"
|
||||
)
|
||||
|
||||
// announceShape is one progress event of a run, which is where the engine states the shape of the
|
||||
// pipeline it is running: an edit denominator of zero means this deployment has no editor.
|
||||
func announceShape(t *testing.T, s *Store, book string, draftTotal, editTotal int) {
|
||||
t.Helper()
|
||||
tx, err := s.pool.Begin(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(t.Context()) }()
|
||||
if err := recordWaveShape(t.Context(), tx, book, draftTotal, editTotal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func shapeEpoch(t *testing.T, s *Store, book string) (epoch int, editor *bool) {
|
||||
t.Helper()
|
||||
if err := s.pool.QueryRow(t.Context(),
|
||||
`select shape_epoch, epoch_editor from books where id = $1`, book).Scan(&epoch, &editor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return epoch, editor
|
||||
}
|
||||
|
||||
// draftedBook is a book whose chapters are fully DRAFTED and not edited: the state every editor
|
||||
// pipeline passes through, and the state both halves of this file start from.
|
||||
func draftedBook(t *testing.T, s *Store) string {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book)
|
||||
return book
|
||||
}
|
||||
|
||||
// THE EDITOR IS REMOVED, and the book stops being frozen half-done (PD-403).
|
||||
//
|
||||
// The old rule read the book's lifetime count through the MONOTONE flag, and the flag cannot come
|
||||
// back down. So a book that had been through an editing pipeline, on a deployment where the operator
|
||||
// has since removed the editor, counted against an edit column that no run would ever fill again: the
|
||||
// count froze, the run closed `ready` at half, and — this is the money — `ChaptersLeft` never fell,
|
||||
// so the purchase scale went on offering chapters that were already translated. The user is sold the
|
||||
// same work twice.
|
||||
//
|
||||
// The owner ruled the cure on 28.08 (D39.165 §2): a change of pipeline shape is an EVENT of the book,
|
||||
// like cutting it again, and the count is legitimately recomputed at the boundary. The monotone flag
|
||||
// is NOT repealed by this — it still refuses to come down, and its pin still stands. What moved is
|
||||
// which fact the count reads.
|
||||
//
|
||||
// Mutation caught: pointing finishedUnits back at the monotone `editWave`; making `epoch_editor`
|
||||
// accumulate (`or`) instead of assign.
|
||||
func TestRemovingTheEditorRecomputesTheBooksCountInsteadOfFreezingIt(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := draftedBook(t, s)
|
||||
|
||||
// Epoch 1: the deployment HAS an editor. Nothing is edited yet, so nothing is finished.
|
||||
announceShape(t, s, book, 2, 2)
|
||||
before, _, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.ChaptersDone != 0 {
|
||||
t.Fatalf("under an editor a merely drafted book reads %d done, want 0", before.ChaptersDone)
|
||||
}
|
||||
epoch, editor := shapeEpoch(t, s, book)
|
||||
if epoch != 0 || editor == nil || !*editor {
|
||||
t.Fatalf("first announcement left epoch=%d editor=%v, want the epoch unmoved and the shape recorded",
|
||||
epoch, editor)
|
||||
}
|
||||
|
||||
// The operator removes the editor and the next run announces a zero edit denominator.
|
||||
announceShape(t, s, book, 2, 0)
|
||||
|
||||
after, _, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.ChaptersDone != 2 {
|
||||
t.Errorf("with the editor gone the book still reads %d/%d done, want 2: it is frozen against an"+
|
||||
" edit column no run will fill, and the purchase scale goes on selling chapters already translated",
|
||||
after.ChaptersDone, after.ChapterCount)
|
||||
}
|
||||
// The recomputation ANNOUNCES itself, or the client cannot tell it from a count walking backwards.
|
||||
if after.ShapeEpoch == before.ShapeEpoch {
|
||||
t.Errorf("the count was recomputed at epoch %d without moving it: the jump is indistinguishable"+
|
||||
" from an illegal move and the canon's amnesty is unobservable", after.ShapeEpoch)
|
||||
}
|
||||
// ⚠ The monotone flag is NOT repealed. D39.153 §4б still holds and its pin still stands.
|
||||
var flag *bool
|
||||
if err := s.pool.QueryRow(ctx, `select edit_wave from books where id = $1`, book).Scan(&flag); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if flag == nil || !*flag {
|
||||
t.Errorf("edit_wave came down to %v: the historical flag is monotone (D39.153 §4б) and this"+
|
||||
" work does not repeal it — only the LIFETIME COUNT stopped being read off it", flag)
|
||||
}
|
||||
|
||||
// And the purchase scale, which is the money half of PD-403: what is left to buy must have fallen.
|
||||
rc, err := s.ReadBookForRun(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.ChaptersLeft != 0 {
|
||||
t.Errorf("the purchase scale still offers %d chapters of a fully translated book", rc.ChaptersLeft)
|
||||
}
|
||||
}
|
||||
|
||||
// THE EDITOR IS ADDED — the one direction the old flag could move — and the count still recomputes,
|
||||
// but it now says so (PD-404).
|
||||
//
|
||||
// The flag flips false→true on the first progress event of the run that has an editor, and the
|
||||
// lifetime count moves to the edit column in the same transaction: a book showing 2/2 shows 0/2,
|
||||
// backwards, inside ONE `structure_version` — which the canon forbade in as many words. The owner's
|
||||
// ruling makes the jump legitimate; what makes it legible is that the epoch moves with it, so a
|
||||
// client sees a new generation rather than a server walking a counter down.
|
||||
//
|
||||
// Mutation caught: not counting a false→true boundary (an epoch that only moves one way).
|
||||
func TestAddingTheEditorMovesTheEpochWithTheCountItRecomputes(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := draftedBook(t, s)
|
||||
|
||||
// Epoch 0: a draft-only deployment. Every drafted chapter is finished, because drafting is the
|
||||
// last pass the book gets.
|
||||
announceShape(t, s, book, 2, 0)
|
||||
before, _, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.ChaptersDone != 2 {
|
||||
t.Fatalf("a draft-only deployment reads %d done on a fully drafted book, want 2", before.ChaptersDone)
|
||||
}
|
||||
|
||||
// The operator ADDS an editor. The work already done is now half-work, and the count says so.
|
||||
announceShape(t, s, book, 2, 2)
|
||||
after, _, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.ChaptersDone != 0 {
|
||||
t.Errorf("after the editor arrived the book reads %d done, want 0: a drafted chapter is no"+
|
||||
" longer finished work", after.ChaptersDone)
|
||||
}
|
||||
if after.ShapeEpoch <= before.ShapeEpoch {
|
||||
t.Errorf("the count went %d → %d — BACKWARDS inside one structure_version — and the epoch"+
|
||||
" stayed at %d: without the boundary this is exactly what the canon forbids",
|
||||
before.ChaptersDone, after.ChaptersDone, after.ShapeEpoch)
|
||||
}
|
||||
}
|
||||
|
||||
// The epoch counts BOUNDARIES, not announcements: a run re-stating the shape it already has moves
|
||||
// nothing. Otherwise every progress event of every run would look to a client like a recomputation,
|
||||
// and the signal would mean nothing by the time it mattered.
|
||||
//
|
||||
// Mutation caught: incrementing unconditionally, or on the FIRST announcement (which crosses nothing).
|
||||
func TestTheEpochMovesOnBoundariesAndNotOnEveryAnnouncement(t *testing.T) {
|
||||
s, _ := testDB(t)
|
||||
book := draftedBook(t, s)
|
||||
announceShape(t, s, book, 2, 2)
|
||||
first, _ := shapeEpoch(t, s, book)
|
||||
for range 3 {
|
||||
announceShape(t, s, book, 2, 2)
|
||||
}
|
||||
same, _ := shapeEpoch(t, s, book)
|
||||
if same != first {
|
||||
t.Errorf("three restatements of the same shape moved the epoch %d → %d", first, same)
|
||||
}
|
||||
announceShape(t, s, book, 2, 0)
|
||||
crossed, _ := shapeEpoch(t, s, book)
|
||||
if crossed != first+1 {
|
||||
t.Errorf("one boundary moved the epoch %d → %d, want exactly one step", first, crossed)
|
||||
}
|
||||
// …and back again is another boundary, not a return to the old number: the epoch is a generation
|
||||
// counter, not a state.
|
||||
announceShape(t, s, book, 2, 2)
|
||||
back, _ := shapeEpoch(t, s, book)
|
||||
if back != crossed+1 {
|
||||
t.Errorf("crossing back left the epoch at %d, want %d: it counts crossings, not shapes", back, crossed+1)
|
||||
}
|
||||
}
|
||||
|
||||
// THE RUN'S BAR is NOT on the epoch, and this test is what holds that line.
|
||||
//
|
||||
// ⚠ Its predecessor asserted the opposite — that a run on a deployment which dropped the editor can
|
||||
// reach one — and that was this pack's own mistake, caught by its adversarial pass and measured on a
|
||||
// live database: pointing the bar at the ASSIGNABLE epoch let one run read 4/4 and then 2/2 across
|
||||
// its own two attempts, the same row, the same `structure_version`, the fraction walking backwards.
|
||||
// The canon holds a run's bar to one monotonic fraction over the run's whole work (row 200), so the
|
||||
// bar stays on the MONOTONE `books.edit_wave` and only the BOOK's lifetime count moved to the epoch.
|
||||
//
|
||||
// What that leaves open is stated in the register rather than hidden here: on a deployment where the
|
||||
// editor was removed, a run's bar still tariffs an edit wave that will not happen (PD-403's second
|
||||
// half). Closing it needs a per-RUN record of the shape that run works under — and the shape is not
|
||||
// known at StartRun, because the engine announces it with the run's FIRST progress event, which is
|
||||
// precisely what PD-401 found. That is a decision of its own size.
|
||||
//
|
||||
// Mutation caught: pointing runDone/runTotal/runStage at epochWave.
|
||||
func TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := draftedBook(t, s)
|
||||
// An editor deployment: the run buys two chapters and the bar tariffs both waves.
|
||||
announceShape(t, s, book, 2, 2)
|
||||
at := time.Now().UTC().Truncate(time.Microsecond)
|
||||
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 2,
|
||||
Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book)
|
||||
before, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The operator removes the editor and the run's SECOND attempt announces a draft-only shape.
|
||||
announceShape(t, s, book, 2, 0)
|
||||
after, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Progress.Done < before.Progress.Done || after.Progress.Total < before.Progress.Total {
|
||||
t.Errorf("the bar of ONE run walked backwards across a shape boundary it spans: %d/%d → %d/%d."+
|
||||
" The canon holds a run's bar to one monotonic fraction, and a run does not get to un-do"+
|
||||
" work it was paid for because the deployment changed shape under it",
|
||||
before.Progress.Done, before.Progress.Total, after.Progress.Done, after.Progress.Total)
|
||||
}
|
||||
// …while the BOOK's count DID move: that is the ruling, and the epoch announces it.
|
||||
card, _, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card.ShapeEpoch == 0 {
|
||||
t.Error("the book crossed a shape boundary and its epoch did not move")
|
||||
}
|
||||
}
|
||||
|
|
@ -133,10 +133,13 @@ func (r *RunSink) effect(ctx context.Context, tx pgx.Tx, ev ingest.Envelope) err
|
|||
if err := decode(ev, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.bump(ctx, tx, `
|
||||
update runs set draft_done = $2, draft_total = $3, edit_done = $4, edit_total = $5,
|
||||
eta_seconds = $6, revision = $7 where id = $1`,
|
||||
r.runID, p.Draft.Done, p.Draft.Total, p.Edit.Done, p.Edit.Total, etaOrNil(p.ETASeconds)); err != nil {
|
||||
// The event's own per-wave counters are NOT stored, and that is deliberate rather than an
|
||||
// omission (register row PD-411): they had two writers and no reader, while the bar the screen
|
||||
// shows is derived from `chapters`. What this event is kept for is the ETA and — through
|
||||
// recordWaveShape below — the SHAPE the engine is announcing.
|
||||
if err := r.bump(ctx, tx,
|
||||
`update runs set eta_seconds = $2, revision = $3 where id = $1`,
|
||||
r.runID, etaOrNil(p.ETASeconds)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recordWaveShape(ctx, tx, r.bookID, p.Draft.Total, p.Edit.Total); err != nil {
|
||||
|
|
@ -214,9 +217,14 @@ func (r *RunSink) effect(ctx context.Context, tx pgx.Tx, ev ingest.Envelope) err
|
|||
// seam, and a value from outside it would land in a column whose constraint refuses it and take the
|
||||
// whole materialization down with it. Under the minor-version rule an unknown value is ignored.
|
||||
//
|
||||
// ⚠ Bounded by what exists, unchanged: without the engine's persisted chapter manifest (unified
|
||||
// backlog row 100) there is no `chapters` row to carry the derived counters, so the fold records the
|
||||
// unit and stops there. The counters the screen reads today come from the progress event.
|
||||
// ⚠ Bounded by what exists: without a materialised chapter tree there is no `chapters` row to carry
|
||||
// the derived counters, so the fold records the unit and stops there. The sentence that used to close
|
||||
// this paragraph — "the counters the screen reads today come from the progress event" — was FALSE and
|
||||
// was register row PD-405's cover: those columns had no reader at all (PD-411, dropped in 00031), and
|
||||
// the whole bar is derived from `chapters`. A run over a book with no tree therefore read 0/total for
|
||||
// its entire life. The window is closed at admission now — a run is refused before its hold is taken
|
||||
// while the book owes its tree (runs.readyToTranslate's caller) — so what remains here is the honest
|
||||
// residue: an announcement that arrives before the tree exists is recorded and counted later.
|
||||
func (r *RunSink) unitDone(ctx context.Context, tx pgx.Tx, ev ingest.Envelope, u ingest.UnitDone) error {
|
||||
if u.Wave != ingest.WaveDraft && u.Wave != ingest.WaveEdit {
|
||||
return nil
|
||||
|
|
@ -399,21 +407,36 @@ func decode(ev ingest.Envelope, into any) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// recordWaveShape keeps the book's answer to "does this pipeline have an editor" — the fact
|
||||
// finishedUnits reads. The engine announces the two denominators at the start of a run, so the FIRST
|
||||
// progress event of a book's first run is what settles it.
|
||||
// recordWaveShape keeps the book's answer to "does this pipeline have an editor". The engine
|
||||
// announces the two denominators at the start of a run, so the FIRST progress event of a book's first
|
||||
// run is what settles it, and every later announcement is compared against what stands.
|
||||
//
|
||||
// Monotone in one direction on purpose: once a book has been through an editing pipeline, a chapter
|
||||
// of it is finished when it is EDITED, and a later run reporting no editor must not make half-done
|
||||
// chapters count as done. The other direction — an operator removing the editor — shows up as more
|
||||
// chapters finished, which is honest and never walks a counter backwards.
|
||||
// TWO facts are written, because one fact was answering two questions and could only be right for
|
||||
// one of them. The predecessor of this comment claimed that removing the editor "shows up as more
|
||||
// chapters finished, which is honest and never walks a counter backwards" — its own code did neither
|
||||
// (the flag cannot come down at all, so the count froze), and it is deleted rather than repaired.
|
||||
//
|
||||
// - `edit_wave` — the HISTORICAL fact, monotone, unchanged and still pinned (D39.153 §4б): once a
|
||||
// book has been through an editing pipeline, a chapter of it is finished when it is EDITED, and a
|
||||
// later run reporting no editor must not make half-done chapters count as done.
|
||||
// - `epoch_editor` + `shape_epoch` — the EPOCH: the shape as it stands NOW, assigned rather than
|
||||
// accumulated, and a counter of how many times it has changed. The lifetime count reads the
|
||||
// epoch, and crossing a boundary legitimately recomputes it — the owner's ruling of 28.08
|
||||
// (D39.165 §2): a change of pipeline shape is an event of the book, like cutting it again.
|
||||
//
|
||||
// The two writes are ONE statement for the reason the pair is worth having at all: a boundary whose
|
||||
// counter landed and whose shape did not would recompute nothing while announcing that it had.
|
||||
// Both call sites already hold the book row's lock, so no new ordering is introduced.
|
||||
func recordWaveShape(ctx context.Context, tx pgx.Tx, bookID string, draftTotal, editTotal int) error {
|
||||
if draftTotal+editTotal == 0 {
|
||||
return nil // this event announced no shape at all
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update books set edit_wave = coalesce(edit_wave, false) or $2 where id = $1`,
|
||||
bookID, editTotal > 0); err != nil {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
update books set edit_wave = coalesce(edit_wave, false) or $2,
|
||||
shape_epoch = shape_epoch +
|
||||
(case when epoch_editor is not null and epoch_editor <> $2 then 1 else 0 end),
|
||||
epoch_editor = $2
|
||||
where id = $1`, bookID, editTotal > 0); err != nil {
|
||||
return fmt.Errorf("pgstore: record the wave shape: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -428,28 +451,33 @@ func etaOrNil(s int) *int {
|
|||
|
||||
// ApplyStatus folds a `tmctl status --json` report into the read model.
|
||||
//
|
||||
// This is the RESYNC channel: a snapshot of a run that keeps moving, taken every few minutes. Since
|
||||
// D39.122 the report carries the per-wave split, so it materializes through the same four counters
|
||||
// as the stream and no longer flattens them — what stays true of it is that it is STALE by up to
|
||||
// the poll interval, which is why it stamps last_resync_at rather than leaving a reader to guess
|
||||
// how old the figures on a quarantined run are.
|
||||
// This is the RESYNC channel: a snapshot of a run that keeps moving, taken every few minutes. It is
|
||||
// STALE by up to the poll interval, which is why it stamps last_resync_at rather than leaving a
|
||||
// reader to guess how old the figures on a quarantined run are.
|
||||
//
|
||||
// ⚠ The sentence that used to stand here — "since D39.122 the report carries the per-wave split, so
|
||||
// it materializes through the same four counters as the stream" — was true of four columns nothing
|
||||
// ever read, and they are gone (register row PD-411, migration 00031). What this channel actually
|
||||
// materialises, and what it does NOT, is spelled out in the body below; the short version is that it
|
||||
// does not repair the bar, and that gap has a register row of its own.
|
||||
func (s *Store) ApplyStatus(ctx context.Context, runID, bookID string, rep ingest.StatusReport, now time.Time) error {
|
||||
return s.inTx(ctx, func(tx pgx.Tx) error {
|
||||
var rev int64
|
||||
if err := tx.QueryRow(ctx, `select revision + 1 from books where id = $1 for update`, bookID).Scan(&rev); err != nil {
|
||||
return fmt.Errorf("pgstore: read revision: %w", err)
|
||||
}
|
||||
// greatest(), so a resync can never move a counter BACKWARDS. A report taken before the
|
||||
// engine's own figures caught up would otherwise walk a visible progress bar back down — the
|
||||
// one thing the contract asks a client never to do and which the server must not do either.
|
||||
// It is also what makes this safe to run over a projection the stream has already moved.
|
||||
// ⚠ WHAT THIS DOES NOT DO, said plainly because the code used to imply otherwise. The report's
|
||||
// per-wave figures are NOT materialised anywhere the screen reads. They used to be written to
|
||||
// `runs.draft_done/…`, which nothing selected (PD-411, columns dropped in 00031), and the
|
||||
// `greatest()` that guarded them was defending the monotonicity of a bar that does not live
|
||||
// there — the bar is derived from `chapters`, which this path does not touch. So the repair
|
||||
// channel repairs the ETA, the freshness stamp and the wave SHAPE, and a run whose stream is
|
||||
// quarantined shows the progress its stream last managed to deliver and no more. That is an
|
||||
// open gap with a register row of its own; it is named here rather than hidden behind a column
|
||||
// that made the code look as though the channel were doing the job.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
update runs set draft_done = greatest(draft_done, $2), draft_total = greatest(draft_total, $3),
|
||||
edit_done = greatest(edit_done, $4), edit_total = greatest(edit_total, $5),
|
||||
eta_seconds = $6, last_resync_at = $7, revision = $8
|
||||
where id = $1`, runID, rep.Progress.Draft.Done, rep.Progress.Draft.Total,
|
||||
rep.Progress.Edit.Done, rep.Progress.Edit.Total,
|
||||
etaOrNil(int(rep.ETASeconds)), now, rev); err != nil {
|
||||
update runs set eta_seconds = $2, last_resync_at = $3, revision = $4
|
||||
where id = $1`, runID, etaOrNil(int(rep.ETASeconds)), now, rev); err != nil {
|
||||
return fmt.Errorf("pgstore: apply status: %w", err)
|
||||
}
|
||||
if err := recordWaveShape(ctx, tx, bookID, rep.Progress.Draft.Total, rep.Progress.Edit.Total); err != nil {
|
||||
|
|
|
|||
|
|
@ -439,44 +439,101 @@ func TestAZeroEtaIsStoredAsAbsent(t *testing.T) {
|
|||
// aggregate (backlog row 99) the resync could put it in the edit counter and nothing else, and the
|
||||
// old test asserted that the draft counter stayed untouched. The split landed on 09.08 (D39.122), so
|
||||
// asserting the old shape would now pin a limitation instead of a property.
|
||||
func TestTheResyncMaterializesThePhaseSplitAndNeverLowersACounter(t *testing.T) {
|
||||
// ⚠ RE-POINTED with PD-411 and the claim NARROWED to what is true, which is the finding rather than
|
||||
// a concession. Its predecessor was called «the resync materializes the phase split and never lowers
|
||||
// a counter» and it asserted over `runs.draft_done/draft_total/edit_done/edit_total` — four columns
|
||||
// with two writers and NO reader, dropped in migration 00031. So the property it proved was real of
|
||||
// those columns and meaningless of the screen: the bar is derived from `chapters`, which this channel
|
||||
// does not write at all. There is nothing left for a resync to walk backwards, because there is
|
||||
// nothing left for a resync to move.
|
||||
//
|
||||
// What that leaves is a GAP, not a passing test: the repair channel for a run whose stream is
|
||||
// quarantined materialises no progress, so such a run's bar stands still however faithfully `tmctl
|
||||
// status` answers. It has a register row of its own. This test pins the honest remainder — the ETA,
|
||||
// the freshness stamp and the announced SHAPE — and the gap is stated here so the next reader does
|
||||
// not re-derive it from a green test.
|
||||
func TestTheResyncRecordsFreshnessAndShapeAndNoProgress(t *testing.T) {
|
||||
s, sink, runID, _ := sinkFixture(t)
|
||||
// ⚠ The stream announces a DRAFT-ONLY shape, and the resync below announces an EDITING one. The
|
||||
// two must differ or the shape assertion proves nothing: this pack's adversarial pass caught the
|
||||
// first edition, where the stream had already written the same shape and deleting the
|
||||
// recordWaveShape call from ApplyStatus entirely left the whole eighteen-package battery green.
|
||||
if err := apply(t, sink, 2, ingest.TypeProgress, ingest.Progress{
|
||||
Draft: ingest.Counter{Done: 9, Total: 20}, Edit: ingest.Counter{Done: 7, Total: 20}}); err != nil {
|
||||
Draft: ingest.Counter{Done: 9, Total: 20}, Edit: ingest.Counter{Done: 0, Total: 0},
|
||||
ETASeconds: 99}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A report taken BEFORE the engine's own figures caught up, folded over a projection the stream
|
||||
// has already moved: nothing may walk backwards.
|
||||
stale := ingest.StatusReport{TotalUnits: 20, Done: 3,
|
||||
Progress: ingest.Progress{Draft: ingest.Counter{Done: 5, Total: 20}, Edit: ingest.Counter{Done: 3, Total: 20}}}
|
||||
if err := s.ApplyStatus(t.Context(), runID, "bk1", stale, time.Now().UTC()); err != nil {
|
||||
if editor, _ := bookShape(t, s, "bk1"); editor == nil || *editor {
|
||||
t.Fatalf("the stream announced a draft-only shape and the book reads %v: the fixture cannot"+
|
||||
" observe what the RESYNC writes", editor)
|
||||
}
|
||||
before := bookRevision(t, s, "bk1")
|
||||
rep := ingest.StatusReport{TotalUnits: 20, Done: 11,
|
||||
Progress: ingest.Progress{Draft: ingest.Counter{Done: 20, Total: 20},
|
||||
Edit: ingest.Counter{Done: 11, Total: 20}}, ETASeconds: 42}
|
||||
at := time.Now().UTC()
|
||||
if err := s.ApplyStatus(t.Context(), runID, "bk1", rep, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var draft, edit int
|
||||
var eta *int
|
||||
var stamp *time.Time
|
||||
if err := s.pool.QueryRow(t.Context(),
|
||||
`select draft_done, edit_done from runs where id=$1`, runID).Scan(&draft, &edit); err != nil {
|
||||
`select eta_seconds, last_resync_at from runs where id=$1`, runID).Scan(&eta, &stamp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if draft != 9 || edit != 7 {
|
||||
t.Errorf("a stale resync walked the projection back to draft=%d edit=%d, from draft=9 edit=7", draft, edit)
|
||||
if eta == nil || *eta != 42 {
|
||||
t.Errorf("the resync's eta folded to %v, want 42", eta)
|
||||
}
|
||||
// A fresher one moves BOTH waves, which is the half no aggregate could do.
|
||||
fresh := ingest.StatusReport{TotalUnits: 20, Done: 11,
|
||||
Progress: ingest.Progress{Draft: ingest.Counter{Done: 20, Total: 20}, Edit: ingest.Counter{Done: 11, Total: 20}}}
|
||||
if err := s.ApplyStatus(t.Context(), runID, "bk1", fresh, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
if stamp == nil {
|
||||
t.Error("the resync left no freshness stamp: how stale its figures are is the one thing only it can say")
|
||||
}
|
||||
var dtotal, etotal int
|
||||
// The SHAPE the report announced reaches the book — this is the resync's other real effect, and
|
||||
// the one that decides how the whole library counts this book. The stream said draft-only; only
|
||||
// ApplyStatus can have written what is read here.
|
||||
editor, epoch := bookShape(t, s, "bk1")
|
||||
if editor == nil || !*editor {
|
||||
t.Errorf("the report announced an edit total of 20 and the book's shape folded to %v: the"+
|
||||
" resync's own announcement never reached the book", editor)
|
||||
}
|
||||
if epoch != 1 {
|
||||
t.Errorf("the resync crossed a shape boundary and the epoch reads %d, want 1", epoch)
|
||||
}
|
||||
// And a client is told to re-read, because a resync is a materialisation like any other.
|
||||
if after := bookRevision(t, s, "bk1"); after <= before {
|
||||
t.Errorf("the resync left the book at revision %d: a watching client is never told to re-read", after)
|
||||
}
|
||||
// ⚠ THE GAP, asserted so it cannot be forgotten: the bar is derived from `chapters`, and the
|
||||
// resync does not write there. A quarantined run shows what its stream last delivered.
|
||||
var draftDone int
|
||||
if err := s.pool.QueryRow(t.Context(),
|
||||
`select draft_done, draft_total, edit_done, edit_total from runs where id=$1`, runID).
|
||||
Scan(&draft, &dtotal, &edit, &etotal); err != nil {
|
||||
`select units_draft_done from chapters where id='c1'`).Scan(&draftDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if draft != 20 || dtotal != 20 || edit != 11 || etotal != 20 {
|
||||
t.Errorf("after a fresher resync: draft %d/%d, edit %d/%d", draft, dtotal, edit, etotal)
|
||||
if draftDone != 0 {
|
||||
t.Errorf("the resync moved a chapter counter to %d: if this channel has learned to materialise"+
|
||||
" progress, the register row that says it cannot is stale and the bar's source has changed", draftDone)
|
||||
}
|
||||
}
|
||||
|
||||
func bookShape(t *testing.T, s *Store, bookID string) (editor *bool, epoch int) {
|
||||
t.Helper()
|
||||
if err := s.pool.QueryRow(t.Context(),
|
||||
`select epoch_editor, shape_epoch from books where id = $1`, bookID).Scan(&editor, &epoch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return editor, epoch
|
||||
}
|
||||
|
||||
func bookRevision(t *testing.T, s *Store, bookID string) int64 {
|
||||
t.Helper()
|
||||
var rev int64
|
||||
if err := s.pool.QueryRow(t.Context(), `select revision from books where id = $1`, bookID).
|
||||
Scan(&rev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rev
|
||||
}
|
||||
|
||||
// What a resync produces is stale by up to the poll interval, and the ONLY thing that says how stale
|
||||
// is the stamp it leaves. A run being tailed has none: the stream carries its own freshness.
|
||||
func TestAResyncRecordsWhenItWasTaken(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -179,7 +179,33 @@ func (s *Service) ApplyBankCorrections(ctx context.Context, in BankCorrectionsIn
|
|||
// edition's mistake (adversarial pass, K1): status projects the STORED memory, and a
|
||||
// correction reaches it only when the next translate folds the bank in — right after an
|
||||
// apply it honestly answers «nothing moved».
|
||||
if ferr := s.Store.RecordBankMove(ctx, in.BookID); ferr != nil {
|
||||
//
|
||||
// ⚠ On a context of its OWN, detached from the caller — the same rule as the intake's
|
||||
// `writeCtx` (books/parse.go) and the idempotency receipt's `settleCtx`
|
||||
// (httpapi/idempotency.go) — and here it is the difference between a fact and nothing.
|
||||
//
|
||||
// ⚠ The references above and below are by SYMBOL, not by line: this pack moved four of the six
|
||||
// numbered anchors an earlier edition of this comment carried, which is the same rot the
|
||||
// register's own anchors are graded for. Grep the name.
|
||||
//
|
||||
// Two mechanics make the request's context the wrong one to write on. The verb comes back
|
||||
// CLEAN on an already-cancelled call: `runner.BankApply` returns (outcome, nil) whenever the
|
||||
// process exited, and the engine's last look at its context is BEFORE its writes — so a
|
||||
// correction whose files LANDED routinely arrives here with ctx already dead, either because
|
||||
// the tab closed or because the verb ate the whole budget set at the top of this function.
|
||||
// And the remedy this door then names — «re-send the same document» — is addressed to
|
||||
// precisely the client that is gone. The fact has no second writer and no sweep (grep
|
||||
// `bank_moved_at =` — one statement sets it, in `pgstore.RecordBankMove`), so on r.Context()
|
||||
// it is lost FOREVER: `bank_moved_at` stays NULL, the next ordinary run is admitted with
|
||||
// resnapshot=false (`runs.Start`, grep `if book.BankMoved`) and dies on the engine's snapshot
|
||||
// guard AFTER its hold was taken (`pgstore.StartRun`, the hold is taken before anything is
|
||||
// spawned). That is PD-425, and it is money.
|
||||
//
|
||||
// recordBudget rather than the intake's 30 s: this write runs UNDER the book lock taken at
|
||||
// the top, so its tail is a delay the next caller of this book pays.
|
||||
c, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordBudget)
|
||||
defer cancel()
|
||||
if ferr := s.Store.RecordBankMove(c, in.BookID); ferr != nil {
|
||||
// The correction LANDED — the engine's files moved — but the fact did not reach the
|
||||
// store, and the next run's consents are decided from it. «Re-send the same document»
|
||||
// is the honest remedy and it CONVERGES: the engine answers the re-send with its byte
|
||||
|
|
|
|||
|
|
@ -447,6 +447,55 @@ func TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// The bank-move fact outlives the client that asked for it. THE money case (PD-425): the correction
|
||||
// verb comes back CLEAN on an already-cancelled call — runner.BankApply returns (outcome, nil)
|
||||
// whenever the process exited, and the engine's last look at its context is before its writes — so a
|
||||
// correction whose files LANDED reaches the fact-write with the request context already dead. On
|
||||
// r.Context() the write is refused by pgx, `bank_moved_at` stays NULL forever (no second writer, no
|
||||
// sweep), and the next ordinary run is admitted without --resnapshot and dies on the engine's
|
||||
// snapshot guard AFTER its hold: the user pays for an attempt that does nothing. The remedy the door
|
||||
// names for a failed write — «re-send the same document» — is by construction addressed to a client
|
||||
// that is gone.
|
||||
//
|
||||
// The verb is held mid-flight and the cancellation lands WHILE it runs, because that is the only
|
||||
// shape that reaches the write: a context cancelled before the call never gets past lockBook, which
|
||||
// selects on ctx.Done and answers ErrBankUnavailable.
|
||||
//
|
||||
// Mutation caught: RecordBankMove on the caller's context.
|
||||
func TestTheBankMoveFactSurvivesAClientThatHungUp(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
fake := &fakeBankApplier{
|
||||
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
fake.out.Report.BookID = f.bookID(t)
|
||||
f.svc.Bank = fake
|
||||
ctx, cancel := context.WithCancel(f.ctx)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.svc.ApplyBankCorrections(ctx, BankCorrectionsInput{
|
||||
UserID: "u1", BookID: f.bookID(t), Preview: false,
|
||||
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
<-fake.entered // the verb is mid-flight and the book lock is held
|
||||
cancel() // the tab closes; the engine's writes have already landed
|
||||
close(fake.release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("the door refused a correction whose files landed: %v", err)
|
||||
}
|
||||
book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !book.BankMoved {
|
||||
t.Fatal("the bank-move fact died with the client: the next ordinary run is admitted without --resnapshot and dies on the engine's guard after taking its hold")
|
||||
}
|
||||
}
|
||||
|
||||
// A start over a moved bank decides BOTH consents once, stores them on the row, and the spawn
|
||||
// renders them — the argv is the admission's decision, stable across respawns (P10 §3.1). The cap
|
||||
// is FUNDED: the run's own hold, never a projection (which does not exist at admission).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package runs
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
|
@ -55,6 +57,80 @@ func (f *fixture) stoppedRun(t *testing.T, in StartRequest, spent money.MicroUSD
|
|||
return run.ID
|
||||
}
|
||||
|
||||
// A run is REFUSED over a book whose chapter tree was never materialised, and refused BEFORE its
|
||||
// hold — which is the whole point of where the guard sits (PD-405).
|
||||
//
|
||||
// The window is the intake's own and it is ordinary: `not_started` is committed in one transaction
|
||||
// and the tree is materialised after it, outside. On the healthy path that is milliseconds (measured
|
||||
// at 0.018 s, act D39.162); when the materialisation FAILED or was deferred it is unbounded, and that
|
||||
// is the shape that costs money. A run admitted there can never move: every counter the screen shows
|
||||
// is a count over `chapters` and there are no rows to count, so the bar reads 0/total for the run's
|
||||
// whole life — and the tree's own debt is frozen meanwhile, because the sweep that would pay it skips
|
||||
// a book with a live run. The user pays for a run that shows nothing and cannot be repaired.
|
||||
//
|
||||
// Mutation caught: moving the guard below the hold; dropping the `ChapterCount > 0` term (which would
|
||||
// refuse a book that legitimately declares no chapters).
|
||||
func TestARunIsRefusedOverABookWhoseChaptersWereNeverMaterialised(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
book := f.bookID(t)
|
||||
// The tree goes away: the state the intake is in between its commit and its materialisation, and
|
||||
// the state it STAYS in when that materialisation broke.
|
||||
if _, err := f.store.Pool().Exec(f.ctx, `delete from chapters where book_id = $1`, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := f.account(t)
|
||||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, CeilingChapters: 10})
|
||||
if !errors.Is(err, ErrBookNotReady) {
|
||||
t.Fatalf("a run over a book with no chapter tree was answered %v, want book_not_ready: its bar"+
|
||||
" can never move and its tree's debt is frozen for as long as it runs", err)
|
||||
}
|
||||
// BEFORE the hold, and this is the half that is money rather than honesty.
|
||||
after := f.account(t)
|
||||
if after.Balance != before.Balance || after.LedgerSum != before.LedgerSum {
|
||||
t.Errorf("the refusal moved money: balance %s → %s, ledger %s → %s",
|
||||
before.Balance.USD(), after.Balance.USD(), before.LedgerSum.USD(), after.LedgerSum.USD())
|
||||
}
|
||||
// And the same book with its tree back is startable: the guard refuses the STATE, not the book.
|
||||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||||
insert into chapters (id, book_id, number, units_total)
|
||||
select 'c' || g, $1, g, 1 from generate_series(1, 500) g`, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, CeilingChapters: 10}); err != nil {
|
||||
t.Fatalf("the book was refused with its tree materialised: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The OTHER half of the same guard, and it exists because this pack's adversarial pass showed the
|
||||
// header above claiming a mutation nothing caught: dropping the `ChapterCount > 0` term left the
|
||||
// whole eighteen-package battery green, because no test built the book it would newly refuse.
|
||||
//
|
||||
// A book that declares NO chapters has no tree to be missing, so the guard must not touch it. It is
|
||||
// refused, but by the thing that should refuse it — there is nothing to buy — and the two refusals
|
||||
// carry different remedies: "wait for the intake" is a lie told about a book whose intake is over.
|
||||
//
|
||||
// Mutation caught: dropping the `ChapterCount > 0` term from the admission guard.
|
||||
func TestABookThatDeclaresNoChaptersIsNotRefusedAsUnmaterialised(t *testing.T) {
|
||||
f := newFixture(t, "10", 0)
|
||||
book := f.bookID(t)
|
||||
var count int
|
||||
if err := f.store.Pool().QueryRow(f.ctx,
|
||||
`select chapter_count from books where id = $1`, book).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("the fixture book declares %d chapters: this test is about the one that declares none", count)
|
||||
}
|
||||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, CeilingChapters: 1})
|
||||
if errors.Is(err, ErrBookNotReady) {
|
||||
t.Errorf("a book that declares no chapters was told its chapters are still being materialised:"+
|
||||
" %v — its intake is OVER, and the honest refusal is that there is nothing to buy", err)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("a run was admitted over a book with nothing in it")
|
||||
}
|
||||
}
|
||||
|
||||
// secondBook registers another book of the same account, with a directory of its own.
|
||||
func (f *fixture) secondBook(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
|
@ -63,6 +139,13 @@ func (f *fixture) secondBook(t *testing.T) string {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// …with its chapter TREE, for the same reason the main fixture has one: a book that declares
|
||||
// chapters and materialised none cannot be started over (PD-405).
|
||||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||||
insert into chapters (id, book_id, number, units_total)
|
||||
select $1 || ':c' || g, $1, g, 1 from generate_series(1, 100) g`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
|
|
@ -77,19 +160,6 @@ func (f *fixture) lastAttempt(t *testing.T, runID string) pgstore.LiveRun {
|
|||
return l
|
||||
}
|
||||
|
||||
// bankReleased reads the bit that tells the first segment of a run from the second. Read with SQL
|
||||
// because it is deliberately NOT on the wire: the client sees one counter, and which pass it is
|
||||
// counting is the platform's own business.
|
||||
func (f *fixture) bankReleased(t *testing.T, runID string) bool {
|
||||
t.Helper()
|
||||
var released bool
|
||||
if err := f.store.Pool().QueryRow(f.ctx,
|
||||
`select bank_released from runs where id = $1`, runID).Scan(&released); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return released
|
||||
}
|
||||
|
||||
func (f *fixture) run(t *testing.T, runID string) pgstore.Run {
|
||||
t.Helper()
|
||||
r, err := f.store.ReadRun(f.ctx, "u1", runID)
|
||||
|
|
@ -379,11 +449,6 @@ func TestResumeLiftsABankStopWithTheDecisionsAsTheyStand(t *testing.T) {
|
|||
if got.Status != "translating" {
|
||||
t.Fatalf("the resumed run is %q, want it going again", got.Status)
|
||||
}
|
||||
// The SEGMENT moved with it: the bar the client reads starts again from zero for the work that
|
||||
// follows the stop, which is what `bank_released` is for.
|
||||
if !f.bankReleased(t, runID) {
|
||||
t.Error("the signing stop was lifted without recording that the second segment had begun")
|
||||
}
|
||||
}
|
||||
|
||||
// A run that spent the whole ceiling it bought is REFUSED: there is no work this call could pay for,
|
||||
|
|
@ -579,13 +644,21 @@ func TestOneSlowRunDoesNotEatThePassOfTheWholeSweep(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// ONLY the user's resume may clear a bank-signing stop. The reconciler restarts INTERRUPTIONS, and a
|
||||
// run standing at the signing stop is not one: the stop writes `awaiting_bank` without `finished_at`,
|
||||
// so the run stays in the sweep's list, and a restart that lifted the stop would spawn the rest of the
|
||||
// ceiling without `--verify-bank` — running past the pause the user paid for, with no click of theirs.
|
||||
// A run standing at the bank-signing stop is NOT an interruption, so the sweep does not restart it —
|
||||
// it closes it AT the stop, and only the user's resume takes it further.
|
||||
//
|
||||
// Mutation caught: keying LiftBankStop on l.Status alone, which is the form that shipped this morning.
|
||||
func TestTheReconcilerDoesNotLiftABankStopNobodySigned(t *testing.T) {
|
||||
// ⚠ RE-SIGNED by pack P12 (unified-backlog row 240), and the re-signing is the point rather than an
|
||||
// edit to keep a test green. This test used to pin a guard that keyed on WHICH BIT a restart wrote
|
||||
// (`LiftBankStop`), and that guard was written against an engine that re-halted on everything still
|
||||
// undecided: keeping `--verify-bank` on the restarted attempt was what re-created the pause. The
|
||||
// engine's presented memory (storage v16, D39.158) ended that — a map whose clusters were already
|
||||
// shown does not stop again, whatever the flag says — so from that day the guard protected NOTHING:
|
||||
// the sweep's restart marched straight through the paid pause with one WARN in the engine's own
|
||||
// journal. What holds instead is the decision one level up, and it is the same rule the ceiling
|
||||
// branch beside it already follows: an engine that ended ON PURPOSE is not restarted.
|
||||
//
|
||||
// Mutation caught: restarting a run whose status is `awaiting_bank` when its unit vanishes.
|
||||
func TestTheReconcilerDoesNotRestartPastABankStopNobodySigned(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true,
|
||||
CeilingChapters: 10})
|
||||
|
|
@ -609,22 +682,30 @@ func TestTheReconcilerDoesNotLiftABankStopNobodySigned(t *testing.T) {
|
|||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var released bool
|
||||
var status string
|
||||
var attempts int
|
||||
var finished *time.Time
|
||||
if err := f.store.Pool().QueryRow(f.ctx,
|
||||
`select r.bank_released, r.status, (select count(*) from run_attempts where run_id = r.id)
|
||||
from runs r where r.id = $1`, run.ID).Scan(&released, &status, &attempts); err != nil {
|
||||
`select r.status, r.finished_at, (select count(*) from run_attempts where run_id = r.id)
|
||||
from runs r where r.id = $1`, run.ID).Scan(&status, &finished, &attempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The fixture must actually reach the restart, or it observes nothing: this assertion is what
|
||||
// caught the first version of this test, which passed under its own mutation.
|
||||
if attempts < 2 {
|
||||
t.Fatalf("the sweep did not restart the run at all (%d attempts): this fixture cannot observe the property", attempts)
|
||||
// The sweep must have LOOKED at this run, or the test observes nothing: the same guard against a
|
||||
// fixture that never reaches the decision which caught the first version of this test.
|
||||
if !f.runner.askedAlive() {
|
||||
t.Fatal("the sweep never reached this run: the fixture cannot observe the property")
|
||||
}
|
||||
if released {
|
||||
t.Errorf("the sweep lifted a signing stop the user never signed (status now %q)", status)
|
||||
if attempts != 1 {
|
||||
t.Errorf("the sweep restarted a run standing at the signing stop (%d attempts): the pause the user paid for was spent on a march through it", attempts)
|
||||
}
|
||||
if status != "awaiting_bank" {
|
||||
t.Errorf("the run left the signing stop as %q, want it still standing there for the user to answer", status)
|
||||
}
|
||||
if finished == nil {
|
||||
t.Error("the run was left LIVE at a stop whose engine is gone: nothing ends it and nothing resumes it")
|
||||
}
|
||||
// Whatever the sweep did spawn, it carried the flag: after D39.158 the platform never masks it,
|
||||
// and where the engine stops is the engine's decision.
|
||||
for i, spec := range f.runner.starts() {
|
||||
if !slices.Contains(spec.Args, "--verify-bank") {
|
||||
t.Errorf("attempt %d was spawned without the signing stop: %v", i+1, spec.Args)
|
||||
|
|
@ -632,6 +713,91 @@ func TestTheReconcilerDoesNotLiftABankStopNobodySigned(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// THE PAID STOP SURVIVES THE TWO PATHS THAT REACH IT THROUGH THIS SWEEP'S OWN DRAIN, and both were
|
||||
// open until this pack's adversarial pass measured them.
|
||||
//
|
||||
// The trap they share: the sweep's `LiveRun` is a by-value snapshot taken BEFORE the journal is
|
||||
// drained, and the bank-stop event lands in that very drain — it writes `awaiting_bank` on the row
|
||||
// and nothing the snapshot carries. A guard that asked the snapshot therefore asked a value that
|
||||
// could not yet know, and the run was restarted past the pause the user paid for. After D39.158 that
|
||||
// restart does not even re-stop: it carries `--verify-bank` and the engine's presented memory
|
||||
// auto-continues over a map it has already shown, one WARN in the engine's own journal.
|
||||
//
|
||||
// Sub-test A — the unit VANISHED, and the stop arrived in this pass's drain.
|
||||
// Sub-test B — a marker exists reading exit 5, an outside SIGTERM. That branch (`interruptedBySomeoneElse`)
|
||||
// answers BEFORE `outcome` is ever consulted, so the rule written into `outcome` could not see it.
|
||||
//
|
||||
// Mutation caught: reading l.Status from the pre-drain snapshot; dropping the awaiting_bank exclusion
|
||||
// from the interrupted-by-someone-else branch.
|
||||
func TestABankStopArrivingInThisSweepsOwnDrainIsNotRestartedPast(t *testing.T) {
|
||||
bankStop := func(t *testing.T, f *fixture) {
|
||||
t.Helper()
|
||||
body := hello(t, f) + `{"seq":2,"type":"bank_stop","data":{}}` + "\n"
|
||||
if err := os.WriteFile(filepath.Join(f.workdir, ingest.JournalFile), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
start := func(t *testing.T) (*fixture, string) {
|
||||
t.Helper()
|
||||
f := newFixture(t, "10", 500)
|
||||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true,
|
||||
CeilingChapters: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bankStop(t, f)
|
||||
return f, run.ID
|
||||
}
|
||||
assertStandsAtTheStop := func(t *testing.T, f *fixture, runID string) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var attempts int
|
||||
if err := f.store.Pool().QueryRow(f.ctx,
|
||||
`select r.status, (select count(*) from run_attempts where run_id = r.id)
|
||||
from runs r where r.id = $1`, runID).Scan(&status, &attempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("the sweep opened attempt %d over a run standing at the signing stop: the pause the"+
|
||||
" user paid for was spent on a march through it", attempts)
|
||||
}
|
||||
if status != "awaiting_bank" {
|
||||
t.Errorf("the run left the signing stop as %q, want it still standing there", status)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("the unit vanished", func(t *testing.T) {
|
||||
f, runID := start(t)
|
||||
f.runner.alive = false
|
||||
late := f.now.Add(2 * time.Hour)
|
||||
f.svc.Now = func() time.Time { return late }
|
||||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertStandsAtTheStop(t, f, runID)
|
||||
})
|
||||
|
||||
t.Run("an outside signal left a marker", func(t *testing.T) {
|
||||
f, runID := start(t)
|
||||
f.runner.alive = false
|
||||
live := f.live(t)
|
||||
// Exit 5 with NO stop recorded by this platform: the shape interruptedBySomeoneElse exists for.
|
||||
if err := runner.WriteMarker(f.svc.markerPath(runID, live.AttemptNo),
|
||||
runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "5"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
late := f.now.Add(2 * time.Hour)
|
||||
f.svc.Now = func() time.Time { return late }
|
||||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertStandsAtTheStop(t, f, runID)
|
||||
})
|
||||
}
|
||||
|
||||
// `blocked` answers ONE question — why is this scale shorter than the account could afford — so it
|
||||
// names another book only when giving that book's hold back would lengthen the scale. A hold that
|
||||
// costs this book nothing must not send the user off to stop a run for no gain.
|
||||
|
|
@ -1110,16 +1276,31 @@ func TestAStopDuringSettlementOutranksThePause(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// waveCounts reads what the projection actually folded out of the journal, per wave.
|
||||
// waveShape reads what a progress line actually folds into the read model: the ETA it carried, and
|
||||
// the SHAPE the engine announced with it — whether this book's pipeline has an editor, and which
|
||||
// generation of that answer the book is on.
|
||||
//
|
||||
// ⚠ It exists because `Book.Progress` left the wire in 0.3.0 and the sweep's own test was then
|
||||
// weakened to counting FRAMES — which says a frame was emitted, not that the numbers behind it are
|
||||
// right. The counters are still in the database; only the way to read them changed.
|
||||
func (f *fixture) waveCounts(t *testing.T, runID string) (draft, edit int) {
|
||||
// ⚠ It replaces `waveCounts`, which read `runs.draft_done/edit_done`. Those columns were dropped with
|
||||
// register row PD-411 — two writers, no reader — so an assertion over them proved that a fold had
|
||||
// happened, not that anything a client can see was right. The re-point is to the numbers this event
|
||||
// STILL materialises, which is the honest remainder rather than a weakened claim: read the wrong
|
||||
// field of the line and the shape comes out wrong, which is what the assertion catches.
|
||||
func (f *fixture) waveShape(t *testing.T, bookID string) (editor *bool, epoch int) {
|
||||
t.Helper()
|
||||
if err := f.store.Pool().QueryRow(f.ctx,
|
||||
`select draft_done, edit_done from runs where id = $1`, runID).Scan(&draft, &edit); err != nil {
|
||||
`select epoch_editor, shape_epoch from books where id = $1`, bookID).Scan(&editor, &epoch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return draft, edit
|
||||
return editor, epoch
|
||||
}
|
||||
|
||||
// runETA is the other half of what a progress line leaves behind.
|
||||
func (f *fixture) runETA(t *testing.T, runID string) *int {
|
||||
t.Helper()
|
||||
var eta *int
|
||||
if err := f.store.Pool().QueryRow(f.ctx,
|
||||
`select eta_seconds from runs where id = $1`, runID).Scan(&eta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return eta
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,10 +133,19 @@ func (s *Service) reconcileOne(ctx context.Context, l pgstore.LiveRun) {
|
|||
return
|
||||
}
|
||||
reason := "the run took its whole budget without finishing"
|
||||
if err != nil {
|
||||
blocked := errors.Is(err, errSettlementBlocked)
|
||||
switch {
|
||||
case blocked:
|
||||
// ONE sentence for one fact, whichever phase reports it: the operator's table prints this
|
||||
// under LAST ERROR, and «the settlement could not be computed» read from two phases in two
|
||||
// different wordings would look like two different problems.
|
||||
reason = settleReason(settlementBlocked, nil, false)
|
||||
s.log().ErrorContext(ctx, "the run's settlement is blocked, so it cannot be restarted; it is deferred",
|
||||
"run", l.RunID, "attempt", l.AttemptNo)
|
||||
case err != nil:
|
||||
reason = err.Error()
|
||||
s.log().ErrorContext(ctx, "run could not be reconciled", "run", l.RunID, "err", err)
|
||||
} else {
|
||||
default:
|
||||
s.log().WarnContext(ctx, "the run took its whole budget without finishing; it is deferred",
|
||||
"run", l.RunID, "attempt", l.AttemptNo)
|
||||
}
|
||||
|
|
@ -148,6 +157,13 @@ func (s *Service) reconcileOne(ctx context.Context, l pgstore.LiveRun) {
|
|||
// `settlementBackoffCap` was shortened to five minutes to prevent. The read costs one indexed
|
||||
// lookup and only on a failure.
|
||||
delay := backoff(l.ReconcileFailures + 1)
|
||||
if blocked {
|
||||
// The SETTLEMENT's schedule, not the live phase's, because it is the settlement that is stuck
|
||||
// and the settlement's deferral is the USER's resume gate (settlementBackoffCap's own
|
||||
// argument). Half an hour of live backoff would put their resume behind it for a blockage
|
||||
// they can do nothing about.
|
||||
delay = settlementDelay(l.ReconcileFailures)
|
||||
}
|
||||
switch ended, qerr := s.Store.AttemptEnded(c, l.AttemptID); {
|
||||
case qerr != nil:
|
||||
s.log().ErrorContext(c, "could not tell whether the attempt had ended; deferring as a live run",
|
||||
|
|
@ -325,6 +341,26 @@ func settleReason(verdict settleVerdict, err error, overran bool) string {
|
|||
return "the settlement could not be computed"
|
||||
}
|
||||
|
||||
// errSettlementBlocked is a RESTART that could not be attempted because the money of the previous
|
||||
// attempt could not be closed. It is an error rather than a silent return, and that is the whole of
|
||||
// register row PD-424.
|
||||
//
|
||||
// The chain it ends: a unit vanishes without a marker → `restart` → `settle` answers
|
||||
// `settlementBlocked` WITHOUT an error → the old code threw the verdict away, walked into `reopen`,
|
||||
// which refuses to open an attempt over an unclosed hold and answers `deferred` → `case deferred:
|
||||
// return nil` → `reconcileOne` reads a successful pass and CLEARS the deferral. Every surface stayed
|
||||
// clean: `reconcile_failures` never left 0, `reconcile_after` stayed NULL, `StalledRuns` was empty,
|
||||
// the stalled gauge read 0 — while the hold stayed frozen and the engine was poked every fifteen
|
||||
// seconds forever. Nothing but the user pressing Stop could end it, and nobody told them.
|
||||
//
|
||||
// The question the row said had to be answered first — what `deferred` MEANS for the counter — is
|
||||
// answered here and only here: a settlement that cannot be COMPUTED is a failure of the phase that
|
||||
// owns it, and it is counted by that phase. `reopen`'s own `deferred` verdict is left exactly as it
|
||||
// was, because after this it can only be reached on a `settlementRaced` whose reservation is briefly
|
||||
// open — self-correcting on the next pass, and the settlement phase argues in as many words that a
|
||||
// race must not reach an operator's counter.
|
||||
var errSettlementBlocked = errors.New("runs: the attempt's settlement could not be computed, so the run cannot be restarted")
|
||||
|
||||
// settleVerdict is what one pass over an attempt's money produced. Three states and not a boolean,
|
||||
// because the middle one must NOT reach the failure counter: see settleOne.
|
||||
type settleVerdict int
|
||||
|
|
@ -472,11 +508,16 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error
|
|||
// readable branch buried exactly that case: a ceiling halt whose marker was corrupted came out
|
||||
// `failed` with no reason at all, on the one path where the exit code cannot say otherwise
|
||||
// either.
|
||||
reason, rerr := s.freshPausedReason(ctx, l)
|
||||
status, reason, rerr := s.freshRunState(ctx, l)
|
||||
if rerr != nil {
|
||||
return moved, rerr // nothing is closed on facts we could not establish; the next pass tries again
|
||||
}
|
||||
l.PausedReason = reason
|
||||
if status != "" {
|
||||
// The snapshot is pre-drain; the row is not. Everything below that reasons about WHERE this
|
||||
// run stands has to reason about now.
|
||||
l.Status = status
|
||||
}
|
||||
if err != nil {
|
||||
// What the marker says cannot be read. Retrying is what the register row is about: the
|
||||
// write is atomic (temp+fsync+rename), so a marker that does not parse was changed by
|
||||
|
|
@ -486,8 +527,16 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error
|
|||
"run", l.RunID, "attempt", l.AttemptNo, "err", err)
|
||||
return true, s.finish(ctx, l, runner.Marker{Unit: l.UnitName, Result: runner.UnreadableMarkerResult})
|
||||
}
|
||||
if interruptedBySomeoneElse(l, marker) {
|
||||
if interruptedBySomeoneElse(l, marker) && l.Status != "awaiting_bank" {
|
||||
// Exit 5 is a caught SIGTERM, and nothing here recorded asking for one. See the function.
|
||||
//
|
||||
// ⚠ …unless the run is standing at the BANK-SIGNING STOP, and that exclusion is the same
|
||||
// rule as the ceiling's one level down: an outside signal landing on a run that has already
|
||||
// paused on purpose does not un-pause it. Without it this branch RESTARTS past the paid
|
||||
// stop before `outcome` — the only other place that knows about the stop — is ever
|
||||
// consulted, and after D39.158 that restart marches straight through the boundary on the
|
||||
// engine's presented memory. Found by this pack's own adversarial pass, which reproduced it
|
||||
// with a marker reading exit 5 on an `awaiting_bank` row.
|
||||
s.log().InfoContext(ctx, "the run was signalled by something that is not this platform's stop; restarting it",
|
||||
"run", l.RunID, "attempt", l.AttemptNo)
|
||||
return true, s.restart(ctx, l)
|
||||
|
|
@ -536,10 +585,13 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error
|
|||
// just cancelled. The intent decides, because it is the one fact here that is ours.
|
||||
return true, s.finishStopped(ctx, l)
|
||||
}
|
||||
reason, err := s.freshPausedReason(ctx, l)
|
||||
status, reason, err := s.freshRunState(ctx, l)
|
||||
if err != nil {
|
||||
return moved, err // same rule as above: a restart is a decision, and this one is not established yet
|
||||
}
|
||||
if status != "" {
|
||||
l.Status = status
|
||||
}
|
||||
if l.PausedReason = reason; l.PausedReason != "" {
|
||||
// The stream already said a ceiling stopped this run, and a ceiling halt is not an
|
||||
// interruption: the engine ended on purpose and the next process would meet the same limit.
|
||||
|
|
@ -550,6 +602,24 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error
|
|||
"run", l.RunID, "attempt", l.AttemptNo)
|
||||
return true, s.finish(ctx, l, runner.Marker{Unit: l.UnitName, Result: runner.UnitVanishedResult})
|
||||
}
|
||||
if l.Status == "awaiting_bank" {
|
||||
// The stream already said this run stopped at the BANK boundary, and a signing stop is not an
|
||||
// interruption for exactly the reason the ceiling above is not: the engine ended on purpose,
|
||||
// at a pause the user paid for and has not answered yet. Restarting here would spend a fresh
|
||||
// hold on a march straight THROUGH that pause — after D39.158 the next attempt carries
|
||||
// `--verify-bank` and the engine's presented memory (storage v16) auto-continues over a map it
|
||||
// has already shown, one WARN in its own journal and nothing on any surface of ours. The stop
|
||||
// the user paid for would simply cease to exist, which is unified-backlog row 240.
|
||||
//
|
||||
// ⚠ This is what protects the paid stop now; the platform's old guard did not. That guard
|
||||
// (`LiftBankStop`, removed with the workaround) only decided which BIT the restart wrote, and
|
||||
// the engine of the memory era stopped consulting the flag for this question — so keying the
|
||||
// protection on the argv could not hold whatever the bit said. The protection belongs where
|
||||
// the decision is: whether this platform restarts the run at all.
|
||||
s.log().InfoContext(ctx, "the run stands at the bank-signing stop and its unit is gone; closing it there rather than restarting past a stop nobody has answered",
|
||||
"run", l.RunID, "attempt", l.AttemptNo)
|
||||
return true, s.finish(ctx, l, runner.Marker{Unit: l.UnitName, Result: runner.UnitVanishedResult})
|
||||
}
|
||||
return true, s.restart(ctx, l)
|
||||
}
|
||||
|
||||
|
|
@ -569,12 +639,24 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error
|
|||
// resumable ceiling halt into `failed` with no reason. "I could not ask" is not an answer here any
|
||||
// more than it is when systemd does not reply; the sweep logs it and the next pass reads the same
|
||||
// world.
|
||||
func (s *Service) freshPausedReason(ctx context.Context, l pgstore.LiveRun) (string, error) {
|
||||
reason, err := s.Store.RunPausedReason(ctx, l.RunID)
|
||||
//
|
||||
// ⚠ It re-reads the STATUS as well as the reason, and that is not a convenience. The bank-stop event
|
||||
// writes `awaiting_bank` on the run row in the very drain this pass performs (pgstore/sink.go,
|
||||
// TypeBankStop), and the snapshot is a by-value copy taken before it — so a guard that asked
|
||||
// `l.Status` about the signing stop asked a value that could not yet know. Measured on the real
|
||||
// journal by this pack's adversarial pass: after the drain the row read `awaiting_bank` while the
|
||||
// snapshot still read `translating`, and the paid stop was restarted past anyway. The two facts are
|
||||
// re-read together because they are re-read for the same reason and on the same branches.
|
||||
func (s *Service) freshRunState(ctx context.Context, l pgstore.LiveRun) (status, reason string, err error) {
|
||||
status, reason, err = s.Store.RunPausedReason(ctx, l.RunID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("runs: the run's paused reason could not be re-read after the drain: %w", err)
|
||||
// ⚠ The wording keeps "paused reason" deliberately: `seam_test.TestAnEndingIsNeverDecidedFromAReadThatFailed`
|
||||
// matches on it, and that pin is about the PROPERTY — an ending is never decided past a read
|
||||
// that failed — which this function still has. Renaming the function was this pack's business;
|
||||
// silently retiring somebody else's assertion is not.
|
||||
return "", "", fmt.Errorf("runs: the run's status and paused reason could not be re-read after the drain: %w", err)
|
||||
}
|
||||
return reason, nil
|
||||
return status, reason, nil
|
||||
}
|
||||
|
||||
// finishStopped closes a run whose end this platform asked for and whose unit left no marker to read:
|
||||
|
|
@ -714,10 +796,13 @@ func (s *Service) maybeResync(ctx context.Context, l pgstore.LiveRun) (bool, err
|
|||
// whose cursor has never moved, or one whose materialization was quarantined and for which this
|
||||
// is now the only source.
|
||||
//
|
||||
// ⚠ This gate used to carry a second job that it no longer has to: before the engine reported the
|
||||
// per-wave split (row 99, landed D39.122) a resync could only fold ONE aggregate over the two
|
||||
// counters the stream keeps apart, which is how a live run showed "edit 10/10" seconds after its
|
||||
// journal said "edit 0/10". ApplyStatus now materializes the same four counters as the stream.
|
||||
// ⚠ What a resync DOES materialise is narrower than this comment used to claim, and the claim is
|
||||
// corrected rather than inherited: "ApplyStatus now materializes the same four counters as the
|
||||
// stream" was true of four columns nothing ever read (PD-411, dropped in migration 00031). The
|
||||
// bar is derived from `chapters`, which this channel does not write — so a run whose stream is
|
||||
// quarantined keeps the progress its stream last delivered, however faithfully the engine answers
|
||||
// here. The repair channel repairs the ETA, the freshness stamp and the wave shape. That gap has
|
||||
// a register row of its own.
|
||||
if l.Position.LastSeq > 0 && !l.Quarantined {
|
||||
return false, nil
|
||||
}
|
||||
|
|
@ -878,6 +963,16 @@ func outcome(l pgstore.LiveRun, m runner.Marker) (status, pausedReason string, e
|
|||
// have been killed on its way out, and the fact does not become less true.
|
||||
return "paused", l.PausedReason, nil
|
||||
}
|
||||
if l.Status == "awaiting_bank" {
|
||||
// Same rule, same reason, for the bank-signing stop: the journal's bank-stop event moved this
|
||||
// run to the pause BEFORE any marker was read, and a process killed on its way out of that
|
||||
// pause does not un-pause it. Without this the run would be recorded `failed`/`interrupted`
|
||||
// and the paid stop would be answered with a retry — the very march past the boundary that
|
||||
// unified-backlog row 240 names. It sits AFTER the engine's own endings (a clean exit above
|
||||
// wins: a run that finished by itself is not waiting for a signature) and after the user's
|
||||
// own stop, whose word is ours and outranks a pause we are only reporting.
|
||||
return "awaiting_bank", "", exitCodeOrNil(code, exited)
|
||||
}
|
||||
if exited && ingest.OutcomeOf(code) == ingest.OutcomeCeiling {
|
||||
// No event reached us — the branch above would have taken it — so WHICH ceiling stopped the
|
||||
// run is genuinely unknown here, and that is what gets recorded. It is not a rare corner: a
|
||||
|
|
@ -1127,8 +1222,24 @@ func (s *Service) settle(ctx context.Context, l pgstore.LiveRun) (settleVerdict,
|
|||
// spend and the interrupted attempt moved it (D39.122, and meter.bookCap for why the reserved figure
|
||||
// is deliberately not in that sum).
|
||||
func (s *Service) restart(ctx context.Context, l pgstore.LiveRun) error {
|
||||
if _, err := s.settle(ctx, l); err != nil {
|
||||
switch v, err := s.settle(ctx, l); {
|
||||
case err != nil:
|
||||
return err
|
||||
case v == settlementBlocked:
|
||||
// ⚠ Returned HERE rather than fallen through, and the early return is not tidiness (PD-424).
|
||||
// `reopen`'s first act is to ask whether the previous attempt's reservation is still open, and
|
||||
// in this state the answer is guaranteed to be yes — so the old path paid for a query to learn
|
||||
// what `settle` had just said, then logged a WARN blaming the RESTART for a blockage that
|
||||
// belongs to the settlement, and finally answered `nil`, which the caller counts as a
|
||||
// successful pass.
|
||||
//
|
||||
// A shutdown is not a failure: the same guard the settlement phase carries (settleOne).
|
||||
// Stopping the daemon cancels every item in flight, and a count a SHUTDOWN invented is the
|
||||
// exact defect that phase found and named.
|
||||
if ctx.Err() == nil {
|
||||
return errSettlementBlocked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
next, v, err := s.reopen(ctx, l, fromALiveRun)
|
||||
if errors.Is(err, pgstore.ErrStopRequested) {
|
||||
|
|
@ -1293,15 +1404,7 @@ func (s *Service) reopen(ctx context.Context, l pgstore.LiveRun, from liveness)
|
|||
AcceptRebill: consent,
|
||||
// The reconciler's own guard against a stale snapshot: if another pass finished this run
|
||||
// while this one was settling, it must not be brought back to life.
|
||||
OnlyIfLive: bool(from),
|
||||
// A run re-opening OUT of the signing stop clears it in the same transaction: the bit decides
|
||||
// which pass the wire's bar counts, and a separate write could half-land.
|
||||
//
|
||||
// ⚠ Only the USER's resume may clear it. A bank stop writes `awaiting_bank` without
|
||||
// `finished_at`, so the run stays in the reconciler's list; keyed on the status alone, a
|
||||
// restart of that run — a reboot that lost the exit marker, an outside SIGTERM — lifted a stop
|
||||
// nobody signed and spawned the rest of the ceiling without `--verify-bank`.
|
||||
LiftBankStop: from == fromAFinishedRun && l.Status == "awaiting_bank",
|
||||
OnlyIfLive: bool(from),
|
||||
UserID: l.UserID,
|
||||
BookID: l.BookID,
|
||||
Ceiling: remaining,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ type fakeRunner struct {
|
|||
alive bool
|
||||
aliveErr error
|
||||
startErr error
|
||||
// aliveCalls counts the "is the unit still there" questions. A test whose property is that the
|
||||
// sweep DID NOT act needs to know the sweep reached the run at all — otherwise a fixture that
|
||||
// never got that far reads as a passing assertion.
|
||||
aliveCalls int
|
||||
// onStop runs INSIDE the fake's Stop, which is what makes the ORDER of "write the intent, then
|
||||
// ask systemd" assertable: by the time this runs, the database must already know.
|
||||
onStop func(unit string)
|
||||
|
|
@ -63,7 +67,18 @@ func (f *fakeRunner) stops() []string {
|
|||
return append([]string(nil), f.stopped...)
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Alive(context.Context, string) (bool, error) { return f.alive, f.aliveErr }
|
||||
func (f *fakeRunner) Alive(context.Context, string) (bool, error) {
|
||||
f.mu.Lock()
|
||||
f.aliveCalls++
|
||||
f.mu.Unlock()
|
||||
return f.alive, f.aliveErr
|
||||
}
|
||||
|
||||
func (f *fakeRunner) askedAlive() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.aliveCalls > 0
|
||||
}
|
||||
|
||||
func (f *fakeRunner) starts() []runner.Spec {
|
||||
f.mu.Lock()
|
||||
|
|
@ -695,16 +710,22 @@ func (f *fixture) owed(t *testing.T) []pgstore.OwedBook {
|
|||
return owed
|
||||
}
|
||||
|
||||
// A resumed run is spawned WITHOUT the engine's signing flag.
|
||||
// A resumed run is spawned WITH the flag it was started with: this platform does not decide where
|
||||
// the engine stops.
|
||||
//
|
||||
// `--verify-bank` is what halts the engine at the bank boundary, and it halts there whenever
|
||||
// undecided terms remain. Re-passing it put the run straight back into the stop it had just been
|
||||
// released from, so a signature moved nothing. Dropping it takes the engine's auto path, which is
|
||||
// the owner's model: the run continues and undecided rows ride into the bank marked ⟨проверить⟩
|
||||
// (unified backlog row 191).
|
||||
// ⚠ RE-SIGNED by pack P12 (order D39.158, ping #21), and this is a commissioned change of contract,
|
||||
// not a test bent to a green. Its predecessor pinned the OPPOSITE — that a resumed attempt drops
|
||||
// `--verify-bank` — and it was right for the engine of its day: that engine halted wherever undecided
|
||||
// terms remained, so re-passing the flag put the run straight back into the stop it had just been
|
||||
// released from, and the platform masked it to get past. The engine now carries a presented memory
|
||||
// (storage schema v16): a stop fires only on a cluster no earlier stop has SHOWN, so the same flag
|
||||
// on a resumed attempt auto-continues at that same boundary by the engine's own predicate, and the
|
||||
// undecided rows still ride to the editor marked ⟨проверить⟩ (unified backlog row 191). Two answers
|
||||
// to one question is what the law of the seam forbids; the answer is the engine's.
|
||||
//
|
||||
// Mutation caught: passing l.VerifyBank unconditionally.
|
||||
func TestAResumedRunIsSpawnedWithoutTheSigningStop(t *testing.T) {
|
||||
// Mutation caught: masking l.VerifyBank on a resumed attempt — the removed workaround, in any of its
|
||||
// forms.
|
||||
func TestAResumedRunIsSpawnedWithTheFlagItStartedWith(t *testing.T) {
|
||||
before := runner.TranslateArgs("/srv/books/bk1", true, "", false, 0, nil)
|
||||
if !slices.Contains(before, "--verify-bank") {
|
||||
t.Fatal("a run that asked for the signing stop is spawned without it")
|
||||
|
|
@ -728,7 +749,7 @@ func TestAResumedRunIsSpawnedWithoutTheSigningStop(t *testing.T) {
|
|||
if len(after) != 2 {
|
||||
t.Fatalf("%d units started, want the resumed attempt to be the second", len(after))
|
||||
}
|
||||
if slices.Contains(after[1].Args, "--verify-bank") {
|
||||
t.Errorf("the resumed attempt carries the signing stop again: %v", after[1].Args)
|
||||
if !slices.Contains(after[1].Args, "--verify-bank") {
|
||||
t.Errorf("the resumed attempt was spawned without the flag its run asked for: %v", after[1].Args)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,6 +268,30 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro
|
|||
// account's money had already been held for it.
|
||||
return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status)
|
||||
}
|
||||
if book.ChapterCount > 0 && !book.HasTree {
|
||||
// The book DECLARES chapters and its tree has not been materialised: the intake committed
|
||||
// `not_started` and the materialisation that follows it — in a separate transaction, outside
|
||||
// the intake's — failed or has not run. Refused HERE, before the hold, because there is no
|
||||
// honest bar for such a run and no way out of it either (PD-405). Every counter the screen
|
||||
// shows is a count over `chapters`, so the run would read 0/total for its entire life while
|
||||
// spending; and the tree's own debt is FROZEN while it runs, because the sweep that pays that
|
||||
// debt skips a book with a live run. The window is the intake's normal one — measured at
|
||||
// 0.018 s on the healthy path — and unbounded exactly when the materialisation broke, which is
|
||||
// the only shape worth refusing.
|
||||
//
|
||||
// `book_not_ready` is the word for it, not a new one: the canon's own gloss is "still
|
||||
// arriving, still being cut, or was rejected", and a book that owes its tree is still being
|
||||
// cut. The remedy is to wait for the intake, which is what the status says.
|
||||
//
|
||||
// ⚠ ONE population does not clear by waiting, and the operator's handle for it exists: a book
|
||||
// whose reading-surface debt was WRITTEN OFF after its attempts (AbandonReadModelDebt) owes
|
||||
// nothing, so no sweep will build its tree and this refusal stands until somebody asks again —
|
||||
// `tmplatformctl book refresh --book <id>`, which is exactly what that command is for, and the
|
||||
// books behind it are listed by `tmplatformctl books --abandoned`. Named here because this
|
||||
// guard is what turns a book that merely showed a frozen bar into one that cannot be started:
|
||||
// the refusal is the honest half, and being able to find the remedy is the other.
|
||||
return pgstore.Run{}, fmt.Errorf("%w: its chapters are still being materialised", ErrBookNotReady)
|
||||
}
|
||||
if book.HasLiveRun {
|
||||
return pgstore.Run{}, pgstore.ErrRunInFlight
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,18 +156,16 @@ func (s *Service) spec(l pgstore.LiveRun, bookCap money.MicroUSD) (runner.Spec,
|
|||
// The path this ATTEMPT is pinned to, which for a resume is the one the run started with
|
||||
// (row 139). Falling back to the configured path is for the first attempt, which has none yet.
|
||||
Binary: s.engineBinary(l),
|
||||
// ⚠ `--verify-bank` is NOT passed again once the stop has been lifted. The reason is the
|
||||
// owner's model, not the engine's old re-halt behaviour: the stop exists to present what is
|
||||
// NEW, and after a lift this run has nothing new to present — the presentation memory (v16)
|
||||
// covers the map's clusters, so even a re-passed flag would auto-continue at the same
|
||||
// boundary. (An earlier edition of this comment quoted the D39.144-era contract — «the stop
|
||||
// clears once every proposed term is promoted or rejected» — which the memory made false,
|
||||
// and the reconciler's LiftBankStop guard built on that reading is what the P9 workflow
|
||||
// review found leaking; the basis is corrected here rather than inherited again.) Dropping
|
||||
// the flag takes the
|
||||
// engine's auto path (pipeline/mining.go, D39.42 п.3): the run continues and undecided
|
||||
// rows ride into the bank marked ⟨проверить⟩ (unified backlog row 191).
|
||||
Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, s.Cfg.KeysFile,
|
||||
// ⚠ `--verify-bank` rides EVERY attempt of a run that asked for it, resumes included, and the
|
||||
// platform no longer decides where the engine stops. That decision moved into the engine with
|
||||
// its presented memory (storage schema v16, D39.158): a stop fires only on a cluster no
|
||||
// earlier stop has shown, so a resumed attempt carrying the flag auto-continues at the same
|
||||
// boundary by the engine's own predicate and its undecided rows ride to the editor marked
|
||||
// ⟨проверить⟩ (backend/internal/pipeline/mining.go, D39.42 п.3). Masking the flag here was
|
||||
// this platform's stand-in for that predicate while the engine still re-halted on everything
|
||||
// undecided; keeping the stand-in after the engine grew the memory would mean two answers to
|
||||
// one question, and the law of the seam gives it to the engine.
|
||||
Args: runner.TranslateArgs(l.Workdir, l.VerifyBank, s.Cfg.KeysFile,
|
||||
l.Resnapshot, l.AcceptRebill, ceiling),
|
||||
Workdir: l.Workdir,
|
||||
Env: engineEnv(engineStreamID(l.RunID, l.AttemptNo)),
|
||||
|
|
|
|||
|
|
@ -109,6 +109,101 @@ func TestARunWhoseSettlementIsStuckReachesTheOperatorsSurfaces(t *testing.T) {
|
|||
_ = clock
|
||||
}
|
||||
|
||||
// THE LIVE HALF of the same disease, which pack P11 did not reach (register row PD-424): a run that
|
||||
// is still LIVE and whose settlement is permanently blocked.
|
||||
//
|
||||
// The chain, reproduced rather than written by hand: the unit vanishes without a marker → the sweep
|
||||
// calls `restart` → `settle` cannot compute the money and answers `settlementBlocked` WITHOUT an
|
||||
// error → the old code discarded that verdict and walked into `reopen`, which refuses to open an
|
||||
// attempt over an unclosed hold and answers `deferred` → `case deferred: return nil` → the pass reads
|
||||
// as a SUCCESS and even clears the deferral. Every surface stayed clean over a frozen hold that the
|
||||
// engine was poked about every fifteen seconds, forever, and the only way out was the user pressing
|
||||
// Stop — which nobody told them.
|
||||
//
|
||||
// The design question the row said had to be answered first is answered by this test's expectations:
|
||||
// a settlement that cannot be COMPUTED is a failure of the phase that owns it, it is COUNTED, and the
|
||||
// count carries the run to the operator's surfaces like any other stall.
|
||||
//
|
||||
// Mutations caught: discarding settle's verdict in `restart`; clearing the deferral on a pass that
|
||||
// could not restart; deferring a blocked settlement on the LIVE phase's half-hour backoff instead of
|
||||
// the settlement cap, which would put the user's own resume behind it.
|
||||
func TestALiveRunWhoseSettlementIsBlockedIsCountedAndReachesTheOperator(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The unit is gone and left NO marker — a reboot, or a kill from outside — so the run is still
|
||||
// live and the reconciler's remedy is a restart.
|
||||
f.runner.alive = false
|
||||
// …and the engine cannot give a committed figure, so the money of the attempt being closed cannot
|
||||
// be computed. Absent is not zero (PD-40).
|
||||
f.engine.set(ingest.StatusReport{TotalUnits: 10, Done: 10}, nil)
|
||||
|
||||
clock := f.now
|
||||
for i := 0; i <= StalledAfter; i++ {
|
||||
clock = f.now.Add(time.Duration(i+1) * time.Hour)
|
||||
f.svc.Now = func() time.Time { return clock }
|
||||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a := f.lastAttempt(t, run.ID)
|
||||
if a.ReconcileFailures < StalledAfter {
|
||||
t.Fatalf("after %d passes over a blocked settlement the count is %d, want at least %d: the"+
|
||||
" passes are being read as successful, so the hold stays frozen and no surface ever says so",
|
||||
StalledAfter+1, a.ReconcileFailures, StalledAfter)
|
||||
}
|
||||
// The run is STILL LIVE — this is the population P11 did not reach — and it is now visible.
|
||||
live := f.live(t)
|
||||
if live.RunID != run.ID {
|
||||
t.Fatalf("the run is no longer live: %+v", live)
|
||||
}
|
||||
stalled, err := f.store.StalledRuns(f.ctx, StalledAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stalled) != 1 || stalled[0].RunID != run.ID {
|
||||
t.Fatalf("`runs --stalled` shows %+v, want the live run whose settlement is blocked", stalled)
|
||||
}
|
||||
if stalled[0].HeldMicroUSD == 0 {
|
||||
t.Error("the row shows no hold, and the frozen hold is the whole reason it is worth printing")
|
||||
}
|
||||
// It is the LIVE half, and the row says so. That matters for the remedy an operator reaches for:
|
||||
// a `settling` row's engine is already gone, this one's run is still the live row of its book —
|
||||
// and it also says PD-418's state is not what this fix makes reachable (that one needs a
|
||||
// PREVIOUS attempt that ended unsettled, which `reopen` still refuses to create).
|
||||
if stalled[0].Settling {
|
||||
t.Error("the row calls itself a settling one: this is a LIVE run whose restart is blocked, and" +
|
||||
" an operator sent to the settling remedy would be looking for an engine that is still there")
|
||||
}
|
||||
obs, err := f.store.Observe(f.ctx, StalledAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if obs.StalledRuns != 1 {
|
||||
t.Errorf("tm_platform_runs_stalled reads %d, want 1: nothing pages an operator over a live"+
|
||||
" run that cannot be restarted", obs.StalledRuns)
|
||||
}
|
||||
// The deferral is on the SETTLEMENT's schedule. The live phase doubles to half an hour, and the
|
||||
// open reservation is the user's own resume gate — so a blockage they can do nothing about must
|
||||
// not also hold their resume for thirty minutes.
|
||||
var after *time.Time
|
||||
if err := f.store.Pool().QueryRow(f.ctx,
|
||||
`select reconcile_after from run_attempts where id = $1`, a.AttemptID).Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after == nil {
|
||||
t.Fatal("the run was not deferred at all: it is retried on every pass forever")
|
||||
}
|
||||
if wait := after.Sub(clock); wait > settlementBackoffCap {
|
||||
t.Errorf("the next look at this run is in %s, past the settlement cap of %s", wait, settlementBackoffCap)
|
||||
}
|
||||
}
|
||||
|
||||
// A settlement that CLOSED is not in the plain operator table, which is the open-reservation half of
|
||||
// the settling predicate.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -101,8 +101,22 @@ func newFixture(t *testing.T, balance string, chapters int) *fixture {
|
|||
t.Fatal(err)
|
||||
}
|
||||
workdir := t.TempDir()
|
||||
if _, err := store.AddBook(ctx, pgstore.NewBook{OwnerID: "u1", Title: "蛊真人", SourceLang: "zh",
|
||||
TargetLang: "ru", ChapterCount: chapters, Workdir: workdir, Now: now}); err != nil {
|
||||
book, err := store.AddBook(ctx, pgstore.NewBook{OwnerID: "u1", Title: "蛊真人", SourceLang: "zh",
|
||||
TargetLang: "ru", ChapterCount: chapters, Workdir: workdir, Now: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ⚠ The book gets its chapter TREE, and the fixture was extended rather than the guard relaxed
|
||||
// (PD-405). Before this it declared N chapters and materialised none — the exact state a run may
|
||||
// no longer be started over, because every counter the screen shows is a count over `chapters`
|
||||
// and a run admitted there reads 0/total for its whole life while spending. The whole runs battery
|
||||
// was riding that state, which is why the guard's arrival is what surfaced it.
|
||||
//
|
||||
// The rows are EMPTY (nothing drafted, nothing edited), which is what the fixture already meant:
|
||||
// with no rows at all every derived counter was zero too, so no assertion here changes.
|
||||
if _, err := store.Pool().Exec(ctx, `
|
||||
insert into chapters (id, book_id, number, units_total)
|
||||
select 'c' || g, $1, g, 1 from generate_series(1, $2) g`, book, chapters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
model, err := pricing.New(pricing.DefaultPerChapter)
|
||||
|
|
@ -376,7 +390,7 @@ func TestTheSweepMaterializesWhateverTheJournalHasGained(t *testing.T) {
|
|||
f.runner.alive = true
|
||||
journal := filepath.Join(f.workdir, ingest.JournalFile)
|
||||
body := hello(t, f) +
|
||||
`{"seq":2,"type":"progress","data":{"draft":{"done":7,"total":20},"edit":{"done":1,"total":20}}}` + "\n"
|
||||
`{"seq":2,"type":"progress","data":{"draft":{"done":7,"total":20},"edit":{"done":1,"total":20},"eta_seconds":42}}` + "\n"
|
||||
if err := os.WriteFile(journal, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -390,9 +404,15 @@ func TestTheSweepMaterializesWhateverTheJournalHasGained(t *testing.T) {
|
|||
t.Fatalf("the progress line materialized no frame")
|
||||
}
|
||||
// ⚠ And the NUMBERS behind that frame. Counting frames only says one was emitted; the projection
|
||||
// folding the journal wrong emits exactly the same frame.
|
||||
if draft, edit := f.waveCounts(t, run.ID); draft != 7 || edit != 1 {
|
||||
t.Fatalf("the journal folded to draft=%d edit=%d, want 7 and 1", draft, edit)
|
||||
// folding the journal wrong emits exactly the same frame. What a progress line materialises is the
|
||||
// ETA it carried and the SHAPE it announced — the per-wave counters it also used to write had no
|
||||
// reader and went with PD-411 — so those are what is asserted: read the wrong field of the line
|
||||
// and both come out wrong.
|
||||
if eta := f.runETA(t, run.ID); eta == nil || *eta != 42 {
|
||||
t.Fatalf("the journal's eta folded to %v, want 42", eta)
|
||||
}
|
||||
if editor, _ := f.waveShape(t, f.bookID(t)); editor == nil || !*editor {
|
||||
t.Fatalf("the line announced an edit wave of 20 and the book's shape folded to %v", editor)
|
||||
}
|
||||
live := f.live(t)
|
||||
if live.EngineRunID != engineStreamID(run.ID, 1) || live.Position.LastSeq != 2 || live.Position.Offset != int64(len(body)) {
|
||||
|
|
@ -406,8 +426,8 @@ func TestTheSweepMaterializesWhateverTheJournalHasGained(t *testing.T) {
|
|||
if n := framesOfKind(t, f, pgstore.FrameProgress); n != 1 {
|
||||
t.Errorf("a second sweep re-materialized the line: %d progress frames", n)
|
||||
}
|
||||
if draft, _ := f.waveCounts(t, run.ID); draft != 7 {
|
||||
t.Errorf("a second sweep changed the projection: draft=%d, want 7", draft)
|
||||
if _, epoch := f.waveShape(t, f.bookID(t)); epoch != 0 {
|
||||
t.Errorf("a second sweep crossed a shape boundary that was never crossed: epoch=%d, want 0", epoch)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue