AI-чат: авто-переподключение к detached-рану после живого обрыва SSE (реконнект есть, но триггерится только перезагрузкой) #430
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Проблема
Автономный (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ран продолжается, а клиент под этот случай не дописали.Техдизайн
Переиспользовать существующую машинерию, добавив недостающий триггер:
onFinishсisDisconnect(и/илиonError), когдаautonomousRunsEnabled→ запустить попыткуresumeStream()с экспоненциальным backoff и лимитом попыток (напр. 5), останавливаясь как толькоstatusсноваstreamingили пришёл терминальный ряд.onNoActiveStream(chat-thread.tsx:587) уже делает ровно это (restore + invalidate +onResumeFallback(true)) — надо, чтобы reconnect-путь через него проходил.Дедуп при 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_MAX_MS = 10 мин(ai-chat-window.tsx:93) — короче реальных прогонов (наблюдались турны 11–25 мин). Пересчитывать кап от активности рана, а не от старта.Acceptance criteria
isDisconnect, 204→poll, отсутствие дублей, лимит попыток.Вне scope
bodyLimit/ jsonb-санитизация (уже сделаны отдельно).