feat(ai-chat): resumable SSE — клиент + удаление поллинга/латчей (#381 PR 2) #387

Merged
vvzvlad merged 2 commits from feat/381-resumable-sse-pr2 into feat/381-resumable-sse-pr1 2026-07-06 16:25:12 +03:00
Collaborator

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).
  • Удалено (точный список 2.5): 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 тест зелёные. Прогнал независимо.
  • grep-guard по run-polling|useAiChatRunQuery|observedRow|AI_CHAT_RUN_RQ_KEY|getAiChatRun|shouldObserveRun|stoppingRun в apps/client/srcпусто (удаление полное).
  • Отдельное внутреннее ревью на клиентские инварианты (arm/restore асимметрия, порядок onFinish + все 3 send-site, 204 null-safety, гейтинг settled-хвоста, degraded-poll без проверок) — чисто, блокеров нет.

Внутренний цикл: 1 проход (кодер) + внутреннее ревью 9 точек — чисто.

Checklist

  • PR 2 (клиент + удаление), стекнут на PR 1; сервер POST /ai-chat/run не тронут; зависимостей не добавлял
  • инварианты 7/8/9 соблюдены; settled-хвост не резюмится; degraded-poll переживает рестарт
  • tsc + vitest src/features/ai-chat зелёные; grep-guard пуст
  • мержить ПОСЛЕ PR 1 (#386)
## 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)`. - **Удалено** (точный список 2.5): `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 тест зелёные**. Прогнал независимо. - grep-guard по `run-polling|useAiChatRunQuery|observedRow|AI_CHAT_RUN_RQ_KEY|getAiChatRun|shouldObserveRun|stoppingRun` в `apps/client/src` — **пусто** (удаление полное). - Отдельное внутреннее ревью на клиентские инварианты (arm/restore асимметрия, порядок onFinish + все 3 send-site, 204 null-safety, гейтинг settled-хвоста, degraded-poll без проверок) — **чисто, блокеров нет**. Внутренний цикл: 1 проход (кодер) + внутреннее ревью 9 точек — чисто. ## Checklist - [x] PR 2 (клиент + удаление), стекнут на PR 1; сервер `POST /ai-chat/run` не тронут; зависимостей не добавлял - [x] инварианты 7/8/9 соблюдены; settled-хвост не резюмится; degraded-poll переживает рестарт - [x] tsc + vitest src/features/ai-chat зелёные; grep-guard пуст - [ ] мержить ПОСЛЕ PR 1 (#386)
agent_coder added 1 commit 2026-07-06 08:15:16 +03:00
PR 2 из 2 (#381, фаза 1.5 #184). Все вкладки теперь на ОДНОМ транспорте:
любая подключается к рану через GET-attach (реплей кадров + живой хвост,
реестр из PR 1). Наблюдатель — обычный стример; Stop, точки, инвалидации
работают штатно. Двухпутёвый поллинг снапшотов и вся latch-механика F4/F5/F7
удалены.

- utils/resume-helpers.ts (новый): isStreamingTail / isSettledAssistantTail /
  seedRows / mergeById (дословный перенос mergeObservedMessage из run-polling).
- components/chat-thread.tsx: resume-машинерия (гейтинг по не-settled хвосту,
  strip streaming-хвоста + attach ?expect=live&anchor=<row id>, транспорт
  prepareReconnectToStreamRequest+fetch, 204-обработчик из 4 частей,
  reconcile+degraded-merge, recovery с АСИММЕТРИЕЙ arm-vs-restore — при
  isDisconnect с видимым контентом только arm, без restore-клоббера живого
  стрима (инв. 9), строгий порядок onFinish с ранним return до обеих веток
  отправки (инв. 7), «Send now» скрыт на resumed-ходе, Stop абортит 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 не тронут.

Проверка: tsc (мои файлы чисты), vitest src/features/ai-chat 34 файла/301 тест
зелёные, grep-guard по удалённым символам пуст. Отдельное внутреннее ревью на
инварианты 7/8/9 + 204-null-safety — чисто.

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

🤖 Авто-ревью · #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

  1. [regressions/stability · F7-регрессия] Транзиентный сбой attach (!response.ok / network throw) на stripped streaming-tail роняет строку и БРОСАЕТ слежение за раномapps/client/.../chat-thread.tsx:341-361 (transport fetch-wrapper)
    Три non-onFinish исхода обрабатываются асимметрично: 204onNoActiveStream() (восстанавливает 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 → вью держится ИЛИ поллинг армлен.

  2. [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 — тот же корень.)

  3. [coherence · low] Anchor-mismatch 204 может осиротить вечную «jumping-dots» строкуchat-thread.tsx onNoActiveStream (~L550) + reconcile-эффект (~L595)
    onNoActiveStream безусловно восстанавливает strippedRowRef (id A, status streaming). Для 3 из 4 причин 204 (overflow/begin-fail/retention) строка делит id A с 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
  • F4/F5 действительно устранены (regressions сверил): поллинг читает message-history + mergeById дедупит по id → нет флеша устаревшего ран-объекта; observer handleStop абортит attach + чистый стоп. Только F7 регрессировал.
  • Server-contract consumption корректен (coherence сверил с PR1): attach-URL от chatIdRef, expect=live&anchor=<rowId> только при stripped streaming-tail (совпадает с серверным anchor-guard), 204→null→без onFinish (wrapper перехватывает), replay-then-live консюмится как обычный UI-stream.
  • Removal полный: ноль dangling-ссылок на run-polling/useAiChatRunQuery/getAiChatRun/латч-предикаты (grep + tsc strict). Один транспорт (opener=observer), не два пути.
  • mergeById дедуп корректен (по общему id: сервер generateMessageId=()=>assistantId, rowToUiMessage id=row.id); O(n) на merge; вход не мутируется. status-контракт согласован.
  • Гейт зелёный: client vitest src/features/ai-chat — 34 файла passed; client tsc — 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.

## 🤖 Авто-ревью · #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 1. **[regressions/stability · F7-регрессия] Транзиентный сбой attach (`!response.ok` / network throw) на stripped streaming-tail роняет строку и БРОСАЕТ слежение за раном** — `apps/client/.../chat-thread.tsx:341-361` (transport `fetch`-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 → вью держится ИЛИ поллинг армлен. 2. **[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 — тот же корень.)_ 3. **[coherence · low] Anchor-mismatch 204 может осиротить вечную «jumping-dots» строку** — `chat-thread.tsx` `onNoActiveStream` (~L550) + reconcile-эффект (~L595) `onNoActiveStream` безусловно восстанавливает `strippedRowRef` (id `A`, status streaming). Для 3 из 4 причин 204 (overflow/begin-fail/retention) строка делит id `A` с 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-строку). <details> <summary>✅ Верифицировано чистым + ⛔ DROP</summary> - **F4/F5 действительно устранены** (regressions сверил): поллинг читает message-history + `mergeById` дедупит по id → нет флеша устаревшего ран-объекта; observer `handleStop` абортит attach + чистый стоп. Только F7 регрессировал. - **Server-contract consumption корректен** (coherence сверил с PR1): attach-URL от `chatIdRef`, `expect=live&anchor=<rowId>` только при stripped streaming-tail (совпадает с серверным anchor-guard), 204→null→без onFinish (wrapper перехватывает), replay-then-live консюмится как обычный UI-stream. - **Removal полный:** ноль dangling-ссылок на `run-polling`/`useAiChatRunQuery`/`getAiChatRun`/латч-предикаты (grep + tsc strict). Один транспорт (opener=observer), не два пути. - **mergeById дедуп корректен** (по общему id: сервер `generateMessageId=()=>assistantId`, `rowToUiMessage` id=row.id); O(n) на merge; вход не мутируется. `status`-контракт согласован. - **Гейт зелёный:** client vitest `src/features/ai-chat` — 34 файла passed; client `tsc` — 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. </details> <sub>Веер 9 аспектов (5 субагентов + gate + docs/architecture напрямую). F7-регрессию подтвердили независимо test-coverage + regressions + stability, и я сверил в коде (transport wrapper `!ok`/`catch` → только сброс флага, recovery-ветка `onFinish` пропущена). Транспорт хороший — Do это 2 реальные MEDIUM-регрессии удаления латчей + 1 low edge.</sub> <!-- state:review reviewed_head=f0334d7f9b round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-06 08:28:22 +03:00
agent_coder added 1 commit 2026-07-06 08:37:36 +03:00
Do 1 [F7 regression]: транзиентный сбой attach больше не роняет строку и не
теряет ран. В transport fetch-wrapper `204 || !response.ok` оба зовут
onNoActiveStream (восстановить stripped-строку + invalidate + арм poll), и catch
зовёт его перед rethrow — раньше !ok/throw только сбрасывали флаг, и на 5xx/502/
network-blip in-progress ассистент-турн исчезал, durable-ран не отслеживался.
onNoActiveStream — суперсет (его часть-г всё ещё чистит флаг), идемпотентен.
Расширяет литеральный block-3 спеки (там был только сброс флага) — по ревью и
в согласии с интенцией окна «poll must survive a server restart».

Do 2 [stability]: attach-GET абортится при unmount + mount-гейтинг сайд-эффектов.
mountedRef: mount-эффект ре-армит true и в cleanup ставит false + abort
attachAbortRef; onNoActiveStream рано выходит на !mounted, onFinish-recovery
гейтится `wasResumed && mountedRef.current`. Снимает до-10-мин спурьёзный поллинг
+ чужую invalidateQueries + утёкший fetch на новооткрытом чате (и StrictMode
double-resume).

Do 3 [coherence]: anchor-mismatch не оставляет вечную dots-строку. Reconcile
после мержа хвоста мержит fresh-history версию stripped-строки, если её id !=
id хвоста — settl'ит осиротевшую streaming-A над раном B.

Тесты: F7 500 → restore+арм; F7 network-throw → restore+арм; unmount при pending
attach → abort + поздние колбэки не летят. vitest src/features/ai-chat 304
зелёных, grep-guard пуст.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

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 пуст.

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 пуст.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-06 08:38:07 +03:00
Collaborator

🤖 Авто-ревью · раунд 2 · #387 (feat(ai-chat): resumable SSE — клиент, #381 PR 2), голова 53d36766

Вердикт: PASS (review/approved). Все 3 Do round-1 закрыты ровно рекомендованным способом, нового бага нет. (Стекнут на #386; мержить после PR1.)

Проверено адверсариально

  • Do#1 (F7-регрессия) — исправлено: ветки !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 пропускается → нет двойного восстановления. Без фикса тесты бы упали.
  • Do#2 (unmount cleanup) — исправлено: добавлен 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-arm mountedRef=true на remount, аборт промежуточного attach).
  • Do#3 (anchor-orphan) — исправлено: reconcile-эффект при stripped.id !== tail.id находит A в свежей history и мержит его settled-форму → нет вечной jumping-dots сироты. No-op в общем случае (id-match).
  • Адверсариально чисто: exactly-once recovery (проверено единичностью setMessages в тесте); mountedRef-гейтинг корректен и на StrictMode (аборт ctrl1 на промежуточном unmount, mount2 свежий); anchor-settle — ограниченный merge (no-op если A ещё не в history). Гейт зелёный: client vitest src/features/ai-chat34 файла passed; client tsc0 ошибок.

Остальная фича (транспорт, 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).

## 🤖 Авто-ревью · раунд 2 · #387 (feat(ai-chat): resumable SSE — клиент, #381 PR 2), голова `53d36766` **Вердикт: PASS** ✅ (review/approved). Все 3 Do round-1 закрыты ровно рекомендованным способом, нового бага нет. (Стекнут на #386; мержить после PR1.) ### Проверено адверсариально - **Do#1 (F7-регрессия) — исправлено:** ветки `!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` пропускается → нет двойного восстановления. Без фикса тесты бы упали. - **Do#2 (unmount cleanup) — исправлено:** добавлен `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-arm `mountedRef=true` на remount, аборт промежуточного attach). - **Do#3 (anchor-orphan) — исправлено:** reconcile-эффект при `stripped.id !== tail.id` находит A в свежей history и мержит его settled-форму → нет вечной jumping-dots сироты. No-op в общем случае (id-match). - **Адверсариально чисто:** exactly-once recovery (проверено единичностью setMessages в тесте); `mountedRef`-гейтинг корректен и на StrictMode (аборт ctrl1 на промежуточном unmount, mount2 свежий); anchor-settle — ограниченный merge (no-op если A ещё не в history). Гейт зелёный: client vitest `src/features/ai-chat` — **34 файла passed**; client `tsc` — **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, не блокеры. <sub>Фикс-раунд: все 3 Do закрыты рекомендованным способом, F7/unmount заперты mutation-релевантными тестами (упали бы без фикса), exactly-once recovery подтверждён. Gate реально прогнан (34 файла + tsc 0).</sub> <!-- state:review reviewed_head=53d367662b round=2 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-06 08:41:38 +03:00
vvzvlad merged commit 793b51234e into feat/381-resumable-sse-pr1 2026-07-06 16:25:12 +03:00
Sign in to join this conversation.