feat(ai-chat): единый resumable SSE-транспорт для агентских ранов — attach/replay вместо поллинга (#184, фаза 1.5) #381
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?
Зачем
PR #234 (фаза 1 #184) сделал раны durable: сервер доводит ход после дисконнекта браузера. Но UI-доставка осталась двухпутёвой: вкладка-стартер видит живой SSE-стрим, а переоткрытая вкладка — 2-секундный поллинг снапшотов
POST /ai-chat/runбез токен-стрима. Вся latch-механика F4/F5/F7 (run-polling.ts,ai-chat-window.tsx) существует только потому, что два транспорта дерутся за вью.Цель: один транспорт для всех вкладок. Ран публикует свой SSE-стрим в серверный реестр; любая вкладка подключается через
GET-attach с реплеем кадров и живым хвостом. Наблюдатель становится обычным стримером (status === 'streaming') → Stop-кнопка, прыгающие точки и инвалидации работают штатными путями. Поллинг и латчи удаляются.⚠️ Правила для исполнителя — прочитать до начала
generateMessageIdпоэтому гейтится наrunId), семантикуPOST /ai-chat/stream,POST /ai-chat/run,POST /ai-chat/stop, public-share чат, схему БД (миграций нет).ai@6.0.134патченный, clientai@6.0.207+@ai-sdk/react@3.0.209). Никакого resumable-stream/Redis/BullMQ.Проверенные факты об SDK (сверены по установленным исходникам — опираться, не перепроверять)
pipeUIMessageStreamToResponse(res, { consumeSseStream })тее-ит поток SSE-строк до сокета (server dist:5911-5920); tee-ветка живёт при смерти клиентского сокета. Кадрыdata: {json}\n\n, терминальныйdata: [DONE]\n\n. Heartbeat'ы (: ping) в tee не попадают.reconnectToStream: GET${api}/${chatId}/stream,chatId= store id useChat (не серверный!), без AbortSignal (client dist:13334-13372).prepareReconnectToStreamRequestполностью заменяет URL. Только 204 → null (тихий no-op, статус не меняется,onFinishНЕ зовётся); другой не-2xx бросает →onError+ красный баннер (иonFinishтоже НЕ зовётся — см. задачу 2.3 п. 3).resumeStream()— mount-only эффект (react dist:221-225). Терминал resumed-стрима зовётonFinishизfinally(dist:13757-13766) — как у локального.text-startвсегда пушит новую парту (dist:5746-5754) — реплей поверх засеянной in-progress строки дублирует текст. То же верно для реплея ЗАВЕРШЁННОГО рана поверх settled-строки — поэтому settled-хвост никогда не резюмим.{"type":"abort"}→[DONE]→ конец. Клиентский процессорabort-чанк игнорирует → у вкладки, не нажимавшей Stop, это чистый финиш (isAbort:false). Принято как паритет — но см. задачу 2.3 п. 8 (подавление флаша). Error-чанк, напротив, БРОСАЕТ в процессоре (dist:6109-6111 → 13734-13736) →isError+ баннер.stop()НЕ абортит resumed-фетч (сигнал не проброшен), но взводитisAbort=trueна будущийonFinish(dist:13686-13688) — источник «зомби»-ловушек, закрытых ниже.assistantIdприсваивается awaited-вставкой (ai-chat.service.ts:842-853) ДО pipe-вызова (:1176) на обоих путях.generateMessageIdбезoriginalMessagesштампуетstart.messageId(dist:5819-5834). Chat id — UUIDv7,ParseUUIDPipeпо умолчанию (all) его принимает.AiChatService.stream:beginRun:574-580 → вставка user-строки :609 → сид ассистент-строки :842 (между ними — загрузка истории, настройки, MCP-хендшейки — секунды!) → pipe :1176. На этом порядке держатся open()-at-begin гарантии.onFinishу streamText (:1026-1039), стартует примерно когда[DONE]уходит на провод. Инвалидация сразу после клиентского onFinish регулярно возвращает строку ещёstatus:'streaming'(со снапшотом последнего ЗАВЕРШЁННОГО шага, для одношагового ответа — возможно пустым сидом). Поэтому мержить streaming-снапшот поверх полного стримленного сообщения НЕЛЬЗЯ — п. 7 задачи 2.3 взводит recovery только когда мержить действительно нужно.query.state.fetchFailureCountСБРАСЫВАЕТСЯ в 0 в начале каждого фетча (query-core dist:353-358, 408-412) — межтиковые consecutive-ошибки им НЕ выразить. Поэтому degraded-poll НЕ гасится по ошибкам вообще (см. задачу 2.4): единственные границы — 10-мин кап, settled, локальный стрим, смена чата. Бонус: поллинг переживает рестарт сервера.ИНВАРИАНТЫ (нарушение любого = сломанный дизайн; на каждый есть тест)
bind/abortEntryмутируют entry только при совпаденииrunId.if (entries.get(chatId) === entry) entries.delete(chatId).open()при замене завершает старую entry зеркально done-пути (started-подписчикамonEnd()И удалить их; paused —pendingEnd=true; снять её таймер) — включая ещё «живую» (ран settled, tee-петля дочитывает).attach(paused-режим); между ними нет await.close-хендлер → unsubscribe) регистрируется до первой записи в сокет, плюс явная проверкаreq.raw.destroyedпосле регистрации (close мог прийти во время await'ов); реплей+start()— в try/catch с unsubscribe.finished-entry приexpect=liveотдаётся БЕЗ регистрации подписчика; безexpect=live— 204. Приexpect=liveсanchorнесовпадениеentry.assistantMessageId !== anchor→ 204 (кросс-рановый реплей запрещён).onResumeFallback(false)), settled-merge (тред зовётonResumeFallback(false)), смена чата, 10-мин кап. Ошибки fetch поллинг НЕ гасят.isDisconnect).PR 1 — сервер
Задача 1.1.
apps/server/src/core/ai-chat/ai-chat-stream-registry.service.ts(новый)Семантика (реализовать ровно так):
open(в момент старта рана, до любых кадров): если поchatIdесть старая entry — завершить её ЗЕРКАЛЬНО done-пути (инвариант 3:finished=true, started-подписчикамonEnd()и удалить их из Set, paused —pendingEnd=true, снять её retainTimer; позднийdoneеё tee-петли сработает по замкнутой ссылке и не тронет новую благодаря identity-проверкам; повторныйonEndневозможен — started уже удалены), затемentries.set(chatId, { runId, frames: [], bytes: 0, overflowed: false, finished: false, subscribers: new Set() }).bind(изconsumeSseStream): найти entry; если нет илиentry.runId !== runId— warn-лог и выход (инвариант 1). Иначе записатьentry.assistantMessageId = assistantMessageIdи fire-and-forget циклreader.read():frames.push(value); bytes += Buffer.byteLength(value);затем еслиbytes > RUN_STREAM_MAX_BUFFER_BYTES→overflowed = true; frames = [];(один warn-лог; пересёкший кадр УЧТЁН и разослан; после overflow пуши в frames прекращаются, рассылка живым продолжается). Рассылка: каждому подписчику — started →try { sub.onFrame(frame) } catch { subscribers.delete(sub) }; paused →sub.pending.push(frame).doneИЛИ ошибка чтения:finished = true; started-подписчикамonEnd()и удалить; paused —pendingEnd = true(их дренируетstart()); взвести retention-таймер с identity-проверкой (инвариант 2),.unref().abortEntry(из внешнего catch стрим-метода): identity-проверка по runId; если entry ещё не finished — тот же терминальный путь, что уdone.attach— синхронно, без await между шагами: entry нет ИЛИoverflowed→null;expectLive && anchor && entry.assistantMessageId !== anchor→null(инвариант 6: стрипнувшая вкладка не должна получить реплей ЧУЖОГО рана — например, N+1 финишировал и N+2 уже открыт в суб-секундном окне между fetch истории и attach);finished && !expectLive→null;finished && expectLive→{ replay: frames.slice(), finished: true, start: noop, unsubscribe: noop }(подписчик НЕ регистрируется); иначе зарегистрировать paused-подписчика (started:false, pending:[], pendingEnd:false) и вернуть{ replay: frames.slice(), finished: false, start, unsubscribe }.start(): синхронно доставитьpendingпо порядку через onFrame, затемstarted = true; еслиpendingEnd—onEnd()и удалить.unsubscribe(): удалить из Set (идемпотентно).bindещё не случился (окно begin→pipe),entry.assistantMessageIdundefined → мисматч с любым anchor → 204 → клиентский restore+poll путь сходится сам. Это норма, не чинить.Задача 1.2. Модуль
ai-chat.module.ts:AiChatStreamRegistryServiceвproviders.Задача 1.3. Врезки в существующий код
ai-chat.controller.ts, begin-хук (замыканиеrunHooks~:345-374): сразу после успешногоbeginRun(там, где известенrunId) добавитьthis.streamRegistry.open(chatId, runId);. Реестр инжектится в контроллер.ai-chat.service.ts, pipe-вызов (~:1176), добавить в options ровно два поля — ОБА гейтятся наrunId(правило 4: legacy-путь при флаге off — байт-в-байт, включая отсутствиеstart.messageId; сидassistantIdпри этом безусловный, поэтому гейт только поassistantIdменял бы legacy-провод):ai-chat.service.ts, внешний catch стрим-метода (~:1241, рядом с аварийнымonSettled):if (runId) this.streamRegistry.abortEntry(chatId, runId);— иначе подписчики открытой entry зависнут навсегда.Задача 1.4. Attach-эндпоинт
ai-chat.controller.ts, роутGET /api/ai-chat/runs/:chatId/stream(+ queryexpect,anchor).@UseGuards/@Throttle— та же комбинация, что уstream-эндпоинта, лимит{ limit: 60, ttl: 60000 };@SkipTransform(). (Бакеты throttler'а раздельные по хендлерам — проверено, с 25/мин стрима не пересекается.)Задача 1.5. Тесты PR 1
finished+expect=live→ реплей без регистрации подписчика;finishedбез expect → null; anchor-мисматч приexpect=live→ null (и до bind —assistantMessageIdundefined → null); совпадение → attach; overflow → attach null, живой подписчик продолжает получать (оракул: push→учёт→проверка, пересёкший кадр разослан); open() поверх retained entry завершает старую и снимает её таймер; open() поверх LIVE entry (ран settled, tee ещё читает): started-подписчики получают ровно ОДИНonEnd(удалены при open — поздний done её петли не дублирует вызов), поздний done не удаляет новую entry (identity, инвариант 3); retention удаляет только свою entry; bind/abortEntry с чужим runId — no-op; бросающий onFrame выкидывает только этого подписчика, ingest-цикл жив.ai-chat.controller.run.spec.ts): 403 чужой чат; 204 (нет entry / finished без expect=live / anchor-мисматч); реплей пишется; close до start() → unsubscribe; close во время await'ов (req.raw.destroyedк моменту регистрации) → unsubscribe, ничего не пишется; cap → destroy; finished-реплей завершает ответ сразу.test/integration/ai-chat-attach.int-spec.ts, по образцу stream-int-спека сMockLanguageModelV3): дисконнект стартера на середине → attach → кадры идентичны полной последовательности вкл.[DONE]; attach СРАЗУ после старта рана (до первого кадра) получает поток с нулевого кадра;requestStop→ подписчик видит{"type":"abort"}+[DONE]+ конец.ai-chat.service.spec.ts:consumeSseStreamИgenerateMessageIdприсутствуют ⇔ run-wrapped (легаси-опции pipe-вызова НЕ содержат ни того, ни другого);generateMessageId= id засеянной строки; abortEntry зовётся из внешнего catch.DoD PR 1: tsc + build; существующие тесты зелёные без правок (кроме
ai-chat.service.spec.ts); новые зелёные; флаг off — байт-в-байт (вкл. отсутствиеstart.messageId). Флаг on со СТАРЫМ клиентом: единственное видимое изменение —start.messageId= DB-id строки; старый клиент к id не привязан (единственный потребитель —mergeObservedMessage, которому совпадение id только помогает); намеренно и допустимо.PR 2 — клиент
Задача 2.1. Типы
types/ai-chat.types.ts: вIAiChatMessageRowдобавитьstatus?: string;(провод уже несёт —baseFieldsвключаютstatus).Задача 2.2.
utils/resume-helpers.ts(новый)isStreamingTail(rows)— хвостrole==='assistant' && status==='streaming'.isSettledAssistantTail(rows)— хвост-ассистент соstatus !== 'streaming'.seedRows(rows, strip)— приstripвернуть без последней строки.mergeById(messages, incoming)— перенос СУЩЕСТВУЮЩЕЙmergeObservedMessageизrun-polling.ts(логика не меняется).Задача 2.3.
components/chat-thread.tsxРазрешён ровно один новый проп:
onResumeFallback?: (active: boolean) => void(управление degraded-poll в окне, задача 2.4).seedRows(initialRows ?? [], attemptResumeRef.current && stripRef.current).map(rowToUiMessage).useMemo:onNoActiveStream, one-shot черезnoStreamHandledRef— НЕ занулятьonNoActiveStreamRef), строго четыре части:strippedRowRef.current— вернуть строку в стор черезmergeById(user-tail 204 сюда НЕ заходит — не разыменовывать null!);reconcileTailRef.current = true+ инвалидацияAI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current);onResumeFallback?.(true). Поллинг окна — тупой таймер с 10-мин капом (задача 2.4); тред гасит его сам черезonResumeFallback(false)на settled/локальном стриме. На user-tail 204 без рана он дотикает до капа впустую (0.4 rps, принято); если ран стартовал в окне send→begin — очередной тик застанет seed и degraded-следование дойдёт до settle;setResumedTurnPair(false)(204 = onFinish не будет; иначе флаг подавил бы флаш СЛЕДУЮЩЕГО локального хода).[initialRows, isStreaming, setMessages]:isStreaming === true→ сброситьreconcileTailRef, вызватьonResumeFallback?.(false)(инвариант 8: локальный ход гасит и мердж, и поллинг), выход;reconcileTailRef.current === trueи последняя строкаinitialRows— ассистент:mergeByIdеё в стор НА КАЖДОЕ обновлениеinitialRows(пока degraded-poll активен, это и есть живой пошаговый прогресс); если еёstatus !== 'streaming'(settled) — сброситьreconcileTailRefИ вызватьonResumeFallback?.(false)(терминальный merge сделан — гасим поллинг явно; окно само его не погасит, у него только временной кап).onFinish, используяwasResumedиз п. 8; арм и restore гейтятся ПО-РАЗНОМУ:onResumeFallback?.(true)): еслиwasResumed && (isDisconnect || !assistantMessageHasVisibleContent(message))(реюз утилиты изutils/message-content.ts);mergeById(strippedRowRef.current)): ТОЛЬКО еслиwasResumed && !assistantMessageHasVisibleContent(message) && strippedRowRef.current. НаisDisconnectС видимым контентом restore ЗАПРЕЩЁН: живой стрим мог уйти далеко вперёд mount-time снапшота, и restore откатил бы контент на экране (клоббер, инвариант 9) — там достаточно арма: поллинг доведёт до настоящего терминала.Здоровый resumed-финиш НЕ взводит НИЧЕГО: стор уже держит полное стримленное сообщение, обновление кэша придёт через
onTurnFinished, а немедленный refetch гонится сfinalizeAssistant(см. факт в шапке). Provider-error mid-run: error-чанк бросает в процессоре →isError+ баннер; при видимом контенте recovery не нужен, при пустом — условие арма и так истинно.onFinish— строго (инвариант 7): (1)const wasResumed = resumedTurnRef.current;и сразуsetResumedTurnPair(false); (2) recovery п. 7 (поwasResumed); (3) стандартные ветки — приwasResumed === trueпропустить ОБЕ ветки отправки: plainflushNext()И веткуflushOnAbortRef;onTurnFinishedи прочее — как обычно (возможная двойная инвалидация messages после п. 7 — намеренная и безвредная, НЕ «дедуплицировать»). Дополнительно флаг сбрасывается при любом ЛОКАЛЬНОМ send (все три call-sitesendMessage: onSend, flushNext, sendNow — перед вызовомsetResumedTurnPair(false)).resumedTurn(стейт из п. 0; остаётся только удаление). Причина: локальныйstop()не абортит resumed-фетч (зомби: клик «проглочен», аflushOnAbortRefвыстрелил бы спустя минуты на естественном финише); вариант «стопнуть сервер и слать» отвергнут из-за 409-гонки с асинхронным finalize.handleStopдобавить первой строкойattachAbortRef.current?.abort();. Остальное не менять. (Наблюдатель, нажавший Stop:isAbortс частичным контентом → recovery по п. 7 не взводится, restore не делается — частичное остаётся + нотис «Response stopped.»; персистентный aborted-маркер появится при следующем открытии — паритет со стартером.)Задача 2.4.
components/ai-chat-window.tsx— degraded-poll фолбэк (замена латчей)const [degradedPoll, setDegradedPoll] = useState(false);+armedAtRef(штампDate.now()при включении); сбрасывать при сменеactiveChatId(инвариант 8).onResumeFallback={...}на<ChatThread>(сеттер, штампующий armedAt приtrue).DEGRADED_POLL_MAX_MS = 10 * 60_000.useAiChatMessagesQueryполучает опциюrefetchInterval(прокинуть через хук), callback-форма — НАМЕРЕННО ТУПАЯ:degradedPoll === true && Date.now() - armedAtRef.current < DEGRADED_POLL_MAX_MS ? 2500 : false. НИКАКИХ проверок ошибок (fetchFailureCountв TanStack v5 сбрасывается на каждом фетче и consecutive-ошибки не выражает — см. факт в шапке; поллинг обязан переживать рестарт сервера) и НИКАКИХ проверок хвоста (semantics живут в треде: settled/локальный стрим гасят черезonResumeFallback(false), п. 6 задачи 2.3). Временной кап — единственная страховка от вечного тика (застрявшая'streaming'-строка до boot-sweep; user-tail без рана).Задача 2.5. Список удаления (точный)
utils/run-polling.ts,utils/run-polling.test.ts— удалить (merge-логика и её кейсы переехали в задаче 2.2).queries/ai-chat-query.ts— удалитьuseAiChatRunQuery,AI_CHAT_RUN_RQ_KEY;queries/ai-chat-run-query.test.tsx— удалить.services/ai-chat-service.ts— удалитьgetAiChatRun(⚠️stopRunоставить).types/ai-chat.types.ts— удалитьIAiChatRun,IAiChatRunResponse.components/ai-chat-window.tsx— удалить:localStreaming+onStreamingChange; латчstoppingRun+ reset;removeQueriesизhandleServerStop(сжимается доstopRun(chatId).catch(notify)); блокuseAiChatRunQuery; F7-эффект;isObservingRun/observedRow; терминальный эффект (finalizedRunIdRef); пропсыobservedRow/onStreamingChange; мёртвые импорты.components/chat-thread.tsx— удалить пропobservedRow+ observer-merge эффект; пропonStreamingChange+ эффект.components/chat-thread.test.tsx— удалить describe «observer run merge (#184)».POST /ai-chat/runне трогать.Задача 2.6. Тесты PR 2
chat-thread.test.tsx(мок-харнесс уже ловит транспорт/useChat; добавить спайresumeStream):resumeStreamна маунте ⇔ флаг on ∧ chatId ∧ хвост НЕ settled-ассистент; сид стрипается только при streaming-хвосте; user-хвост — attempt без стрипа;?expect=live&anchor=<id стрипнутой строки>только при стрипе; без стрипа — без query;onResumeFallback(true);onResumeFallback(true)+ сброс resumedTurn;resumedTurnсброшен (следующий локальный ход флашит очередь);onFinishне флашит очередь (оба пути — plain и flush-on-abort); локальный send сбрасывает подавление;onResumeFallbackНЕ зовётся, стор сохраняет полное сообщение (инвариант 9);onResumeFallback(true)), restore НЕТ (стрипнутый снапшот не затирает более свежий частичный стрим);reconcileTailRefвзведён, обновлениеinitialRowsмержит хвост; settled-хвост гасит флаг И зовётonResumeFallback(false);isStreamingгасит флаг и зовётonResumeFallback(false);handleStopабортит attach-контроллер и зовётonServerStop.Окно (
ai-chat-window/ хук): degraded-интервал 2500 ⇔degradedPoll∧ младше 10 мин — И НИЧЕГО БОЛЬШЕ; ошибки fetch НЕ гасят (тик продолжается приisError); гаснет поonResumeFallback(false), капу, смене чата.DoD PR 2: tsc;
npx vitest run src/features/ai-chatцеликом; grep поrun-polling|useAiChatRunQuery|observedRow|AI_CHAT_RUN_RQ_KEY|getAiChatRun|shouldObserveRun|stoppingRunвapps/client/srcпуст.Ожидаемое поведение (спецификация; тесты сверять с ней)
expect=live+ anchor совпал: полный реплей + end → onFinish → (здоровый) ничего не мержится.Память реестра (для оценки)
Worst-case footprint: (число одновременных активных ранов) × ≤4MB кадров + ≤8MB на каждого реально застрявшего подписчика + 30s retention-хвосты. Однопроцессный деплой, раны ограничены «один на чат» — практический потолок единицы-десятки MB.
Не входит в объём
Redis/BullMQ-реестр (интерфейс готов); resumable-stream; изменения
POST /ai-chat/run//stop; миграции; public-share; «чистый финиш» у второй вкладки при Stop (паритет); mainline-баг sendNow-без-server-stop у стартера (п. 6 правил); composer, активный в 1-3с окно установления attach (паритет, строгое улучшение против целого рана в мейнлайне).Связанное