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
pull from: feat/489-tool-resilience
merge into: vvzvlad:feat/487-terminal-lifecycle
vvzvlad:main
vvzvlad:feat/490-data-discipline
vvzvlad:feat/488-client-fsm
vvzvlad:ci/476-publish-gates
vvzvlad:feat/487-terminal-lifecycle
vvzvlad:fix/486-iter1-prod-fires
vvzvlad:develop
vvzvlad:feat/481-version-coherence
vvzvlad:refactor/347-client-md-paste
vvzvlad:perf/479-getpage-cache
vvzvlad:refactor/449-write-invariants
vvzvlad:refactor/450-split-client
vvzvlad:feat/443-get-page-context
vvzvlad:feat/443-get-tree
vvzvlad:feat/443-search
vvzvlad:docs/415-lossy-descriptions
vvzvlad:feat/413-markdown-default
vvzvlad:refactor/412-camelcase-tool-names
vvzvlad:feat/425-drawio-graph
vvzvlad:fix/464-diffdocs-cpu-guard
vvzvlad:refactor/411-update-page-markdown
vvzvlad:feat/424-drawio-rules
vvzvlad:fix/409-invalid-node-validation
vvzvlad:refactor/448-generate-inventory
vvzvlad:refactor/445-execute-mapping
vvzvlad:refactor/446-derive-client-types
vvzvlad:fix/447-registry-stamp-ci
vvzvlad:fix/444-agent-loop-guards
vvzvlad:fix/396-sendnow-detached-run
vvzvlad:fix/452-immutable-cache-control
vvzvlad:docs/footnote-authoring-comment-cleanup
vvzvlad:feat/437-error-diagnostics
vvzvlad:fix/442-cursor-pagination
vvzvlad:feat/419-footnote-normalize
vvzvlad:perf/399-comment-resolve-async
vvzvlad:perf/435-collab-token-cache
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/423-drawio-crud
vvzvlad:perf/401-collab-hotpath
vvzvlad:feat/430-live-reconnect
vvzvlad:perf/400-collab-session
vvzvlad:refactor/414-dedup-node-ops
vvzvlad:feat/417-new-comments-signal
vvzvlad:feat/408-createcomment-hints
vvzvlad:feat/407-persist-tool-errors
vvzvlad:feat/420-footnote-render
vvzvlad:dummy-review/mcp-hang-fix
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/346-compression-cache
vvzvlad:feat/196-multi-cursor
vvzvlad:feat/370-page-versioning
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#508
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/489-tool-resilience"
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
Итерация 3 эпика #497 — устойчивость тулов AI-чата. closes #489.
sanitizeUserParts(whitelisttext), convert-before-insert (ретрай не плодит дубль user-строки),convertHistoryResilient(пер-строчная деградация битой строки в[tool context omitted], а не «кирпич»-чат).How verified
jest --maxWorkers=2: 210 suites / 2478 passed / 0 failed (из чистого состояния, после ребейза на develop-зелёный feat/487).node --test test/unit test/mock: 832 passed / 0 failed.Checklist
Стоит стопкой на #487 (PR #504) → #486 (PR #500). При мерже базу перенацелю на develop в порядке 486→487→489.
Юзер-сообщение персистилось в 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>На ревью. Итерация 3 эпика (#489). Детали в теле PR; здесь — ключевое для ревью.
Проверка на стенде/в чистом состоянии: ветка ребейзнута на актуальный feat/487 (зелёный на develop). Полный серверный сьют
jest --maxWorkers=2— 210 suites / 2478 passed / 0 failed; mcpnode --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).Ревью #508 — устойчивость тулов (#489). Вердикт: changes-requested (только доки)
Инженерно очень чисто: security airtight, stability/coherence/regressions/test-coverage — LGTM, гейт зелёный, все ключевые пути мутационно-покрыты. Блокирует ровно один класс — рассинхрон документации (новая env-var + устаревшая заметка + CHANGELOG). Стоит на #504 (approved) → #500.
Открыто: F1 (доки #489: env-var + устаревшая заметка о SSE-таймауте + CHANGELOG).
🔧 Сделать, потом ре-ревью
.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 поведение.✅ Что проверено (7 аспектов + гейт)
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-resultpart не доживает до истории/контекста (prompt-injection закрыт); только санитайзнутое персистится. Без утечки секретов в recovery (decryptHeaders логит фикс-строку, shortError усекает). SSE bodyTimeout 10мин ограничен (headers 1мин, call-cap 2мин) — без DoS.stopped()3×), write no-retry; shared-cache изоляция — recovery пишет только в per-runboundBinding/leaseSet, никогда не нулит общийentry→ конкурентные раны независимы, чинится на TTL; лизы закрываются в терминальных колбэках (без утечки).guardedFetch→Http/Sse,buildPinnedDispatcherарг,namespaceтип, новые экспорты) — все вызыватели обновлены, битых ассертов нет;convertHistoryResilientна валидной истории байт-идентичен старому пути (деградация только в catch); REGISTRY_STAMP симметричен;sanitizeUserPartsне роняет ничего легитимного (клиент шлёт только{text}).node --test833/833; серверный tsc чист; external-mcp (recovery+service) 19/19; ai-chat parts-resilience+service+lifecycle 117/117. Полный серверный сьют кодера 210/2478 (в чистой комнате OOM-ит — прогнал blast-radius #489).isInternalDocmostServer=false) — намеренный forward-gate (механизм построен, включится, когда появится доверенный внутренний сервер), строго безопаснее. Задокументировано в коде.7 аспектов на изолированной дельте #489 (база = #504 head). security/stability/coherence/regressions/test-coverage — чисто; single DO — доки. Мержить после #504 (стоит стопкой 486→487→489).
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.text+ convert-before-insert + деградация одной строки в[tool context omitted]); (2) транспорт-дропы к external-MCP восстанавливаются in-run (readOnly ретраится раз, write никогда; один флапающий сервер не роняет общий кэш) + новая env-var.Кода не трогал, так что гейт неизменен (был 210/2478 сервер + 833 mcp зелёный). Стоит стопкой на #504 (approved) → #500 (approved).
Ревью #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 + одиночному байт-молчаливому вызову. Точно.Всё существенное подтверждено в р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.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.