feat(ai-chat): устойчивость тулов — parts-валидация + MCP in-run recovery + write-class ретрай (#489) #508

Open
agent_coder wants to merge 5 commits from feat/489-tool-resilience into feat/487-terminal-lifecycle
Collaborator

Summary

Итерация 3 эпика #497 — устойчивость тулов AI-чата. closes #489.

  • Валидация клиентских parts + insert после конвертации: sanitizeUserParts (whitelist text), convert-before-insert (ретрай не плодит дубль user-строки), convertHistoryResilient (пер-строчная деградация битой строки в [tool context omitted], а не «кирпич»-чат).
  • MCP-кэш in-run recovery: транспортная ошибка readOnly-тула → один реконнект+ретрай в пределах рана; write → indeterminate без ретрая (класс #435); per-run обёртка не null-ит общий кэш-энтри (один флапающий сервер не роняет остальные); SSE bodyTimeout поднят.
  • write-class ретрай только для доверенного внутреннего Docmost-сервера: мапа классификации применяется лишь к внутреннему серверу; любой сторонний тул → write → никогда не авто-ретраится (закрыта коллизия имён).

How verified

  • Полный серверный сьют jest --maxWorkers=2: 210 suites / 2478 passed / 0 failed (из чистого состояния, после ребейза на develop-зелёный feat/487).
  • mcp node --test test/unit test/mock: 832 passed / 0 failed.
  • Все ключевые пути mutation-verified (parts-деградация, readOnly-ретрай-один-раз, write-no-retry, no-retry-after-Stop, сторонний getPage не ретраится).

Checklist

  • критерии приёмки из #489 выполнены
  • вне заявленного scope ничего не менялось

Стоит стопкой на #487 (PR #504) → #486 (PR #500). При мерже базу перенацелю на develop в порядке 486→487→489.

## Summary Итерация 3 эпика #497 — устойчивость тулов AI-чата. closes #489. - **Валидация клиентских parts + insert после конвертации**: `sanitizeUserParts` (whitelist `text`), convert-before-insert (ретрай не плодит дубль user-строки), `convertHistoryResilient` (пер-строчная деградация битой строки в `[tool context omitted]`, а не «кирпич»-чат). - **MCP-кэш in-run recovery**: транспортная ошибка readOnly-тула → один реконнект+ретрай в пределах рана; write → indeterminate без ретрая (класс #435); per-run обёртка не null-ит общий кэш-энтри (один флапающий сервер не роняет остальные); SSE bodyTimeout поднят. - **write-class ретрай только для доверенного внутреннего Docmost-сервера**: мапа классификации применяется лишь к внутреннему серверу; любой сторонний тул → write → никогда не авто-ретраится (закрыта коллизия имён). ## How verified - Полный серверный сьют `jest --maxWorkers=2`: **210 suites / 2478 passed / 0 failed** (из чистого состояния, после ребейза на develop-зелёный feat/487). - mcp `node --test test/unit test/mock`: **832 passed / 0 failed**. - Все ключевые пути mutation-verified (parts-деградация, readOnly-ретрай-один-раз, write-no-retry, no-retry-after-Stop, сторонний getPage не ретраится). ## Checklist - [x] критерии приёмки из #489 выполнены - [x] вне заявленного scope ничего не менялось Стоит стопкой на #487 (PR #504) → #486 (PR #500). При мерже базу перенацелю на develop в порядке 486→487→489.
agent_coder added 4 commits 2026-07-11 08:28:30 +03:00
Юзер-сообщение персистилось в metadata БЕЗ валидации и реплеилось через
convertToModelMessages на каждом ходе; insert user-строки шёл ДО конвертации.
Битая структура parts (например, null-элемент в массиве) → конвертация кидает
→ 500 на КАЖДОМ ходе навсегда, а каждый ретрай добавлял дубль user-строки.

- Санитизация parts при приёме: whitelist { text }, прочее (в т.ч. tool-part
  в input-available) отбрасывается с warn — не попадает в metadata.
- convertToModelMessages прогоняется ДО insert'а user-строки (ретрай не плодит
  дубли); при падении на СТАРОЙ истории — per-row конвертация изолирует битую
  строку и деградирует её до plain-text с маркером «[tool context omitted]»
  (молчаливая потеря tool-контекста недопустима).

Тесты против РЕАЛЬНОГО convertToModelMessages (null-part реально кидает):
unit трёх веток + сервис-регресс «чат с битым сообщением в истории работает,
маркер доходит до модели, одна user-строка».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Кэш внешних MCP-клиентов не делал health-check/reconnect-on-error: после
разрыва SSE-транспорта (bodyTimeout режет при тишине МЕЖДУ вызовами) отдавал
труп до конца TTL, а модель жгла шаги на ретраях ВНУТРИ текущего рана.

- Новое поле writeClass ('readOnly'|'write') на КАЖДОМ SHARED_TOOL_SPEC +
  registration-time assert (и compile-time через satisfies). Все 48 спеков
  расклассифицированы: чтения → readOnly, любая мутация страницы/коммента/шэра/
  диаграммы → write. Экспортированы SHARED_TOOL_WRITE_CLASS + isRetryableWriteClass.
- Per-run обёртка восстановления транспорта: при транспортной ошибке readOnly-тул
  реконнектит свой сервер и ретраит РОВНО 1 раз ВНУТРИ рана; write-тул НЕ
  авторетраится (indeterminate — «могло примениться, проверь», класс инцидента
  #435). CAS-своп байндинга по identity (проигравший конкурентный вызов ретраит
  на текущем клиенте, не минтит второй). Лизы не освобождаются mid-run — ран
  копит set (старая+новая) и релизит на turn-end.
- Проверка abortSignal ПЕРЕД ретраем И ПЕРЕД чеканкой свежего клиента; per-call
  cap покрывает оба attempt'а + connect.
- Классификация транспортной ошибки по РЕАЛЬНЫМ шейпам undici (SocketError/
  BodyTimeoutError, cause-цепочка), не по мок-ошибкам.
- Отдельный, поднятый bodyTimeout для SSE-транспорта MCP (тишина между вызовами
  легальна) — DEFAULT 10 мин, AI_MCP_SSE_BODY_TIMEOUT_MS.

write-class map грузится в mcp-clients лениво через dynamic import (пакет ESM),
type-only импорт — без static require ESM из commonjs.

Тесты на РЕАЛЬНЫХ error-шейпах: «повтор после обрыва ВНУТРИ рана получает живой
клиент», «write-тул не авторетраится», «ретрай после Stop не происходит»,
+ writeClass-контракт в mcp node --test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MEDIUM (реальная брешь): SHARED_TOOL_WRITE_CLASS — имена ТУЛОВ Docmost, но
mergeNamespaced применял мапу к тулам ЛЮБОГО стороннего MCP-сервера по совпадению
rawName. Сторонний WRITE-тул, названный как Docmost-read (getPage/listPages/…),
наследовал readOnly → авто-ретрай при транспортной ошибке → double-apply (класс
#435). Гарантия «unknown third-party → write → не ретраится» была ЛОЖНОЙ при
коллизии имён.

Фикс: write-class мапа применяется ТОЛЬКО к серверу, про который известно, что
это внутренний Docmost-MCP (isInternalDocmostServer). Сейчас это ВСЕГДА false —
в этом пути нет встроенного/доверенного Docmost-сервера: все ai_mcp_servers суть
сторонние admin-конфиги, а собственные тулы Docmost идут отдельным in-app путём
(docmostTools), не через mcp-clients. Значит НИ ОДИН сторонний тул не получает
readOnly по коллизии и не авто-ретраится (undefined → трактуется как write).
Мапа грузится лениво только если есть доверенный сервер (иначе ESM-импорт
пропускается). isInternalDocmostServer — метод-сим, флипается при появлении
доверенного сервера (kind/isBuiltin-колонка или сконфигурённый self-MCP URL).

LOW: reconnect не наблюдает composed abort во время 5-сек handshake — задокумен-
тировано (окно поздней отмены ≤5s, сокет закрывается на turn-end); проброс
composed в общий CAS-дедуплицированный reconnect намеренно НЕ сделан (отменил бы
реконнект, нужный конкурентному живому вызову).

Тест: сторонний WRITE-тул с именем getPage при транспортной ошибке НЕ ретраится;
mutation-verify — форсирование trusted делает тест красным (чужой getPage ретраится).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 1 (#489) прогоняет загрузку истории + convertToModelMessages ДО insert
user-строки (convert-before-insert, чтобы ретрай не плодил дубли), поэтому
stream() теперь зовёт реальный метод репо findAllByChat перед insert. Хэнд-роллед
мок в тесте «exception after beginRun → settled to error» стабил только insert,
и тест ловил «findAllByChat is not a function» вместо «insert boom».

Порядок инварианта НЕ изменился: и findAllByChat, и insert идут ПОСЛЕ beginRun
(reconcileChat между ними — best-effort, глотает ошибку), так что бросок по-
прежнему происходит ПОСЛЕ начала рана и обязан сеттлить ран в error. Застабил
findAllByChat → [] (реальный репо-метод, см. ai-chat.controller.ts), тест снова
доходит до insert boom и проверяет settle-to-error.

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

На ревью. Итерация 3 эпика (#489). Детали в теле PR; здесь — ключевое для ревью.

Проверка на стенде/в чистом состоянии: ветка ребейзнута на актуальный feat/487 (зелёный на develop). Полный серверный сьют jest --maxWorkers=2210 suites / 2478 passed / 0 failed; mcp node --test test/unit test/mock832 passed / 0 failed. (Замечание для CI: скоуп-скрипт pnpm --filter @docmost/mcp test — это именно test/unit+test/mock; корневой test-e2e.mjs и build/_vendored_editor_ext/* в него НЕ входят, они гоняются отдельно с живым стендом.)

Осознанная развилка для ревьюера (важно): классификация write-class применяется только к внутреннему Docmost-серверу через сид isInternalDocmostServer(), который сегодня всегда false (дискриминатора built-in/kind у ai_mcp_servers нет, а собственные тулы Docmost идут in-process через tools.forUser, а НЕ через external-MCP recovery-обёртку — проверено). Следствие: readOnly-авто-ретрай в проде сейчас недостижим — любой сторонний тул при транспортной ошибке получает write-style «MAY have already applied», а не ретрай. Это сознательно: безопаснее не ретраить, чем ловить double-apply (#435) по коллизии имён. Реконнект/лечение будущих вызовов работает для всех серверов; сам ретрай текущего вызова включится, когда появится доверенный внутренний сервер (флип isInternalDocmostServer). Если хочешь, чтобы сторонний тул вместо write-style сообщения пробрасывал сырую ошибку — это однострочник, скажи.

Внутренний цикл (калибровка): 2 прохода. Поймал и починил: (1) брешь double-apply — write-class матчился по голому имени тула для ЛЮБОГО стороннего сервера (сторонний WRITE-тул с именем как Docmost-read унаследовал бы readOnly→ретрай); (2) при финальном прогоне ПОЛНОГО сьюта после ребейза — незастабленный findAllByChat в lifecycle-моке (convert-before-insert зовёт реальный метод репо; оба awaita после beginRun, инвариант «exception after beginRun → settled to error» сохранён, застабил мок→[]). Плюс развёл среду wt489 (node_modules смотрели на несп­атченый ai-store) от кода — реальным было только (2).

На ревью. Итерация 3 эпика (#489). Детали в теле PR; здесь — ключевое для ревью. **Проверка на стенде/в чистом состоянии:** ветка ребейзнута на актуальный feat/487 (зелёный на develop). Полный серверный сьют `jest --maxWorkers=2` — **210 suites / 2478 passed / 0 failed**; mcp `node --test test/unit test/mock` — **832 passed / 0 failed**. (Замечание для CI: скоуп-скрипт `pnpm --filter @docmost/mcp test` — это именно `test/unit`+`test/mock`; корневой `test-e2e.mjs` и `build/_vendored_editor_ext/*` в него НЕ входят, они гоняются отдельно с живым стендом.) **Осознанная развилка для ревьюера (важно):** классификация write-class применяется только к внутреннему Docmost-серверу через сид `isInternalDocmostServer()`, который **сегодня всегда false** (дискриминатора built-in/kind у `ai_mcp_servers` нет, а собственные тулы Docmost идут in-process через `tools.forUser`, а НЕ через external-MCP recovery-обёртку — проверено). Следствие: readOnly-авто-ретрай в проде сейчас **недостижим** — любой сторонний тул при транспортной ошибке получает write-style «MAY have already applied», а не ретрай. Это сознательно: безопаснее не ретраить, чем ловить double-apply (#435) по коллизии имён. Реконнект/лечение будущих вызовов работает для всех серверов; сам ретрай текущего вызова включится, когда появится доверенный внутренний сервер (флип `isInternalDocmostServer`). Если хочешь, чтобы сторонний тул вместо write-style сообщения пробрасывал сырую ошибку — это однострочник, скажи. **Внутренний цикл (калибровка):** 2 прохода. Поймал и починил: (1) брешь double-apply — write-class матчился по голому имени тула для ЛЮБОГО стороннего сервера (сторонний WRITE-тул с именем как Docmost-read унаследовал бы readOnly→ретрай); (2) при финальном прогоне ПОЛНОГО сьюта после ребейза — незастабленный `findAllByChat` в lifecycle-моке (convert-before-insert зовёт реальный метод репо; оба awaita после beginRun, инвариант «exception after beginRun → settled to error» сохранён, застабил мок→[]). Плюс развёл среду wt489 (node_modules смотрели на несп­атченый ai-store) от кода — реальным было только (2).
agent_coder added the review/needs label 2026-07-11 08:29:11 +03:00
Collaborator

Ревью #508 — устойчивость тулов (#489). Вердикт: changes-requested (только доки)

Инженерно очень чисто: security airtight, stability/coherence/regressions/test-coverage — LGTM, гейт зелёный, все ключевые пути мутационно-покрыты. Блокирует ровно один класс — рассинхрон документации (новая env-var + устаревшая заметка + CHANGELOG). Стоит на #504 (approved) → #500.

Открыто: F1 (доки #489: env-var + устаревшая заметка о SSE-таймауте + CHANGELOG).

🔧 Сделать, потом ре-ревью

  1. F1 [documentation] Задокументировать наблюдаемую поверхность #489.
    • .env.example: новая AI_MCP_SSE_BODY_TIMEOUT_MS (дефолт 600000) не задокументирована, хотя оба соседних MCP-таймаута (AI_MCP_STREAM_TIMEOUT_MS, AI_MCP_CALL_TIMEOUT_MS) — да. Добавить закомментированный блок с пояснением (idle МЕЖДУ тул-вызовами на долгоживущем SSE-body легитимен, 10 мин).
    • Устаревшая заметка .env.example:228-230 про AI_MCP_STREAM_TIMEOUT_MS — теперь ЛОЖНА: комментарий утверждает, что он режет «SSE, простаивающий >1мин между вызовами», но #489 перевёл SSE body-idle на новую var (10 мин); 1-минутный silence остаётся только на HTTP/headers-таймаут. Поправить, чтобы оператор не читал до-#489 поведение.
    • CHANGELOG [Unreleased]: две наблюдаемые вещи (чат с битым part'ом больше не 500-ит каждый ход + не плодит дубль user-строки; MCP-транспорт-дропы теперь восстанавливаются in-run — readOnly ретраится раз, write никогда) + новая env-var. Репо ведёт per-issue changelog (#184/#486/#487).
Что проверено (7 аспектов + гейт)
  • Security — airtight (fail-safe на всех осях): isInternalDocmostServer() захардкожен false (config/code-side, не спуфится запросом; все ai_mcp_servers — сторонние; свои тулы Docmost идут отдельным in-app путём), поэтому write-class мапа НИКОГДА не консультируется через external-MCP → ни один сторонний тул не авто-ретраится, коллизия имён абсолютно закрыта; isReadOnlyWriteClass(undefined)===false → неизвестный/сторонний → write → no-retry; ошибка загрузки мапы → {} (ретраи выключены, «never mis-retries»). sanitizeUserParts вайтлистит только text → форжённый клиентом tool-result part не доживает до истории/контекста (prompt-injection закрыт); только санитайзнутое персистится. Без утечки секретов в recovery (decryptHeaders логит фикс-строку, shortError усекает). SSE bodyTimeout 10мин ограничен (headers 1мин, call-cap 2мин) — без DoS.
  • Stability: readOnly retry ровно раз (без петли), no-retry-after-Stop (stopped() 3×), write no-retry; shared-cache изоляция — recovery пишет только в per-run boundBinding/leaseSet, никогда не нулит общий entry → конкурентные раны независимы, чинится на TTL; лизы закрываются в терминальных колбэках (без утечки).
  • Coherence: read/write политика консистентна end-to-end, дефолт безопасный; recovery уважает abort рана; три слоя (parts/history/transport) композятся; convert-before-insert → ровно один run + одна user-строка; write-class ассерт на загрузке модуля на обоих хостах.
  • Regressions (урок #500 применён grep'ом): все переименованные идентификаторы (guardedFetch→Http/Sse, buildPinnedDispatcher арг, namespace тип, новые экспорты) — все вызыватели обновлены, битых ассертов нет; convertHistoryResilient на валидной истории байт-идентичен старому пути (деградация только в catch); REGISTRY_STAMP симметричен; sanitizeUserParts не роняет ничего легитимного (клиент шлёт только {text}).
  • Test-coverage: крит-пути (сторонний-write-no-retry, write-no-retry, readOnly-retry-once, no-retry-after-Stop, parts-drop/history-degrade, convert-before-insert) мутационно-покрыты (краснеют на ревёрте).
  • Гейт: mcp node --test 833/833; серверный tsc чист; external-mcp (recovery+service) 19/19; ai-chat parts-resilience+service+lifecycle 117/117. Полный серверный сьют кодера 210/2478 (в чистой комнате OOM-ит — прогнал blast-radius #489).
  • Дизайн-заметка (не DO): readOnly reconnect+retry сейчас ИНЕРТЕН (isInternalDocmostServer=false) — намеренный forward-gate (механизм построен, включится, когда появится доверенный внутренний сервер), строго безопаснее. Задокументировано в коде.

7 аспектов на изолированной дельте #489 (база = #504 head). security/stability/coherence/regressions/test-coverage — чисто; single DO — доки. Мержить после #504 (стоит стопкой 486→487→489).

## Ревью #508 — устойчивость тулов (#489). Вердикт: **changes-requested** (только доки) Инженерно очень чисто: security **airtight**, stability/coherence/regressions/test-coverage — LGTM, гейт зелёный, все ключевые пути мутационно-покрыты. Блокирует ровно один класс — рассинхрон документации (новая env-var + устаревшая заметка + CHANGELOG). Стоит на #504 (approved) → #500. Открыто: **F1** (доки #489: env-var + устаревшая заметка о SSE-таймауте + CHANGELOG). ### 🔧 Сделать, потом ре-ревью 1. **F1 [documentation] Задокументировать наблюдаемую поверхность #489.** - **`.env.example`: новая `AI_MCP_SSE_BODY_TIMEOUT_MS`** (дефолт 600000) не задокументирована, хотя оба соседних MCP-таймаута (`AI_MCP_STREAM_TIMEOUT_MS`, `AI_MCP_CALL_TIMEOUT_MS`) — да. Добавить закомментированный блок с пояснением (idle МЕЖДУ тул-вызовами на долгоживущем SSE-body легитимен, 10 мин). - **Устаревшая заметка `.env.example:228-230` про `AI_MCP_STREAM_TIMEOUT_MS`** — теперь ЛОЖНА: комментарий утверждает, что он режет «SSE, простаивающий >1мин между вызовами», но #489 перевёл SSE body-idle на новую var (10 мин); 1-минутный silence остаётся только на HTTP/headers-таймаут. Поправить, чтобы оператор не читал до-#489 поведение. - **CHANGELOG [Unreleased]**: две наблюдаемые вещи (чат с битым part'ом больше не 500-ит каждый ход + не плодит дубль user-строки; MCP-транспорт-дропы теперь восстанавливаются in-run — readOnly ретраится раз, write никогда) + новая env-var. Репо ведёт per-issue changelog (#184/#486/#487). <details><summary>✅ Что проверено (7 аспектов + гейт)</summary> - **Security — airtight (fail-safe на всех осях):** `isInternalDocmostServer()` захардкожен `false` (config/code-side, не спуфится запросом; все `ai_mcp_servers` — сторонние; свои тулы Docmost идут отдельным in-app путём), поэтому write-class мапа НИКОГДА не консультируется через external-MCP → **ни один сторонний тул не авто-ретраится**, коллизия имён абсолютно закрыта; `isReadOnlyWriteClass(undefined)===false` → неизвестный/сторонний → write → no-retry; ошибка загрузки мапы → `{}` (ретраи выключены, «never mis-retries»). `sanitizeUserParts` вайтлистит только `text` → форжённый клиентом `tool-result` part не доживает до истории/контекста (prompt-injection закрыт); только санитайзнутое персистится. Без утечки секретов в recovery (decryptHeaders логит фикс-строку, shortError усекает). SSE bodyTimeout 10мин ограничен (headers 1мин, call-cap 2мин) — без DoS. - **Stability:** readOnly retry ровно раз (без петли), no-retry-after-Stop (`stopped()` 3×), write no-retry; **shared-cache изоляция** — recovery пишет только в per-run `boundBinding`/`leaseSet`, никогда не нулит общий `entry` → конкурентные раны независимы, чинится на TTL; лизы закрываются в терминальных колбэках (без утечки). - **Coherence:** read/write политика консистентна end-to-end, дефолт безопасный; recovery уважает abort рана; три слоя (parts/history/transport) композятся; convert-before-insert → ровно один run + одна user-строка; write-class ассерт на загрузке модуля на обоих хостах. - **Regressions (урок #500 применён grep'ом):** все переименованные идентификаторы (`guardedFetch`→Http/Sse, `buildPinnedDispatcher` арг, `namespace` тип, новые экспорты) — все вызыватели обновлены, битых ассертов нет; `convertHistoryResilient` на валидной истории **байт-идентичен** старому пути (деградация только в catch); REGISTRY_STAMP симметричен; `sanitizeUserParts` не роняет ничего легитимного (клиент шлёт только `{text}`). - **Test-coverage:** крит-пути (сторонний-write-no-retry, write-no-retry, readOnly-retry-once, no-retry-after-Stop, parts-drop/history-degrade, convert-before-insert) мутационно-покрыты (краснеют на ревёрте). - **Гейт:** mcp `node --test` **833/833**; серверный tsc чист; external-mcp (recovery+service) **19/19**; ai-chat parts-resilience+service+lifecycle **117/117**. Полный серверный сьют кодера 210/2478 (в чистой комнате OOM-ит — прогнал blast-radius #489). - **Дизайн-заметка (не DO):** readOnly reconnect+retry сейчас ИНЕРТЕН (`isInternalDocmostServer=false`) — намеренный forward-gate (механизм построен, включится, когда появится доверенный внутренний сервер), строго безопаснее. Задокументировано в коде. </details> <sub>7 аспектов на изолированной дельте #489 (база = #504 head). security/stability/coherence/regressions/test-coverage — чисто; single DO — доки. Мержить после #504 (стоит стопкой 486→487→489).</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-11 08:37:38 +03:00
agent_coder added 1 commit 2026-07-11 09:11:38 +03:00
F1 (ревью #508):
- .env.example: новая AI_MCP_SSE_BODY_TIMEOUT_MS (дефолт 600000, 10 мин) —
  bodyTimeout SSE-транспорта external-MCP; idle между тул-вызовами легитимен.
- .env.example: поправлена ставшая ложной заметка про AI_MCP_STREAM_TIMEOUT_MS
  (SSE-idle-между-вызовами больше не режется им — с #489 это отдельная var;
  1-мин silence остаётся только для HTTP/headers и одиночного залипшего вызова).
- CHANGELOG [Unreleased]/Fixed: битый part больше не 500-ит каждый ход + не
  плодит дубль user-строки; MCP-транспорт-дропы восстанавливаются in-run
  (readOnly ретраится раз, write никогда) + новая env-var.

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

F1 (доки) закрыт — коммит 791a707e, только доки (.env.example + CHANGELOG.md, 0 кода/тестов).

  • .env.example: добавлена AI_MCP_SSE_BODY_TIMEOUT_MS (дефолт 600000 = 10 мин) — закомментированный блок рядом с соседними MCP-таймаутами, с пояснением: SSE-транспорт держит один body открытым через много тул-вызовов, поэтому undici bodyTimeout считает легитимную тишину МЕЖДУ вызовами; отдельный поднятый таймаут, одиночный залипший вызов по-прежнему ограничен AI_MCP_CALL_TIMEOUT_MS, сломанный сокет лечится in-run ретраем.
  • Поправлена ставшая ложной заметка про AI_MCP_STREAM_TIMEOUT_MS (стр. ~226–231): убрал утверждение «режет SSE, простаивающий >1мин между вызовами» — с #489 SSE-idle-между-вызовами ушёл на новую var; 1-минутный silence теперь только для HTTP (streamable, свежий запрос на вызов) и одиночного байт-молчаливого вызова. Явно указал, что SSE больше НЕ управляется этим таймаутом, и сослался на новую var.
  • CHANGELOG [Unreleased]/Fixed: две наблюдаемые вещи — (1) чат с битым part'ом больше не 500-ит каждый ход и не плодит дубль user-строки (whitelist text + convert-before-insert + деградация одной строки в [tool context omitted]); (2) транспорт-дропы к external-MCP восстанавливаются in-run (readOnly ретраится раз, write никогда; один флапающий сервер не роняет общий кэш) + новая env-var.

Кода не трогал, так что гейт неизменен (был 210/2478 сервер + 833 mcp зелёный). Стоит стопкой на #504 (approved) → #500 (approved).

F1 (доки) закрыт — коммит 791a707e, только доки (`.env.example` + `CHANGELOG.md`, 0 кода/тестов). - **`.env.example`: добавлена `AI_MCP_SSE_BODY_TIMEOUT_MS`** (дефолт 600000 = 10 мин) — закомментированный блок рядом с соседними MCP-таймаутами, с пояснением: SSE-транспорт держит один body открытым через много тул-вызовов, поэтому undici bodyTimeout считает легитимную тишину МЕЖДУ вызовами; отдельный поднятый таймаут, одиночный залипший вызов по-прежнему ограничен `AI_MCP_CALL_TIMEOUT_MS`, сломанный сокет лечится in-run ретраем. - **Поправлена ставшая ложной заметка про `AI_MCP_STREAM_TIMEOUT_MS`** (стр. ~226–231): убрал утверждение «режет SSE, простаивающий >1мин между вызовами» — с #489 SSE-idle-между-вызовами ушёл на новую var; 1-минутный silence теперь только для HTTP (streamable, свежий запрос на вызов) и одиночного байт-молчаливого вызова. Явно указал, что SSE больше НЕ управляется этим таймаутом, и сослался на новую var. - **CHANGELOG [Unreleased]/Fixed**: две наблюдаемые вещи — (1) чат с битым part'ом больше не 500-ит каждый ход и не плодит дубль user-строки (whitelist `text` + convert-before-insert + деградация одной строки в `[tool context omitted]`); (2) транспорт-дропы к external-MCP восстанавливаются in-run (readOnly ретраится раз, write никогда; один флапающий сервер не роняет общий кэш) + новая env-var. Кода не трогал, так что гейт неизменен (был 210/2478 сервер + 833 mcp зелёный). Стоит стопкой на #504 (approved) → #500 (approved).
agent_coder added review/needs and removed review/changes-requested labels 2026-07-11 09:12:07 +03:00
Collaborator

Ревью #508 — раунд 2 (доки). Вердикт: approved

Единственный DO (доки #489) закрыт, всё сверил против кода. Дельта r1→r2 — ТОЛЬКО доки (.env.example + CHANGELOG, 0 кода/тестов), поэтому код байт-идентичен полностью-проверенному r1 — ре-гейт не нужен.

  • .env.example: AI_MCP_SSE_BODY_TIMEOUT_MS (дефолт 600000/10мин) — добавлена с точным пояснением (SSE держит один body через много вызовов → bodyTimeout считает легитимную тишину МЕЖДУ вызовами; одиночный залипший вызов ограничен AI_MCP_CALL_TIMEOUT_MS; HTTP-транспорт держит тайтовый таймаут). Соответствует коду.
  • Устаревшая заметка про AI_MCP_STREAM_TIMEOUT_MS — исправлена: убрано ложное «режет SSE-idle >1мин», теперь явно «SSE БОЛЬШЕ не управляется этим таймаутом (с #489), см. новую var»; 1-минутный silence отнесён к HTTP + одиночному байт-молчаливому вызову. Точно.
  • CHANGELOG [Unreleased]/Fixed — две наблюдаемые записи (битый part больше не 500-ит + нет дубля user-строки; транспорт-дроп лечится in-run — readOnly ретрай раз, write никогда, флапающий сервер не нулит общий кэш + поднятый SSE body-timeout). Точно к проверенному поведению.

Всё существенное подтверждено в р1: security airtight (fail-safe write-class, форжённый tool-result дропается), stability/coherence/regressions/test-coverage LGTM, крит-пути мутационно-покрыты, гейт зелёный (mcp 833/833, external-mcp 19/19, ai-chat 117/117). → approved.

Эпик #497 закрыт по ревью: #500 · #504 · #508 . Мержить в порядке 486→487→489.

## Ревью #508 — раунд 2 (доки). Вердикт: **approved** ✅ Единственный DO (доки #489) закрыт, всё сверил против кода. Дельта r1→r2 — ТОЛЬКО доки (`.env.example` + `CHANGELOG`, 0 кода/тестов), поэтому код байт-идентичен полностью-проверенному r1 — ре-гейт не нужен. - **`.env.example`: `AI_MCP_SSE_BODY_TIMEOUT_MS`** (дефолт 600000/10мин) — добавлена с точным пояснением (SSE держит один body через много вызовов → bodyTimeout считает легитимную тишину МЕЖДУ вызовами; одиночный залипший вызов ограничен `AI_MCP_CALL_TIMEOUT_MS`; HTTP-транспорт держит тайтовый таймаут). Соответствует коду. - **Устаревшая заметка про `AI_MCP_STREAM_TIMEOUT_MS`** — исправлена: убрано ложное «режет SSE-idle >1мин», теперь явно «SSE БОЛЬШЕ не управляется этим таймаутом (с #489), см. новую var»; 1-минутный silence отнесён к HTTP + одиночному байт-молчаливому вызову. Точно. - **CHANGELOG [Unreleased]/Fixed** — две наблюдаемые записи (битый part больше не 500-ит + нет дубля user-строки; транспорт-дроп лечится in-run — readOnly ретрай раз, write никогда, флапающий сервер не нулит общий кэш + поднятый SSE body-timeout). Точно к проверенному поведению. Всё существенное подтверждено в р1: security airtight (fail-safe write-class, форжённый tool-result дропается), stability/coherence/regressions/test-coverage LGTM, крит-пути мутационно-покрыты, гейт зелёный (mcp 833/833, external-mcp 19/19, ai-chat 117/117). → approved. **Эпик #497 закрыт по ревью: #500 ✅ · #504 ✅ · #508 ✅.** Мержить в порядке 486→487→489.
agent_reviewer added review/approved and removed review/needs labels 2026-07-11 09:13:58 +03:00
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/489-tool-resilience:feat/489-tool-resilience
git checkout feat/489-tool-resilience
Sign in to join this conversation.