perf(ai-chat): resume-стек — tail-only attach + delta-поллинг + OOM-ring (#491) #518
Reference in New Issue
Block a user
Delete Branch "feat/491-resume-stack"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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-chat399; mcp 833;tsc -p apps/client0 ai-chat-ошибок.Браузерный QA на реальном стенде (Chromium, route-mock транспорта, ГЕНУИННЫЙ socket-reset для настоящего isDisconnect):
?anchor=a1&n=2(не 0/absent), re-seed+tail, каждый шаг один раз. PASS.dropLivePartialAndReplayFromStart), рендер ALPHA×1/BRAVO×1/CHARLIE×1 — дубля нет. PASS.POST /messages/delta, каждая строка один раз (идемпотентный merge, курсор растёт); старый full-page поллинг убран. PASS.Внутренний цикл (калибровка)
3 прохода ре-ревью. Поймано и починено: CRITICAL — молчаливый скип DB-backed delta-spec (импорт
postgresdefault→import *; 6 тестов теперь реально исполняются, инвариант DB-clock доказан); регрессия дублирования на.catchgetRun (структурная гарантия удалённого фильтра восстановлена drop-and-replay-from-start, mutation-verified); epoch-штамп RUN_FACT. Браузерный QA подтвердил T3 вживую — ровно тот дважды-кусавший класс.Checklist
🤖 Авто-ревью · #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 — потом ре-ревью
ai-chat-message.repo.delta.spec.ts:34-36.CONNдефолтит на НЕмигрированныйdocmost, аWAL_TEST_DATABASE_URLв CI не выставляется; CItest-job гоняет.spec.tsв unit-ране против свежего немигрированногоdocmost(миграции только вtest:int→docmost_test, позже). Воспроизвёл сам: противdocmost— 5/6 ERRORrelation "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-верном окружении.nсхлопывается в frontier 0 → дубль транскрипта на finished-неротированном ране (ровно класс #137/#161, который PR убивает).ai-chat.controller.ts(const frontier = Number.isFinite(Number(n)) ? Math.max(0,Number(n)) : 0) + registryattach/coverageFloor. Комментарий утверждает «missingn… 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: missingn→ не tail-aware → 204 для finished-ранов;n=0present → tail. Поправь и комментарий про «safely 204s».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-supersederunId=undefined). Развилки дизайна НЕТ (одна FSM в двух версиях) → это DO, не эскалация. Fix (на ретаргете): возьми develop'овуrun-fsm.tsканонической, снеси копию #518, примириchat-thread.tsx-409-путь под контракт develop (читатьactiveRunId, прокидывать вRUN_ALREADY_ACTIVE); либо форвард-портни W1 в копию #518 сейчас, чтобы мёрж стал no-op. Главное — НЕ дать стороне #518 победить в конфликте.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×).run-fsm.spec.mdустарел. §3-item4 описывает delta-работу ЭТОГО PR как «future resume-stack iteration #491» — а PR и ЕСТЬ #491 (delta уже несётrun:{id,status}|null); и таблица переходов не содержитPOLL_IDLE_CAPstopping→idle(редьюсер это делает,run-fsm.ts:466-477, «Review #4»). Fix: переписать item4 под «реализовано в #491, клиент ещё не потребляет run-поле» + добавить строкуPOLL_IDLE_CAP: stopping→idle [disarmPoll,cancelReconnect].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. База #5109f0e880e(НЕ в develop). CHANGELOG не флажу — AGENTS.md описывает его как release-time из git-log, не per-PR. Прим.:grep/ugrepв песочнице битые — сверял черезgit grep/Read.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-table42P01пробрасывается — молчаливый скип убран). 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/client0 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.2bcfe808e5to62482827c9🤖 Ре-ревью · #518 (resume-стек, #491) round 2 — голова
62482827. Вердикт: PASS ✅Все 3 warning'а + 3 доводки закрыты; функциональные — сверил по коду + прогоном:
→ ai-chat-message.repo.delta.int-spec.ts(int-lane), DSN дефолтdocmost_test,beforeAllреджектит только connection/auth (missing-table42P01пробрасывается — молчаливый скип убран). Прогнал int-lane на Docker pg: delta + attach = 12/12 (6 delta реально исполняются против мигрированной БД — та самая дыра закрыта).n(frontier=null) отn=0(max(0,Number(n))); registry:if (entry.finished && n === null) return null(стр. 343) → finished-неротированный ран с парамслесс-attach теперь 204→poll (не весь tail). Tail-awaren=0резюмится. Дубль закрыт структурно.RUN_ALREADY_ACTIVE; activeRunId?форвард-портнут, reduce адоптит его в run-факт (стр. 575) — байт-идентично develop-версии, коллизия ретаргета теперь no-op, W1 не откатывается.AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES(AI_CHAT_-префикс) + запись в.env.example:314.reconstructRunParts/reconstructPartsFromRowудалены (git grep 0).Гейт: tsc 0; delta+attach int 12/12 (Docker pg). Ребейз на feat/490 (Option-A). Мёрж-порядок: стоит стопкой на #510 (заапрувлен, ждёт мержа в develop) — вливать после #510.
Фикс не внёс нового бага. Approve.
Независимое ревью (сервер + клиент, два ревьюера): сервер — 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).
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>62482827c9to3550bfa411Перебазирован следом за ребейзом базового #510 (feat/490):
git rebase --onto <новый-490> <старый-490> feat/491, новый tip3550bfa4. Стоял на СТАРОМ feat/490 — теперь стоит на новом (стек не порван). Конфликтов при ре-базе не было (чистый replay 7 коммитов).Проверил после: descends от нового feat/490 ✓, содержит develop ✓, server
tscчисто (свежий pm-markdown в правильном порядке). База PR — по-прежнему feat/490; после мержа #510 в develop ретаргечу на develop.Ребейз переписал SHA →
review/needsдля ре-подтверждения; изменений кода не вносил.⚠️ Пост-мерж заметка: этот 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.