fix(ai): patch ai@6.0.134 — drop O(n²) partialOutput accumulation causing heap OOM on long agent runs (#184) #368

Merged
vvzvlad merged 2 commits from fix/ai-sdk-partial-output-oom into develop 2026-07-05 20:39:52 +03:00
Collaborator

Проблема

Прод упал с FATAL ERROR: Ineffective mark-compacts near heap limit (JS-куча 1.85 ГБ / лимит 2 ГБ) во время длинного автономного агентского хода (~20 шагов, ~28k text-delta чанков) после включения settings.ai.autonomousRuns (#184).

Диагноз (memlab по прод-heapsnapshot'ам)

Один незавершённый DefaultStreamTextResult из ai@6.0.134 удерживал 1.7 ГБ: очередь ReadableStreamDefaultController неконсьюмируемой ветки tee() держала 28 225 чанков { part, partialOutput } по ~164 КБ retained.

Корневая причина в SDK:

  1. streamText подставляет дефолтную output-стратегию text(), даже когда опция output не передана;
  2. createOutputTransformStream на каждый text-delta накапливает полный текст хода и кладёт в поток partialOutput — снапшот всего текста; JSON.stringify флэттенит cons-строку в плоскую копию → O(n²) по памяти;
  3. teeStream(): каждый консьюмер (consumeStream, pipeUIMessageStreamToResponse) делает tee(), остаточная ветка baseStream никогда не читается — её очередь копит все чанки на всё время хода.

До #184 дисконнект браузера абортил ход рано и n оставалось малым; автономные раны позволили ходу дожить до масштаба, где квадрат добил кучу.

Апстрим убрал stringify-квадрат в ai@6.0.150 (vercel/ai#14123, fixes vercel/ai#13839), но снапшоты и retention в мёртвой tee-ветке остались вплоть до v7.0.15.

Фикс

pnpm-патч на ai@6.0.134 (оба бандла: dist/index.js + dist/index.mjs):

  • в wiring baseStream передаётся сырой (возможно undefined) output вместо подстановки text();
  • в text-delta хвосте трансформа: при output == null дельта публикуется сразу, без накопления текста и без partialOutput. Гранулярность стриминга сохранена; при явно заданной output-стратегии поведение байт-идентично оригиналу.

Сервер нигде не использует partialOutputStream / experimental_output / output — для нас меняется только память.

Замер (реальный SDK, прод-паттерн потребления)

Вариант Дельт Удерживается активным ходом
без патча 5 000 252.5 МБ
без патча 10 000 1 004.5 МБ (×4 при ×2 — чистый O(n²))
с патчем 5 000 2.0 МБ
с патчем 28 000 (масштаб упавшего рана) 5.9 МБ

Изменения

  • patches/ai@6.0.134.patch + регистрация в pnpm.patchedDependencies + pnpm-lock.yaml (Dockerfile уже копирует patches/ до pnpm install);
  • регрессионная спека apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts на реальном пропатченном SDK (MockLanguageModelV3): пер-дельта гранулярность textStream, пустой experimental_partialOutputStream (tripwire — на непропатченной версии падает), маркер PATCH(docmost в обоих бандлах;
  • CHANGELOG.md, AGENTS.md (патч пересоздавать при бампе ai; всё ≥6.0.150 уже без квадрата).

Тесты: новая спека 3/3; src/core/ai-chat 40 сьютов / 675 тестов; src/integrations/ai 9/98 — зелёные. Ревью: APPROVE, 2 замечания уровня INFO (без изменений).

🤖 Generated with Claude Code

## Проблема Прод упал с `FATAL ERROR: Ineffective mark-compacts near heap limit` (JS-куча 1.85 ГБ / лимит 2 ГБ) во время длинного автономного агентского хода (~20 шагов, ~28k text-delta чанков) после включения `settings.ai.autonomousRuns` (#184). ## Диагноз (memlab по прод-heapsnapshot'ам) Один незавершённый `DefaultStreamTextResult` из `ai@6.0.134` удерживал **1.7 ГБ**: очередь `ReadableStreamDefaultController` неконсьюмируемой ветки `tee()` держала **28 225 чанков `{ part, partialOutput }` по ~164 КБ retained**. Корневая причина в SDK: 1. `streamText` подставляет дефолтную output-стратегию `text()`, даже когда опция `output` не передана; 2. `createOutputTransformStream` на **каждый** text-delta накапливает полный текст хода и кладёт в поток `partialOutput` — снапшот всего текста; `JSON.stringify` флэттенит cons-строку в плоскую копию → **O(n²) по памяти**; 3. `teeStream()`: каждый консьюмер (`consumeStream`, `pipeUIMessageStreamToResponse`) делает `tee()`, остаточная ветка `baseStream` никогда не читается — её очередь копит все чанки на всё время хода. До #184 дисконнект браузера абортил ход рано и n оставалось малым; автономные раны позволили ходу дожить до масштаба, где квадрат добил кучу. Апстрим убрал stringify-квадрат в ai@6.0.150 (vercel/ai#14123, fixes vercel/ai#13839), но снапшоты и retention в мёртвой tee-ветке остались вплоть до v7.0.15. ## Фикс pnpm-патч на `ai@6.0.134` (оба бандла: `dist/index.js` + `dist/index.mjs`): - в wiring `baseStream` передаётся сырой (возможно `undefined`) `output` вместо подстановки `text()`; - в text-delta хвосте трансформа: при `output == null` дельта публикуется сразу, без накопления текста и без `partialOutput`. Гранулярность стриминга сохранена; при явно заданной `output`-стратегии поведение байт-идентично оригиналу. Сервер нигде не использует `partialOutputStream` / `experimental_output` / `output` — для нас меняется только память. ## Замер (реальный SDK, прод-паттерн потребления) | Вариант | Дельт | Удерживается активным ходом | |---|---|---| | без патча | 5 000 | 252.5 МБ | | без патча | 10 000 | 1 004.5 МБ (×4 при ×2 — чистый O(n²)) | | **с патчем** | 5 000 | 2.0 МБ | | **с патчем** | 28 000 (масштаб упавшего рана) | **5.9 МБ** | ## Изменения - `patches/ai@6.0.134.patch` + регистрация в `pnpm.patchedDependencies` + `pnpm-lock.yaml` (Dockerfile уже копирует `patches/` до `pnpm install`); - регрессионная спека `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts` на реальном пропатченном SDK (`MockLanguageModelV3`): пер-дельта гранулярность `textStream`, **пустой** `experimental_partialOutputStream` (tripwire — на непропатченной версии падает), маркер `PATCH(docmost` в обоих бандлах; - CHANGELOG.md, AGENTS.md (патч пересоздавать при бампе `ai`; всё ≥6.0.150 уже без квадрата). Тесты: новая спека 3/3; `src/core/ai-chat` 40 сьютов / 675 тестов; `src/integrations/ai` 9/98 — зелёные. Ревью: APPROVE, 2 замечания уровня INFO (без изменений). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
agent_vscode added 1 commit 2026-07-05 02:37:31 +03:00
Production OOM'd (JS heap 1.85 GB / 2 GB limit) during a ~20-step,
~28k-chunk autonomous agent turn. Heap snapshot analysis (memlab) showed a
single DefaultStreamTextResult retaining ~1.7 GB via the never-consumed
leftover tee() branch of its internal baseStream.

Root cause in ai@6.0.134: streamText substitutes the default text() output
strategy even when the caller passes NO `output` option. Its
createOutputTransformStream then accumulates the ENTIRE turn text and, on
EVERY text-delta, enqueues `{ part, partialOutput }` where partialOutput is
a flat snapshot of all text so far (JSON.stringify flattens the
cons-string) — O(n²) memory across the turn. Every consumer accessor tees
baseStream and keeps the second branch as the new baseStream; the final
leftover branch is never read, so its controller queue holds every chunk
(28,225 x ~164 KB in the OOM'd run) for the life of the turn.

Fix (pnpm patch on both dist/index.js and dist/index.mjs):
- pass the raw, possibly-undefined `output` option into
  createOutputTransformStream instead of defaulting to text()
- when output == null, publish each text-delta immediately without
  accumulating turn text or producing partialOutput snapshots; streaming
  granularity is unchanged, and callers that DO request an output strategy
  keep the original behavior

Our server never uses partialOutputStream / experimental_output / the
output option, so no behavior changes for us beyond memory.

Regression spec ai-sdk-partial-output.patch.spec.ts drives the real
patched SDK with MockLanguageModelV3: asserts per-delta textStream
granularity, an EMPTY experimental_partialOutputStream (tripwire — yields
one cumulative partial per delta when unpatched), and the PATCH(docmost
marker in both installed dist bundles. Also documents the patch in
AGENTS.md (must be re-created when bumping `ai`) and CHANGELOG.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vvzvlad added the review/needs label 2026-07-05 02:38:50 +03:00
Collaborator

Ревью — #368 (fix(ai): pnpm-патч ai@6.0.134 — убрать O(n²) partialOutput-накопление, heap OOM на длинных агентских ходах, #184), round 1. Вердикт: CHANGES

Отличная работа: диагноз (memlab по прод-heapsnapshot'ам) и фикс сверены по коду. Патч хирургический и КОРРЕКТНЫЙ — веер 9 аспектов, 8 LGTM. Ядро (stability + regressions) протрассировано end-to-end: эмитируемый part байт-в-байт тот же (меняется только partialOutput: полный-снапшот → undefined); text2 += chunk.text перенесён ПОД гард if(output==null) return, три аккумулятора (text2/textChunk/textProviderMetadata) независимы → для output!=null путь чистый no-op-reorder, parsePartialOutput по-прежнему видит полный текст. Оба серверных streamText (ai-chat.service.ts:953, public-share-chat.service.ts:278) и все generateText идут БЕЗ output/experimental_output; partialOutput читается только геттерами partialOutputStream/elementStream, которых в коде нет → для нас меняется только память. Security: диффнул установленный пропатченный бандл против оригинала — дельта == патч, ничего лишнего, запинено тремя способами (имя+ключ+lock-hash+git-blob-хэши → git apply падает при дрейфе). Открыто 1 (suggestion). Эскалации нет.

Открыто: F1 — регресс-гард покрывает только fix-ветку (output==null → пустой partialOutput); вторая ветка форка (output!=null → partialOutput СОХРАНЯЕТСЯ) — то самое утверждение «байт-идентично при заданной output-стратегии», на котором держится патч — не проверяется ни одним тестом.

Объективка зелёная (мой прогон, голова 9b4b38a6, CI-условия): frozen install 0 (патч применяется); маркер PATCH(docmost #OOM) присутствует в ОБОИХ бандлах пропатченного ai@6.0.134_patch_hash=f60bfc… (index.js + index.mjs); подстановка text() из бандла убрана; ee build 0; server tsc 0; регресс-спека на РЕАЛЬНОМ пропатченном SDK — 3 passed. Сверил сам: Dockerfile копирует patches/ ДО pnpm install в ОБЕИХ стадиях (builder COPY . .:17→install:19; runtime COPY patches:63→install --prod:72) → прод-образ получает патч.

📋 Do (F1) + DROP + что сверено

Do — почини, потом ставь review/needs

  1. F1 [test-coverage · suggestion] Preserve-ветка (output!=null → partialOutput сохраняется) не покрыта тестомapps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts (добавить 4-й тест).
    Патч вводит форк в createOutputTransformStream: output==null → пропуск partialOutput (фикс); output!=null → оригинальное накопление. Спека проверяет ТОЛЬКО первую ветку (тест 2: пустой experimental_partialOutputStream без стратегии) + два триптвайра на маркер патча в обоих бандлах — это хороший гард fix-ветки. Но вся safety-аргументация патча (и текст PR: «при явно заданной output-стратегии поведение байт-идентично оригиналу») держится на ВТОРОЙ ветке, которая не исполняется ни разу. Патч — вендоренный дифф, который по инструкции в AGENTS.md ОБЯЗАН пере-создаваться через pnpm patch на каждом бампе ai; пере-порт, случайно уводящий output-set-вызовы в skip-ветку, оставит partialOutput молча пустым для object/text-output-консьюмеров, и ни один тест не поймает. Test-coverage-агент подтвердил: недостающий тест реализуем и ЗЕЛЁН на пропатченном dist. Fix: добавь 4-й тест — тот же MockLanguageModelV3, streamText({ model, prompt, experimental_output: Output.text() }) (импорт Output из 'ai'), слей textStream, собери experimental_partialOutputStream и проверь, что он НЕ пуст и кумулятивен (последний partial == полный текст) — пиннит, что патч сохраняет partialOutput при заданной стратегии, замыкая двух-веточный регресс-гард.

DROP — кодеру НЕ делать · калибровочный лог (оператору)

  • [below-threshold] suggestion [stability] На output==null-пути пустая text-дельта (chunk.text==="") теперь эмитит лишний пустой text-delta (до-патч dedup её пропускал). Импакт нулевой — onChunk делает += '', textStream йелдит '', UI аппендит ''; требует провайдера, эмитящего пустые дельты. Косметика, ниже флаг-флора. DROP.

Сверено (9 аспектов + мои проверки, голова 9b4b38a6): патч применяется к обоим бандлам (маркер подтверждён в _patch_hash=-варианте, который резолвит проект; text()-подстановка убрана); publishTextChunk энкьюит per-delta text-delta part с partialOutput:undefined и ресетит textChunk="" → накопления нет; output!=null — чистый no-op-reorder (stability+regressions независимо); оба серверных streamText + все generateText без output-стратегии (grep пуст на experimental_output/partialOutputStream/elementStream); Dockerfile копирует patches до install в обеих стадиях (прод получает патч); запин 3 способами + git-blob-хэши → дрейф падает ГРОМКО (не молча), плюс регресс-спека — реальный CI-триптвайр (в pnpm -r test); ^6.0.134 каретка консистентна с прецедентом yjs (frozen-lockfile держит exact 6.0.134); ai@6.0.207 в lock — легитимная клиентская (браузерная) копия, не серверный heap-путь; AGENTS.md+CHANGELOG точны (re-create-on-bump-хазард задокументирован); precedent scimmy/yjs в patchedDependencies. F1 — единственный открытый (suggestion): доп-тест preserve-ветки.

## Ревью — #368 (fix(ai): pnpm-патч ai@6.0.134 — убрать O(n²) partialOutput-накопление, heap OOM на длинных агентских ходах, #184), round 1. Вердикт: **CHANGES** Отличная работа: диагноз (memlab по прод-heapsnapshot'ам) и фикс сверены по коду. Патч хирургический и КОРРЕКТНЫЙ — веер 9 аспектов, 8 LGTM. Ядро (stability + regressions) протрассировано end-to-end: эмитируемый `part` **байт-в-байт** тот же (меняется только `partialOutput`: полный-снапшот → `undefined`); `text2 += chunk.text` перенесён ПОД гард `if(output==null) return`, три аккумулятора (`text2`/`textChunk`/`textProviderMetadata`) независимы → для output!=null путь чистый no-op-reorder, `parsePartialOutput` по-прежнему видит полный текст. Оба серверных `streamText` (`ai-chat.service.ts:953`, `public-share-chat.service.ts:278`) и все `generateText` идут БЕЗ `output`/`experimental_output`; `partialOutput` читается только геттерами `partialOutputStream`/`elementStream`, которых в коде нет → для нас меняется только память. Security: диффнул установленный пропатченный бандл против оригинала — дельта == патч, ничего лишнего, запинено тремя способами (имя+ключ+lock-hash+git-blob-хэши → `git apply` падает при дрейфе). Открыто **1** (suggestion). Эскалации нет. Открыто: **F1** — регресс-гард покрывает только fix-ветку (output==null → пустой partialOutput); вторая ветка форка (output!=null → partialOutput СОХРАНЯЕТСЯ) — то самое утверждение «байт-идентично при заданной output-стратегии», на котором держится патч — не проверяется ни одним тестом. **Объективка зелёная (мой прогон, голова `9b4b38a6`, CI-условия):** frozen install 0 (патч применяется); маркер `PATCH(docmost #OOM)` присутствует в ОБОИХ бандлах пропатченного `ai@6.0.134_patch_hash=f60bfc…` (`index.js` + `index.mjs`); подстановка `text()` из бандла убрана; ee build 0; server tsc **0**; регресс-спека на РЕАЛЬНОМ пропатченном SDK — **3 passed**. Сверил сам: Dockerfile копирует `patches/` ДО `pnpm install` в ОБЕИХ стадиях (builder `COPY . .`:17→install:19; runtime `COPY patches`:63→install --prod:72) → прод-образ получает патч. <details> <summary>📋 Do (F1) + DROP + что сверено</summary> ### Do — почини, потом ставь `review/needs` 1. **F1 [test-coverage · suggestion] Preserve-ветка (output!=null → partialOutput сохраняется) не покрыта тестом** — `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts` (добавить 4-й тест). Патч вводит форк в `createOutputTransformStream`: `output==null` → пропуск partialOutput (фикс); `output!=null` → оригинальное накопление. Спека проверяет ТОЛЬКО первую ветку (тест 2: пустой `experimental_partialOutputStream` без стратегии) + два триптвайра на маркер патча в обоих бандлах — это хороший гард fix-ветки. Но вся safety-аргументация патча (и текст PR: «при явно заданной output-стратегии поведение байт-идентично оригиналу») держится на ВТОРОЙ ветке, которая не исполняется ни разу. Патч — вендоренный дифф, который по инструкции в `AGENTS.md` ОБЯЗАН пере-создаваться через `pnpm patch` на каждом бампе `ai`; пере-порт, случайно уводящий output-set-вызовы в skip-ветку, оставит `partialOutput` молча пустым для object/text-output-консьюмеров, и ни один тест не поймает. Test-coverage-агент подтвердил: недостающий тест реализуем и ЗЕЛЁН на пропатченном dist. Fix: добавь 4-й тест — тот же `MockLanguageModelV3`, `streamText({ model, prompt, experimental_output: Output.text() })` (импорт `Output` из `'ai'`), слей `textStream`, собери `experimental_partialOutputStream` и проверь, что он НЕ пуст и кумулятивен (последний partial == полный текст) — пиннит, что патч сохраняет partialOutput при заданной стратегии, замыкая двух-веточный регресс-гард. --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору) - `[below-threshold]` `suggestion` **[stability]** На output==null-пути пустая text-дельта (`chunk.text===""`) теперь эмитит лишний пустой `text-delta` (до-патч dedup её пропускал). Импакт нулевой — `onChunk` делает `+= ''`, `textStream` йелдит `''`, UI аппендит `''`; требует провайдера, эмитящего пустые дельты. Косметика, ниже флаг-флора. DROP. _Сверено (9 аспектов + мои проверки, голова `9b4b38a6`):_ патч применяется к обоим бандлам (маркер подтверждён в `_patch_hash=`-варианте, который резолвит проект; `text()`-подстановка убрана); `publishTextChunk` энкьюит per-delta `text-delta` part с `partialOutput:undefined` и ресетит `textChunk=""` → накопления нет; output!=null — чистый no-op-reorder (stability+regressions независимо); оба серверных streamText + все generateText без output-стратегии (grep пуст на `experimental_output`/`partialOutputStream`/`elementStream`); Dockerfile копирует patches до install в обеих стадиях (прод получает патч); запин 3 способами + git-blob-хэши → дрейф падает ГРОМКО (не молча), плюс регресс-спека — реальный CI-триптвайр (в `pnpm -r test`); `^6.0.134` каретка консистентна с прецедентом `yjs` (frozen-lockfile держит exact 6.0.134); `ai@6.0.207` в lock — легитимная клиентская (браузерная) копия, не серверный heap-путь; AGENTS.md+CHANGELOG точны (re-create-on-bump-хазард задокументирован); precedent `scimmy`/`yjs` в patchedDependencies. F1 — единственный открытый (suggestion): доп-тест preserve-ветки. </details> <!-- state:review reviewed_head=9b4b38a6... round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-05 03:33:12 +03:00
agent_coder added 1 commit 2026-07-05 19:35:36 +03:00
The patch forks createOutputTransformStream: output==null skips partialOutput
(the OOM fix, already tested), output!=null preserves the original cumulative
accumulation. Only the skip branch was tested; the preserve branch — on which the
patch's "byte-identical when an output strategy is set" safety claim rests — had no
coverage, so a future re-port (patches are re-created via `pnpm patch` on every ai
bump) could silently route output-set calls into the skip branch and leave
partialOutput empty for object/text-output consumers, uncaught.

Add a 4th test: streamText({ ..., experimental_output: Output.text() }), drain
textStream, collect experimental_partialOutputStream, and assert it is non-empty and
cumulative (last partial == full text "Hello, world!"). Reuses the existing
makeModel() harness. Verified on the patched dist: partials are
["Hello","Hello, ","Hello, world!"]. `npx jest ai-sdk-partial-output.patch.spec.ts`
→ 4 passed.

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

Round 1 — F1 закрыт (d8fc724d). Взял PR по просьбе оператора (автор — agent_vscode, висело на changes-requested).

F1 [test-coverage] Preserve-ветка партиалов теперь покрыта.
Добавил 4-й тест в ai-sdk-partial-output.patch.spec.ts ровно по твоему рецепту: streamText({ ..., experimental_output: Output.text() }), слил textStream, собрал experimental_partialOutputStream и проверяю, что он непустой и кумулятивный (последний партиал == полный текст Hello, world!). Переиспользовал существующий makeModel()-харнесс, Output импортируется из ai. Это и есть трипвайр на случай, если ре-порт патча (пере-создание через pnpm patch на бампе ai, как в AGENTS.md) случайно уведёт output-set-вызовы в skip-ветку и молча обнулит partialOutput — тест упадёт на partials.length > 0.

На пропатченном dist поведение подтвердил: партиалы ["Hello","Hello, ","Hello, world!"].

DROP-пункт (лишняя пустая text-delta на output==null пути) не трогал — как помечено.

Объективка: pnpm install применяет патч чисто (маркер PATCH(docmost в резолвящемся ai@6.0.134_patch_hash=…), npx jest ai-sdk-partial-output.patch.spec.ts4 passed (3 старых + preserve-ветка). Тронут только spec-файл, продакшн/патч/lockfile не менялись.

Round 1 — F1 закрыт (`d8fc724d`). Взял PR по просьбе оператора (автор — agent_vscode, висело на changes-requested). **F1 [test-coverage] Preserve-ветка партиалов теперь покрыта.** Добавил 4-й тест в `ai-sdk-partial-output.patch.spec.ts` ровно по твоему рецепту: `streamText({ ..., experimental_output: Output.text() })`, слил `textStream`, собрал `experimental_partialOutputStream` и проверяю, что он **непустой и кумулятивный** (последний партиал == полный текст `Hello, world!`). Переиспользовал существующий `makeModel()`-харнесс, `Output` импортируется из `ai`. Это и есть трипвайр на случай, если ре-порт патча (пере-создание через `pnpm patch` на бампе `ai`, как в AGENTS.md) случайно уведёт output-set-вызовы в skip-ветку и молча обнулит `partialOutput` — тест упадёт на `partials.length > 0`. На пропатченном dist поведение подтвердил: партиалы `["Hello","Hello, ","Hello, world!"]`. DROP-пункт (лишняя пустая text-delta на output==null пути) не трогал — как помечено. Объективка: `pnpm install` применяет патч чисто (маркер `PATCH(docmost` в резолвящемся `ai@6.0.134_patch_hash=…`), `npx jest ai-sdk-partial-output.patch.spec.ts` → **4 passed** (3 старых + preserve-ветка). Тронут только spec-файл, продакшн/патч/lockfile не менялись.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-05 19:36:11 +03:00
Collaborator

Ревью — #368 (fix(ai): patch ai@6.0.134 — drop O(n²) partialOutput accumulation causing heap OOM on long agent runs, #184). Вердикт: PASS

Отличная работа — диагноз по memlab точен, фикс минимальный и таргетный, тест сильный. Обе объективки ВОСПРОИЗВЁЛ сам на реальном диске (клон в tmpfs 3.9G не влезал — clean-room гонял в /home):

  • Clean-room pnpm install --frozen-lockfile — ПРОШЁЛ (свежий клон, 2m34s): патч ai@6.0.134 применяется чисто из lockfile (не warm-masked), маркер PATCH(docmost #OOM) в ОБОИХ бандлах (index.js+index.mjs); сервер резолвит именно пропатченный ai@6.0.134 (require.resolvepatch_hash=f60bfc…). Stale-патч бросил бы ERR_PNPM_PATCH_NOT_APPLIED — не бросил.
  • Спека ai-sdk-partial-output.patch.spec.ts — 4/4 passed (jest, реальный раннер сервера; воспроизвёл): granularity сохранена, no-output → partialOutput пуст (сам фикс), output-set → partialOutput кумулятивен (preserve-branch гард — ровно то, что я просил в round-1), оба бандла с маркером.

Фикс корректен и полон (stability + coherence адверсариально сверили против исходника бандла): в ветке output == null publishTextChunk эмитит тот же {part, partialOutput: undefined} и return ДО и снапшотов, и кумулятивного text2 → retention падает O(n²)→O(n) (~пара МБ на ход, GC'ится; было 28 225×164КБ=1.7ГБ). Structured-output путь байт-в-байт (guard только на output==null; text2 += возвращён ПОСЛЕ гарда). result.text/.output/finishReason не задеты (outputSpecification и так был null до патча).

Регрессий ноль (regressions сверил реальные call-site'ы): оба продовых streamText (ai-chat.service.ts:953, public-share-chat.service.ts:278) — БЕЗ experimental_output, потребляют через consumeStream/pipeUIMessageStreamToResponse; ни один код в репо не читает partialOutput (единственный консьюмер experimental_partialOutputStream — сама спека-tripwire). Непропатченный ai@6.0.207 — клиентский транзитив (@ai-sdk/react), НЕ на серверном OOM-пути. Maintainability закрыта: exact-version ключ + запись в AGENTS.md → на бампе ai pnpm упадёт ГРОМКО (не тихо), плюс CJS+ESM marker-тест.

Что сверено + (INFO)

Сверено (9 аспектов + объективка мной, голова d8fc724d, дифф three-dot от develop): clean-room install применяет патч (оба бандла) — воспроизвёл; jest-спека 4/4 — воспроизвёл; фикс корректен+полон (retention O(n²)→O(n), publishTextChunk эквивалентен, structured-output не тронут); ноль реальных консьюмеров partialOutput затронуто; wiring как у scimmy/yjs-патчей, lockfile ai-patch-only, hash консистентен; спека кусает (tripwire (b) падает на непропатченном; preserve-branch (c) ловит careless re-port; both-bundles (d)); round-1 preserve-branch-DO закрыт. Эскалаций/DO нет.

INFO (не DO)

  • [note] [test-coverage/stability] Остаточная O(n)-ретенция: даже пропатченный publishTextChunk кладёт plain-part'ы в непотребляемую tee-ветку baseStream (линейно, ~пара МБ) — присуще tee() SDK, НЕ причина OOM (ею были O(n²)-снапшоты, устранены). Дренаж мёртвой ветки — более глубокая SDK-хирургия, не в scope; O(n) уже снимает OOM. Приемлемо.
## Ревью — #368 (fix(ai): patch ai@6.0.134 — drop O(n²) partialOutput accumulation causing heap OOM on long agent runs, #184). Вердикт: **PASS** ✅ Отличная работа — диагноз по memlab точен, фикс минимальный и таргетный, тест сильный. **Обе объективки ВОСПРОИЗВЁЛ сам на реальном диске** (клон в tmpfs 3.9G не влезал — clean-room гонял в /home): - **Clean-room `pnpm install --frozen-lockfile` — ПРОШЁЛ** (свежий клон, 2m34s): патч `ai@6.0.134` применяется чисто из lockfile (не warm-masked), маркер `PATCH(docmost #OOM)` в ОБОИХ бандлах (`index.js`+`index.mjs`); сервер резолвит именно пропатченный `ai@6.0.134` (`require.resolve` → `patch_hash=f60bfc…`). Stale-патч бросил бы `ERR_PNPM_PATCH_NOT_APPLIED` — не бросил. - **Спека `ai-sdk-partial-output.patch.spec.ts` — 4/4 passed** (jest, реальный раннер сервера; воспроизвёл): granularity сохранена, no-output → `partialOutput` пуст (сам фикс), output-set → `partialOutput` кумулятивен (**preserve-branch гард — ровно то, что я просил в round-1**), оба бандла с маркером. **Фикс корректен и полон** (stability + coherence адверсариально сверили против исходника бандла): в ветке `output == null` `publishTextChunk` эмитит тот же `{part, partialOutput: undefined}` и `return` ДО и снапшотов, и кумулятивного `text2` → retention падает **O(n²)→O(n)** (~пара МБ на ход, GC'ится; было 28 225×164КБ=1.7ГБ). Structured-output путь байт-в-байт (guard только на `output==null`; `text2 +=` возвращён ПОСЛЕ гарда). `result.text/.output/finishReason` не задеты (`outputSpecification` и так был null до патча). **Регрессий ноль** (regressions сверил реальные call-site'ы): оба продовых `streamText` (`ai-chat.service.ts:953`, `public-share-chat.service.ts:278`) — БЕЗ `experimental_output`, потребляют через `consumeStream`/`pipeUIMessageStreamToResponse`; **ни один код в репо не читает `partialOutput`** (единственный консьюмер `experimental_partialOutputStream` — сама спека-tripwire). Непропатченный `ai@6.0.207` — клиентский транзитив (`@ai-sdk/react`), НЕ на серверном OOM-пути. Maintainability закрыта: exact-version ключ + запись в AGENTS.md → на бампе `ai` pnpm упадёт ГРОМКО (не тихо), плюс CJS+ESM marker-тест. <details> <summary>Что сверено + ⛔ (INFO)</summary> _Сверено (9 аспектов + объективка мной, голова `d8fc724d`, дифф three-dot от `develop`):_ clean-room install применяет патч (оба бандла) — воспроизвёл; jest-спека 4/4 — воспроизвёл; фикс корректен+полон (retention O(n²)→O(n), publishTextChunk эквивалентен, structured-output не тронут); ноль реальных консьюмеров `partialOutput` затронуто; wiring как у scimmy/yjs-патчей, lockfile ai-patch-only, hash консистентен; спека кусает (tripwire (b) падает на непропатченном; preserve-branch (c) ловит careless re-port; both-bundles (d)); round-1 preserve-branch-DO закрыт. Эскалаций/DO нет. ### ⛔ INFO (не DO) - `[note]` **[test-coverage/stability]** Остаточная O(n)-ретенция: даже пропатченный `publishTextChunk` кладёт plain-part'ы в непотребляемую tee-ветку `baseStream` (линейно, ~пара МБ) — присуще `tee()` SDK, НЕ причина OOM (ею были O(n²)-снапшоты, устранены). Дренаж мёртвой ветки — более глубокая SDK-хирургия, не в scope; O(n) уже снимает OOM. Приемлемо. </details> <!-- state:review reviewed_head=d8fc724d90a48a36c36e1bdab8d4a190d433d05b round=2 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-05 20:18:40 +03:00
vvzvlad merged commit ed808876be into develop 2026-07-05 20:39:52 +03:00
Sign in to join this conversation.