feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE #432

Merged
vvzvlad merged 1 commits from feat/430-live-reconnect into develop 2026-07-10 04:27:18 +03:00
Collaborator

Summary

Автономный (detached) ран продолжается на сервере, даже когда SSE браузер↔сервер обрывается посреди ответа (Safari роняет длинный стрим): сервер честно дописывает турн, но в браузере остаётся баннер «Lost connection» и мёртвая вкладка до ручной перезагрузки. Причина: resumeStream() звался ровно один раз — на mount. Добавлен недостающий триггер, вся остальная resumable-машинерия переиспользована.

  • Триггер: в onFinish на живом isDisconnect (гард !wasResumed && autonomousRunsEnabled && mounted && assistant) → beginReconnect с экспон. backoff (1/2/4/8/16с, лимит 5). Стоп-условия: status→streaming / 2xx re-attach / терминальный хвост reconcile / stop / unmount; исчерпание → ручной Retry. Отключённый autonomous-путь не тронут (старый терминальный нотис).
  • Дедуп (главный риск) — зеркалит mount strip/anchor: пиннит текущий streaming-ряд как anchor (id ассистент-строки), удаляет его из стора ДО replay, сервер ?expect=live&anchor=<id> пересобирает без дублей; на отказе/204 строка восстанавливается через onNoActiveStream (контент не теряется). Внутреннее ревreview подтвердило: окна «убрали и не replay/не restore» нет.
  • 204/overflow → degraded poll через существующий onNoActiveStream.
  • Кэпы: RUN_STREAM_MAX_BUFFER_BYTES 4→32МБ (марафонские раны 11–25мин переполняли 4МБ; освобождается на finish/overflow; 204→poll остаётся backstop). Degraded-poll: фиксированный 10-мин-от-старта кап → inactivity-кап (продлевается пока меняется ссылка messageRows = реальный прогресс через TanStack structural sharing).
  • UI: баннер «reconnecting… (N/5)» + ручной Retry.

closes #430

Известные не-блокеры (ниты внутреннего ревью)

  • 32МБ replay-буфер (+64МБ per-subscriber) на активный ран — консидерейшн по памяти при МНОГИХ одновременных autonomous-ранах в multi-tenant (correctness не зависит; overflow→poll — backstop).
  • Повторный обрыв УЖЕ переподключённого (resumed) стрима деградирует в poll + legacy-нотис вместо reconnect-баннера (контент не теряется, поведение == pre-existing resumed-disconnect). Опционально дошлифовать.
  • Эвристика «смена ссылки = прогресс» полагается на байт-стабильность строки застрявшего рана.

