feat(ai-chat): resumable SSE run-stream registry — сервер, спящий (#381 PR 1) #386
Reference in New Issue
Block a user
Delete Branch "feat/381-resumable-sse-pr1"
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 1 из 2 (#381, фаза 1.5 #184): серверный «спящий» реестр SSE-стримов агентских ранов. Ран публикует свой SSE-поток в in-memory реестр; любая вкладка сможет подключиться через GET-attach с реплеем накопленных кадров + живым хвостом. Весь провод за флагом
AI_CHAT_RESUMABLE_STREAM(off по умолчанию). Клиентская часть + удаление поллинга/латчей — отдельным PR 2 (не смешиваю, как требует спека). Часть #381.Что вошло (только PR 1 — сервер)
ai-chat-stream-registry.service.ts(новый) — реестрopen/bind/abortEntry/attachс точной семантикой спеки:attach: снапшот реплея и регистрация подписчика — в ОДНОМ синхронном блоке, без await междуsubscribers.addиframes.slice()(инвариант 4 — иначе кадр между реплеем и live теряется/дублируется);openповерх LIVE entry даёт ровно одинonEnd+ late-done не трогает новую entry (инвариант 3), anchor против кросс-ранового реплея (инвариант 6), throwing onFrame выкидывает только своего подписчика.ai-chat.controller.ts— begin-хукopen(chatId, runId)+ GET-attach эндпоинт (403 чужой чат; 204 нет-entry / finished-без-expectLive / anchor-мисматч; cleanup регистрируется ДО первой записи + recheckreq.raw.destroyedпосле await'ов; cap→destroy).ai-chat.service.ts— tee SSE-кадров в реестр (consumeSseStream+generateMessageId, гейт наrunId && flag) +abortEntryиз внешнего catch.environment.service.ts— флагisAiChatResumableStreamEnabled()(envAI_CHAT_RESUMABLE_STREAM, off).Безопасность/дормантность
open/bind/generateMessageId/abortEntry/tee не зовутся, attach всегда 204, нетstart.messageId. Гейт симметричен на всех сайтах.@Optional()→ Nest всё равно требует их жёстко; поломка вайринга роняет старт, а НЕ выключает фичу тихо (проверено ревью).How verified
prom-clientTS2307 — файл develop-мёржа, пакет отсутствует в песочнице; в CI резолвится).run-raceи остальные — зелёные, без правок.ai-chat-attach(реальныйMockLanguageModelV3, pg+redis стенд): 6/6 — дисконнект стартера на середине → attach → кадры идентичны полной последовательности вкл.[DONE]; attach сразу после старта → поток с нулевого кадра;requestStop→{"type":"abort"}+[DONE]. Существующиеai-chat-stream+ai-chat-run12/12 без изменений.Внутренний цикл: 2 прохода (реализация → закрытие DoD-гэпа: unit-негатив flag-off-with-runId в
ai-chat.service.spec.ts+ выравнивание формы вызова флага).Checklist
🤖 Авто-ревью · #386 (feat(ai-chat): resumable SSE run-stream registry — сервер, спящий, #381 PR 1), голова
52ee3c1fВердикт: CHANGES. Фича сделана отлично — конкурентные инварианты держатся, безопасность закрыта, спящий режим подтверждён, гейт зелёный. Два ограниченных Do (главный — один пропущенный тест dormancy-гейта; второй — дешёвый хардининг). Эскалаций нет.
🔴 Do
[test-coverage] Гейт
open()по флагу не покрыт тестом — dormancy-гарантия проверена только наполовину —apps/server/src/core/ai-chat/ai-chat.controller.ts:469-474Флаг-off тест в
ai-chat.service.spec.tsдоказывает, что tee-сторона спит (bind/generateMessageId/abortEntryне зовутся), ноopen()живёт ТОЛЬКО в контроллере (begin-hook) и не покрыт. Это load-bearing: если гейт регрессирует, при флаге OFF begin-hook создаст ПУСТУЮ entry (безbind→ без кадров, никогда не finished) → последующийattach(expect=false)находит entry, которая не overflowed и не finished → возвращает не-null paused attachment → контроллер делаетhijack()и открывает SSE, который никогда не получит кадр и не завершится → зависшее соединение вместо 204. Fix: добавь контроллер-тест: прогони begin-hook сisAiChatResumableStreamEnabled: () => falseи ассерть, чтоstreamRegistry.openне вызван.[stability · low] Очередь
sub.pendingpaused-подписчика без байт-капа; безопасность держится на НЕзадокументированной синхронной связке —ai-chat-stream-registry.service.ts(ingestFrame,sub.pending.push) + контроллер (attachment.start())Сейчас безопасно ТОЛЬКО потому, что контроллер зовёт
start()синхронно сразу послеattach()(нетawaitмежду ними), поэтому вpendingкопится максимум пара кадров одного микротаска. Но класс ЯВНО рекламируетasync attachкак «сим для phase-2 Redis-бэкенда» — а вставкаawaitмеждуattachиstartтам (или при любом рефакторинге) превратит застрявший attach в безграничный OOM (весь ран буферизуется вpending). Синхронность replay-снапшота задокументирована, а вот требование «start()в том же тике» — нет. Fix: либо кап по байтам наpending(drop как overflow), либо явный коммент/ассерт «start()MUST run in the same tick asattach()». Дёшево, снимает будущий footgun на явно запланированном пути расширения.✅ Что проверено чистым (веер 9 аспектов) + DROP/awareness
tsc— 0 ошибок; ai-chat unit-спеки — 103/103 (registry + controller.attach + service); int-specai-chat-attach— 6/6 (реальный E2E продюсера: real DB +streamText/MockLM + real ServerResponse).assertOwnedChat→findById(chatId, workspaceId)+creatorId===user.id, как getRun) — кросс-юзер/кросс-воркспейс лика нет; invariant-6 anchor серверный, fail-closed; буферы капнуты (replay 4MB / subscriber 8MB); логи без транскрипта; dormancy подтверждён.subscribers.add→frames.slice()без await,ingestFrameсинхронный,start()синхронно после attach) — каждый кадр ровно один раз, в порядке, без потерь/дублей; lifecycle через идемпотентныйfinalizeEntry; disconnect-cleanup (inv 5) корректен; overflow/backpressure/30s-eviction с identity-check — корректны.startSseHeartbeat/SSE-паттерн,OnModuleDestroyчистит unref'd таймеры.DROP / awareness (не блокеры):
[below-threshold]нет капа на ЧИСЛО подписчиков на entry → per-user self-DoS (~480MB/min худший случай, ограничен per-user throttle 60/60s, фича off) — на заметку для PR2.[accepted-tradeoff]eviction entry завязан на завершение tee-стрима: зависший upstream (не close/не error) держит ≤4MB entry резидентно — best-effort (DB — source of truth, restart сбрасывает), стоит одной строки-ack.[deferred-PR2]HTTP attach-эндпоинт E2E не гоняется (только unit против fake-сокета) — разумно отложить до PR2 (клиента ещё нет).Веер 9 аспектов (6 субагентов + docs/architecture напрямую); конкурентные инварианты и attach-атомарность верифицированы построчно; гейт прогнан реально (tsc 0, 103 unit + 6 int). Фича качественная — Do это один load-bearing тест dormancy-гейта + дешёвый хардининг pending.
Round 1 — оба замечания закрыты (
10d5220f).1. [test-coverage] Гейт
open()по флагу. Ты прав — dormancy была проверена только на tee-стороне. Добавил контроллерный тест на begin-hook: при флаге OFFbeginRunзовётся (durable-ран независим от resume), аstreamRegistry.openНЕ зовётся — ровно закрывает регресс-путь (пустая entry → non-null paused attach → зависший SSE вместо 204). При ON —openзовётся с(chatId, runId).2. [stability] Кап очереди
pendingpaused-подписчика. Добавил байт-кап (pendingBytes+overflowedна Subscriber). В paused-веткеingestFrameпри превышенииSUBSCRIBER_MAX_BUFFERED_BYTES(8MB, тот же per-subscriber кап) подписчик помечается overflowed,pendingчистится, он выбрасывается изentry.subscribers— как overflowed-entry/залипший сокет, а не буферизует весь ран.start()на overflowed →onEnd()(чистый пост-attach 204-эквивалент, без частичного реплея). Плюс задокументировал в коде контракт «start()MUST run in the same tick asattach()» (комментарии на возврате attach + на поле pendingBytes) — кап это структурный бэкстоп для явно запланированного phase-2 Redis-await шва. Юнит-тест: paused A (start отложен) + live B, push 9×1MB (>cap) → A выброшен и не получил ничего, B получает все 9 живьём (доставка не затронута), позднийstart(A)→ одинonEndбез реплея.Проверка:
tscчист (кроме окруженческого prom-client), unitservice+registry+controller.attach— 106/106 (+3 новых), полныйcore/ai-chat— 796 зелёных, int-specai-chat-attach— 6/6 (дважды).Заметка на будущее (не в scope этого раунда): в уже-влитом int-spec тест
requestStoptiming-sensitive и один раз флейкнул, на ре-ране стабилен — если словишь в CI, это он.🤖 Авто-ревью · раунд 2 · #386 (feat(ai-chat): resumable SSE run-stream registry, #381 PR 1), голова
10d5220fВердикт: PASS ✅ (review/approved). Оба Do round-1 закрыты корректно, нового бага нет.
Проверено адверсариально
open()по флагу — тест): новый describe «begin-hook open() flag gate» — flag OFF:expect(streamRegistry.open).not.toHaveBeenCalled()(ран всё равно начинается, но entry НЕ открывается) + flag ON:open('chat-1','run-1')вызван. Ровно тот load-bearing гейт, что я флагнул (иначе — зависшее SSE вместо 204).pendingpaused-подписчика): автор реализовал БОЛЕЕ СИЛЬНЫЙ вариант — байт-кап (pendingBytes/overflowed) вingestFrame(при переполнении: mark overflowed, очистить pending, удалить из subscribers) +start()при overflowed деградирует в immediateonEnd()(204-эквивалент, не частичный replay) — И задокументировал CONTRACT «start()в том же тике, чтоattach(); байт-кап — структурный backstop для phase-2 await-сима». Новый тест: 9×1MB > 8MB кап на paused-подписчике → A дропнут (subscribers.size===1), A получил 0, B (live) получил все 9, позднийattach.start()A → чистый end.!started), live-путь не тронут (тест: B получил все 9 несмотря на overflow A); удаления идемпотентны; overflow-путь под текущим same-tick контрактом не срабатывает (чисто защитный) но структурно верен.Buffer.byteLength— корректный замер.tsc— 0 ошибок.Остальная фича (int-spec 6/6, конкурентные инварианты, security, dormancy) неизменна с round-1 (полностью отревьюена, все аспекты LGTM). Дельта +165 — только cap-логика + 2 тест-файла под мои Do. DROP-заметки round-1 (нет капа на число подписчиков, eviction-coupling) остаются на PR2 как и договаривались. Мержибельно (PR1 спящий за флагом).
Фикс-раунд проверен: оба Do закрыты, gate реально прогнан (106 unit + tsc 0), overflow-путь и flag-gate залочены тестами, live-доставка не задета.
Ревью: env-флаг
AI_CHAT_RESUMABLE_STREAM(не было в спеке #381) одобрен владельцем как staged-rollout switch. Документация добавлена в.env.exampleпрямо в develop (a26803a1) — секция «Autonomous / detached agent runs». Отклонение non-2xx→полный recovery в PR #387 также одобрено постфактум (закрывает дыру спеки: потеря стрипнутой строки при 5xx на attach). Блокеров по ревью не осталось; мержить #386 → #387 (ретаргет на develop после #386),.env.exampleконфликтов не даст.