Стабилизация, итерация 6: resume-стек — дельта-поллинг, step-aligned registry, tail-only attach (один PR, 3 коммита) #491

Open
opened 2026-07-11 02:04:40 +03:00 by agent_vscode · 0 comments
Collaborator

Один PR. Зависит от #488 (интерфейс attachStrategy и автомат — клиентская часть attach здесь подменяет стратегию) и #490 (желательно: нераздутые строки). Координаты — develop@363f20ab.

Контекст: реестр стримов буферизует до 32 МБ сырых SSE-фреймов НА КАЖДЫЙ активный ран (ai-chat-stream-registry.service.ts:18-40; в памяти V8 до ×2) + attach-реплей пишет весь буфер в сокет синхронно без drain (ai-chat.controller.ts:333) — 4–6 марафонов на контейнере 1 ГБ = OOM. Degraded poll рефетчит ВСЕ страницы infinite-query каждые 2.5 c с полными parts. Персист отстаёт от live-стрима на целый текущий шаг (in-progress текст не персистится — updateStreaming передаёт '', service:1145) — это делает персист выровненным по границе шага, на чём и строится схема.

Коммит 1 — perf(ai-chat): дельта-эндпоинт поллинга + run-факт в ответе

Эндпоинт «rows updated after cursor»: курсор от часов БД; стампы updatedAt дельта-релевантных записей переводятся на SQL-now() (сейчас репы пишут app-клоком new Date()ai-chat-message.repo.ts:174, ai-chat-run.repo.ts:139; смешанные источники часов — независимый источник пропусков); окно перекрытия now()−5s (одиночные автокоммитные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ повторы → контракт: клиентский merge идемпотентен по id (mergeById уже такой — зафиксировать контрактом). Ответ: { rows, run: {id, status} | null } — run-факт для автомата #488 едет в дельте (отдельный /run-поллинг = удвоение QPS, отвергнуто); rows несут stepsPersisted (коммит 2). Тесты: дельта-семантика; race «коммит позже с updatedAt раньше курсора» (окно ловит); идемпотентность merge; «стампы идут от часов БД» (юнит по репе).

Коммит 2 — feat(ai-chat): персист step-маркера

metadata.stepsPersisted пишется ТЕМ ЖЕ flush'ем, что и parts (атомарно, в flushAssistant). run.stepCount как источник НЕгоден (recordStep fire-and-forget, не атомарен с parts → рассинхрон сид↔маркер). Контракт reconstructRunParts(runId) → { parts, stepsPersisted } — единственный интерфейс чтения живого рана; потребители: attach (коммит 3), дельта (коммит 1), экспорт. /messages и дельта отдают маркер в row.

Коммит 3 — refactor(ai-chat): registry — step-aligned retention + tail-only attach

Retention: кадры стампуются номером шага при tee (конвенция: run-level кадры ДО первого шага = шаг 0); кольцо ротируется ТОЛЬКО по подтверждённому персисту шага N — требуется рефактор: updateStreaming должен СИГНАЛИЗИРОВАТЬ исход (сейчас глотает ошибку внутри catch→warn, :1148-1154; наивный хук в .then() ротировал бы после НЕзаписанного шага, инвертируя безопасность). Провал персиста ⇒ нет ротации ⇒ кольцо покрывает больше (auto-safe); переполнение кольца ⇒ гэп-флаг ⇒ attach 204. Гэп-флаг НЕ липкий (очищается, когда запоздавший персист подтвердился и ротация вычистила дырявые шаги); floor покрытия = первый ПОЛНОСТЬЮ присутствующий шаг (не стамп первого кадра). Кап кольца — env-tunable, дефолт 4 МБ (кадры несут НЕкомпактированные outputs + фрейминг-оверхед ×1.5–2; шаг «2–3 крупных чтения + reasoning» пробивает 2 МБ штатно) + метрика/лог gap-204; SUBSCRIBER-кап — производная от капа кольца.
Attach: step-маркер N — только ОТ КЛИЕНТА (параметр, аналог anchor; сервер строку НЕ читает — серверное определение N даёт молчаливую дыру-в-один-шаг при устаревшем сиде) → attach полностью синхронный, same-tick инвариант реестра СОХРАНЯЕТСЯ: [один тик: регистрация подписчика (paused) + слайс кольца + проверка «floorStep слайса ≤ N+1»] → не покрывает → 204 → существующий restore+poll обновляет сид → повторный attach со свежим N; покрывает → синтетический start-фрейм с messageMetadata: {runId, chatId} (источник run-факта на re-attach) + кадры слайса с фильтром «шаг > N» → drain pending тем же фильтром → live. Слайс иммунен к конкурентной ротации. Отсечка по N — во ВСЕХ ветках, включая finished-retained replay (registry :191-199 — отдельный код-путь; N=N_final ⇒ пустой хвост + finish-кадр, чтобы клиент закрыл стрим). Семантика expect=live/anchor и док-комментарии про стрип переписываются. Запись хвоста — чанками с учётом drain.
Клиент: сид из rows существующим путём (rowToUiMessage), streaming-строка НЕ стрипается (содержит шаги ≤N); вход в attaching/reconnecting — ВСЕГДА через re-seed из персиста (fetch → mergeById → N из строки); live-стор никогда не база для tail-apply (при живом дисконнекте он впереди границы шага — tail-apply задублировал бы частичный шаг). Подмена attachStrategy из #488; strip-механика удаляется.
Опора на SDK (проверено на ai@6.0.207 построчно): createStreamingUIMessageState продолжает последний assistant-message; start-фрейм НЕ сбрасывает parts; text/reasoning-части не пересекают границу шага (finish-step сбрасывает active*Parts). Пин-спека (трипвайр) на эту континуацию обязательна — апгрейд ai без пина молча сломает attach.
Тесты: property эквивалентности реконструкции по НОРМАЛИЗОВАННЫМ транскриптам (step-start-parts выкинуть, text-parts склеить в пределах шага — live/persisted формы легально различаются); «провал персиста шага N, кольцо влезло → attach УСПЕШЕН (хвост включает N)» и «провал персиста + переполнение → 204» — ДВА разных кейса; «stale N (сид отстал) → 204 → после рефетча успех»; «attach в середине шага»; «finished-retained + N=N_final → пустой хвост»; смок памяти «5 параллельных марафонов ≤ кап».

Один PR. **Зависит от #488** (интерфейс attachStrategy и автомат — клиентская часть attach здесь подменяет стратегию) и **#490** (желательно: нераздутые строки). Координаты — develop@363f20ab. Контекст: реестр стримов буферизует до 32 МБ сырых SSE-фреймов НА КАЖДЫЙ активный ран (`ai-chat-stream-registry.service.ts:18-40`; в памяти V8 до ×2) + attach-реплей пишет весь буфер в сокет синхронно без drain (`ai-chat.controller.ts:333`) — 4–6 марафонов на контейнере 1 ГБ = OOM. Degraded poll рефетчит ВСЕ страницы infinite-query каждые 2.5 c с полными parts. Персист отстаёт от live-стрима на целый текущий шаг (in-progress текст не персистится — `updateStreaming` передаёт `''`, service:1145) — это делает персист выровненным по границе шага, на чём и строится схема. ### Коммит 1 — perf(ai-chat): дельта-эндпоинт поллинга + run-факт в ответе Эндпоинт «rows updated after cursor»: курсор от часов БД; стампы `updatedAt` дельта-релевантных записей переводятся на SQL-`now()` (сейчас репы пишут app-клоком `new Date()` — `ai-chat-message.repo.ts:174`, `ai-chat-run.repo.ts:139`; смешанные источники часов — независимый источник пропусков); окно перекрытия `now()−5s` (одиночные автокоммитные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ повторы → **контракт: клиентский merge идемпотентен по id** (`mergeById` уже такой — зафиксировать контрактом). Ответ: `{ rows, run: {id, status} | null }` — run-факт для автомата #488 едет в дельте (отдельный /run-поллинг = удвоение QPS, отвергнуто); rows несут `stepsPersisted` (коммит 2). Тесты: дельта-семантика; race «коммит позже с updatedAt раньше курсора» (окно ловит); идемпотентность merge; «стампы идут от часов БД» (юнит по репе). ### Коммит 2 — feat(ai-chat): персист step-маркера `metadata.stepsPersisted` пишется ТЕМ ЖЕ flush'ем, что и parts (атомарно, в flushAssistant). `run.stepCount` как источник НЕгоден (recordStep fire-and-forget, не атомарен с parts → рассинхрон сид↔маркер). Контракт **`reconstructRunParts(runId) → { parts, stepsPersisted }`** — единственный интерфейс чтения живого рана; потребители: attach (коммит 3), дельта (коммит 1), экспорт. `/messages` и дельта отдают маркер в row. ### Коммит 3 — refactor(ai-chat): registry — step-aligned retention + tail-only attach **Retention:** кадры стампуются номером шага при tee (конвенция: run-level кадры ДО первого шага = шаг 0); кольцо ротируется ТОЛЬКО по подтверждённому персисту шага N — **требуется рефактор: `updateStreaming` должен СИГНАЛИЗИРОВАТЬ исход** (сейчас глотает ошибку внутри catch→warn, :1148-1154; наивный хук в `.then()` ротировал бы после НЕзаписанного шага, инвертируя безопасность). Провал персиста ⇒ нет ротации ⇒ кольцо покрывает больше (auto-safe); переполнение кольца ⇒ гэп-флаг ⇒ attach 204. Гэп-флаг НЕ липкий (очищается, когда запоздавший персист подтвердился и ротация вычистила дырявые шаги); floor покрытия = первый ПОЛНОСТЬЮ присутствующий шаг (не стамп первого кадра). Кап кольца — env-tunable, дефолт 4 МБ (кадры несут НЕкомпактированные outputs + фрейминг-оверхед ×1.5–2; шаг «2–3 крупных чтения + reasoning» пробивает 2 МБ штатно) + метрика/лог gap-204; SUBSCRIBER-кап — производная от капа кольца. **Attach:** step-маркер N — только ОТ КЛИЕНТА (параметр, аналог anchor; сервер строку НЕ читает — серверное определение N даёт молчаливую дыру-в-один-шаг при устаревшем сиде) → attach полностью синхронный, same-tick инвариант реестра СОХРАНЯЕТСЯ: [один тик: регистрация подписчика (paused) + слайс кольца + проверка «floorStep слайса ≤ N+1»] → не покрывает → 204 → существующий restore+poll обновляет сид → повторный attach со свежим N; покрывает → синтетический `start`-фрейм с `messageMetadata: {runId, chatId}` (источник run-факта на re-attach) + кадры слайса с фильтром «шаг > N» → drain pending тем же фильтром → live. Слайс иммунен к конкурентной ротации. Отсечка по N — во ВСЕХ ветках, включая finished-retained replay (registry :191-199 — отдельный код-путь; N=N_final ⇒ пустой хвост + finish-кадр, чтобы клиент закрыл стрим). Семантика `expect=live`/`anchor` и док-комментарии про стрип переписываются. Запись хвоста — чанками с учётом drain. **Клиент:** сид из rows существующим путём (`rowToUiMessage`), streaming-строка НЕ стрипается (содержит шаги ≤N); **вход в attaching/reconnecting — ВСЕГДА через re-seed из персиста** (fetch → mergeById → N из строки); live-стор никогда не база для tail-apply (при живом дисконнекте он впереди границы шага — tail-apply задублировал бы частичный шаг). Подмена attachStrategy из #488; strip-механика удаляется. **Опора на SDK (проверено на ai@6.0.207 построчно):** `createStreamingUIMessageState` продолжает последний assistant-message; `start`-фрейм НЕ сбрасывает parts; text/reasoning-части не пересекают границу шага (`finish-step` сбрасывает active*Parts). **Пин-спека (трипвайр) на эту континуацию обязательна** — апгрейд `ai` без пина молча сломает attach. Тесты: property эквивалентности реконструкции по НОРМАЛИЗОВАННЫМ транскриптам (step-start-parts выкинуть, text-parts склеить в пределах шага — live/persisted формы легально различаются); «провал персиста шага N, кольцо влезло → attach УСПЕШЕН (хвост включает N)» и «провал персиста + переполнение → 204» — ДВА разных кейса; «stale N (сид отстал) → 204 → после рефетча успех»; «attach в середине шага»; «finished-retained + N=N_final → пустой хвост»; смок памяти «5 параллельных марафонов ≤ кап».
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#491