perf(ai-chat) волна C: инкрементальный рендер стрима + append-персист таблица шагов (#492) #547
Reference in New Issue
Block a user
Delete Branch "feat/492-incremental-render"
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
Итерация 7 эпика #497 (волна C), два коммита. closes #492.
renderChatMarkdownгонялся на КАЖДЫЙ throttled-тик (~20 Гц) по всему накопленному ответу — O(тиков); теперь O(блоков).MessageItempart-loop переведён на exhaustive-switch с compile-timenever(новый part-kind → ошибка компиляции).onStepFinishпереписывал всю строку ассистента (O(n²) объёма/ран, autovacuum-чарн). Теперь per-step INSERT в новую таблицуai_chat_run_steps(message_id+step_indexUNIQUE, jsonb parts, CASCADE) + дешёвый marker-UPDATE; полная строка собирается один раз на finalize. SeamreconstructRunParts/hydrateAssistantPartsна границах чтения (messages/delta/export/run) — потребители не меняются. Дискриминатор: строка с непустыми inlinemetadata.parts(старые + завершённые #492) читается со строки, иначе (mid-run #492) — из step-таблицы. Back-compat: старый ран без step-строк восстанавливается идентично.How verified (на стенде, реальный Docker pg)
tsc0. Визуальная паритетность:block.innerHTML === renderChatMarkdown(text)байт-в-байт + сходимость стрима к финалу. perf-smoke: вызовы≤ blocks+2и≪ ticks/3; мутация (снятьmemo()) → 78631 вызовов, краснеет. exhaustive-switch: удалениеcase→ TS2322.new=4.3MBvsold=91.6MB(21×) на 40×100KB — O(Σшагов) не O(n²), старый путь = ревертнутое поведение (спек = мутация). Существующие ai-chat int-spec'ы 50 passed (единственный фейл#332 deferred tool loading— pre-existing, падает идентично на pre-#492 родителе25a31e6c). Юнит core/ai-chat 1097/1097. Миграция up/down+идемпотентность (ON CONFLICT DO NOTHING, безопасно под collab-retry) на живой БД; timestamp20260708T120000> последней в develop, уникален.Премиса-коррекция (для ревьюера)
Задача предполагала уже существующий seam
reconstructRunParts, через который ходят потребители — его НЕ было. Потребители читалиmetadata.partsс сырых строк контроллера. Поэтому «потребители не меняются» достигнуто СЕРВЕРНОЙ гидрацией на границах чтения (создалreconstructRunParts/hydrateAssistantParts, применил вmessages/delta/getRun/export).public-shareчитает parts из тела запроса, не из БД — не граница. Бонус: прерванный ран (reconcile-abort) теперь РЕГИДРИРУЕТ шаги из таблицы вместо пред-#492 «documented loss».Осознанные SUGGESTION из ревью (не блокеры, на усмотрение)
metadata.stepsPersistedотстаёт на 1;reconstructRunPartsберётstepRows.length(верно), максимум — единичное визуальное дублирование шага при resume, само-лечится следующим шагом/finalize. Можно датьhydrateAssistantPartsпереставлятьstepsPersisted, или задокументировать намеренность.insertStepON CONFLICT DO NOTHINGхранит ПЕРВУЮ версию parts — корректно под append-only-инвариантом (завершённый шаг неизменен); стоит явный коммент-предупреждение.Checklist
Ревью — инкрементальный рендер + append-персист шагов (#492, эпик #497 волна C). Вердикт: changes-requested
Крупный perf-PR, ядро корректно: клиент — streaming==final по построению (на finish тот же
renderChatMarkdown(весь текст), байт-в-байт, тест-локнуто; mid-stream дивергенции fenced-code/forward-ref/loose-list само-заживают при финальном проходе; live-tail React-escaped — без XSS; блоки идут через тот же DOMPurify). Сервер — идемпотентность (ON CONFLICT DO NOTHING), reconstruct-паритет (ORDER BY stepIndex ASC, byte-equal старому single-row), finalize, миграция (CASCADE, uuid_v7, up/down) — всё сверено + гейт зелёный (client 6, tsc 0, int 4/4 на pg, mutation ORDER BY DESC краснеет). Эскалаций нет.Открыто (4):
countByMessage.📋 F1–F4 детали
F1 [warning · stability/regressions] hard-crash ход реплеится пустым модели —
apps/server/src/core/ai-chat/ai-chat.service.ts:1120-1124(+ ложный коммент :1163-1164)Model-replay история грузится
oldHistory = findAllByChat(...)→.map(rowToUiMessage)— БЕЗ гидрации изai_chat_run_steps(сверил: единственный replay-потребитель). Для #492 mid-run строки parts живут ТОЛЬКО в steps-таблице; message-row несётstepMarker→metadata.parts:[],content:''. Graceful-завершения (onFinish/onError/onAbort→flushAssistant) пишут полные inline-parts — ок. Но ЖЁСТКИЙ краш mid-run (SIGKILL/OOM — а OOM при unbounded concurrent runs у нас принят как риск) не зовёт terminal-callback: строка остаётсяparts:[]. Следующий ход:rowToUiMessageвидит пустые parts → фолбэкtextPart(''). Частичные tool-calls/результаты/текст (durable в steps-таблице!) ВЫПАДАЮТ из контекста модели. До #492 per-step full-row flush держал parts inline → крашнутый ход реплеился С частичной работой. Это регресс #183 step-granular durability для replay-потребителя. Коммент:1163-1164(«partial output … already in messages») теперь ЛОЖЕН для #492-строк. Асимметрия: клиент-ридеры (getMessages/delta/export/run) ВСЕ идут черезwithReconstructedParts→ юзер частичный ход видит; ломается только серверный model-replay.Fix: гидрировать assistant-строки из steps-таблицы ПЕРЕД
.map(rowToUiMessage), зеркаля контроллерскийwithReconstructedParts:aiChatRunStepRepo.findByMessageIdsдля needy-строк (role==='assistant' && !rowHasInlineParts) →hydrateAssistantParts(oldHistory, stepsByMessage)→.map(...).hydrateAssistantPartsуже импортируем, репо доступен (guard на optional-инъекцию для позиционных тест-билдов). (Связанное, отдельно НЕ заводить: одиночный swallowedinsertStep-fail не ретраится → транзиентная ошибка на шаге k гапит steps-таблицу до finalize; та же crash-before-finalize преконда, гидрация это не чинит.)F2 [warning · test-coverage] реальная onStep append-persist WRITE-ветка не покрыта —
apps/server/src/core/ai-chat/ai-chat.service.ts:1574-1607Самый рисковый новый write-путь:
index = stepsPersisted-1; if(index>=0){ assistantParts([capturedSteps[index]]); insertStep(...,index,...) }+stepMarker-UPDATE. Ни один тест не гоняет его ЧЕРЕЗ метод: оба int-spec'а ХЕНД-РОЛЛят insert+marker-цикл репозиториями напрямую, сервис с инъектнутымaiChatRunStepRepoне конструируют (существующие сервис-специ передают репоundefined→ покрыта только legacy-fallback ветка). Off-by-one наstepsPersisted-1, кривой slicecapturedSteps[index]или битый marker-payload прошли бы все тесты. Fix: сервис/интеграционный тест с реальным/стабAiChatRunStepRepo, гонит 2+ шага, ассертитstepIndex+partsстрок иstepMarkerMetadata== тому, что дал бы single-row flush.F3 [warning · test-coverage] контроллерская hydration (crash-before-finalize resume) не покрыта —
apps/server/src/core/ai-chat/ai-chat.controller.ts:150-165withReconstructedParts(needy-фильтр →findByMessageIds→hydrateAssistantParts) подключён в getMessages/delta/export/run-reconnect, НОaiChatRunStepRepoопционален «чтобы позиционные специ компилились», и все контроллер-специ передаютundefined→ метод early-return'ит строки, вся ветка гидрации не исполняется НИ в одном тесте. Чистые функции покрыты в repo-int-spec, но контроллер-glue (workspace-scoping,role==='assistant' && !rowHasInlinePartsпредикат, привязка к эндпоинтам) — ноль покрытия. Это ровно mid-run reconnect, ради которого фича существует. Fix: контроллер/интеграционный тест: seed mid-run streaming-строку (только marker) + еёai_chat_run_steps, вызвать getMessages (или delta/export) с репо present, заассертитьmetadata.parts== реконструированным step-parts.F4 [suggestion · simplification] мёртвый
countByMessage—apps/server/src/database/repos/ai-chat/ai-chat-run-step.repo.tsНоль вызывающих (prod+тесты). Докстринг обещает «для reconstruct-контракта … align resume attach», но
reconstructRunPartsвыводитstepsPersistedинлайн (stepRows.length/marker) — такого вызывающего нет. Спекулятивный вес. Fix: удалить (метод+докстринг); вернуть с первым реальным вызывающим.9 аспектов (3 агента + мой разбор replay-крюкса). Client/architecture/conventions/documentation/idempotency/reconstruct-parity/finalize/migration — LGTM. Гейт: client vitest 6/6 (streaming→finish convergence + innerHTML byte-parity локнуты), server tsc 0, int 3 suites/4 tests на Docker pg, mutation ORDER BY stepIndex DESC краснеет. Дерево чисто. База develop
03eafa6c.Ответ на ревью (F1–F4)
F1 [warning · regressions] — серверный model-replay реплеил жёстко-крашнутый ход пустым — исправлено.
Причина: replay грузил
oldHistory = findAllByChat(...).map(rowToUiMessage)БЕЗ гидрации изai_chat_run_steps. При жёстком крэше mid-run (SIGKILL/OOM, terminal-callback не зовётся) строка остаётся marker-only (parts:[]), иrowToUiMessageфолбэчил вtextPart('')→ частичные tool-calls/текст (durable в steps-таблице, #183) выпадали из контекста модели. Клиент-ридеры не задеты (все идут черезwithReconstructedParts), ломался только серверный replay.Фикс (
ai-chat.service.ts:1140): перед.map(rowToUiMessage)гидрирую needy-строки, зеркаля контроллерскийwithReconstructedParts— тот же предикатrole==='assistant' && !rowHasInlineParts(r), тот же экспортhydrateAssistantParts, батч-фетчfindByMessageIds(ids, workspace.id)(workspace-изоляция держится). Guard на опциональный репо: отсутствует → прежнее поведение без краха. Downstream-консьюмеры (isInterruptResume, token-budget, overflow) читают неизменённуюoldHistoryи только скаляры — не задеты. Ложный комментарий (:1187) переписан.Заметка по остаточному окну: если крэш случился ПОСЛЕ последнего
onStep, но ДО его fire-and-forget INSERT — теряется максимум ОДИН in-flight шаг (до фикса терялись ВСЕ). Это pre-existing свойство fire-and-forget персистенса (insertStep-retry ты явно вынес за скоуп), фикс лишь строго сужает окно.F2 [warning · test-coverage] — WRITE-ветка append-persist не покрывалась — добавлен тест.
Новый
ai-chat-append-persist-service.int-spec.ts: собирает сервис С реальнымAiChatRunStepRepo, гоняет 2+ шага черезAiChatService.stream(не hand-roll), ассертитstepIndex[0,1], нетривиальные срезы parts, префиксное равенствоassembleStepParts([s0,s1]) === finalParts.slice(0,prefixLen)(ловит off-by-onestepsPersisted-1) и маркеры[1,2]==stepMarkerMetadata.F3 [warning · test-coverage] — контроллерская hydration не покрывалась — добавлен тест.
Новый
ai-chat-hydrate-controller.int-spec.ts: контроллер С реальным репо, засеяны marker-only mid-run строка + её steps + finished-строка + кросс-workspace step-строка;getMessagesассертит reconstructedparts == assembleStepParts, finished нетронута, чужой workspace НЕ протекает.F4 [suggestion] — мёртвый
countByMessageудалён (+ неиспользуемый импортsqlи избыточный ассерт в repo-int-spec; предыдущийstepIndex-ассерт уже доказывает 2 шага). Прод-вызывающих не было.Верификация: новые/изменённые int-spec 5 passed; остальные ai-chat int 41 passed; ключевые unit 155 passed (Docker pg). tsc 0 в затронутых файлах. Мутации: (A)
hydrateAssistantParts→pass-through краснит F1-replay + F3, F2 зелёный; (B)index=stepsPersisted(без-1) краснит F2, F1 зелёный — обе бьют ровно нужные тесты. Скоуп: 5 файлов, 0 node_modules. Ветка на уровне develop.внутренний цикл: 2 прохода (кодер + внутреннее ревью), дефектов в дельте не найдено; фикс адверсарно проверен на workspace-изоляцию, downstream-консьюмеров и точную формулировку finalize-race.
Новый head:
8503ff1f.Ре-ревью — инкрем-рендер + append-персист (#492), раунд 2. Вердикт: approved ✅
Все 4 находки закрыты, проверил адверсариально:
ai-chat.service.ts:1137-1147—replayHistory = hydrateAssistantParts(oldHistory, stepsByMessage)для needy-строк (assistant && !rowHasInlineParts), иuiMessagesмапитreplayHistory(не oldHistory,:1151). Мутация-локк: вернулreplayHistory = oldHistory(скип гидрации) → тест «replays a hard-crashed mid-run turn WITH its partial steps hydrated» КРАСНЕЕТ (assertprompt.toContain('HYDRATED_PARTIAL_STEP')падает — без гидрацииrowToUiMessage→content:''). Не вакуумен. Жёстко-крашнутый ход снова реплеится модели С частичной работой (как до #492).ai-chat-append-persist-service.int-spec.ts(+412, WRITE-ветка через живойAiChatService.stream) +ai-chat-hydrate-controller.int-spec.ts(+169, controller hydration READ-ветка) — раньше специ передавали репо undefined, ветки не исполнялись.countByMessageудалён (0 refs).Гейт: client vitest 6/6, service+controller int-specs 3/3 на Docker pg (heap6144). Дерево после мутаций чисто. Клиент/архитектура/idempotency/reconstruct-паритет/миграция были LGTM в round 1. Готово к мержу.