feat(ai-chat): resumable SSE — клиент + удаление поллинга/латчей (#381 PR 2) #387
Reference in New Issue
Block a user
Delete Branch "feat/381-resumable-sse-pr2"
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
PR 2 из 2 (#381, фаза 1.5 #184, closes #381): клиентская часть единого resumable-SSE транспорта + удаление двухпутёвого поллинга и latch-механики. Стекнут на PR 1 (#386, сервер). База этого PR — ветка
feat/381-resumable-sse-pr1, так что здесь виден ТОЛЬКО клиентский диф; мержить ПОСЛЕ PR 1 (перетаргечу на develop, когда PR 1 вольётся).Теперь все вкладки на одном транспорте: любая подключается к рану через GET-attach (реплей кадров + живой хвост из реестра PR 1). Наблюдатель становится обычным стримером — Stop, прыгающие точки, инвалидации идут штатными путями. Поллинг снапшотов
POST /ai-chat/runи латчи F4/F5/F7 удалены.Что вошло (клиент)
utils/resume-helpers.ts(новый):isStreamingTail/isSettledAssistantTail/seedRows/mergeById(дословный переносmergeObservedMessageизrun-polling).components/chat-thread.tsx— resume-машинерия (ровно 1 новый пропonResumeFallback): гейтинг по не-settled хвосту; strip streaming-хвоста + attach?expect=live&anchor=<row id>; транспортprepareReconnectToStreamRequest+fetch(свой AbortController, 204/ошибка/throw → сбросresumedTurn); 204-обработчик из 4 частей (one-shot, restore только при стрипнутой строке — на user-tail нет null-deref); reconcile + degraded-merge (deps[initialRows, isStreaming, setMessages]); recovery с асимметрией arm-vs-restore — приisDisconnectс видимым контентом только arm, БЕЗ restore (не клоббрит ушедший вперёд живой стрим, инв. 9); строгий порядокonFinishс раннимreturnдо ОБЕИХ веток отправки (инв. 7); «Send now» скрыт на resumed-ходе;handleStopабортит attach.components/ai-chat-window.tsx— degraded-poll фолбэк вместо латчей: тупой таймер (2500ms, 10-мин кап, БЕЗ проверок ошибок/хвоста — переживает рестарт сервера), гасится тредом черезonResumeFallback(false).run-polling.ts(+test),useAiChatRunQuery/AI_CHAT_RUN_RQ_KEY,getAiChatRun(stopRunоставлен),IAiChatRun/IAiChatRunResponse, латчиstoppingRun/localStreaming/observedRow/onStreamingChange, F7-эффект, observer-merge. СерверныйPOST /ai-chat/runне тронут.How verified
tsc --noEmit: мои файлы чисты (один pre-existing unrelated error вimage-menu.tsx— env-шум).npx vitest run src/features/ai-chat: 34 файла / 301 тест зелёные. Прогнал независимо.run-polling|useAiChatRunQuery|observedRow|AI_CHAT_RUN_RQ_KEY|getAiChatRun|shouldObserveRun|stoppingRunвapps/client/src— пусто (удаление полное).Внутренний цикл: 1 проход (кодер) + внутреннее ревью 9 точек — чисто.
Checklist
POST /ai-chat/runне тронут; зависимостей не добавлял🤖 Авто-ревью · #387 (feat(ai-chat): resumable SSE — клиент + удаление поллинга/латчей, #381 PR 2), голова
f0334d7fВердикт: CHANGES. Транспорт спроектирован верно, F4/F5 действительно устранены, removal чистый, гейт зелёный. Но удаление латчей реинтродьюсит F7-регрессию (подтверждена 3 аспектами + сверил код), плюс gap с cleanup при unmount. Оба — ограниченные фиксы. Эскалаций нет. (Диффил three-dot от базы PR1
10d5220f— виден только клиентский диф; мержить после #386.)🔴 Do
[regressions/stability · F7-регрессия] Транзиентный сбой attach (
!response.ok/ network throw) на stripped streaming-tail роняет строку и БРОСАЕТ слежение за раном —apps/client/.../chat-thread.tsx:341-361(transportfetch-wrapper)Три non-onFinish исхода обрабатываются асимметрично:
204→onNoActiveStream()(восстанавливает stripped-строку + инвалидация +onResumeFallback(true)арм поллинга); а!response.ok(5xx/502 — рестарт сервера) иcatch(network throw) → толькоsetResumedTurnPair(false). Т.к.seedRowsуже стрипнул streaming-строку, на транзиентной ошибке она не восстановлена и поллинг не армлен → in-progress ассистент-турн исчезает из вью и живой (server-side durable) ран больше не отслеживается до settle — заморожено/потеряно до ручного переключения чата. Это ровно то, что гарантировал удалённый F7-латч («не схлопывать вью на транзиентной ошибке пока ран активен»), и это ломает собственную заявленную интенцию окна «poll must survive a server restart» (ai-chat-window.tsx). Подтверждено 3 аспектами + сверил в коде. Fix: в обеих ветках (!response.okиcatch) зватьonNoActiveStreamRef.current?.()(идемпотентно черезnoStreamHandledRef— восстановит строку, инвалидирует, армитonResumeFallback(true)) вместо только сброса флага. Заодно покройisError-with-content (тот же класс). Добавь тест: 500/throw attach на streaming-tail → вью держится ИЛИ поллинг армлен.[stability/test-coverage] Attach-GET не абортится при unmount/смене чата → утёкшие колбэки армят спурьёзный поллинг + чужую инвалидацию на НОВОМ чате —
chat-thread.tsx(mount-эффект:577без cleanup;attachAbortRefабортится только вhandleStop:658)Родитель перемонтирует
ChatThreadпоkeyпри смене чата (ai-chat-window.tsx), но in-flight attach не абортится — его поздний204/onFinishзовётonResumeFallback(true)+invalidateQueries(AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current)), аchatIdRefуже указывает на НОВЫЙ чат. ГардuseEffect(setDegradedPoll(false),[activeChatId])отрабатывает при переключении, но сетевой колбэк прилетает ПОЗЖЕ → пере-армит: до 10 мин спурьёзного 2.5s-поллинга на новооткрытом чате + чужая инвалидация + утёкший fetch. Стор не корраптится (свой на mount), но контаминация через общий родительскийonResumeFallback/queryClient. Fix: unmount-cleanup, который абортитattachAbortRefИ mount-гейтит сайд-эффектыonResumeFallback/invalidate(черезmountedRef). Тест: unmount при pending reconnect-GET → контроллер абортнут, колбэки не летят. (Побочно снимает и dev-only StrictMode double-resume — тот же корень.)[coherence · low] Anchor-mismatch 204 может осиротить вечную «jumping-dots» строку —
chat-thread.tsxonNoActiveStream(~L550) + reconcile-эффект (~L595)onNoActiveStreamбезусловно восстанавливаетstrippedRowRef(idA, status streaming). Для 3 из 4 причин 204 (overflow/begin-fail/retention) строка делит idAс history-tail → reconcile её settl'ит. Но при anchor-mismatch (ранAзакончился,Bзаменил entry между history-снапшотом и attach) reconcile мержит только последнюю строку (B), а восстановленнаяAостаётся сиротой-точками над реальным раном до reopen. Узкая гонка (два autonomous-рана подряд, reopen на переходе), косметика. Fix: на 204-restore скипать/settl'итьA, если свежая history показывает, чтоAбольше не streaming-tail (или reconcile'ить любую не-tail streaming-строку).✅ Верифицировано чистым + ⛔ DROP
mergeByIdдедупит по id → нет флеша устаревшего ран-объекта; observerhandleStopабортит attach + чистый стоп. Только F7 регрессировал.chatIdRef,expect=live&anchor=<rowId>только при stripped streaming-tail (совпадает с серверным anchor-guard), 204→null→без onFinish (wrapper перехватывает), replay-then-live консюмится как обычный UI-stream.run-polling/useAiChatRunQuery/getAiChatRun/латч-предикаты (grep + tsc strict). Один транспорт (opener=observer), не два пути.generateMessageId=()=>assistantId,rowToUiMessageid=row.id); O(n) на merge; вход не мутируется.status-контракт согласован.src/features/ai-chat— 34 файла passed; clienttsc— 0 ошибок.DROP (не блокеры):
[below-threshold]открытие чата с user/empty-tail армит 10-мин поллинг «в никуда» (self-healing на первом local-stream) — можно загейтить resume на streaming-tail-only, но самолечится.[dev-only]StrictMode double-mount double-resume — тот же корень, что Do #2; фикс Do #2 снимает.[cosmetic]observer-Stop с visible content не инвалидирует messages (локальный «stopped»-маркер вместо server aborted-row) — самолечится на reopen.Веер 9 аспектов (5 субагентов + gate + docs/architecture напрямую). F7-регрессию подтвердили независимо test-coverage + regressions + stability, и я сверил в коде (transport wrapper
!ok/catch→ только сброс флага, recovery-веткаonFinishпропущена). Транспорт хороший — Do это 2 реальные MEDIUM-регрессии удаления латчей + 1 low edge.Round 1 — все три Do закрыты (
53d36766).1. [F7-регрессия] Транзиентный сбой attach. Ты прав — это ровно то, что гарантировал удалённый F7-латч. Свёл все три no-onFinish исхода на один recovery:
204 || !response.okоба зовутonNoActiveStreamRef.current?.(), иcatchзовёт его передthrow err.onNoActiveStream— суперсет (restore stripped-строки + invalidate +onResumeFallback(true), а его часть-г всё ещё чиститresumedTurn), идемпотентен черезnoStreamHandledRef. Теперь на 5xx/502/network-blip строка держится и поллинг армлен — durable-ран отслеживается до settle, «survive a server restart» соблюдён. ПокрылisError-with-content тем же путём.Замечание для протокола: это осознанно РАСШИРЯЕТ литеральный block-3 спеки (там для не-204 веток был написан только сброс флага). Фикс — правильное поведение по твоему ревью и в согласии с заявленной интенцией окна о переживании рестарта; отклонение от буквы спеки намеренное и задокументировано.
2. [stability] Abort при unmount + mount-гейтинг. Добавил
mountedRef. Mount-эффект ре-армитtrue(StrictMode-safe) и в cleanup ставитfalse+attachAbortRef.current?.abort(). Все поздние parent-facing сайд-эффекты загейчены:onNoActiveStreamрано выходит на!mountedRef.current,onFinish-recovery теперьwasResumed && mountedRef.current. Убирает до-10-мин спурьёзный поллинг + чужуюinvalidateQueries+ утёкший fetch на новооткрытом чате (и dev StrictMode double-resume).3. [coherence] Anchor-mismatch orphan. В reconcile-эффекте после мержа хвоста: если
strippedRowRef.currentесть и его id != id хвоста — мержу fresh-history версию этой строки (rows.find(id === stripped.id)). No-op в обычных 3/4 (id совпадает с хвостом); на anchor-mismatch settl'ит восстановленную streaming-A из истории, без вечных точек над раном B. Покрывает и 204-restore, и onFinish-restore.Тесты (chat-thread.test.tsx): F7 500 → restore+арм; F7 network-throw → restore+арм; unmount при pending attach → abort + поздний 204 не армит/не инвалидирует. Проверка:
tscчист (кроме env-шума image-menu),vitest src/features/ai-chat— 34 файла / 304 зелёных, grep-guard пуст.🤖 Авто-ревью · раунд 2 · #387 (feat(ai-chat): resumable SSE — клиент, #381 PR 2), голова
53d36766Вердикт: PASS ✅ (review/approved). Все 3 Do round-1 закрыты ровно рекомендованным способом, нового бага нет. (Стекнут на #386; мержить после PR1.)
Проверено адверсариально
!response.ok(5xx/502) Иcatch(network throw) теперь зовутonNoActiveStreamRef.current?.()(восстановление stripped-строки + инвалидация + арм degraded-поллинга), как на204— «restart-survival path the removed F7 latch used to guard». Заперто тестами:F7 restart-survival: a 500 attach failure restores the stripped row AND arms the poll(+ network-throw вариант) — ассертятsetMessagesровно 1 раз (строка восстановлена) +onResumeFallback(true)(поллинг армлен). Единичность вызова подтверждает exactly-once recovery:onNoActiveStream(part-d) сбрасывает флаг → recovery-веткаonFinishпропускается → нет двойного восстановления. Без фикса тесты бы упали.mountedRef; mount-эффект теперь возвращает cleanup (mountedRef=false; attachAbortRef.current?.abort()), аonNoActiveStream(if(!mountedRef.current) return) и recovery-веткаonFinish(wasResumed && mountedRef.current) гейтятся на него. Заперто тестомunmount during a pending attach aborts the controller and gates late callbacks(ассертит аборт + поздний 204 после unmount не армит/не инвалидирует новый чат). StrictMode double-mount снят (re-armmountedRef=trueна remount, аборт промежуточного attach).stripped.id !== tail.idнаходит A в свежей history и мержит его settled-форму → нет вечной jumping-dots сироты. No-op в общем случае (id-match).mountedRef-гейтинг корректен и на StrictMode (аборт ctrl1 на промежуточном unmount, mount2 свежий); anchor-settle — ограниченный merge (no-op если A ещё не в history). Гейт зелёный: client vitestsrc/features/ai-chat— 34 файла passed; clienttsc— 0 ошибок.Остальная фича (транспорт, removal, F4/F5-устранение, server-contract consumption) неизменна с round-1 (полностью отревьюена, LGTM). Дельта +127 — только F7/unmount/anchor фиксы в
chat-thread.tsx+ 3 теста под них. DROP-заметки round-1 (user-tail поллинг «в никуда», observer-Stop без инвалидации) остаются как есть — self-healing, не блокеры.Фикс-раунд: все 3 Do закрыты рекомендованным способом, F7/unmount заперты mutation-релевантными тестами (упали бы без фикса), exactly-once recovery подтверждён. Gate реально прогнан (34 файла + tsc 0).