feat(mcp): гарды зеркал реестра + write-целостность (#494) #513
Reference in New Issue
Block a user
Delete Branch "feat/494-mcp-registry-guards"
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
Итерация 9 эпика #497 — MCP: гарды зеркал реестра + write-целостность. closes #494. 4 коммита. Принцип: комментарий «mirror this» гардом не считается — каждое зеркало закрыто настоящим guard/assert. Зависит от #489 (поле write-класса), стоит стопкой на feat/489.
assertEverySpecIsRegisterable()(на загрузке модуля, оба хоста): заменяет комментарий-зеркало на call-time TypeError у MCP-хоста и молчаливыйif(!run) continueу in-app. (write-class assert из #489 — на месте, не дублируется.)unregisteredProseToolMentions: каждый tool-токен в ROUTING_PROSE зарегистрирован), паритет export-ярлыков (en/ru — один набор реальных in-app тулов), дедуп зондаlistComments(одна фабрика на оба хоста), делегацияcountAnchorMatchesединственномуresolveAnchorSelection. normalize+sha256 оставлены как parity-mirror (извлечение общего модуля через границу пакета рискует load-bearing #486-стемпом; критерий «guard ЛИБО parity-тест» выполнен cross-impl-тестом).success:true, nodeId:null+ «written NESTED, do NOT re-create», вместо исключения.nodeId: string|null.How verified
node --test test/unit test/mock: 847 pass. Затронутые server-jest (chat-markdown, comment-signal-inapp, loader, service, tool-tiers, contract, cap): 351 pass.getPageContentz; label-паритет краснеет на unknown-ключе; drawio no-throw+once; busy-сессия спарена + all-busy → INDETERMINATE).Follow-up (не блокер, из ревью)
isCollabIndeterminateErrorпока без прод-потребителя — текущая защита от double-apply держится на отсутствии авто-ретрая коллаб-записи + тексте ошибки, не на потреблении маркера. Если позже кто-то добавит авто-ретрай внутренней Docmost-коллаб-записи — ОБЯЗАН проверять этот предикат, иначе double-apply вернётся. Предлагаю форвард-комментарий в точке будущего ретрая (или задокументировать предикат как future-API) отдельным заходом.Checklist
🤖 Авто-ревью · #513 (MCP: гарды зеркал реестра + write-целостность, #494) — голова
7ed558e4Вердикт: CHANGES — 1 конкурентный warning + 3 контракт/док-доводки + 1 несогласованный докстринг. Эскалаций нет. Тезис PR («комментарий-зеркало ≠ гард») в основном выполнен и сверен: каждый из 6 гардов реально краснеет на дрейфе, write-целостность корректна, live double-apply отсутствует. Но одна гонка в LRU-эвикции бьёт по собственной цели PR, и три места-документа отстали от кода.
Прогнал полный 9-аспектный веер + гейт. Security/regressions/conventions/architecture/simplification/test-coverage — LGTM. Сверил лично: 6 гардов не вакуумны (broken-registry→throw, prose-гард ловит
getPageContentz, en/ru-паритет, cross-impl sha256 с обеих сторон, LRU busy-spared/idle-evicted + all-busy→INDETERMINATE, drawio no-throw uploads==1); assert на загрузке НЕ роняет бут (уважаетmcpOnly/inAppOnly/inlineBothHosts, хост-паритет #440 закрыт);countAnchorMatches→resolveAnchorSelectionповедение-сохраняющая; drawionodeId:nullникто не разыменовывает (типы в lockstep); INDETERMINATE корректно пропагейтит (ретрай коллаб-записи гейтится толькоisCollabAuthFailedError) — double-apply сейчас невозможен.🔧 Do — потом ре-ревью
collab-session.ts:~740-758. «Prefer idle victim»-обход берёт первую сессию сisBusy()===false. НоisBusy()=!dead && inflightReject!==undefined(стр. 635) → сессия в состоянииconnecting(open() ещё идёт, mutate не начат) НЕ busy → считается idle-жертвой. АacquireCollabSessionделаетsessions.set(key)(стр. 769) ДОawait session.open()(771) — await уступает eventloop. В мультиюзер-HTTP (глобальная мапа по wsUrl+pageId+token) два параллельных acquire на РАЗНЫЕ страницы интерливятся: при насыщении (все прочие busy) второй acquire в цикле эвикции проскакивает busy и садится на свежевставленную connecting-сессию первого →evictForCap→destroy→pendingopen()первого реджектится «evicted (LRU cap)». Легитимная, ещё НЕ применённая запись падает спурьёзно; при потоке новых acquire против all-busy — старвейшн новых записей. Урон безопасный (ничего не записано, double-apply нет), но это превращает заявленную цель «беречь in-flight запись» в «убей параллельного открывателя» и расширяет прежний узкий strict-LRU-случай. Fix: в выборе idle-жертвы считать не-готовую сессию как busy — условиеif (s.isBusy() || s.state !== "ready"); тогда connecting-сессия падает вoldestBusy-корзину и эвиктится только когда ВСЕ busy-или-connecting.drawioCreateподnodeId:null.tool-specs.ts:~1966-1972. Описание безусловно: «Returns { nodeId … } …nodeIdis an index-based "#" handle … can be fed straight back into drawioGet/drawioUpdate». После изменения при вложенной вставке возвращается{success:true, nodeId:null}— описание ВРЁТ модели (агент сочтёт nodeId всегда пригодным хэндлом). Fix: допиши, что при вложенной записиnodeId=null, запись успешна, «#»-хэндла нет, перечитывать через getOutline/getPageJson по attachmentId — синхронно с warning и типомstring|null.isCollabIndeterminateError— в КОД, а не только в описание PR.context.ts:~554-564(writeWithCollabAuthRetry). Предикат шипнут без прод-потребителя (grep: только определение+тест). Сейчас безопасно (ретрай auth-only, INDETERMINATE пропагейтит). Но твой же follow-up: расширят авто-ретрай коллаб-записи → ОБЯЗАН проверить предикат, иначе double-apply (#435) вернётся. По твоему же тезису PR незакрытая заметка не должна жить только в описании — она невидима будущему разработчику в точке, где он сломает инвариант. Fix: коммент у гейтаisCollabAuthFailedError: ретрай ДОЛЖЕН оставаться auth-only; при расширении — сперваisCollabIndeterminateError(e)и НЕ ретраить indeterminate-запись.comment-anchor.ts:27-32противоречит коду и РЕ-АССЕРТИТ удалённое зеркало. Хедер всё ещё гласит: «countAnchorMatcheskeeps its OWN parallel exact-wins implementation … MUST stay in sync if either is changed». Но твой коммит #2 сделалcountAnchorMatchesделегатом единственногоresolveAnchorSelection(стр. 440; докстринг самой функции на 427 это подтверждает) — параллельной реализации БОЛЬШЕ НЕТ. Хедер не только устарел, но и предписывает поддерживать те «две копии в синхроне», которые PR закрыл — мейнтейнер может реинтродьюсить дубль, считая это требованием. Прямо подрывает single-source-of-truth-цель PR. Fix: перепиши 25-32: все четыре точки (canAnchorInDoc/getAnchoredText/applyAnchorInDoc/countAnchorMatches) делят решение черезresolveAnchorSelection, count выводится из формы-победителя; выкинь фразу про «two exact-wins implementations MUST stay in sync».[Unreleased]. Сиблинг #489 несёт запись. Внешне видимо: drawioCreate больше не кидает на вложенной вставке (контрактное для MCP-агента — раньше ложный failure провоцировал дубль-re-create); LRU-кап больше не роняет чужую in-flight коллаб-запись как hard-failure. Fix: запись(#494)под[Unreleased](Changed/Fixed).⛔ DROP — кодеру НЕ делать
[below-threshold][simplification] два no-op type-cast вcreateListCommentsProbe(comment-signal.ts:77,84), унаследованы от untyped-anyинлайна; интерфейс уже типизирует — косметика.[below-threshold][test-coverage] третий «mutation check» en/ru — тривиально-избыточен (сам гард покрыт соседним тестом).9 аспектов. Гейт зелёный: server tsc 0 (первичный TS2305
ToolWriteClass— стейл-дист@docmost/mcp, как на #510; послеpnpm --filter @docmost/mcp build→ tsc 0, env не #513), затронутый server-jest 395 passed, mcp build 0, mcp node:test 847 pass / 0 fail. База develop (three-dot от791a707=#489, уже в develop), мёржится чисто.normalize+sha256осознанно не вынесены (риск #486-стемпа) — «guard ЛИБО parity-тест» закрыт cross-impl-тестом.Do-лист закрыт; база PR перенацелена на develop (волна 1 смержена). Над develop — ровно 4 коммита #494 + этот фикс-коммит.
WARNING [stability] — гонка LRU-эвикции: эвикция могла выселить ЧУЖУЮ ещё-
connectingсессию как idle-жертву (isBusy()=false во времяopen(), сессия в мапе доawait open()); параллельный acquire на ДРУГУЮ страницу при насыщении рвал её pendingopen()→ спурьёзный reject не-начатой записи / старвейшн. Фикс: idle-victim скан теперьs.isBusy() || s.state !== "ready"—connectingуходит в last-resortoldestBusy, честный idle по-прежнему предпочитается, кап держится, коалесинг того же ключа (сериализован per-page lock) не задет. Тест на реальный интерливинг (B вconnectingне выселяется под насыщением, A→INDETERMINATE). Mutation-verify: откат предиката →not ok 18(B выселяется).Доводки (проза, логику не меняют): drawioCreate description (
nodeId:nullна nested-вставке); forward-коммент уwriteWithCollabAuthRetry(ретрай auth-only; при расширении —isCollabIndeterminateErrorпервым, #435); хедер comment-anchor (все 4 точки делегируют в единыйresolveAnchorSelection); CHANGELOG.Гейт: mcp
node --test848/848; serverai-chat-tools.service.spec39. Внутренний ре-ревью подтвердил: кап держится, честный idle выселяется,deadавто-удаляется teardown'ом, новой starvation/deadlock нет.🤖 Ре-ревью · #513 (MCP гарды зеркал + write-целостность, #494) round 2 — голова
bc433d12. Вердикт: PASS ✅Все 5 DO закрыты, сверил по коду:
if (s.isBusy() || s.state !== "ready"): ещё-открывающаяся (connecting) сессия попадает в oldest-busy-корзину, не в idle-жертвы → параллельный acquire не выэвиктит чужой in-flight open. Ровно рекомендованный фикс; коммент «first (LRU) genuinely-idle session».nodeIdisnull».writeWithCollabAuthRetry(context.ts:559-561): «FIRST check isCollabIndeterminateError(e) … blindly retrying duplicates».Гейт: mcp node:test 848/848 (первичный 1 фейл «CANON CONVERGENCE» — стейл-билд
@docmost/prosemirror-markdown, как класс #510; после rebuild депа — 14/14, #513 конвертер не трогает). Ребейз на develop, 0 отставания.Фикс не внёс нового бага. Approve.
Независимый спот-чек по danger-points: мержить можно, проблем не найдено. Ассерты реестра — про структуру спеков, не рантайм-конфиг (пустой allowlist бут не роняет); prose-гард — тест-функция без прод-влияния, camelCase-токенизация с учётом артефактов;
nodeId: string|nullконсистентен по всем потребителям (tsc-подтверждено); LRU-эвикция idle-first, connecting исключены, INDETERMINATE тегирован машинно-читаемо (isCollabIndeterminateError— свойство, не текст), форвард-комментарий в точке будущего ретрая на месте (правило эпика соблюдено); рефакторcountAnchorMatches→resolveAnchorSelectionповедение-сохраняющий. Пересечение с #514 проверено merge-tree: единственный общий файлcomment-anchor.ts, регионы не пересекаются — мержатся в develop в любом порядке. mcp: tsc чисто, node --test 848/848.