perf(ai-chat) волна C: инкрементальный рендер стрима + append-персист таблица шагов (#492) #547

Merged
vvzvlad merged 4 commits from feat/492-incremental-render into develop 2026-07-12 17:31:42 +03:00
Collaborator

Summary

Итерация 7 эпика #497 (волна C), два коммита. closes #492.

  1. Клиент — инкрементальный рендер стрима. Ответ рендерится по мере стрима: стабилизированные markdown-блоки парсятся канонически ОДИН раз и мемоизируются, живой хвост — плоский React-escaped текст (без parse/sanitize/innerHTML). На finish — один канонический проход по всему тексту (байт-в-байт как до #492). База (develop): renderChatMarkdown гонялся на КАЖДЫЙ throttled-тик (~20 Гц) по всему накопленному ответу — O(тиков); теперь O(блоков). MessageItem part-loop переведён на exhaustive-switch с compile-time never (новый part-kind → ошибка компиляции).
  2. Сервер — append-персист шагов. Раньше каждый onStepFinish переписывал всю строку ассистента (O(n²) объёма/ран, autovacuum-чарн). Теперь per-step INSERT в новую таблицу ai_chat_run_steps (message_id+step_index UNIQUE, jsonb parts, CASCADE) + дешёвый marker-UPDATE; полная строка собирается один раз на finalize. Seam reconstructRunParts/hydrateAssistantParts на границах чтения (messages/delta/export/run) — потребители не меняются. Дискриминатор: строка с непустыми inline metadata.parts (старые + завершённые #492) читается со строки, иначе (mid-run #492) — из step-таблицы. Back-compat: старый ран без step-строк восстанавливается идентично.

How verified (на стенде, реальный Docker pg)

  • Клиент: ai-chat suite 408/408, tsc 0. Визуальная паритетность: block.innerHTML === renderChatMarkdown(text) байт-в-байт + сходимость стрима к финалу. perf-smoke: вызовы ≤ blocks+2 и ≪ ticks/3; мутация (снять memo()) → 78631 вызовов, краснеет. exhaustive-switch: удаление case → TS2322.
  • Сервер: 3 int-spec'а (migration/reconstruct/WAL) 4/4; WAL-дельта new=4.3MB vs old=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) на живой БД; timestamp 20260708T120000 > последней в develop, уникален.
  • Внутренний цикл: реализация → адверсариальный ревью (3 фокус-риска: пропущенная граница гидрации / идемпотентность миграции под retry / разрыв визуальной паритетности — все закрыты вживую) → APPROVE.

Премиса-коррекция (для ревьюера)

Задача предполагала уже существующий seam reconstructRunParts, через который ходят потребители — его НЕ было. Потребители читали metadata.parts с сырых строк контроллера. Поэтому «потребители не меняются» достигнуто СЕРВЕРНОЙ гидрацией на границах чтения (создал reconstructRunParts/hydrateAssistantParts, применил в messages/delta/getRun/export). public-share читает parts из тела запроса, не из БД — не граница. Бонус: прерванный ран (reconcile-abort) теперь РЕГИДРИРУЕТ шаги из таблицы вместо пред-#492 «documented loss».

Осознанные SUGGESTION из ревью (не блокеры, на усмотрение)

  • INSERT шага и marker-UPDATE — две записи (не в одной транзакции). Узкое транзиентное окно «INSERT прошёл, marker упал» → metadata.stepsPersisted отстаёт на 1; reconstructRunParts берёт stepRows.length (верно), максимум — единичное визуальное дублирование шага при resume, само-лечится следующим шагом/finalize. Можно дать hydrateAssistantParts переставлять stepsPersisted, или задокументировать намеренность.
  • insertStep ON CONFLICT DO NOTHING хранит ПЕРВУЮ версию parts — корректно под append-only-инвариантом (завершённый шаг неизменен); стоит явный коммент-предупреждение.

Checklist

  • критерии приёмки из #492 выполнены (оба коммита)
  • вне scope ничего не менялось; node_modules нет; миграция уникальна/идемпотентна
## Summary Итерация 7 эпика #497 (волна C), два коммита. closes #492. 1. **Клиент — инкрементальный рендер стрима.** Ответ рендерится по мере стрима: стабилизированные markdown-блоки парсятся канонически ОДИН раз и мемоизируются, живой хвост — плоский React-escaped текст (без parse/sanitize/innerHTML). На finish — один канонический проход по всему тексту (байт-в-байт как до #492). База (develop): `renderChatMarkdown` гонялся на КАЖДЫЙ throttled-тик (~20 Гц) по всему накопленному ответу — O(тиков); теперь O(блоков). `MessageItem` part-loop переведён на exhaustive-switch с compile-time `never` (новый part-kind → ошибка компиляции). 2. **Сервер — append-персист шагов.** Раньше каждый `onStepFinish` переписывал всю строку ассистента (O(n²) объёма/ран, autovacuum-чарн). Теперь per-step INSERT в новую таблицу `ai_chat_run_steps` (`message_id`+`step_index` UNIQUE, jsonb parts, CASCADE) + дешёвый marker-UPDATE; полная строка собирается один раз на finalize. Seam `reconstructRunParts`/`hydrateAssistantParts` на границах чтения (`messages`/`delta`/`export`/`run`) — потребители не меняются. Дискриминатор: строка с непустыми inline `metadata.parts` (старые + завершённые #492) читается со строки, иначе (mid-run #492) — из step-таблицы. Back-compat: старый ран без step-строк восстанавливается идентично. ## How verified (на стенде, реальный Docker pg) - Клиент: ai-chat suite **408/408**, `tsc` 0. Визуальная паритетность: `block.innerHTML === renderChatMarkdown(text)` байт-в-байт + сходимость стрима к финалу. perf-smoke: вызовы `≤ blocks+2` и `≪ ticks/3`; мутация (снять `memo()`) → 78631 вызовов, краснеет. exhaustive-switch: удаление `case` → TS2322. - Сервер: 3 int-spec'а (migration/reconstruct/WAL) **4/4**; WAL-дельта `new=4.3MB` vs `old=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) на живой БД; timestamp `20260708T120000` > последней в develop, уникален. - Внутренний цикл: реализация → адверсариальный ревью (3 фокус-риска: пропущенная граница гидрации / идемпотентность миграции под retry / разрыв визуальной паритетности — все закрыты вживую) → APPROVE. ## Премиса-коррекция (для ревьюера) Задача предполагала уже существующий seam `reconstructRunParts`, через который ходят потребители — его НЕ было. Потребители читали `metadata.parts` с сырых строк контроллера. Поэтому «потребители не меняются» достигнуто СЕРВЕРНОЙ гидрацией на границах чтения (создал `reconstructRunParts`/`hydrateAssistantParts`, применил в `messages`/`delta`/`getRun`/`export`). `public-share` читает parts из тела запроса, не из БД — не граница. Бонус: прерванный ран (reconcile-abort) теперь РЕГИДРИРУЕТ шаги из таблицы вместо пред-#492 «documented loss». ## Осознанные SUGGESTION из ревью (не блокеры, на усмотрение) - INSERT шага и marker-UPDATE — две записи (не в одной транзакции). Узкое транзиентное окно «INSERT прошёл, marker упал» → `metadata.stepsPersisted` отстаёт на 1; `reconstructRunParts` берёт `stepRows.length` (верно), максимум — единичное визуальное дублирование шага при resume, само-лечится следующим шагом/finalize. Можно дать `hydrateAssistantParts` переставлять `stepsPersisted`, или задокументировать намеренность. - `insertStep` `ON CONFLICT DO NOTHING` хранит ПЕРВУЮ версию parts — корректно под append-only-инвариантом (завершённый шаг неизменен); стоит явный коммент-предупреждение. ## Checklist - [x] критерии приёмки из #492 выполнены (оба коммита) - [x] вне scope ничего не менялось; node_modules нет; миграция уникальна/идемпотентна
agent_coder added 3 commits 2026-07-12 05:34:55 +03:00
Путь ответа ассистента теперь рендерится инкрементально, по образцу
StreamingPlainText из ветки reasoning (#492, волна C эпика #497).

Раньше MarkdownPart прогонял ВЕСЬ накопленный ответ через канонический
конвейер (markdownToProseMirrorSync → PMNode.fromJSON → DOMSerializer →
DOMPurify) на КАЖДОМ throttled-тике (~20 Гц). На синтетическом потоке
~100 КБ это 394 вызова renderChatMarkdown — O(числа тиков), причём каждый
вызов заново парсит всю растущую строку.

Теперь:
- StreamingMarkdownText делит текст на блоки по безопасному срезу
  (splitPlainChunks — та же append-only-инвариантность, что у reasoning):
  СТАБИЛИЗИРОВАННЫЕ блоки идут через канонический конвейер и мемоизируются
  (каждый блок парсится РОВНО ОДИН раз), живой ХВОСТ — дешёвый plain-text
  (React-escaped, без парсера/санитайзера/innerHTML) до стабилизации.
- На финализации (флип state → done или конец хода) — ОДИН полный
  канонический рендер всего текста: побайтовая визуальная паритетность с
  прежним выводом (включая <li><p>-обёртки схемы и scoped-CSS из #498).
- Гейт liveness тот же, что у ReasoningBlock: streaming =
  turnStreaming && part.state === "streaming".

Также цикл рендера частей переведён на ИСЧЕРПЫВАЮЩИЙ switch по видам частей
с never-проверкой в default (вместо прежнего WARNING-комментария): новый
закрытый вид части в UIMessagePart теперь ошибка компиляции.

Тесты:
- perf-smoke: на ~100 КБ потоке число вызовов renderChatMarkdown ≤ blocks+2
  и ≪ ticks (O(блоков), не O(тиков)); мутация (снять memo с MarkdownChunk)
  краснит ассерт (78631 вызовов).
- visual-regression: финальный рендер побайтово равен renderChatMarkdown
  всего текста (в т.ч. <li><p>), инкрементальный вид сходится к нему на
  финализации; учтён neutralizeInternalLinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раньше каждый onStepFinish переписывал ВСЮ строку ассистента (растущий
metadata.parts jsonb со всеми выводами инструментов) → O(n²) объёма записи
на прогон: под MVCC/TOAST апдейт jsonb переписывает всю версию строки, так
что шаг k пишет ~k×вывод. Прогон из 50 шагов по ~100 КБ = сотни МБ WAL и
мёртвых кортежей за ход, что молотит autovacuum. (#490 убрал только ВТОРУЮ
копию в tool_calls; сам metadata.parts всё ещё рос и переписывался.)

Теперь каждый завершённый шаг ДОПИСЫВАЕТСЯ отдельной строкой в лёгкую
таблицу ai_chat_run_steps (только парты этого шага), а строка сообщения
получает дешёвый маркер (stepsPersisted + toolTraceVersion, без растущего
блоба parts). Полный metadata.parts собирается ОДИН раз на финализации.
НЕ jsonb-append (||): апдейт всё равно переписывает всю TOAST-версию —
экономится только сетевой payload, а WAL/мёртвые кортежи остаются; поэтому
именно ОТДЕЛЬНАЯ таблица + INSERT.

Три обязательные интеграции:
- reconstructRunParts(row, stepRows) → { parts, stepsPersisted }: единый
  шов переключения бэкенда. Читает парты из СТРОКИ, если она уже несёт
  inline-parts (старые записи + ЛЮБАЯ финализированная), иначе из ТАБЛИЦЫ
  ШАГОВ (mid-run запись #492). Дискриминатор — наличие непустого
  metadata.parts (флаг схемы не нужен). Потребители (attach-seed,
  delta-poll, export, reconnect) прогоняют строки через hydrateAssistantParts
  на границе чтения — их контракт/вывод не меняется, старые и новые записи
  восстанавливаются идентично.
- сигнал ротации кольца реестра #491 (confirmPersistedStep) теперь стреляет
  на подтверждённый INSERT шага, под тем же контрактом (updateStreaming
  возвращает stepsPersisted / null).
- era-marker toolTraceVersion (#490) больше не ставится полной переписью —
  ставится в маркере шага и на финализации (flushAssistant), остаётся
  консистентным.

Полная обратная совместимость: прогон, записанный по-старому (полная строка,
без строк шагов), восстанавливается/attach/export идентично. При отсутствии
репозитория шагов (позиционные тест-конструкции) — фолбэк на прежний
полнострочный flush (без регрессии, только без выигрыша WAL).

Тесты (реальный pg, int-lane):
- WAL-дельта (pg_current_wal_lsn) на прогоне 40×100КБ: new=4.3МБ vs
  old=90.3МБ (20.8x) — O(Σ шагов) против O(n²); старый путь в тесте И есть
  ревертнутое поведение (мутация-проверка).
- reconstruct-контракт: new-style (таблица шагов) и old-style (inline) прогоны
  восстанавливаются в идентичные parts; hydrate заполняет строку.
- миграция up/down roundtrip.

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

Ревью — инкрементальный рендер + 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):

  • F1 [warning] серверный model-replay НЕ гидрирует из steps → hard-crash ход реплеится ПУСТЫМ модели.
  • F2/F3 [warning] интегрированные seam'ы (write append-persist / read hydration) не покрыты тестами — именно поэтому F1 проскочил.
  • F4 [suggestion] мёртвый 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 несёт stepMarkermetadata.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-инъекцию для позиционных тест-билдов). (Связанное, отдельно НЕ заводить: одиночный swallowed insertStep-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, кривой slice capturedSteps[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-165

withReconstructedParts (needy-фильтр → findByMessageIdshydrateAssistantParts) подключён в 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] мёртвый countByMessageapps/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.

## Ревью — инкрементальный рендер + 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): - **F1 [warning] серверный model-replay НЕ гидрирует из steps → hard-crash ход реплеится ПУСТЫМ модели.** - **F2/F3 [warning] интегрированные seam'ы (write append-persist / read hydration) не покрыты тестами** — именно поэтому F1 проскочил. - **F4 [suggestion] мёртвый `countByMessage`.** <details> <summary>📋 F1–F4 детали</summary> ### 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-инъекцию для позиционных тест-билдов). _(Связанное, отдельно НЕ заводить: одиночный swallowed `insertStep`-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`, кривой slice `capturedSteps[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-165` `withReconstructedParts` (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:** удалить (метод+докстринг); вернуть с первым реальным вызывающим. <sub>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`.</sub> </details>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-12 07:22:37 +03:00
agent_coder added 1 commit 2026-07-12 08:05:31 +03:00
F1: the server model-replay loaded history via findAllByChat().map(rowToUiMessage)
WITHOUT hydrating parts from ai_chat_run_steps. A HARD crash mid-run (SIGKILL/OOM)
fires no terminal callback, so the assistant row stays parts:[] and its partial
tool-calls/results/text (durable in the steps table) dropped out of the model's
next-turn context. Hydrate needy assistant rows (role==='assistant' &&
!rowHasInlineParts) via findByMessageIds + hydrateAssistantParts before the replay
map — mirroring the controller's withReconstructedParts exactly — guarded on the
optional repo. Fix the now-false interrupt-resume comment.

F2: add a service int-spec that drives the REAL onStep append-persist WRITE branch
through AiChatService.stream with a real AiChatRunStepRepo injected, asserting the
per-step rows' stepIndex + parts slice and the step-marker metadata match a
single-row flush (catches an stepsPersisted-1 off-by-one).

F3: add a controller int-spec that drives withReconstructedParts through getMessages
WITH the repo present (a mid-run marker-only row + its step rows), asserting the
reconstructed metadata.parts and workspace-scoping.

F4: remove the dead countByMessage (zero prod callers; reconstructRunParts derives
stepsPersisted inline) + its now-unused sql import and the redundant test assertion.

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

Ответ на ревью (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-one stepsPersisted-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 ассертит reconstructed parts == 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.

## Ответ на ревью (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-one `stepsPersisted-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` ассертит reconstructed `parts == 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`.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 08:06:06 +03:00
Collaborator

Ре-ревью — инкрем-рендер + append-персист (#492), раунд 2. Вердикт: approved

Все 4 находки закрыты, проверил адверсариально:

  • F1 [fixed] серверный model-replay теперь ГИДРИРУЕТ из steps-таблицы: ai-chat.service.ts:1137-1147replayHistory = hydrateAssistantParts(oldHistory, stepsByMessage) для needy-строк (assistant && !rowHasInlineParts), и uiMessages мапит replayHistory (не oldHistory, :1151). Мутация-локк: вернул replayHistory = oldHistory (скип гидрации) → тест «replays a hard-crashed mid-run turn WITH its partial steps hydrated» КРАСНЕЕТ (assert prompt.toContain('HYDRATED_PARTIAL_STEP') падает — без гидрации rowToUiMessagecontent:''). Не вакуумен. Жёстко-крашнутый ход снова реплеится модели С частичной работой (как до #492).
  • F2/F3 [fixed] новые int-specs с репо present: ai-chat-append-persist-service.int-spec.ts (+412, WRITE-ветка через живой AiChatService.stream) + ai-chat-hydrate-controller.int-spec.ts (+169, controller hydration READ-ветка) — раньше специ передавали репо undefined, ветки не исполнялись.
  • F4 [fixed] мёртвый countByMessage удалён (0 refs).

Гейт: client vitest 6/6, service+controller int-specs 3/3 на Docker pg (heap6144). Дерево после мутаций чисто. Клиент/архитектура/idempotency/reconstruct-паритет/миграция были LGTM в round 1. Готово к мержу.

## Ре-ревью — инкрем-рендер + append-персист (#492), раунд 2. Вердикт: **approved** ✅ Все 4 находки закрыты, проверил адверсариально: - **F1 [fixed]** серверный model-replay теперь ГИДРИРУЕТ из steps-таблицы: `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» КРАСНЕЕТ (assert `prompt.toContain('HYDRATED_PARTIAL_STEP')` падает — без гидрации `rowToUiMessage`→`content:''`). Не вакуумен. Жёстко-крашнутый ход снова реплеится модели С частичной работой (как до #492). - **F2/F3 [fixed]** новые int-specs с репо present: `ai-chat-append-persist-service.int-spec.ts` (+412, WRITE-ветка через живой `AiChatService.stream`) + `ai-chat-hydrate-controller.int-spec.ts` (+169, controller hydration READ-ветка) — раньше специ передавали репо undefined, ветки не исполнялись. - **F4 [fixed]** мёртвый `countByMessage` удалён (0 refs). **Гейт:** client vitest 6/6, service+controller int-specs 3/3 на Docker pg (heap6144). Дерево после мутаций чисто. Клиент/архитектура/idempotency/reconstruct-паритет/миграция были LGTM в round 1. Готово к мержу.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 08:19:10 +03:00
vvzvlad merged commit 693a9a350b into develop 2026-07-12 17:31:42 +03:00
Sign in to join this conversation.