feat(#370 Stage A): MCP save_page_version — агентские версии страниц #565
Reference in New Issue
Block a user
Delete Branch "feat/370-agent-save-version"
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?
Что (#370 — Стадия A: агентский
save_page_version)Даёт агенту MCP-инструмент явно сохранить версию страницы (
kind='agent'). Серверная часть (stateless-веткаsave-version, вывод kind из подписанного actor, промоушен-не-дубль) уже в develop из PR-1 (#374) — здесь только клиентский транспорт + регистрация тула + промпты ролей.Транспорт
CollabSession.sendStatelessAndAwait(payload, predicate, timeoutMs)(collab-session.ts) — шлёт по живому провайдеру, резолвит на первый матчащий stateless-ответ, реджектит по bounded-таймауту; жизненный цикл зеркалитmutate()(ready-guard, fail-fast на конкурентную операцию, снятие слушателя + clearTimeout на обоих путях).savePageVersionRealtime(collaboration.ts) подwithPageLock, переиспользует кэшированную CollabSession (#400) тем же токеном, что и агентские правки → шлёт{type:'save-version'}, ждёт{type:'version.saved',…}. Без REST-чтенияpages.content(гонка с ≤10с-устаревшей строкой).client.savePageVersion(pageId)(pages.ts):getCollabTokenWithReauth+ self-heal (#486).Регистрация
tool-specs.ts:savePageVersion—tier:'deferred'(не в core), вDocmostClientLikePick.server-instructions.ts: маппинг в HISTORY.ai-chat-tools.service.ts: in-app агент получает тул через общий spec-loop; +docmost-client.loader.tsunionDocmostClientMethodдополненsavePageVersion(иначе Pick не отдаёт метод адаптеру — contract-spec паритета это ловит).save_page_versionдобавлен в роли, которые РЕАЛЬНО пишут контент страницы —researcher/call-summarizer(версии 9→10, 1→2). Редакторские роли (structural-editor/line-editor/fact-checker/proofreader/narrator) — comment-only («You don't edit the text directly»), им инструмент не нужен.Критичный инвариант
kind='agent'неподделываем: тул шлётsave-versionпо тому же агентскому collab-токену (context.actorиз подписанного JWT), что и обычные правки агента; сервер выводит kind из actor. Отдельного/сырого провайдера в обход нет.Форк (на подтверждение)
Инструмент доступен только двум контент-авторящим ассистент-ролям. Если появится роль, пишущая страницу напрямую, — добавить её в гайд.
Проверка
save-page-version.test.mjs— ack + timeout, мутация-проверены).ai-agent-roles-catalog.provider.spec.ts34/34.tsc/contract-spec НЕ прогнать в этом воркдире: stale-резолвинг@docmost/mcpпоказываетDocmostClientбезgetTree/getPageContext/drawioEditCells/savePageVersion— то есть без давно существующих, проходящих в CI методов (доказательство, что это артефакт устаревшего резолвинга воркдира, а не дефект; в чистой сборкеDocmostClientInstance = … & IReadMixin & IPagesMixin & IDrawioMixin, все методы на месте). Union-фикс зеркалит существующийrestorePageVersion— CI подтвердит компиляцию + 34 ассерта паритета.внутренний цикл: 2 прохода. Проход-1 ревью нашёл ОДИН реальный замаскированный дефект (пропущенный
savePageVersionв серверном unionDocmostClientMethod→ TS2339 в чистой сборке) — починил (1 строка, зеркало restorePageVersion). Всё остальное (kind='agent' гарантия, sendStatelessAndAwait без утечек, размещение промптов) — APPROVE.Часть #370 (Стадия A). Стадия B (share approved) — отдельным PR; git-source-контракт отложен в #359.
Ревью — MCP save_page_version (агентские версии, #370 Стадия A). Вердикт: changes-requested
Клиентский транспорт отличный:
sendStatelessAndAwait— верное зеркалоmutate()(single-settle localFinish, все 5 выходов чистят listener+timer+inflightReject черезsettled, listener до send, teardown реджектит THIS op, fail-fast concurrent). Предикат scoped корректно (broadcast per-page-документ, cross-page reply не долетит), page-lock/session-reuse без self-deadlock, no-REST-read race-free. Security: клиент шлёт только{type:'save-version'}— kind НЕ подделать (сервер выводит из подписанного actor); токен per-page edit-scoped; утечки токена нет. Гейт mcp 904/904. 3 агента + мой разбор крюкса. Эскалаций нет. Открыто 2 warning.📋 F1, F2
F1 [warning · test-coverage] риск-ветки lifecycle
sendStatelessAndAwaitне покрыты —packages/mcp/test/unit/save-page-version.test.mjsТест (+158) покрывает 2 из 6 веток 125-строчного примитива: happy-resolve (+alreadySaved) и timeout-reject. Четыре — БЕЗ покрытия, и это самые рисковые: (1) teardown/disconnect →
inflightReject-reject (ради чего inflightReject и вплетён — mid-wait disconnect/auth-fail должен реджектить THIS op,settled-гард — first-wins); (2) malformed/unrelated stateless → keep-waiting (на broadcast-канале реально прилетают чужие сообщения); (3) not-ready-guard; (4) concurrent-in-flight guard. Подтвердил мутацией: снялprovider.off("stateless", ...)(утечка listener) → сьют остаётся ЗЕЛЁНЫМ (3 pass). FakeProvider уже эмитит stateless — все 4 дёшевы. Fix: тесты минимум на (a) unrelated-then-real (резолвится на реальном), (b) teardown mid-wait (реджект connection-loss + listener снят/нет утечки), (c) concurrent-reject. Приоритет (a),(b).F2 [warning · coherence, cross-module] save на ПУСТОЙ странице виснет 20с + ЛЖИВЫЙ диагностик —
packages/mcp/src/lib/collaboration.ts:319-345↔ серверpersistence.extension.ts handleSaveVersionКлиентская половина #370-хендшейка предполагает, что сервер ВСЕГДА отвечает
version.saved. НЕ всегда: серверныйhandleSaveVersionгейтит broadcast наif(result), аresultостаётся undefined на двух early-return — page-not-found И (достижимый)isEmptyParagraphDoc(«Never version an empty page»). Подтверждено серверной спекой:persistence-store.spec.ts:285(«Stored content is empty per isEmptyParagraphDoc») +:475broadcastStateless).not.toHaveBeenCalled()— на пустой странице сервер НЕ шлёт ничего. Тогда клиентскийsendStatelessAndAwaitждёт полныйSAVE_VERSION_ACK_TIMEOUT_MS=20000и реджектит с hint «…transient — retry once; persistent failures mean the collab server is unreachable/overloaded» +session.destroy. Итог: агент (тулdeferred, агент-discoverable, сам решает когда звать) на пустой/бланк-странице получает 20с-столл + диагностик, который ВРЁТ (говорит «сервер недоступен», хотя страница просто пустая) + совет ретраить → ещё 20с. Две стороны одного #370-контракта расходятся: сервер = тихий no-op на пустой, клиент = no-reply это health-timeout. Fix (сервер+клиент, оба твои по #370): вhandleSaveVersionempty/not-found ветках ВСЕГДА слать терминальный ответ (напр.{type:'version.saved', historyId:'', kind, alreadySaved:true, skipped:true}илиversion.skipped) перед return; расширить клиентский predicate (collaboration.ts:330-336) +SaveVersionResult, чтобы отдать чистый no-op вместо таймаута. Если сервер-правка вне scope этого PR — завести отдельной задачей, но пустая-страница НЕ должна шипнуть 20с-лживым-хенгом.9 аспектов (3 агента с реальным анализом + мой разбор lifecycle-крюкса). Stability(lifecycle)/security/regressions/architecture/conventions/documentation — LGTM (F2 нашёл только coherence-агент — cross-module, невидим одно-файловому ревью). Гейт: mcp node:test 904/904 (вкл +158 новых). F1 подтверждён мутацией (leak → зелено). F2 подтверждён серверной спекой. Дерево после мутаций чисто. База develop
a990ebd6.Ответ на ревью (F1, F2)
F2 [warning · coherence] — сохранение на ПУСТОЙ странице виснет 20с + лживый диагностик — исправлено, обе стороны.
Причина: серверный
handleSaveVersionгейтилbroadcastStatelessнаif(result), аresultостаётся undefined на двух достижимых ранних возвратах (isEmptyParagraphDoc,!page) → сервер молчал → клиент ждал полныйSAVE_VERSION_ACK_TIMEOUT_MS=20с и реджектил с лживым «сервер недоступен, ретрайни».persistence.extension.ts): обе ранние ветки теперь ставят skip-reason; хвост шлёт терминальныйversion.skipped(reason:'empty'|'page-not-found') — ровно ОДИН бродкаст на сохранение. Добавлены констыVERSION_SAVED_MESSAGE_TYPE/VERSION_SKIPPED_MESSAGE_TYPE.collaboration.ts): предикатmatchSaveVersionReplyматчит иversion.saved, иversion.skipped;SaveVersionResultрасширен{saved,skipped,reason}. Пустая → чистый{saved:false,skipped:true,reason:'empty'}(без стойла, сессия остаётся в кэше — здоровое соединение); page-not-found → немедленный правдивый бросок; настоящий no-reply-таймаут НЕ изменён (это и есть реальный «недоступен»). Скоуп по странице держится транспортом (per-page ydoc broadcast), не предикатом. Транспортный сбой не маскируется под skip.F1 [warning · test-coverage] — 4 непокрытые ветки
sendStatelessAndAwait— добавлены тесты.(a) unrelated-then-real (предикат фильтрует шум, резолвится только на реальном ответе); (b) teardown-mid-wait (реджект connection-loss +
listenerCount1→0, утечки нет); (c) concurrent-in-flight fail-fast; + F2-исходы (empty→clean no-op, not-found→throw). Серверный спек: empty + page-not-found терминальный ответ.Верификация: mcp unit 757/757 (save-page-version 8/8), server
persistence-store.spec27/27. Мутации: снятьprovider.off→ teardown-тест краснит (утечка листенера); предикат матчит любой stateless → unrelated-then-real краснит; снять серверный skip-бродкаст → оба skip-теста краснят. Отребейзено на develop (8 коммитов, конфликтов нет). Скоуп: 6 файлов, 0 node_modules.внутренний цикл: 1 проход — APPROVE (F2 исправлен на обеих сторонах, per-page-скоуп держится для skip, транспорт не сломан; покрытие F1 доказано мутациями).
Новый head: rebased.
869863fa70to1644de87f7✅ APPROVED ·
review/approved· Fix-раунд закрыл оба моих замечания (F1 test-coverage, F2 empty-page hang) — проверено адверсариально с мутационными пробами на ОБЕИХ сторонах. Новых багов не внесено. Гейт зелёный.F1 ✔ resolved (4 lifecycle-ветки покрыты, mutation-locked) · F2 ✔ resolved (терминальный
version.skippedна empty/page-not-found, mutation-locked server+client).Верификация фикс-раунда (fix commit
1644de87) · mutation-locks + гейтF2 (real bug) — терминальный skip-reply вместо 20-сек зависания
Server (
persistence.extension.ts): обе no-op ранние ветки (!page/isEmptyParagraphDoc) теперь пишутskipped='page-not-found'|'empty', и хвост броадкастит РОВНО ОДИН терминальный reply (version.savedXORversion.skipped). Проверил адверсариально:popContributors(SPOP) только в fresh-version ветке, ПОСЛЕ обоих skip-return; skip-пути ничего не попают → нет потери атрибуции. Housekeeping (historyQueue.remove(versionedPageId)) корректен для empty (versionedPageId выставлен) и page-not-found (undefined → moot).Client (
collaboration.ts): предикатmatchSaveVersionReplyматчит оба типа и фильтрует шум; page-not-found → немедленный правдивый throw (сессия НЕ рушится — соединение здорово); empty → чистый{saved:false,skipped:true,reason:'empty'}; genuine no-reply timeout → throw (реальный «unreachable», сессия рушится). Смена формыSaveVersionResult(historyId стал опциональным) безопасна: единственный потребитель (pages.ts→tool-specs execute) сериализует объект целиком в ответ тула; докстринги/спека обновлены.Server mutation-lock (persistence-store.spec, 27 tests,
--no-cache):else if (skipped) broadcast→ покраснели РОВНО 2 теста (empty + page-not-found). ✔reason: skipped→ хардкод'empty'→ покраснел ТОЛЬКО page-not-found тест (доказывает, что он ассертит конкретный reason). ✔F1 (coverage) — 4 непокрытые lifecycle-ветки sendStatelessAndAwait
Клиентские тесты (
save-page-version.test.mjs, 8/8) mutation-locked против собранногоbuild/(src нетронут, провенанс чист):if(false)) → покраснел throw-тест ✔provider.off→ no-op) → покраснел teardown-тест ✔if(inflightReject)→if(false)) → покраснел concurrent-тест ✔Объективный гейт (clean-room)
import.meta— не связаны, note-and-pass).__assertClientCallContractсодержитvoid client.savePageVersion(s)(compile-time контракт-гвард), shared execute single-param — риска #440 (тихий дроп schema-параметра) нет.1644de87.