AI-чат: авто-переподключение к detached-рану после живого обрыва SSE (реконнект есть, но триггерится только перезагрузкой) #430

Closed
opened 2026-07-10 02:06:08 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема

Автономный (detached) ран продолжается на сервере, даже когда SSE браузер↔сервер обрывается посреди ответа. Частый кейс: Safari роняет длинный марафонский стрим — при этом сервер честно дописывает турн до конца (в логах AI chat stream DIAGNOSTIC (finish), heartbeatsSent идут, нет client disconnected, сокет со стороны сервера жив). Но в браузере остаётся баннер «Lost connection to the server» и мёртвая вкладка: контент не догоняется, пока пользователь вручную не перезагрузит страницу.

Пользовательски это выглядит как «стрим сломался — почему он сам не переподключился?». Вся машинерия для этого уже есть (resumable stream, #184 phase 1.5): resumeStream(), attach-эндпоинт GET /api/ai-chat/runs/:chatId/stream, реестр стримов с replay + live-tail, prepareReconnectToStreamRequest, обработка 204. Но у неё один-единственный триггер.

Диагноз (по коду)

resumeStream() в клиенте вызывается ровно один раз — mount-эффект chat-thread.tsx:618-634 (deps [], «Mount-only by design»). На живой обрыв его никто не переиспользует:

  • onFinish({ isDisconnect }): для живого турна wasResumed = false, поэтому блок восстановления (reconcile + degraded poll + onResumeFallback) под if (wasResumed && ...) (chat-thread.tsx:461) пропускается, а дальше if (isAbort || isDisconnect || isError) return; (chat-thread.tsx:516) просто выходит, показав баннер.
  • onError (chat-thread.tsx:526) — только console.error + onTurnFinished.

Итог: живой isDisconnect не запускает ни resumeStream(), ни fallback-поллинг (поллинг взводится только для resumed-турна). Единственный способ переподцепиться — перезагрузка (пере-mount → mount-resume).

Исторически это было ок: до автономных ранов обрыв браузера прерывал турн на сервере — переподключаться было не к чему. С autonomousRuns=true ран продолжается, а клиент под этот случай не дописали.

Техдизайн

Переиспользовать существующую машинерию, добавив недостающий триггер:

  1. На onFinish с isDisconnect (и/или onError), когда autonomousRunsEnabled → запустить попытку resumeStream() с экспоненциальным backoff и лимитом попыток (напр. 5), останавливаясь как только status снова streaming или пришёл терминальный ряд.
  2. Если attach вернул 204 (буфер переполнен / ран уже завершился) → взвести degraded poll как fallback. Обработчик onNoActiveStream (chat-thread.tsx:587) уже делает ровно это (restore + invalidate + onResumeFallback(true)) — надо, чтобы reconnect-путь через него проходил.
  3. UI: вместо финального «Lost connection» показывать «соединение потеряно, переподключаюсь… (попытка N)», с ручной кнопкой Retry на исчерпании попыток.

Дедуп при live-replay (главный риск)

Сейчас strip/anchor для reconnect считаются на mount (stripRef/strippedRowRef/attemptResumeRef, chat-thread.tsx:202-210) из персистентного хвоста. Для живого обрыва хвост — это уже показанное частичное сообщение в messages, а не персистентная строка. text-start при replay пушит новую часть → задвоение. Нужно перед reconnect зафиксировать текущий streaming-ряд как anchor (id ассистент-строки рана) и стрипнуть его из стора так же, как mount-путь, чтобы replay пересобрал его без дублей. Сервер уже поддерживает ?expect=live&anchor=<assistantMessageId> (ai-chat.controller.ts:269, invariant 6).

Затрагиваемые файлы

  • apps/client/src/features/ai-chat/components/chat-thread.tsx — disconnect→reconnect handler, backoff-стейт, стыковка с существующими resumeStream / onNoActiveStream / degraded-poll.
  • Возможно apps/client/src/features/ai-chat/utils/resume-helpers.ts — предикаты для live-anchor.
  • chat-thread.test.tsx — сейчас проверяет «resumeStream зовётся 1 раз на mount»; добавить кейсы на reconnect после isDisconnect.

Связанные ограничения (нужны для end-to-end)

Без них реконнект «есть, но всё равно упирается» — стоит закрыть в рамках этой же задачи:

  • Реестр стримов: RUN_STREAM_MAX_BUFFER_BYTES = 4 МБ (ai-chat-stream-registry.service.ts:21) — на марафонских ранах переполняется → attach отдаёт 204. Поднять кап или на overflow отдавать «прицепись к БД-проекции» вместо голого 204.
  • Degraded poll: DEGRADED_POLL_MAX_MS = 10 мин (ai-chat-window.tsx:93) — короче реальных прогонов (наблюдались турны 11–25 мин). Пересчитывать кап от активности рана, а не от старта.

Acceptance criteria

  • Живой обрыв SSE detached-рана → клиент без перезагрузки переподключается к живому хвосту и продолжает показывать стрим.
  • При невозможности attach (204/overflow) → авто-fallback на поллинг БД-проекции, догоняющий ран до терминала.
  • Никакого задвоения уже показанного контента после reconnect.
  • Экспоненциальный backoff + лимит попыток; при исчерпании — понятный статус + ручной Retry.
  • Работает для длинных ранов (>10 мин) и переживает несколько обрывов подряд.
  • Тесты: reconnect после isDisconnect, 204→poll, отсутствие дублей, лимит попыток.

Вне scope

  • Первопричина обрывов Safari (браузерная; отдельно — дробление длинных турнов / смена транспорта на EventSource).
  • Серверные таймауты / bodyLimit / jsonb-санитизация (уже сделаны отдельно).
  • UI-индикатор таймаута инструмента — #404.
## Проблема Автономный (detached) ран продолжается на сервере, даже когда SSE браузер↔сервер обрывается посреди ответа. Частый кейс: Safari роняет длинный марафонский стрим — при этом сервер честно **дописывает турн до конца** (в логах `AI chat stream DIAGNOSTIC (finish)`, `heartbeatsSent` идут, **нет** `client disconnected`, сокет со стороны сервера жив). Но в браузере остаётся баннер **«Lost connection to the server»** и мёртвая вкладка: контент не догоняется, пока пользователь вручную не перезагрузит страницу. Пользовательски это выглядит как «стрим сломался — почему он сам не переподключился?». Вся машинерия для этого **уже есть** (resumable stream, #184 phase 1.5): `resumeStream()`, attach-эндпоинт `GET /api/ai-chat/runs/:chatId/stream`, реестр стримов с replay + live-tail, `prepareReconnectToStreamRequest`, обработка 204. Но у неё один-единственный триггер. ## Диагноз (по коду) `resumeStream()` в клиенте вызывается **ровно один раз** — mount-эффект `chat-thread.tsx:618-634` (deps `[]`, «Mount-only by design»). На живой обрыв его никто не переиспользует: - `onFinish({ isDisconnect })`: для **живого** турна `wasResumed = false`, поэтому блок восстановления (reconcile + degraded poll + `onResumeFallback`) под `if (wasResumed && ...)` (`chat-thread.tsx:461`) **пропускается**, а дальше `if (isAbort || isDisconnect || isError) return;` (`chat-thread.tsx:516`) просто выходит, показав баннер. - `onError` (`chat-thread.tsx:526`) — только `console.error` + `onTurnFinished`. Итог: живой `isDisconnect` не запускает ни `resumeStream()`, ни fallback-поллинг (поллинг взводится только для *resumed*-турна). Единственный способ переподцепиться — перезагрузка (пере-mount → mount-resume). Исторически это было ок: до автономных ранов обрыв браузера **прерывал** турн на сервере — переподключаться было не к чему. С `autonomousRuns=true` ран продолжается, а клиент под этот случай не дописали. ## Техдизайн Переиспользовать существующую машинерию, добавив недостающий триггер: 1. На `onFinish` с `isDisconnect` (и/или `onError`), когда `autonomousRunsEnabled` → запустить попытку `resumeStream()` с экспоненциальным backoff и лимитом попыток (напр. 5), останавливаясь как только `status` снова `streaming` или пришёл терминальный ряд. 2. Если attach вернул 204 (буфер переполнен / ран уже завершился) → взвести degraded poll как fallback. Обработчик `onNoActiveStream` (`chat-thread.tsx:587`) уже делает ровно это (restore + invalidate + `onResumeFallback(true)`) — надо, чтобы reconnect-путь через него проходил. 3. UI: вместо финального «Lost connection» показывать «соединение потеряно, переподключаюсь… (попытка N)», с ручной кнопкой Retry на исчерпании попыток. ### Дедуп при live-replay (главный риск) Сейчас strip/anchor для reconnect считаются на mount (`stripRef`/`strippedRowRef`/`attemptResumeRef`, `chat-thread.tsx:202-210`) из **персистентного** хвоста. Для живого обрыва хвост — это уже показанное частичное сообщение в `messages`, а не персистентная строка. `text-start` при replay пушит новую часть → **задвоение**. Нужно перед reconnect зафиксировать текущий streaming-ряд как anchor (id ассистент-строки рана) и стрипнуть его из стора так же, как mount-путь, чтобы replay пересобрал его без дублей. Сервер уже поддерживает `?expect=live&anchor=<assistantMessageId>` (`ai-chat.controller.ts:269`, invariant 6). ## Затрагиваемые файлы - `apps/client/src/features/ai-chat/components/chat-thread.tsx` — disconnect→reconnect handler, backoff-стейт, стыковка с существующими `resumeStream` / `onNoActiveStream` / degraded-poll. - Возможно `apps/client/src/features/ai-chat/utils/resume-helpers.ts` — предикаты для live-anchor. - `chat-thread.test.tsx` — сейчас проверяет «resumeStream зовётся 1 раз на mount»; добавить кейсы на reconnect после `isDisconnect`. ## Связанные ограничения (нужны для end-to-end) Без них реконнект «есть, но всё равно упирается» — стоит закрыть в рамках этой же задачи: - **Реестр стримов**: `RUN_STREAM_MAX_BUFFER_BYTES = 4 МБ` (`ai-chat-stream-registry.service.ts:21`) — на марафонских ранах переполняется → attach отдаёт 204. Поднять кап или на overflow отдавать «прицепись к БД-проекции» вместо голого 204. - **Degraded poll**: `DEGRADED_POLL_MAX_MS = 10 мин` (`ai-chat-window.tsx:93`) — короче реальных прогонов (наблюдались турны 11–25 мин). Пересчитывать кап от активности рана, а не от старта. ## Acceptance criteria - [ ] Живой обрыв SSE detached-рана → клиент **без перезагрузки** переподключается к живому хвосту и продолжает показывать стрим. - [ ] При невозможности attach (204/overflow) → авто-fallback на поллинг БД-проекции, догоняющий ран до терминала. - [ ] Никакого задвоения уже показанного контента после reconnect. - [ ] Экспоненциальный backoff + лимит попыток; при исчерпании — понятный статус + ручной Retry. - [ ] Работает для длинных ранов (>10 мин) и переживает несколько обрывов подряд. - [ ] Тесты: reconnect после `isDisconnect`, 204→poll, отсутствие дублей, лимит попыток. ## Вне scope - Первопричина обрывов Safari (браузерная; отдельно — дробление длинных турнов / смена транспорта на EventSource). - Серверные таймауты / `bodyLimit` / jsonb-санитизация (уже сделаны отдельно). - UI-индикатор таймаута инструмента — #404.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#430