feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE #432
Reference in New Issue
Block a user
Delete Branch "feat/430-live-reconnect"
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
Автономный (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-путь не тронут (старый терминальный нотис).?expect=live&anchor=<id>пересобирает без дублей; на отказе/204 строка восстанавливается черезonNoActiveStream(контент не теряется). Внутреннее ревreview подтвердило: окна «убрали и не replay/не restore» нет.onNoActiveStream.RUN_STREAM_MAX_BUFFER_BYTES4→32МБ (марафонские раны 11–25мин переполняли 4МБ; освобождается на finish/overflow; 204→poll остаётся backstop). Degraded-poll: фиксированный 10-мин-от-старта кап → inactivity-кап (продлевается пока меняется ссылкаmessageRows= реальный прогресс через TanStack structural sharing).closes #430
Известные не-блокеры (ниты внутреннего ревью)
How verified
pnpm --filter ./apps/client exec vitest run src/features/ai-chat/→ 326 passed (+6 новых #430: resumeStream post-mount с корректнымexpect=live&anchorURL; 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
Ревью — #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 jestai-chat-stream-registry17/17 · client tsc 0 · client vitestchat-thread34/34 · ПОЛНЫЙ client-сьют (реальный CI-гейт покрытия) exit 0 (порог веток 53% проходит — одиночный прогон файла даёт ложный exit-1 из-за глобального порога, это артефакт, не падение тестов).Что проверено (клик)
resumeStream(?expect=live&anchor=<id>)→ 2xx: live-реаттач, баннер снимается; 204: degraded-poll + экспоненциальный бэкофф (до 5 попыток) → после исчерпания ручной Retry. Anchor-строка вырезается ПЕРЕД реплеем → нет дублей, нет разрыва.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 не подцепить.Вне scope — кандидат на отдельную задачу (НЕ часть вердикта)
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 нет.