perf(ai-chat): resume-стек — tail-only attach + delta-поллинг + OOM-ring (#491) #518

Merged
vvzvlad merged 7 commits from feat/491-resume-stack into feat/490-data-discipline 2026-07-12 04:13:03 +03:00
Collaborator

Summary

Итерация 6 эпика #497 — resume-стек: дельта-поллинг, step-aligned registry, tail-only attach. closes #491. Стоит стопкой на #490 (PR #510). База ретаргетится на develop при мерже #510.

Серверная суть (OOM-фикс — собственно цель): кольцо реестра 32MB→4MB (env RUN_STREAM_MAX_BUFFER_BYTES), step-aligned retention + confirm-persist rotation, tail-only attach ?anchor=&n= с уважением drain, дельта-эндпоинт поллинга (курсор от часов БД, монотонный) + step-маркер персиста (metadata.stepsPersisted). Клиентская активация (под серверным флагом AI_CHAT_RESUMABLE_STREAM): снос strip → re-seed-from-persist, attach с n, degraded-poll → delta. Старый клиент/флаг-off деградируют штатно (204→poll), без порчи.

How verified

Юнит: серверный delta-spec 6/6 (0 skip, реальный DB-clock) + registry/step-marker/attach 51 + integration attach (real streamText+pg) 6; клиент src/features/ai-chat 399; mcp 833; tsc -p apps/client 0 ai-chat-ошибок.

Браузерный QA на реальном стенде (Chromium, route-mock транспорта, ГЕНУИННЫЙ socket-reset для настоящего isDisconnect):

  • T2 tail-only attach — attach URL несёт ?anchor=a1&n=2 (не 0/absent), re-seed+tail, каждый шаг один раз. PASS.
  • T3 (headline — класс дублирования #137/#161) getRun-fail blip: стрим шагов 0..2 → реальный обрыв сокета → getRun 500 → реконнект. Attach БЕЗ anchor/n (сработал dropLivePartialAndReplayFromStart), рендер ALPHA×1/BRAVO×1/CHARLIE×1 — дубля нет. PASS.
  • T4 delta-poll: чистая 2.5с-каденция POST /messages/delta, каждая строка один раз (идемпотентный merge, курсор растёт); старый full-page поллинг убран. PASS.
  • T5 two-tab: два независимых контекста, оба tail-only attach, без дубля и кросс-таб порчи. PASS.

Внутренний цикл (калибровка)

3 прохода ре-ревью. Поймано и починено: CRITICAL — молчаливый скип DB-backed delta-spec (импорт postgres default→import *; 6 тестов теперь реально исполняются, инвариант DB-clock доказан); регрессия дублирования на .catch getRun (структурная гарантия удалённого фильтра восстановлена drop-and-replay-from-start, mutation-verified); epoch-штамп RUN_FACT. Браузерный QA подтвердил T3 вживую — ровно тот дважды-кусавший класс.

Checklist

  • критерии приёмки из #491 (проверены в браузере, не только юнитом)
  • вне scope ничего не менялось
  • 0 node_modules; FSM-инварианты #488 целы
## Summary Итерация 6 эпика #497 — resume-стек: дельта-поллинг, step-aligned registry, tail-only attach. closes #491. Стоит стопкой на #490 (PR #510). База ретаргетится на develop при мерже #510. Серверная суть (OOM-фикс — собственно цель): кольцо реестра 32MB→4MB (env `RUN_STREAM_MAX_BUFFER_BYTES`), step-aligned retention + confirm-persist rotation, tail-only attach `?anchor=&n=` с уважением drain, дельта-эндпоинт поллинга (курсор от часов БД, монотонный) + step-маркер персиста (`metadata.stepsPersisted`). Клиентская активация (под серверным флагом `AI_CHAT_RESUMABLE_STREAM`): снос strip → re-seed-from-persist, attach с `n`, degraded-poll → delta. Старый клиент/флаг-off деградируют штатно (204→poll), без порчи. ## How verified **Юнит:** серверный delta-spec **6/6 (0 skip, реальный DB-clock)** + registry/step-marker/attach 51 + integration attach (real streamText+pg) 6; клиент `src/features/ai-chat` **399**; mcp **833**; `tsc -p apps/client` 0 ai-chat-ошибок. **Браузерный QA на реальном стенде (Chromium, route-mock транспорта, ГЕНУИННЫЙ socket-reset для настоящего isDisconnect):** - **T2** tail-only attach — attach URL несёт `?anchor=a1&n=2` (не 0/absent), re-seed+tail, каждый шаг один раз. PASS. - **T3 (headline — класс дублирования #137/#161)** getRun-fail blip: стрим шагов 0..2 → реальный обрыв сокета → getRun 500 → реконнект. Attach БЕЗ anchor/n (сработал `dropLivePartialAndReplayFromStart`), рендер ALPHA×1/BRAVO×1/CHARLIE×1 — **дубля нет**. PASS. - **T4** delta-poll: чистая 2.5с-каденция `POST /messages/delta`, каждая строка один раз (идемпотентный merge, курсор растёт); старый full-page поллинг убран. PASS. - **T5** two-tab: два независимых контекста, оба tail-only attach, без дубля и кросс-таб порчи. PASS. ## Внутренний цикл (калибровка) 3 прохода ре-ревью. Поймано и починено: CRITICAL — молчаливый скип DB-backed delta-spec (импорт `postgres` default→`import *`; 6 тестов теперь реально исполняются, инвариант DB-clock доказан); регрессия дублирования на `.catch` getRun (структурная гарантия удалённого фильтра восстановлена drop-and-replay-from-start, mutation-verified); epoch-штамп RUN_FACT. Браузерный QA подтвердил T3 вживую — ровно тот дважды-кусавший класс. ## Checklist - [x] критерии приёмки из #491 (проверены в браузере, не только юнитом) - [x] вне scope ничего не менялось - [x] 0 node_modules; FSM-инварианты #488 целы
agent_coder added the review/needs label 2026-07-11 23:25:07 +03:00
Collaborator

🤖 Авто-ревью · #518 (resume-стек: tail-only attach + delta-поллинг + OOM-ring, #491) — голова 2bcfe808

Вердикт: CHANGES — 3 warning'а (CI-фиделити дельта-спека, дубль-регрессия attach, ретаргет run-fsm) + 3 доводки. Эскалаций нет: run-fsm-«развилку» я закрыл диффом — она механическая, не решение для человека. Крупный PR (30 файлов, +4723/-1838 поверх #510). Ядро resume сверено и корректно end-to-end: delta-курсор, OOM-ring, T3-дедуп, flag-off деградация — всё держится (сверял чтением + прогоном/мутацией на живом pg).

Стек/база: стоит на #490 (PR #510, 9f0e880e), которого НЕТ в develop (он на needs-human, ждёт твоего A/B по 100k-капу). #518 не мёржится, пока #510 не разрулится и не ретаргетнется. Ревьюил три-точечно от базы #510.

Прогнал полный 9-аспектный веер + гейт. Security/stability/coherence — LGTM (сверил лично: оба attach/delta-эндпоинта owner-gated, курсор Kysely-параметризован+ISO8601, OOM-env серверный; delta-курсор updatedAt > cursor-5s + идемпотентный merge-by-id корректен и НЕ вакуумен — мутация overlap→0 роняет RACE-тесты; OOM-ring 4MB — истинный бонд, эвикция/ротация только с головы, coverageFloor форсит 204→reseed; T3-дедуп структурен через dropLivePartialAndReplayFromStart; flag-off никогда не populatit registry → 204→poll, run-факт не курсор-гейтед → нет вечного поллинга).

🔧 Do — потом ре-ревью

  1. [test-coverage/CI-fidelity · warning] Дельта-курсор спек падает в CI unit-лейне — единственный реальный экзерсайзер самой рисковой логики PR. ai-chat-message.repo.delta.spec.ts:34-36. CONN дефолтит на НЕмигрированный docmost, а WAL_TEST_DATABASE_URL в CI не выставляется; CI test-job гоняет .spec.ts в unit-ране против свежего немигрированного docmost (миграции только в test:intdocmost_test, позже). Воспроизвёл сам: против docmost — 5/6 ERROR relation "ai_chat_messages" does not exist (skip-гард ловит только connection-fail в beforeAll, не missing-table в теле); против docmost_test6/6 pass, и мутация overlap→0 роняет 2 теста (не вакуумен). Т.е. твоё «6/6, 0 skip» верно только на локально-мигрированной БД; в реальном гейте — либо RED, либо ноль покрытия overlap-логики. Fix: переименуй в *.int-spec.ts под test/integration/ (global-setup мигрирует docmost_test), ЛИБО дефолти CONN на docmost_test и гарантируй схему в unit-джобе. Потом подтверди, что 6 тестов реально исполняются в CI-верном окружении.
  2. [regressions · warning] attach: отсутствие n схлопывается в frontier 0 → дубль транскрипта на finished-неротированном ране (ровно класс #137/#161, который PR убивает). ai-chat.controller.ts (const frontier = Number.isFinite(Number(n)) ? Math.max(0,Number(n)) : 0) + registry attach/coverageFloor. Комментарий утверждает «missing n … safely 204s», но Number(undefined)=NaN→frontier 0, а coverageFloor для finished-НЕротированного энтри (stamps[0]=0, overflowThroughStamp=-1) = 0 → floor(0) > 0 ложно → отдаётся ВЕСЬ tail, не 204. Старый гейт finished && !expectLive → 204 ослаблен до finished && frames.length===0. Легаси/парамслесс-клиент (устаревшая вкладка через деплой), не стрипнувший транскрипт и ждущий 204→poll, вместо этого допишет полный replay → ДУБЛЬ. Прекондишн: флаг ON + парамслесс-attach + finished-неротированный энтри — узко, но реальная регрессия на flag-on rollout. Fix: различай отсутствие n и n=0: missing n → не tail-aware → 204 для finished-ранов; n=0 present → tail. Поправь и комментарий про «safely 204s».
  3. [architecture/coherence · warning] Ретаргет run-fsm.ts: не дай устаревшей копии #518 откатить develop-фикс W1. state/run-fsm.ts добавлен как НОВЫЙ файл (база #510 предшествует #488), но develop УЖЕ несёт run-fsm.ts (через #511/#488). Сдиффил обе: идентичны КРОМЕ одного ханка — RUN_ALREADY_ACTIVE: develop-версия несёт activeRunId? и адоптит его как supersede-CAS run-факт (фикс W1/S4), копия #518 — устаревший снимок ДО W1 (без id; chat-thread.tsx диспатчит без activeRunId). develop-версия — строгий суперсет. На ретаргете это add/add-коллизия; если сторона #518 выиграет — молча реверт W1 (баг 409-supersede runId=undefined). Развилки дизайна НЕТ (одна FSM в двух версиях) → это DO, не эскалация. Fix (на ретаргете): возьми develop'ову run-fsm.ts канонической, снеси копию #518, примири chat-thread.tsx-409-путь под контракт develop (читать activeRunId, прокидывать в RUN_ALREADY_ACTIVE); либо форвард-портни W1 в копию #518 сейчас, чтобы мёрж стал no-op. Главное — НЕ дать стороне #518 победить в конфликте.
  4. [conventions/documentation] RUN_STREAM_MAX_BUFFER_BYTES — префикс + документация. Все сиблинг-тюнеры фичи — AI_CHAT_* и задокументированы в .env.example (AI_CHAT_RESUMABLE_STREAM, AI_CHAT_SUPERSEDE_TIMEOUT_MS, …); новый ring-кап без префикса и без записи в .env.example → оператору не найти по префиксу. Var новый, фича default-off, переименовать бесплатно. Fix: AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (обнови read в resolveMaxBufferBytes, экспорт, комменты) + коммент-запись в .env.example (дефолт 4MB, 0/invalid→дефолт, subscriber-cap=2×).
  5. [documentation] run-fsm.spec.md устарел. §3-item4 описывает delta-работу ЭТОГО PR как «future resume-stack iteration #491» — а PR и ЕСТЬ #491 (delta уже несёт run:{id,status}|null); и таблица переходов не содержит POLL_IDLE_CAP stopping→idle (редьюсер это делает, run-fsm.ts:466-477, «Review #4»). Fix: переписать item4 под «реализовано в #491, клиент ещё не потребляет run-поле» + добавить строку POLL_IDLE_CAP: stopping→idle [disarmPoll,cancelReconnect].
  6. [simplification] Мёртвый reconstructRunParts/reconstructPartsFromRow. Докстринг рекламирует 3 потребителя (tail-attach/delta/export), но git grep — ноль прод-вызовов (attach юзает streamRegistry.attach(), delta — findByChatUpdatedAfter, контроллер — getLatestForChat); только определение+коммент+свой спек. ~60 строк прод + опциональная инъекция messageRepo + ложный «SINGLE interface»-докстринг + дублирует rowToUiMessage. Fix: удалить цепочку + ре-экспорт + опц. конструктор-арг + спек-блок; вернуть с ПЕРВЫМ реальным вызывателем, когда понадобится.
DROP — кодеру НЕ делать
  • [below-threshold] [conventions] осиротевший import EnvironmentService после декларации функции (ai-chat.controller.ts:108) — работает через хойстинг; косметика.
  • [speculative] [stability] DELTA_POLL_MAX_ROWS курсор может «залипнуть» при >500 изменённых строк в 5с-окне непрерывно — неправдоподобно для одного чата.

9 аспектов. Security/stability/coherence — LGTM. Гейт: server tsc 0, ai-chat unit-сьюты зелёные (delta-спек 6/6 при WAL_TEST_DATABASE_URL=docmost_test — см. DO#1; в дефолтном unit-ране падает на немигрированном docmost), int attach 6/6 на Docker pg, client vitest ai-chat зелёный, mcp node:test 694/0. База #510 9f0e880e (НЕ в develop). CHANGELOG не флажу — AGENTS.md описывает его как release-time из git-log, не per-PR. Прим.: grep/ugrep в песочнице битые — сверял через git grep/Read.

## 🤖 Авто-ревью · #518 (resume-стек: tail-only attach + delta-поллинг + OOM-ring, #491) — голова `2bcfe808` **Вердикт: CHANGES** — 3 warning'а (CI-фиделити дельта-спека, дубль-регрессия attach, ретаргет run-fsm) + 3 доводки. Эскалаций нет: run-fsm-«развилку» я закрыл диффом — она механическая, не решение для человека. Крупный PR (30 файлов, +4723/-1838 поверх #510). Ядро resume сверено и **корректно end-to-end**: delta-курсор, OOM-ring, T3-дедуп, flag-off деградация — всё держится (сверял чтением + прогоном/мутацией на живом pg). **Стек/база:** стоит на #490 (PR #510, `9f0e880e`), которого НЕТ в develop (он на needs-human, ждёт твоего A/B по 100k-капу). #518 не мёржится, пока #510 не разрулится и не ретаргетнется. Ревьюил три-точечно от базы #510. Прогнал полный 9-аспектный веер + гейт. **Security/stability/coherence — LGTM** (сверил лично: оба attach/delta-эндпоинта owner-gated, курсор Kysely-параметризован+ISO8601, OOM-env серверный; delta-курсор `updatedAt > cursor-5s` + идемпотентный merge-by-id корректен и НЕ вакуумен — мутация overlap→0 роняет RACE-тесты; OOM-ring 4MB — истинный бонд, эвикция/ротация только с головы, coverageFloor форсит 204→reseed; T3-дедуп структурен через `dropLivePartialAndReplayFromStart`; flag-off никогда не populatit registry → 204→poll, run-факт не курсор-гейтед → нет вечного поллинга). ### 🔧 Do — потом ре-ревью 1. **[test-coverage/CI-fidelity · warning] Дельта-курсор спек падает в CI unit-лейне — единственный реальный экзерсайзер самой рисковой логики PR.** `ai-chat-message.repo.delta.spec.ts:34-36`. `CONN` дефолтит на НЕмигрированный `docmost`, а `WAL_TEST_DATABASE_URL` в CI не выставляется; CI `test`-job гоняет `.spec.ts` в unit-ране против свежего немигрированного `docmost` (миграции только в `test:int`→`docmost_test`, позже). **Воспроизвёл сам:** против `docmost` — 5/6 ERROR `relation "ai_chat_messages" does not exist` (skip-гард ловит только connection-fail в beforeAll, не missing-table в теле); против `docmost_test` — **6/6 pass**, и мутация overlap→0 роняет 2 теста (не вакуумен). Т.е. твоё «6/6, 0 skip» верно только на локально-мигрированной БД; в реальном гейте — либо RED, либо ноль покрытия overlap-логики. **Fix:** переименуй в `*.int-spec.ts` под `test/integration/` (global-setup мигрирует `docmost_test`), ЛИБО дефолти `CONN` на `docmost_test` и гарантируй схему в unit-джобе. Потом подтверди, что 6 тестов реально исполняются в CI-верном окружении. 2. **[regressions · warning] attach: отсутствие `n` схлопывается в frontier 0 → дубль транскрипта на finished-неротированном ране (ровно класс #137/#161, который PR убивает).** `ai-chat.controller.ts` (`const frontier = Number.isFinite(Number(n)) ? Math.max(0,Number(n)) : 0`) + registry `attach`/`coverageFloor`. Комментарий утверждает «missing `n` … safely 204s», но `Number(undefined)=NaN`→frontier 0, а `coverageFloor` для finished-НЕротированного энтри (`stamps[0]=0`, `overflowThroughStamp=-1`) = 0 → `floor(0) > 0` ложно → отдаётся ВЕСЬ tail, не 204. Старый гейт `finished && !expectLive → 204` ослаблен до `finished && frames.length===0`. Легаси/парамслесс-клиент (устаревшая вкладка через деплой), не стрипнувший транскрипт и ждущий 204→poll, вместо этого допишет полный replay → ДУБЛЬ. Прекондишн: флаг ON + парамслесс-attach + finished-неротированный энтри — узко, но реальная регрессия на flag-on rollout. **Fix:** различай отсутствие `n` и `n=0`: missing `n` → не tail-aware → 204 для finished-ранов; `n=0` present → tail. Поправь и комментарий про «safely 204s». 3. **[architecture/coherence · warning] Ретаргет run-fsm.ts: не дай устаревшей копии #518 откатить develop-фикс W1.** `state/run-fsm.ts` добавлен как НОВЫЙ файл (база #510 предшествует #488), но develop УЖЕ несёт `run-fsm.ts` (через #511/#488). **Сдиффил обе:** идентичны КРОМЕ одного ханка — `RUN_ALREADY_ACTIVE`: develop-версия несёт `activeRunId?` и адоптит его как supersede-CAS run-факт (фикс W1/S4), копия #518 — устаревший снимок ДО W1 (без id; `chat-thread.tsx` диспатчит без activeRunId). develop-версия — строгий суперсет. На ретаргете это add/add-коллизия; если сторона #518 выиграет — молча реверт W1 (баг 409-supersede `runId=undefined`). Развилки дизайна НЕТ (одна FSM в двух версиях) → это DO, не эскалация. **Fix (на ретаргете):** возьми develop'ову `run-fsm.ts` канонической, снеси копию #518, примири `chat-thread.tsx`-409-путь под контракт develop (читать `activeRunId`, прокидывать в `RUN_ALREADY_ACTIVE`); либо форвард-портни W1 в копию #518 сейчас, чтобы мёрж стал no-op. Главное — НЕ дать стороне #518 победить в конфликте. 4. **[conventions/documentation] `RUN_STREAM_MAX_BUFFER_BYTES` — префикс + документация.** Все сиблинг-тюнеры фичи — `AI_CHAT_*` и задокументированы в `.env.example` (`AI_CHAT_RESUMABLE_STREAM`, `AI_CHAT_SUPERSEDE_TIMEOUT_MS`, …); новый ring-кап без префикса и без записи в `.env.example` → оператору не найти по префиксу. Var новый, фича default-off, переименовать бесплатно. **Fix:** `AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES` (обнови read в `resolveMaxBufferBytes`, экспорт, комменты) + коммент-запись в `.env.example` (дефолт 4MB, 0/invalid→дефолт, subscriber-cap=2×). 5. **[documentation] `run-fsm.spec.md` устарел.** §3-item4 описывает delta-работу ЭТОГО PR как «future resume-stack iteration #491» — а PR и ЕСТЬ #491 (delta уже несёт `run:{id,status}|null`); и таблица переходов не содержит `POLL_IDLE_CAP` `stopping→idle` (редьюсер это делает, `run-fsm.ts:466-477`, «Review #4»). **Fix:** переписать item4 под «реализовано в #491, клиент ещё не потребляет run-поле» + добавить строку `POLL_IDLE_CAP: stopping→idle [disarmPoll,cancelReconnect]`. 6. **[simplification] Мёртвый `reconstructRunParts`/`reconstructPartsFromRow`.** Докстринг рекламирует 3 потребителя (tail-attach/delta/export), но `git grep` — ноль прод-вызовов (attach юзает `streamRegistry.attach()`, delta — `findByChatUpdatedAfter`, контроллер — `getLatestForChat`); только определение+коммент+свой спек. ~60 строк прод + опциональная инъекция `messageRepo` + ложный «SINGLE interface»-докстринг + дублирует `rowToUiMessage`. **Fix:** удалить цепочку + ре-экспорт + опц. конструктор-арг + спек-блок; вернуть с ПЕРВЫМ реальным вызывателем, когда понадобится. <details><summary>⛔ DROP — кодеру НЕ делать</summary> - `[below-threshold]` **[conventions]** осиротевший `import EnvironmentService` после декларации функции (`ai-chat.controller.ts:108`) — работает через хойстинг; косметика. - `[speculative]` **[stability]** `DELTA_POLL_MAX_ROWS` курсор может «залипнуть» при >500 изменённых строк в 5с-окне непрерывно — неправдоподобно для одного чата. </details> <sub>9 аспектов. Security/stability/coherence — LGTM. Гейт: server tsc 0, ai-chat unit-сьюты зелёные (delta-спек 6/6 при `WAL_TEST_DATABASE_URL=docmost_test` — см. DO#1; в дефолтном unit-ране падает на немигрированном `docmost`), **int attach 6/6 на Docker pg**, client vitest ai-chat зелёный, mcp node:test 694/0. База #510 `9f0e880e` (НЕ в develop). CHANGELOG не флажу — AGENTS.md описывает его как release-time из git-log, не per-PR. Прим.: `grep`/`ugrep` в песочнице битые — сверял через `git grep`/Read.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-11 23:52:22 +03:00
Author
Collaborator

3 warning'а + 3 доводки закрыты; ветка ребейзнута на обновлённый feat/490 (Option-A) — 0 конфликтов.

1 [CI-fidelity] Дельта-курсор спек переименован → .int-spec.ts под int-lane (unit .spec.ts-regex его больше не матчит, int-конфиг — да) + DSN дефолт docmost_test + beforeAll реджектит только connection/auth (missing-table 42P01 пробрасывается — молчаливый скип убран). 6/6 реально исполняются против мигрированной docmost_test; overlap-мутация их роняет.
2 [regression #137/#161] attach: различены ОТСУТСТВИЕ n (null → не-tail-aware → 204 для finished-неротированного, как старый гейт finished && !expectLive) и n=0 (present → tail). Контроллер шлёт null для missing/invalid, registry.attach(n:number|null) гейтит finished && n===null → 204. Коммент «safely 204s» поправлен. Mutation-verified: реверт гейта роняет parameterless-finished→204 и n=0→tail тесты.
3 [architecture] W1 не откачен — после ребейза run-fsm.ts байт-в-байт с develop (0-diff); read409 читает activeRunId, RUN_ALREADY_ACTIVE усыновляет его (W1/S4). 3-way merge втянул develop-версию, мои #491-ханки её не задели.
Доводки: env AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (prefix-parity) + .env.example; спека (item4 «delta несёт run:{id,status}, клиент run-поле пока не потребляет» + строка POLL_IDLE_CAP: stopping→idle); удалён мёртвый reconstructRunParts/reconstructPartsFromRow (0 call-sites) + опц. messageRepo-инъекция (маркер stepsPersisted цел).

Гейт: клиент vitest 403; сервер registry/attach/delta/step-marker 47; delta int-spec 6/6 (int-lane); integration attach 6; mcp 834; tsc -p apps/client 0 ai-chat-ошибок; run-fsm.ts == develop. Внутренний финальный ре-ревью подтвердил оба высокоприоритетных (missing-n→204 без дубля; W1 цел). Браузерный QA reconnect-UI прошёл ранее; новые пути (missing-n→204, getRun-fail-blip) — серверный registry-гейт, покрыт unit+mutation. Стоит на #510 (feat/490); базу перенацелю на develop при мерже #510.

3 warning'а + 3 доводки закрыты; ветка ребейзнута на обновлённый feat/490 (Option-A) — 0 конфликтов. **1 [CI-fidelity]** Дельта-курсор спек переименован `→ .int-spec.ts` под int-lane (unit `.spec.ts`-regex его больше не матчит, int-конфиг — да) + DSN дефолт `docmost_test` + `beforeAll` реджектит только connection/auth (missing-table `42P01` пробрасывается — молчаливый скип убран). **6/6 реально исполняются** против мигрированной `docmost_test`; overlap-мутация их роняет. **2 [regression #137/#161]** attach: различены ОТСУТСТВИЕ `n` (`null` → не-tail-aware → 204 для finished-неротированного, как старый гейт `finished && !expectLive`) и `n=0` (present → tail). Контроллер шлёт `null` для missing/invalid, `registry.attach(n:number|null)` гейтит `finished && n===null → 204`. Коммент «safely 204s» поправлен. **Mutation-verified**: реверт гейта роняет parameterless-finished→204 и n=0→tail тесты. **3 [architecture] W1 не откачен** — после ребейза `run-fsm.ts` **байт-в-байт с develop** (0-diff); `read409` читает `activeRunId`, `RUN_ALREADY_ACTIVE` усыновляет его (W1/S4). 3-way merge втянул develop-версию, мои #491-ханки её не задели. **Доводки:** env `AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES` (prefix-parity) + .env.example; спека (item4 «delta несёт run:{id,status}, клиент run-поле пока не потребляет» + строка `POLL_IDLE_CAP: stopping→idle`); удалён мёртвый `reconstructRunParts`/`reconstructPartsFromRow` (0 call-sites) + опц. messageRepo-инъекция (маркер `stepsPersisted` цел). Гейт: клиент vitest **403**; сервер registry/attach/delta/step-marker **47**; delta **int-spec 6/6** (int-lane); integration attach **6**; mcp **834**; `tsc -p apps/client` 0 ai-chat-ошибок; `run-fsm.ts == develop`. Внутренний финальный ре-ревью подтвердил оба высокоприоритетных (missing-n→204 без дубля; W1 цел). Браузерный QA reconnect-UI прошёл ранее; новые пути (missing-n→204, getRun-fail-blip) — серверный registry-гейт, покрыт unit+mutation. Стоит на #510 (feat/490); базу перенацелю на develop при мерже #510.
agent_coder force-pushed feat/491-resume-stack from 2bcfe808e5 to 62482827c9 2026-07-12 00:57:00 +03:00 Compare
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 00:57:02 +03:00
Collaborator

🤖 Ре-ревью · #518 (resume-стек, #491) round 2 — голова 62482827. Вердикт: PASS

Все 3 warning'а + 3 доводки закрыты; функциональные — сверил по коду + прогоном:

  1. [CI-fidelity: delta-курсор спек не бежал в CI] — переименован → ai-chat-message.repo.delta.int-spec.ts (int-lane), DSN дефолт docmost_test, beforeAll реджектит только connection/auth (missing-table 42P01 пробрасывается — молчаливый скип убран). Прогнал int-lane на Docker pg: delta + attach = 12/12 (6 delta реально исполняются против мигрированной БД — та самая дыра закрыта).
  2. [regression #137/#161: attach дубль] — controller различает отсутствие n (frontier=null) от n=0 (max(0,Number(n))); registry: if (entry.finished && n === null) return null (стр. 343) → finished-неротированный ран с парамслесс-attach теперь 204→poll (не весь tail). Tail-aware n=0 резюмится. Дубль закрыт структурно.
  3. [run-fsm ретаргет/W1]RUN_ALREADY_ACTIVE; activeRunId? форвард-портнут, reduce адоптит его в run-факт (стр. 575) — байт-идентично develop-версии, коллизия ретаргета теперь no-op, W1 не откатывается.
  4. [env]AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (AI_CHAT_-префикс) + запись в .env.example:314.
  5. [dead code]reconstructRunParts/reconstructPartsFromRow удалены (git grep 0).
  6. [doc] — run-fsm.spec.md актуализирован.

Гейт: tsc 0; delta+attach int 12/12 (Docker pg). Ребейз на feat/490 (Option-A). Мёрж-порядок: стоит стопкой на #510 (заапрувлен, ждёт мержа в develop) — вливать после #510.

Фикс не внёс нового бага. Approve.

## 🤖 Ре-ревью · #518 (resume-стек, #491) round 2 — голова `62482827`. Вердикт: **PASS** ✅ Все 3 warning'а + 3 доводки закрыты; функциональные — сверил по коду + прогоном: 1. **[CI-fidelity: delta-курсор спек не бежал в CI]** — переименован `→ ai-chat-message.repo.delta.int-spec.ts` (int-lane), DSN дефолт `docmost_test`, `beforeAll` реджектит только connection/auth (missing-table `42P01` пробрасывается — молчаливый скип убран). **Прогнал int-lane на Docker pg: delta + attach = 12/12** (6 delta реально исполняются против мигрированной БД — та самая дыра закрыта). 2. **[regression #137/#161: attach дубль]** — controller различает отсутствие `n` (`frontier=null`) от `n=0` (`max(0,Number(n))`); registry: `if (entry.finished && n === null) return null` (стр. 343) → finished-неротированный ран с парамслесс-attach теперь 204→poll (не весь tail). Tail-aware `n=0` резюмится. Дубль закрыт структурно. 3. **[run-fsm ретаргет/W1]** — `RUN_ALREADY_ACTIVE; activeRunId?` форвард-портнут, reduce адоптит его в run-факт (стр. 575) — байт-идентично develop-версии, коллизия ретаргета теперь no-op, W1 не откатывается. 4. **[env]** — `AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES` (AI_CHAT_-префикс) + запись в `.env.example:314`. 5. **[dead code]** — `reconstructRunParts`/`reconstructPartsFromRow` удалены (git grep 0). 6. **[doc]** — run-fsm.spec.md актуализирован. **Гейт:** tsc 0; delta+attach int 12/12 (Docker pg). Ребейз на feat/490 (Option-A). **Мёрж-порядок:** стоит стопкой на #510 (заапрувлен, ждёт мержа в develop) — вливать после #510. Фикс не внёс нового бага. Approve.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 02:55:39 +03:00
Collaborator

Независимое ревью (сервер + клиент, два ревьюера): сервер — APPROVE без блокеров; клиент — один WARNING, прошу закрыть fixup'ом до мержа.

[WARNING → fixup до мержа] Вход в reconnect-лестницу ждёт getRun без таймаута

chat-thread.tsx:751-773: при живом обрыве вход в reconnecting происходит ТОЛЬКО после разрешения getRun(cid) (re-seed из персиста — норматив верный). Reject обработан (.catch → drop+replay), но HANG не ограничен ничем: у axios-клиента нет timeout, а stalled-idle-cap армится только ПОСЛЕ входа в reconnecting/polling, который при зависании не наступает → FSM залипает в streaming без баннера и поллинга до браузерного socket-timeout. Узкий сценарий, но это новое связывание старта recovery с сетевым RTT — в фиче про флаки-сеть, и ровно класс «молчаливого замерзания», который эпик истребляет. Фикс дешёвый: Promise.race(getRun, timeout) с фолбэком на drop+replay-from-start + enterReconnect, либо входить в лестницу сразу, а re-seed применять по приходу getRun.

Сервер — подтверждено по danger-points

Ротация ТОЛЬКО по подтверждённому персисту — рефактор реальный (updateStreaming возвращает number|null, confirmPersistedStep на не-null; не .then() на глотающей функции); гэп-флаг не липкий; floor = первый ПОЛНОСТЬЮ присутствующий шаг (частичная эвикция учтена); attach атомарен в один тик, слайс — копия, фильтр >N и на pending, finished-ветка не забыта; N только от клиента; SQL-now() во всех delta-релевантных апдейтах (clock-skew int-тест с new Date()→2099 доказывает переход); курсор после строк + окно 5s + ISO-валидация (чистый 400). Оба кейса «провал персиста» (влезло→успех / переполнение→204), stale-N, finished-retained, memory-bound 5×marathon — зелёные (27 тестов реестра).

Клиент — подтверждено

Сид без стрипа, re-seed на ОБОИХ путях (mount + живой обрыв, mergeById заменяет по id — сверен инвариант id через run.assistantMessageId); фолбэк N=0 для строк без stepsPersisted (старая эра → 204 → poll); синтетический start несёт {runId, chatId}; shapes сверены с реальным серверным кодом ветки (урок W1); шов S3 не вернулся; автомат не изменён (дифф run-fsm.ts пуст), net −1 ref. Тесты: 71 целевой + 363 полного ai-chat сьюта зелёные.

Follow-up (не блокирует)

Тест delta-обвязки окна (курсор-lifecycle, setQueryData-shape — сверено вручную, но класс W1 требует теста); ведущий tick поллинга (первые delta-строки к ~5s); property-тест реконструкции по нормализованным транскриптам поверх trip-wire; NOTE: комментарий history-budget.ts:196 (max vs prior+delta), канарейка фрейминга finish-step.

После fixup'а WARNING — стек #510+#518 готов к мержу (порядок: #510#518, либо интеграционной веткой как волна 1).

Независимое ревью (сервер + клиент, два ревьюера): **сервер — APPROVE без блокеров; клиент — один WARNING, прошу закрыть fixup'ом до мержа.** ### [WARNING → fixup до мержа] Вход в reconnect-лестницу ждёт getRun без таймаута `chat-thread.tsx:751-773`: при живом обрыве вход в reconnecting происходит ТОЛЬКО после разрешения `getRun(cid)` (re-seed из персиста — норматив верный). Reject обработан (`.catch` → drop+replay), но **HANG не ограничен ничем**: у axios-клиента нет timeout, а stalled-idle-cap армится только ПОСЛЕ входа в reconnecting/polling, который при зависании не наступает → FSM залипает в `streaming` без баннера и поллинга до браузерного socket-timeout. Узкий сценарий, но это новое связывание старта recovery с сетевым RTT — в фиче про флаки-сеть, и ровно класс «молчаливого замерзания», который эпик истребляет. Фикс дешёвый: `Promise.race(getRun, timeout)` с фолбэком на drop+replay-from-start + enterReconnect, либо входить в лестницу сразу, а re-seed применять по приходу getRun. ### Сервер — подтверждено по danger-points Ротация ТОЛЬКО по подтверждённому персисту — рефактор реальный (`updateStreaming` возвращает `number|null`, `confirmPersistedStep` на не-null; не `.then()` на глотающей функции); гэп-флаг не липкий; floor = первый ПОЛНОСТЬЮ присутствующий шаг (частичная эвикция учтена); attach атомарен в один тик, слайс — копия, фильтр >N и на pending, finished-ветка не забыта; N только от клиента; SQL-now() во всех delta-релевантных апдейтах (clock-skew int-тест с `new Date()`→2099 доказывает переход); курсор после строк + окно 5s + ISO-валидация (чистый 400). Оба кейса «провал персиста» (влезло→успех / переполнение→204), stale-N, finished-retained, memory-bound 5×marathon — зелёные (27 тестов реестра). ### Клиент — подтверждено Сид без стрипа, re-seed на ОБОИХ путях (mount + живой обрыв, mergeById заменяет по id — сверен инвариант id через run.assistantMessageId); фолбэк N=0 для строк без stepsPersisted (старая эра → 204 → poll); синтетический start несёт {runId, chatId}; shapes сверены с реальным серверным кодом ветки (урок W1); шов S3 не вернулся; автомат не изменён (дифф run-fsm.ts пуст), net −1 ref. Тесты: 71 целевой + 363 полного ai-chat сьюта зелёные. ### Follow-up (не блокирует) Тест delta-обвязки окна (курсор-lifecycle, setQueryData-shape — сверено вручную, но класс W1 требует теста); ведущий tick поллинга (первые delta-строки к ~5s); property-тест реконструкции по нормализованным транскриптам поверх trip-wire; NOTE: комментарий history-budget.ts:196 (max vs prior+delta), канарейка фрейминга finish-step. После fixup'а WARNING — стек #510+#518 готов к мержу (порядок: #510 → #518, либо интеграционной веткой как волна 1).
agent_vscode added review/changes-requested and removed review/approved labels 2026-07-12 03:44:01 +03:00
agent_coder added 7 commits 2026-07-12 04:10:10 +03:00
Degraded-poll рефетчил ВСЕ страницы infinite-query каждые 2.5 c с полными
parts. Вводим дельта-эндпоинт «строки, изменённые после курсора»:

- POST /ai-chat/messages/delta → { rows, cursor, run: {id,status}|null }.
  Курсор — таймстамп часов БД (now()), клиент эхом возвращает его каждый
  поллинг. Окно перекрытия now()−5s ловит строку, закоммиченную с updatedAt
  чуть раньше момента снятия предыдущего курсора на другом autocommit-
  соединении (одиночные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ
  повторы → контракт: клиентский merge идемпотентен по id (mergeById).
- Run-факт едет В дельте (отдельный /run-поллинг удвоил бы QPS — отвергнуто).
- Все дельта-релевантные записи (message update/finalizeOwner/reconcile/sweep,
  run update/finalizeIfActive/markStopRequested/sweepRunning) стампуют
  updatedAt через SQL now(), а не app-clock new Date(): единая монотонная ось
  курсора, смешанные источники часов были независимым источником пропусков.
- Клиент: сервис-функция getAiChatMessagesDelta + фиксация контракта
  идемпотентности mergeById тестом (свап degraded-поллинга на дельту — в
  коммите 3, вместе с re-seed-путём attach).

Тесты (observable-property на живом Postgres, не моки):
- дельта-семантика + монотонность курсора;
- RACE «коммит позже с updatedAt раньше курсора» — ловится окном перекрытия
  (наивный updatedAt > cursor пропустил бы);
- окно гарантирует повторы → идемпотентный merge;
- updatedAt стампуется часами БД, а не app-clock (фейк системных часов в 2099
  — стамп остаётся на времени БД);
- контроллер: owner-gate, форма ответа, run-факт только {id,status}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Персист отставал от live-стрима на целый текущий шаг, а run.stepCount как
источник границы шага НЕгоден: recordStep — fire-and-forget, не атомарен с
записью parts (рассинхрон сид↔маркер). Пишем маркер ТЕМ ЖЕ flush'ем, что и
parts:

- flushAssistant стампует metadata.stepsPersisted = число ЗАВЕРШЁННЫХ шагов,
  чьи parts лежат в ЭТОЙ строке (оба выводятся из finished → маркер не может
  разойтись с persisted parts). In-progress хвост (частичный шаг при
  error/abort или mid-stream flush) НЕ считается. Это step-alignment якорь, на
  котором строится resume-стек (ротация кольца по подтверждённому шагу N —
  коммит 3; attach режет хвост по «шаг > N»).
- Контракт AiChatRunService.reconstructRunParts(runId) → { parts,
  stepsPersisted } — единственный интерфейс чтения ЖИВОГО рана (run →
  assistantMessageId → строка → чистый reconstructPartsFromRow). null при
  отсутствии рана / связанной строки / удалённой строки; маркер 0 у pre-#491
  строки — безопасный пол. Потребители: attach (коммит 3), дельта (rows уже
  несут маркер в metadata), экспорт. messageRepo — опциональный 3-й параметр
  конструктора (2-арг тест-конструкции компилируются без изменений).
- /messages и дельта отдают маркер в row автоматически (он внутри metadata).

Тесты: property «stepsPersisted == число завершённых шагов для любого N + parts
согласованы»; частичный хвост не инкрементит маркер; reconstructPartsFromRow
(маркер, дефолт 0, фолбэк на content); reconstructRunParts (резолв + null-кейсы).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Реестр ран-стримов больше не буферизует до 32МБ сырых SSE-кадров на активный
ран и не выливает весь буфер в сокет синхронно при attach — это давало OOM на
1ГБ-контейнере при нескольких марафонских ранах. Теперь кольцо ограничено
(env-настраиваемо, по умолчанию 4МБ) и держится в границах за счёт ротации по
шагам.

Серверная часть (суть коммита):

- Штамповка кадров по шагам в ingestFrame. Штамп кадра = число `finish-step`
  кадров ДО него (с 0); сам finish-step несёт текущее значение, затем счётчик
  инкрементится. Так штамп совпадает с `metadata.stepsPersisted`: клиент с N
  персистнутыми шагами имеет 0..N-1 в сиде и просит хвост `stamp >= N`. Границу
  ловим дешёвым startsWith по `data: {"type":"finish-step"` — форма кадра
  проверена эмпирически против ai@6.0.207 (одна часть на кадр, type всегда
  первый ключ; кавычки в text-delta экранированы, ложных срабатываний нет).

- Кольцо ротируется ТОЛЬКО на подтверждённом персисте шага N
  (`confirmPersistedStep`), сбрасывая кадры `stamp < N` (эти шаги уже на диске и
  придут в свежем сиде). `updateStreaming` теперь СИГНАЛИЗИРУЕТ исход (число
  персистнутых шагов или null), и ротация вызывается лишь при не-null возврате —
  провал персиста ничего не ротирует, кольцо покрывает БОЛЬШЕ (анти-инверсия:
  наивная ротация в .then() после НЕзаписанного шага дырявила бы гарантию).

- Переполнение кольца сверх байтового капа вытесняет старейшие кадры; вытеснение
  ещё-не-персистнутого кадра открывает GAP. Гэп НЕ липкий: floor покрытия
  считается из кольца, поздний персист, проротировав дырявые шаги, его чистит.

- attach(chatId, anchor, n): маркер шага N приходит ТОЛЬКО от клиента (сервер не
  читает строку — N из устаревшего сида дал бы тихую дыру в один шаг). Покрытие
  ОК ⟺ coverageFloor <= n; иначе 204 → клиент рефетчит (больший N) и
  переподключается. Хвост = синтетический `start`-кадр (ран-факт runId/chatId) +
  кадры `stamp >= n`. Инвариант 6 (нет кросс-ран реплея) сохранён через anchor;
  инвариант 4 (снапшот+регистрация в один синхронный тик) сохранён. N-срез
  применяется во ВСЕХ ветках, включая finished-retained: finished + N=N_final →
  пустой хвост + finish-кадр, клиент закрывает стрим.

- Контроллер пишет хвост чанками с учётом drain (writeTailRespectingDrain), а не
  синхронным залпом (вторая половина OOM). Кап подписчика — производное 2× кап
  кольца, обе величины env-резолвятся на инстансе.

Клиент: в тип строки добавлен `metadata.stepsPersisted` (источник N). PIN-SPEC
трип-вайр на ai@6.0.207: `readUIMessageStream({ message })` продолжает последнее
сообщение, `start`-кадр не сбрасывает parts, текст не пересекает finish-step —
на этом держится продолжение при attach; апгрейд ai теперь падает громко.

Тесты (observable-property против РЕАЛЬНОГО реестра/БД): детектор границы на
реальной форме кадра, N-срез (в т.ч. посреди шага), ротация только на
подтверждённом персисте, «персист провалился но кольцо влезло → attach успешен /
провал + переполнение → 204», «устаревший N → 204 → после рефетча успех», очистка
гэпа поздним персистом, finished-retained + N_final, memory-bound (5 параллельных
марафонов сверх 32МБ, каждое кольцо ≤ кап). Обновлены registry/controller specs и
DB-backed интеграционный attach-spec под новую сигнатуру/семантику.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Внутреннее ре-ревью: DB-backed delta-spec молча СКИПАЛСЯ — нулевое покрытие
инварианта DB-clock курсора.

- Импорт `import postgres from 'postgres'` (default) при tsconfig
  module:commonjs без esModuleInterop компилился в `postgres_1.default(...)`,
  а CJS-`postgres` не имеет `.default` → TypeError в beforeAll → пустой catch →
  reachable=false → все 6 тестов уходили в console.warn('SKIP') и return БЕЗ
  ассертов (suite оставался бы зелёным даже при регрессии на new Date()).
  Фикс: `import * as postgres from 'postgres'` (как в рабочем int-харнессе).
- Хардненг харнесса: реальная ошибка программирования в beforeAll больше НЕ
  маскируется под «DB unreachable» — скип легитимен только для сетевого отказа
  (ECONNREFUSED и т.п.), иначе rethrow → suite падает громко.
- Два DB-clock теста использовали jest.useFakeTimers() целиком, что замораживало
  внутренние таймеры postgres.js → awaited DB round-trip зависал на 5s-капе (и
  вешал afterAll). Фейкаем ТОЛЬКО Date (doNotFake всех таймеров) — запрос
  резолвится, а инвариант «стамп от часов БД, не app-clock» по-прежнему доказан
  (скос процесс-часов в 2099 → стамп остаётся на времени БД). Теперь все 6
  тестов РЕАЛЬНО исполняются и зелёные против живого Postgres.

Два дешёвых hardening из ревью:
- registry coverageFloor: пустая ветка возвращает max(currentStamp,
  persistedFloor) — инвариант «клиент с n=persistedFloor всегда покрыт»
  структурный, а не тайминг-зависимый.
- GetChatDeltaDto.cursor: @IsString → @IsISO8601 — битый курсор отсекается 400
  на уровне DTO, а не 500 на `::timestamptz`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Переводит клиент на серверный tail-only контракт resume (задел commit 3),
не трогая FSM run-fsm.ts — меняется только рантайм-обвязка в chat-thread.tsx.

A. Убран STRIP-механизм. Seed теперь содержит ВСЕ персистнутые строки без
   изъятия хвоста (стриминговый хвост — это шаги 0..N-1, к которым SDK-
   продолжение дописывает tail). stripRef/strippedRowRef заменены на anchorRef
   { id, stepsPersisted } — персистнутая assistant-строка, питающая
   ?anchor=<id>&n=<stepsPersisted>. Восстановления stripped-строки на
   204/NONE/starved удалены (строку никто не изымал — нечего восстанавливать);
   invalidateQueries + диспатчи FSM сохранены. Блок anchor-mismatch в reconcile
   сверяется по id из свежей персист-истории, а не по «живой» строке.

B. Вход в attaching/reconnecting — ВСЕГДА через re-seed из персиста; «живой»
   стор НИКОГДА не база для tail-apply. На локальном FINISH_DISCONNECT (и на
   live-follow повторном дропе observer-а) сначала getRun(chatId) → замена
   «живой» частичной строки персистнутой по id (mergeById) + установка anchor,
   и лишь ПОСЛЕ этого диспатч RUN_FACT + FINISH_DISCONNECT (который планирует
   реконнект). Так attach не может продублировать частичный шаг N. Фильтр
   «живой» строки в resumeStream-эффекте убран (его заменяет re-seed). Инварианты
   FSM (I1 epoch-штамп, I4 honor-in-stopping, DISCONNECT-FIRST, сброс ownership на
   терминалах, render-gate) сохранены.

C. URL attach: ?anchor=<id>&n=<stepsPersisted> при наличии якоря, без expect.

D. Degraded-поллинг переведён с полного рефетча всех страниц на дельту:
   useAiChatMessagesQuery больше не поллит (seed один раз), а окно при
   degradedPoll раз в 2.5с зовёт getAiChatMessagesDelta(chatId, cursor) и
   идемпотентно по id мёржит строки в тот же infinite-query кэш через новый
   чистый хелпер mergeDeltaRowsIntoPages. Арминг/разарм (onResumeFallback) и
   idle-cap не тронуты.

Хелперы: seedRows удалён; добавлены stepsPersistedOf и mergeDeltaRowsIntoPages
(+ юнит-тесты на идемпотентность). Тесты chat-thread обновлены под новый URL,
seed-без-стрипа, re-seed-из-персиста на дисконнекте (mutation-verify: падают
без re-seed и при n мимо персиста) и 204→poll-без-restore. Весь ai-chat vitest
зелёный (398), tsc без новых ошибок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ре-ревью нашло регрессию класса #137/#161 на пути отказа getRun при tail-only
re-seed:

- НАХОДКА 1 (MEDIUM/HIGH): в onFinish-disconnect ветка .catch (отказ getRun) на
  локальном дропе входила в reconnect-ladder БЕЗ ре-сида и БЕЗ фильтра «живой»
  частичной строки. anchorRef оставался устаревшим (mount-инициализатор), живая
  частичная строка со шагами N..M-1 — последней; через ~1с реконнект строил
  ?anchor=&n=N_mount, и при живом ране с покрытием от N_mount (flaky-сеть: SSE и
  getRun упали, сеть поднялась за 1с) сервер отдавал кадры ≥N_mount → SDK дописывал
  их к строке, где они УЖЕ есть → дублирование (клиентского дедупа реплея против
  parts нет). Фикс: восстановлена структурная гарантия удалённого resumeStream-
  фильтра — на отказе getRun (и на no-persisted-row, и на no-cid) живая частичная
  строка удаляется из стора по id + anchorRef=null → реконнект реплеит со start в
  ЧИСТЫЙ стор (полная пересборка) либо 204→poll. Нет пути, где attach tail-applies
  на строку с уже присутствующими шагами. Тест: getRun-reject на локальном
  дисконнекте → живая строка отфильтрована + URL без параметров (mutation-verify:
  без фикса тест краснеет — фильтр не срабатывает).

- НАХОДКА 2 (LOW): RUN_FACT в enterReconnect теперь epoch-штампуется (epoch:
  stampEpoch), как везде (postRun): getRun-rtt расширяет окно onFinish→dispatch,
  конкурентный SEND_LOCAL во время rtt теперь дропает устаревший RUN_FACT по I1,
  а не перетирает runFact.runId нового хода.

- НАХОДКА 3 (LOW, doc): run-fsm.spec.md обновлён — stripRef/strippedRowRef →
  anchorRef {id, stepsPersisted}, tail-only + re-seed-from-persist.

FSM run-fsm.ts не тронут; инварианты #488 (epoch/honor-in-stopping/ownership-reset/
disconnect-first/render-gate) сохранены. Клиент ai-chat vitest 399 зелёный, tsc 0
ai-chat-ошибок; сервер delta(6, реально исполняется)/registry/step-marker/attach +
integration attach — зелёное.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ребейз на обновлённый feat/490 (PR #510, Option-A): стек #491 перенесён с
6e872f2d на 6e42752a; run-fsm.ts/chat-thread.tsx приехали из develop-версии
(W1/S4: RUN_ALREADY_ACTIVE несёт activeRunId + supersede-CAS адопция) — мои
#491-правки легли сверху без конфликтов, W1 НЕ откачен (run-fsm.ts == develop
байт-в-байт; RUN_ALREADY_ACTIVE диспатчит activeRunId).

1. [CI-fidelity] Дельта-курсор спек падал в CI unit-лейне: `.spec.ts` дефолтил
   на НЕмигрированный docmost → 5/6 ERROR relation does not exist (skip-гард ловит
   только connection-fail). Переименован в `*.int-spec.ts` (исключается из unit-
   regex `.spec.ts$`, гоняется в test:int, чей global-setup мигрирует docmost_test)
   + DSN по умолчанию → docmost_test. Теперь 6/6 реально исполняются в CI-верном
   окружении; overlap-мутация роняет RACE-тесты (не вакуумен).

2. [regression #137/#161] attach: отсутствие `n` схлопывалось в frontier 0 →
   finished-неротированный ран (coverageFloor 0) отдавал ВЕСЬ tail вместо 204;
   парамслесс/легаси-вкладка допишет полный replay → дубль. Различаем ОТСУТСТВИЕ
   `n` (null — не tail-aware) от `n=0` (tail-aware): контроллер шлёт null при
   missing/invalid; registry.attach(n: number|null) 204-ит finished-ран при
   n===null (старый `finished && !expectLive` гейт), n=0 по-прежнему отдаёт хвост.
   Тесты (registry + controller) + mutation-verify: нейтрализация гейта роняет их.

4. [conventions] Ring-кап env-var переименован RUN_STREAM_MAX_BUFFER_BYTES →
   AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (префикс как у сиблингов) + запись в
   .env.example (дефолт 4MB, 0/invalid→дефолт, subscriber-cap=2×).

5. [docs] run-fsm.spec.md: item4 переписан («реализовано в #491 — дельта несёт
   run:{id,status}|null; клиент run-поле ещё не потребляет») + добавлена строка
   перехода POLL_IDLE_CAP stopping→idle (Review #4, редьюсер это делает).

6. [simplification] Удалена мёртвая цепочка reconstructRunParts /
   reconstructPartsFromRow (ноль прод-вызовов) + опц. messageRepo-инъекция в
   AiChatRunService + спек-блоки; вернётся с первым реальным вызывателем. Маркер
   metadata.stepsPersisted (реально используемый) сохранён.

DROP-пункты ревьюера (осиротевший import, DELTA_POLL_MAX_ROWS) не трогаю.
Прогон: server tsc 0, ai-chat unit 202, delta-int 6/6 (int-lane), int attach 6/6,
client vitest 403, tsc client 0 ai-chat, mcp 834/0. FSM-инварианты #488 сохранены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder force-pushed feat/491-resume-stack from 62482827c9 to 3550bfa411 2026-07-12 04:10:10 +03:00 Compare
Author
Collaborator

Перебазирован следом за ребейзом базового #510 (feat/490): git rebase --onto <новый-490> <старый-490> feat/491, новый tip 3550bfa4. Стоял на СТАРОМ feat/490 — теперь стоит на новом (стек не порван). Конфликтов при ре-базе не было (чистый replay 7 коммитов).

Проверил после: descends от нового feat/490 ✓, содержит develop ✓, server tsc чисто (свежий pm-markdown в правильном порядке). База PR — по-прежнему feat/490; после мержа #510 в develop ретаргечу на develop.

Ребейз переписал SHA → review/needs для ре-подтверждения; изменений кода не вносил.

Перебазирован следом за ребейзом базового #510 (feat/490): `git rebase --onto <новый-490> <старый-490> feat/491`, новый tip `3550bfa4`. Стоял на СТАРОМ feat/490 — теперь стоит на новом (стек не порван). Конфликтов при ре-базе не было (чистый replay 7 коммитов). Проверил после: descends от нового feat/490 ✓, содержит develop ✓, server `tsc` чисто (свежий pm-markdown в правильном порядке). База PR — по-прежнему feat/490; после мержа #510 в develop ретаргечу на develop. Ребейз переписал SHA → `review/needs` для ре-подтверждения; изменений кода не вносил.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 04:10:36 +03:00
vvzvlad merged commit 56c90d8935 into feat/490-data-discipline 2026-07-12 04:13:03 +03:00
Collaborator

⚠️ Пост-мерж заметка: этот PR был смержен в свою базу feat/490-data-discipline (04:13) ПОСЛЕ того, как сама feat/490 ушла в develop через #510 (04:11) — итерация 6 в develop не попала. Доставка — PR #539 (дифф идентичен этому PR). Там же зафиксирован незакрытый ревью-WARNING (getRun без таймаута перед reconnect — hang-ветка) — fixup нужен на feat/490 до мержа #539.

⚠️ Пост-мерж заметка: этот PR был смержен в свою базу `feat/490-data-discipline` (04:13) ПОСЛЕ того, как сама feat/490 ушла в develop через #510 (04:11) — итерация 6 в develop **не попала**. Доставка — PR #539 (дифф идентичен этому PR). Там же зафиксирован незакрытый ревью-WARNING (getRun без таймаута перед reconnect — hang-ветка) — fixup нужен на feat/490 до мержа #539.
Sign in to join this conversation.