How verified

  • pnpm --filter ./apps/client exec vitest run src/features/ai-chat/326 passed (+6 новых #430: resumeStream post-mount с корректным expect=live&anchor URL; anchor-ряд стрипается до replay (без дублей); 2xx убирает баннер; 204→poll+attempt-2; исчерпание→Retry→рестарт; disabled-autonomous без реконнекта).
  • pnpm --filter ./apps/client exec tsc --noEmit — чисто по ai-chat (одна pre-existing несвязанная ошибка в editor/image-menu.tsx).
  • Сервер: jest ai-chat-stream-registry + ai-chat.controller.attach28 passed (overflow-тесты сделаны cap-relative).

Внутренний цикл: 1 проход coder → внутреннее ревreview (дедуп по 4 под-пунктам + безопасность цикла + отключённый путь + тесты, сверка с исходником SDK-колбэков) → APPROVE WITH SUGGESTIONS (только ниты выше).

Checklist

  • живой обрыв → реконнект к live-хвосту без перезагрузки; 204→poll до терминала; без дублей; backoff+лимит+Retry; переживает несколько обрывов
  • вне заявленного scope ничего (5 файлов)
## Summary Автономный (detached) ран продолжается на сервере, даже когда SSE браузер↔сервер обрывается посреди ответа (Safari роняет длинный стрим): сервер честно дописывает турн, но в браузере остаётся баннер «Lost connection» и мёртвая вкладка до ручной перезагрузки. Причина: `resumeStream()` звался ровно один раз — на mount. Добавлен недостающий триггер, вся остальная resumable-машинерия переиспользована. - **Триггер**: в `onFinish` на живом `isDisconnect` (гард `!wasResumed && autonomousRunsEnabled && mounted && assistant`) → `beginReconnect` с экспон. backoff (1/2/4/8/16с, лимит 5). Стоп-условия: `status→streaming` / 2xx re-attach / терминальный хвост reconcile / stop / unmount; исчерпание → ручной Retry. Отключённый autonomous-путь не тронут (старый терминальный нотис). - **Дедуп (главный риск)** — зеркалит mount strip/anchor: пиннит текущий streaming-ряд как anchor (id ассистент-строки), удаляет его из стора ДО replay, сервер `?expect=live&anchor=<id>` пересобирает без дублей; на отказе/204 строка восстанавливается через `onNoActiveStream` (контент не теряется). Внутреннее ревreview подтвердило: окна «убрали и не replay/не restore» нет. - **204/overflow → degraded poll** через существующий `onNoActiveStream`. - **Кэпы**: `RUN_STREAM_MAX_BUFFER_BYTES` 4→32МБ (марафонские раны 11–25мин переполняли 4МБ; освобождается на finish/overflow; 204→poll остаётся backstop). Degraded-poll: фиксированный 10-мин-от-старта кап → **inactivity-кап** (продлевается пока меняется ссылка `messageRows` = реальный прогресс через TanStack structural sharing). - **UI**: баннер «reconnecting… (N/5)» + ручной Retry. closes #430 ## Известные не-блокеры (ниты внутреннего ревью) - 32МБ replay-буфер (+64МБ per-subscriber) на активный ран — консидерейшн по памяти при МНОГИХ одновременных autonomous-ранах в multi-tenant (correctness не зависит; overflow→poll — backstop). - Повторный обрыв УЖЕ переподключённого (resumed) стрима деградирует в poll + legacy-нотис вместо reconnect-баннера (контент не теряется, поведение == pre-existing resumed-disconnect). Опционально дошлифовать. - Эвристика «смена ссылки = прогресс» полагается на байт-стабильность строки застрявшего рана. ## How verified - `pnpm --filter ./apps/client exec vitest run src/features/ai-chat/` → **326 passed** (+6 новых #430: resumeStream post-mount с корректным `expect=live&anchor` URL; anchor-ряд стрипается до replay (без дублей); 2xx убирает баннер; 204→poll+attempt-2; исчерпание→Retry→рестарт; disabled-autonomous без реконнекта). - `pnpm --filter ./apps/client exec tsc --noEmit` — чисто по ai-chat (одна pre-existing несвязанная ошибка в editor/image-menu.tsx). - Сервер: `jest ai-chat-stream-registry + ai-chat.controller.attach` → **28 passed** (overflow-тесты сделаны cap-relative). Внутренний цикл: 1 проход coder → внутреннее ревreview (дедуп по 4 под-пунктам + безопасность цикла + отключённый путь + тесты, сверка с исходником SDK-колбэков) → APPROVE WITH SUGGESTIONS (только ниты выше). ## Checklist - [x] живой обрыв → реконнект к live-хвосту без перезагрузки; 204→poll до терминала; без дублей; backoff+лимит+Retry; переживает несколько обрывов - [x] вне заявленного scope ничего (5 файлов)
agent_coder added 1 commit 2026-07-10 02:52:36 +03:00
Автономный ран продолжается на сервере при обрыве SSE (Safari роняет длинный
стрим), но клиент показывал баннер 'Lost connection' и мёртвую вкладку до ручной
перезагрузки: resumeStream() звался только на mount. Добавлен недостающий триггер.

- chat-thread.tsx: в onFinish на живом isDisconnect (гард !wasResumed &&
  autonomousRunsEnabled && mounted && assistant) -> beginReconnect с экспон.
  backoff (1/2/4/8/16с, лимит 5). Стоп: status->streaming / 2xx re-attach /
  терминальный хвост reconcile / stop / unmount. Исчерпание -> Retry.
- Дедуп (главный риск): зеркалит mount strip/anchor — пиннит текущий streaming-ряд
  как anchor (id ассистент-строки), стрипает его из стора ДО replay, сервер
  ?expect=live&anchor=<id> пересобирает без дублей; на отказе/204 строка
  восстанавливается через onNoActiveStream (контент не теряется).
- 204/overflow -> degraded poll через существующий onNoActiveStream.
- RUN_STREAM_MAX_BUFFER_BYTES 4->32МБ (марафонские раны 11-25мин переполняли 4МБ);
  204->poll остаётся backstop. Degraded-poll: фиксированный 10-мин-от-старта кап
  заменён на inactivity-кап (продлевается пока приходят новые ряды).
- UI: баннер 'reconnecting… (N/5)' + ручной Retry на исчерпании.

closes #430

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-10 02:52:38 +03:00
Collaborator

Ревью — #432 feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE

Вердикт: PASS

Полный веер из 9 аспектов по всему диффу PR (merge-base d90c3b8b..22f687c3, +462/−28; клиент + сервер). Гейт зелёный (clean-room /home/claude/gm-ci): server tsc 0 · server jest ai-chat-stream-registry 17/17 · client tsc 0 · client vitest chat-thread 34/34 · ПОЛНЫЙ client-сьют (реальный CI-гейт покрытия) exit 0 (порог веток 53% проходит — одиночный прогон файла даёт ложный exit-1 из-за глобального порога, это артефакт, не падение тестов).

Что проверено (клик)
  • End-to-end когерентность: клиент ловит live-обрыв на не-resumed турне → пинит текущую assistant-строку как anchor → resumeStream(?expect=live&anchor=<id>) → 2xx: live-реаттач, баннер снимается; 204: degraded-poll + экспоненциальный бэкофф (до 5 попыток) → после исчерпания ручной Retry. Anchor-строка вырезается ПЕРЕД реплеем → нет дублей, нет разрыва.
  • Серверный контракт подтверждён напрямую (не в этом диффе — уже на develop): ai-chat.controller.ts:272-284 парсит @Query('expect')/@Query('anchor'); ai-chat-stream-registry.service.ts:181-191 обрабатывает anchor-mismatch→204, finished+expectLive→replay-only. Фича работает end-to-end.
  • Авторизация: реаттач проверяет владение через assertOwnedChat — чужой ран по id не подцепить.
  • Безопасность стрима: токен-дедуп на реплее (anchor-строка отфильтрована), бэкофф с жёстким капом 5 (нет hot-loop), очистка на unmount корректна (таймер + attach-controller), буфер освобождается на finish (finalizeEntry после 30с ретенции).
  • Нет регрессий: нормальный (без обрыва) путь не тронут; degraded-poll стал inactivity-based (улучшение, не таймаут посреди марафона); буфер 4→32МБ = живой реаттач для ранов 4-32МБ, >32МБ по-прежнему 204→degraded-poll.
  • Стейт-машина реконнекта чистая (3 состояния), 236 строк оправданы. Мутации подтверждают: attempt-bound / 204-fallback / Retry ловятся.

Вне scope — кандидат на отдельную задачу (НЕ часть вердикта)

  • [risk/DoS] Нет капа на число ОДНОВРЕМЕННЫХ буферизуемых рановai-chat-stream-registry.service.ts (реестр entries без лимита размера). Подъём буфера 4МБ→32МБ (subscriber-кап 2× = 64МБ) осознан и задокументирован (марафон-раны 11-25 мин), корректность держит fallback overflow→204→degraded-poll («correctness never depends on this bound»). НО апскейл усиливает pre-existing экспозицию: N одновременных ранов × 32МБ (при N=1000 → ~32ГБ). Число ранов не ограничено (это было и на 4МБ — #432 не вносит новый класс уязвимости, но усиливает ×8). Рекомендация: отдельная задача — per-workspace/global кап на число активных буферизуемых ранов (при превышении → 204 → degraded-poll). Не блокирует #432.
DROP — кодеру НЕ делать (лог калибровки)
  • [below-threshold] med/high [test-coverage] тест исчерпания хардкодит for n=1..5 вместо импорта RECONNECT_MAX_ATTEMPTS — тест РАБОТАЕТ и ловит мутацию attempt-bound; при смене константы упадёт «запутанно», но упадёт. Server-spec в этом же PR использует cap-relative паттерн — для консистентности можно импортировать константу, но автор вправе оставить (5 стабильна).
  • [below-threshold] low/med [test-coverage] unmount во время backoff-реконнекта не покрыт отдельным тестом — код-гарды (clearTimeout в cleanup) присутствуют и корректны (подтверждено stability), путь маргинален.
  • [artifact] [gate] одиночный прогон chat-thread.test.tsx даёт exit-1 по глобальному порогу покрытия веток — НЕ дефект #432: chat-thread.tsx сам 76% > порога, роняют другие непокрытые файлы (pre-existing); полный сьют проходит (exit 0).

DO/ESCALATE нет. Фича корректна и покрыта, контракт с сервером подтверждён, гейт зелёный. Память под многими одновременными ранами — pre-existing, вынес отдельной задачей. Approve.

9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence). Серверный expect/anchor-контракт и полный client-coverage-гейт проверены мной напрямую. Память = pre-existing out-of-scope. Escalate нет.

<!-- state:review reviewed_head=22f687c3 round=1 verdict=approved --> ## Ревью — #432 `feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE` **Вердикт: PASS** ✅ Полный веер из 9 аспектов по всему диффу PR (merge-base `d90c3b8b`..`22f687c3`, +462/−28; клиент + сервер). Гейт зелёный (clean-room `/home/claude/gm-ci`): server tsc 0 · server jest `ai-chat-stream-registry` **17/17** · client tsc 0 · client vitest `chat-thread` **34/34** · ПОЛНЫЙ client-сьют (реальный CI-гейт покрытия) **exit 0** (порог веток 53% проходит — одиночный прогон файла даёт ложный exit-1 из-за глобального порога, это артефакт, не падение тестов). <details> <summary>Что проверено (клик)</summary> - **End-to-end когерентность**: клиент ловит live-обрыв на не-resumed турне → пинит текущую assistant-строку как anchor → `resumeStream(?expect=live&anchor=<id>)` → 2xx: live-реаттач, баннер снимается; 204: degraded-poll + экспоненциальный бэкофф (до 5 попыток) → после исчерпания ручной Retry. Anchor-строка вырезается ПЕРЕД реплеем → нет дублей, нет разрыва. - **Серверный контракт подтверждён напрямую** (не в этом диффе — уже на develop): `ai-chat.controller.ts:272-284` парсит `@Query('expect')`/`@Query('anchor')`; `ai-chat-stream-registry.service.ts:181-191` обрабатывает anchor-mismatch→204, finished+expectLive→replay-only. Фича работает end-to-end. - **Авторизация**: реаттач проверяет владение через `assertOwnedChat` — чужой ран по id не подцепить. - **Безопасность стрима**: токен-дедуп на реплее (anchor-строка отфильтрована), бэкофф с жёстким капом 5 (нет hot-loop), очистка на unmount корректна (таймер + attach-controller), буфер освобождается на finish (finalizeEntry после 30с ретенции). - **Нет регрессий**: нормальный (без обрыва) путь не тронут; degraded-poll стал inactivity-based (улучшение, не таймаут посреди марафона); буфер 4→32МБ = живой реаттач для ранов 4-32МБ, >32МБ по-прежнему 204→degraded-poll. - Стейт-машина реконнекта чистая (3 состояния), 236 строк оправданы. Мутации подтверждают: attempt-bound / 204-fallback / Retry ловятся. </details> --- ### Вне scope — кандидат на отдельную задачу (НЕ часть вердикта) - **[risk/DoS] Нет капа на число ОДНОВРЕМЕННЫХ буферизуемых ранов** — `ai-chat-stream-registry.service.ts` (реестр `entries` без лимита размера). Подъём буфера 4МБ→32МБ (subscriber-кап 2× = 64МБ) осознан и задокументирован (марафон-раны 11-25 мин), корректность держит fallback overflow→204→degraded-poll («correctness never depends on this bound»). НО апскейл усиливает **pre-existing** экспозицию: N одновременных ранов × 32МБ (при N=1000 → ~32ГБ). Число ранов не ограничено (это было и на 4МБ — #432 не вносит новый класс уязвимости, но усиливает ×8). Рекомендация: отдельная задача — per-workspace/global кап на число активных буферизуемых ранов (при превышении → 204 → degraded-poll). Не блокирует #432. <details> <summary>⛔ DROP — кодеру НЕ делать (лог калибровки)</summary> - `[below-threshold]` `med/high` **[test-coverage]** тест исчерпания хардкодит `for n=1..5` вместо импорта `RECONNECT_MAX_ATTEMPTS` — тест РАБОТАЕТ и ловит мутацию attempt-bound; при смене константы упадёт «запутанно», но упадёт. Server-spec в этом же PR использует cap-relative паттерн — для консистентности можно импортировать константу, но автор вправе оставить (5 стабильна). - `[below-threshold]` `low/med` **[test-coverage]** unmount во время backoff-реконнекта не покрыт отдельным тестом — код-гарды (`clearTimeout` в cleanup) присутствуют и корректны (подтверждено stability), путь маргинален. - `[artifact]` **[gate]** одиночный прогон `chat-thread.test.tsx` даёт exit-1 по глобальному порогу покрытия веток — НЕ дефект #432: chat-thread.tsx сам 76% > порога, роняют другие непокрытые файлы (pre-existing); полный сьют проходит (exit 0). </details> DO/ESCALATE нет. Фича корректна и покрыта, контракт с сервером подтверждён, гейт зелёный. Память под многими одновременными ранами — pre-existing, вынес отдельной задачей. Approve. <sub>9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence). Серверный expect/anchor-контракт и полный client-coverage-гейт проверены мной напрямую. Память = pre-existing out-of-scope. Escalate нет.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-10 03:03:33 +03:00
vvzvlad merged commit 86830b860d into develop 2026-07-10 04:27:18 +03:00
Sign in to join this conversation.