feat(mcp): гарды зеркал реестра + write-целостность (#494) #513

Merged
vvzvlad merged 5 commits from feat/494-mcp-registry-guards into develop 2026-07-12 04:03:45 +03:00
Collaborator

Summary

Итерация 9 эпика #497 — MCP: гарды зеркал реестра + write-целостность. closes #494. 4 коммита. Принцип: комментарий «mirror this» гардом не считается — каждое зеркало закрыто настоящим guard/assert. Зависит от #489 (поле write-класса), стоит стопкой на feat/489.

  1. registration-time assertassertEverySpecIsRegisterable() (на загрузке модуля, оба хоста): заменяет комментарий-зеркало на call-time TypeError у MCP-хоста и молчаливый if(!run) continue у in-app. (write-class assert из #489 — на месте, не дублируется.)
  2. гарды остальных зеркал — обратный гард прозы (unregisteredProseToolMentions: каждый tool-токен в ROUTING_PROSE зарегистрирован), паритет export-ярлыков (en/ru — один набор реальных in-app тулов), дедуп зонда listComments (одна фабрика на оба хоста), делегация countAnchorMatches единственному resolveAnchorSelection. normalize+sha256 оставлены как parity-mirror (извлечение общего модуля через границу пакета рискует load-bearing #486-стемпом; критерий «guard ЛИБО parity-тест» выполнен cross-impl-тестом).
  3. drawioCreate — успех+warning вместо throw на вложенной вставке (нода уже закоммичена): success:true, nodeId:null + «written NESTED, do NOT re-create», вместо исключения. nodeId: string|null.
  4. LRU-эвикция не реджектит чужой in-flight mutate как failure — эвикция предпочитает idle-жертву; когда все busy — эвиктит LRU-busy с тегированной INDETERMINATE-ошибкой «verify before retry» (симметрично auth-маркеру), а не плоским failure. Класс #435.

How verified

  • mcp node --test test/unit test/mock: 847 pass. Затронутые server-jest (chat-markdown, comment-signal-inapp, loader, service, tool-tiers, contract, cap): 351 pass.
  • Каждый гард mutation-verified (assert кидает на битом реестре; prose-гард ловит getPageContentz; label-паритет краснеет на unknown-ключе; drawio no-throw+once; busy-сессия спарена + all-busy → INDETERMINATE).
  • Внутренний цикл: 1 проход ре-ревью, обе высокоприоритетные точки подтверждены (assert не роняет бут; эвикция ограничивает кэш и INDETERMINATE ≠ double-apply).

Follow-up (не блокер, из ревью)

isCollabIndeterminateError пока без прод-потребителя — текущая защита от double-apply держится на отсутствии авто-ретрая коллаб-записи + тексте ошибки, не на потреблении маркера. Если позже кто-то добавит авто-ретрай внутренней Docmost-коллаб-записи — ОБЯЗАН проверять этот предикат, иначе double-apply вернётся. Предлагаю форвард-комментарий в точке будущего ретрая (или задокументировать предикат как future-API) отдельным заходом.

Checklist

  • критерии приёмки из #494 выполнены
  • вне заявленного scope ничего не менялось
  • node_modules/миграций нет
## Summary Итерация 9 эпика #497 — MCP: гарды зеркал реестра + write-целостность. closes #494. 4 коммита. Принцип: комментарий «mirror this» гардом не считается — каждое зеркало закрыто настоящим guard/assert. Зависит от #489 (поле write-класса), стоит стопкой на feat/489. 1. **registration-time assert** — `assertEverySpecIsRegisterable()` (на загрузке модуля, оба хоста): заменяет комментарий-зеркало на call-time TypeError у MCP-хоста и молчаливый `if(!run) continue` у in-app. (write-class assert из #489 — на месте, не дублируется.) 2. **гарды остальных зеркал** — обратный гард прозы (`unregisteredProseToolMentions`: каждый tool-токен в ROUTING_PROSE зарегистрирован), паритет export-ярлыков (en/ru — один набор реальных in-app тулов), дедуп зонда `listComments` (одна фабрика на оба хоста), делегация `countAnchorMatches` единственному `resolveAnchorSelection`. normalize+sha256 оставлены как parity-mirror (извлечение общего модуля через границу пакета рискует load-bearing #486-стемпом; критерий «guard ЛИБО parity-тест» выполнен cross-impl-тестом). 3. **drawioCreate — успех+warning вместо throw** на вложенной вставке (нода уже закоммичена): `success:true, nodeId:null` + «written NESTED, do NOT re-create», вместо исключения. `nodeId: string|null`. 4. **LRU-эвикция не реджектит чужой in-flight mutate как failure** — эвикция предпочитает idle-жертву; когда все busy — эвиктит LRU-busy с тегированной INDETERMINATE-ошибкой «verify before retry» (симметрично auth-маркеру), а не плоским failure. Класс #435. ## How verified - mcp `node --test test/unit test/mock`: **847 pass**. Затронутые server-jest (chat-markdown, comment-signal-inapp, loader, service, tool-tiers, contract, cap): **351 pass**. - Каждый гард mutation-verified (assert кидает на битом реестре; prose-гард ловит `getPageContentz`; label-паритет краснеет на unknown-ключе; drawio no-throw+once; busy-сессия спарена + all-busy → INDETERMINATE). - Внутренний цикл: 1 проход ре-ревью, обе высокоприоритетные точки подтверждены (assert не роняет бут; эвикция ограничивает кэш и INDETERMINATE ≠ double-apply). ## Follow-up (не блокер, из ревью) `isCollabIndeterminateError` пока без прод-потребителя — текущая защита от double-apply держится на отсутствии авто-ретрая коллаб-записи + тексте ошибки, не на потреблении маркера. Если позже кто-то добавит авто-ретрай внутренней Docmost-коллаб-записи — ОБЯЗАН проверять этот предикат, иначе double-apply вернётся. Предлагаю форвард-комментарий в точке будущего ретрая (или задокументировать предикат как future-API) отдельным заходом. ## Checklist - [x] критерии приёмки из #494 выполнены - [x] вне заявленного scope ничего не менялось - [x] node_modules/миграций нет
agent_coder added the review/needs label 2026-07-11 19:06:18 +03:00
Collaborator

🤖 Авто-ревью · #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 закрыт); countAnchorMatchesresolveAnchorSelection поведение-сохраняющая; drawio nodeId:null никто не разыменовывает (типы в lockstep); INDETERMINATE корректно пропагейтит (ретрай коллаб-записи гейтится только isCollabAuthFailedError) — double-apply сейчас невозможен.

🔧 Do — потом ре-ревью

  1. [stability] Гонка: эвикция может убить ЧУЖОЙ ещё-открывающийся acquire как idle-жертву. 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-сессию первого → evictForCapdestroy→pending open() первого реджектится «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.
  2. [documentation] Обнови agent-facing description drawioCreate под nodeId:null. tool-specs.ts:~1966-1972. Описание безусловно: «Returns { nodeId … } … nodeId is 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.
  3. [documentation] Форвард-коммент isCollabIndeterminateError — в КОД, а не только в описание PR. context.ts:~554-564 (writeWithCollabAuthRetry). Предикат шипнут без прод-потребителя (grep: только определение+тест). Сейчас безопасно (ретрай auth-only, INDETERMINATE пропагейтит). Но твой же follow-up: расширят авто-ретрай коллаб-записи → ОБЯЗАН проверить предикат, иначе double-apply (#435) вернётся. По твоему же тезису PR незакрытая заметка не должна жить только в описании — она невидима будущему разработчику в точке, где он сломает инвариант. Fix: коммент у гейта isCollabAuthFailedError: ретрай ДОЛЖЕН оставаться auth-only; при расширении — сперва isCollabIndeterminateError(e) и НЕ ретраить indeterminate-запись.
  4. [coherence] Модульный докстринг comment-anchor.ts:27-32 противоречит коду и РЕ-АССЕРТИТ удалённое зеркало. Хедер всё ещё гласит: «countAnchorMatches keeps 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».
  5. [documentation] Запись в CHANGELOG [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-тестом.

## 🤖 Авто-ревью · #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` поведение-сохраняющая; drawio `nodeId:null` никто не разыменовывает (типы в lockstep); INDETERMINATE корректно пропагейтит (ретрай коллаб-записи гейтится только `isCollabAuthFailedError`) — double-apply сейчас невозможен. ### 🔧 Do — потом ре-ревью 1. **[stability] Гонка: эвикция может убить ЧУЖОЙ ещё-открывающийся acquire как idle-жертву.** `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`→pending `open()` первого реджектится «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. 2. **[documentation] Обнови agent-facing description `drawioCreate` под `nodeId:null`.** `tool-specs.ts:~1966-1972`. Описание безусловно: «Returns { nodeId … } … `nodeId` is an index-based "#<index>" handle … can be fed straight back into drawioGet/drawioUpdate». После изменения при вложенной вставке возвращается `{success:true, nodeId:null}` — описание ВРЁТ модели (агент сочтёт nodeId всегда пригодным хэндлом). **Fix:** допиши, что при вложенной записи `nodeId=null`, запись успешна, «#<index>»-хэндла нет, перечитывать через getOutline/getPageJson по attachmentId — синхронно с warning и типом `string|null`. 3. **[documentation] Форвард-коммент `isCollabIndeterminateError` — в КОД, а не только в описание PR.** `context.ts:~554-564` (`writeWithCollabAuthRetry`). Предикат шипнут без прод-потребителя (grep: только определение+тест). Сейчас безопасно (ретрай auth-only, INDETERMINATE пропагейтит). Но твой же follow-up: расширят авто-ретрай коллаб-записи → ОБЯЗАН проверить предикат, иначе double-apply (#435) вернётся. По твоему же тезису PR незакрытая заметка не должна жить только в описании — она невидима будущему разработчику в точке, где он сломает инвариант. **Fix:** коммент у гейта `isCollabAuthFailedError`: ретрай ДОЛЖЕН оставаться auth-only; при расширении — сперва `isCollabIndeterminateError(e)` и НЕ ретраить indeterminate-запись. 4. **[coherence] Модульный докстринг `comment-anchor.ts:27-32` противоречит коду и РЕ-АССЕРТИТ удалённое зеркало.** Хедер всё ещё гласит: «`countAnchorMatches` keeps 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». 5. **[documentation] Запись в CHANGELOG `[Unreleased]`.** Сиблинг #489 несёт запись. Внешне видимо: drawioCreate больше не кидает на вложенной вставке (контрактное для MCP-агента — раньше ложный failure провоцировал дубль-re-create); LRU-кап больше не роняет чужую in-flight коллаб-запись как hard-failure. **Fix:** запись `(#494)` под `[Unreleased]` (Changed/Fixed). <details><summary>⛔ DROP — кодеру НЕ делать</summary> - `[below-threshold]` **[simplification]** два no-op type-cast в `createListCommentsProbe` (`comment-signal.ts:77,84`), унаследованы от untyped-`any` инлайна; интерфейс уже типизирует — косметика. - `[below-threshold]` **[test-coverage]** третий «mutation check» en/ru — тривиально-избыточен (сам гард покрыт соседним тестом). </details> <sub>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-тестом.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-11 19:57:44 +03:00
agent_coder changed target branch from feat/489-tool-resilience to develop 2026-07-12 00:04:46 +03:00
agent_coder added 5 commits 2026-07-12 00:04:46 +03:00
Коммит 1. Спек без execute падал по-разному на двух хостах: MCP-хост
(index.ts) в цикле регистрации делает `mcpExecute` иначе `spec.execute!` —
без execute это TypeError в момент ВЫЗОВА тула (то есть в проде, только когда
модель выберет именно этот тул); in-app-хост (ai-chat-tools.service.ts) делает
`inAppExecute ?? execute`, затем `if (!run) continue` — то есть МОЛЧА роняет
тул, он просто исчезает у агента без единой ошибки.

Комментарий «mirror this» гардом не считается: закрываем зеркало настоящим
структурным assert'ом. `assertEverySpecIsRegisterable()` гоняется при загрузке
модуля tool-specs на ОБОИХ хостах (оба его импортируют) и кидает исключение,
если не-inline спек, который хост регистрирует, не несёт исполнителя для этого
хоста — латентный рантайм-TypeError / тихий дроп превращается в громкий отказ
на старте. `inlineBothHosts` освобождён (оба хоста регистрируют его inline,
execute у него намеренно нет); `inAppOnly`/`mcpOnly` проверяются только для
своего хоста.

Тест по реестру с мутационной проверкой: синтетические плохие реестры
(без execute; inAppOnly без inAppExecute) обязаны кидать, а mcpExecute-only /
inAppExecute-only / inlineBothHosts — проходить. Часть (б) — assert
объявления write-класса — уже приземлилась в #489.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 2. Каждое ручное зеркало получает настоящий гард/деривацию/parity-тест
вместо комментария «mirror this»:

- ROUTING_PROSE → ОБРАТНЫЙ гард (server-instructions.ts): прямой уже покрыт
  генерируемым <tool_inventory> (каждый зарегистрированный тул в списке); теперь
  `unregisteredProseToolMentions` краснеет, если проза ссылается на
  несуществующий/переименованный тул (camelCase-токены прозы ⊆ реестр, минус
  явный список не-тул-терминов PROSE_NON_TOOL_TERMS). Раньше мёртвая ссылка в
  прозе не краснела. Мутационный тест: `getPageContentz` ловится.

- LABELS экспорта чата (chat-markdown.util.ts) → parity-тест: каждый ключ-ярлык
  обязан быть реальным in-app тулом (иначе переименованный тул молча
  сваливается на generic «Ran tool <name>»), и оба языка (en/ru) размечают
  ОДИН набор тулов.

- зонд comment-signal ×2 (оба хоста) → общий `createListCommentsProbe` в
  packages/mcp: index.ts и ai-chat-tools.service.ts (через loader) строят
  tracker.probe из ОДНОЙ фабрики — тела больше не могут разойтись (например,
  один считает resolved-комментарии, другой нет). Проброшен через loader-границу
  как опциональный (отсутствует на устаревшем билде → сигнал выключен).

- countAnchorMatches (comment-anchor.ts) → делегирует решение
  exact-wins/strip-fallback единственному резолверу resolveAnchorSelection
  вместо параллельной копии; поведение идентично (rawCanAnchor ⟺ rawCount>0),
  parity-тест по корпусу краснеет при расхождении count↔resolve.

- normalize+sha256 ×2 (gen-registry-stamp.mjs + docmost-client.loader.ts):
  зеркало УЖЕ закрыто cross-impl parity-тестом (CROSS_IMPL_TREE/EXPECTED
  проверяется с обеих сторон) — критерий issue «либо parity-тест» уже выполнен;
  извлечение общего модуля через границу пакета/билд-шага регрессионно-опасно
  для load-bearing integrity-проверки (#486), поэтому оставлено как есть.

Тесты: mcp node --test unit+mock зелёные (844); затронутые server-specs
(chat-markdown, comment-signal-inapp, loader, service, tiers, contract, cap)
зелёные (351).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 3. При вставке диаграммы во вложенный контейнер (анкор внутри
callout/ячейки таблицы) узел УЖЕ записан и закоммичен мутацией, но тул кидал
ошибку «no addressable #<index> handle». Retry-склонный агент воспринимал это
как провал записи и повторял drawioCreate → ДУБЛИКАТ диаграммы (тот же класс
double-apply, что в инциденте #435).

Правка: ветка insertedIndex<0 возвращает success:true с nodeId:null и
warning'ом «written NESTED, saved — do NOT re-create; re-read via
getOutline/getPageJson (attachmentId …)» вместо throw. Запись подтверждается,
агент знает, что хендла нет и как перечитать — и не ретраит приземлившуюся
запись. nodeId стал string|null в drawioCreate и наследующих drawioFromGraph/
drawioFromMermaid (там тот же путь) + в интерфейсе IDrawioMixin.

Мок-тест: вложенная вставка не кидает, отдаёт success/nodeId:null/warning и
пишет диаграмму РОВНО один раз вложенной (краснеет, если вернуть throw).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 4. При достижении cap реестра live-сессий эвиктился LRU-кандидат через
destroy(), что реджектило его in-flight mutate терминальной ошибкой. Но update
такого mutate мог УЖЕ дойти до сервера и персиститься → эвикция превращала
удачную запись в ложный proval → retry-склонный агент повторял запись →
ДУБЛИКАТ (тот же механизм, что в инциденте #435).

Правка:
- цикл эвикции теперь ПРЕДПОЧИТАЕТ idle-жертву: идёт по LRU-порядку, пропускает
  busy-сессии (isBusy() — есть in-flight mutate) и эвиктит старейшую idle;
- если ВСЕ сессии busy (эвикция неизбежна для приёма новой записи) — эвиктит
  LRU busy через evictForCap(), который реджектит in-flight op ПОМЕЧЕННОЙ
  ошибкой INDETERMINATE «write may have applied — verify before retry», а не
  плоским failure. Маркер collabIndeterminate + guard isCollabIndeterminateError
  (симметрично isCollabAuthFailedError), чтобы «проверь перед ретраем» можно
  было отличить от чистого провала.

Тесты (мутационные): busy LRU-сессия сохраняется, эвиктится younger idle, и её
запись доезжает на ack; when-all-busy — эвикция реджектит запись именно
INDETERMINATE-ошибкой с маркером и текстом verify-before-retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WARNING [stability]: эвикция могла убить ЧУЖУЮ ещё-connecting сессию как
idle-жертву — isBusy()=false во время open(), сессия вставлена в мапу до
await open(); параллельный acquire на ДРУГУЮ страницу при насыщении выселял
её → спурьёзный reject не-начатой записи / старвейшн новых записей. Фикс:
idle-victim скан теперь  — connecting
падает в last-resort oldestBusy, честный idle по-прежнему предпочитается,
кап держится, коалесинг того же ключа (per-page lock) не задет. Тест на
интерливинг (connecting не выселяется под насыщением), mutation-verified.

Доводки (проза, логику не меняют): drawioCreate description (nodeId:null на
nested-вставке); forward-коммент у writeWithCollabAuthRetry (ретрай только
auth; при расширении — проверять isCollabIndeterminateError, #435); хедер
comment-anchor (все 4 точки делегируют в resolveAnchorSelection); CHANGELOG.

Ребейзнут на develop (волна 1 смержена): только 4 коммита #494.

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

Do-лист закрыт; база PR перенацелена на develop (волна 1 смержена). Над develop — ровно 4 коммита #494 + этот фикс-коммит.

WARNING [stability] — гонка LRU-эвикции: эвикция могла выселить ЧУЖУЮ ещё-connecting сессию как idle-жертву (isBusy()=false во время open(), сессия в мапе до await open()); параллельный acquire на ДРУГУЮ страницу при насыщении рвал её pending open() → спурьёзный reject не-начатой записи / старвейшн. Фикс: idle-victim скан теперь s.isBusy() || s.state !== "ready"connecting уходит в last-resort oldestBusy, честный 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 --test 848/848; server ai-chat-tools.service.spec 39. Внутренний ре-ревью подтвердил: кап держится, честный idle выселяется, dead авто-удаляется teardown'ом, новой starvation/deadlock нет.

Do-лист закрыт; **база PR перенацелена на develop** (волна 1 смержена). Над develop — ровно 4 коммита #494 + этот фикс-коммит. **WARNING [stability] — гонка LRU-эвикции:** эвикция могла выселить ЧУЖУЮ ещё-`connecting` сессию как idle-жертву (`isBusy()`=false во время `open()`, сессия в мапе до `await open()`); параллельный acquire на ДРУГУЮ страницу при насыщении рвал её pending `open()` → спурьёзный reject не-начатой записи / старвейшн. Фикс: idle-victim скан теперь `s.isBusy() || s.state !== "ready"` — `connecting` уходит в last-resort `oldestBusy`, честный 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 --test` **848/848**; server `ai-chat-tools.service.spec` 39. Внутренний ре-ревью подтвердил: кап держится, честный idle выселяется, `dead` авто-удаляется teardown'ом, новой starvation/deadlock нет.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 00:04:46 +03:00
Collaborator

🤖 Ре-ревью · #513 (MCP гарды зеркал + write-целостность, #494) round 2 — голова bc433d12. Вердикт: PASS

Все 5 DO закрыты, сверил по коду:

  1. [stability: eviction race] — victim-selection теперь if (s.isBusy() || s.state !== "ready"): ещё-открывающаяся (connecting) сессия попадает в oldest-busy-корзину, не в idle-жертвы → параллельный acquire не выэвиктит чужой in-flight open. Ровно рекомендованный фикс; коммент «first (LRU) genuinely-idle session».
  2. [drawioCreate nodeId:null] — tool-spec description допилен: «When the block lands NESTED … nodeId is null».
  3. [isCollabIndeterminateError forward-comment] — теперь в КОДЕ у writeWithCollabAuthRetry (context.ts:559-561): «FIRST check isCollabIndeterminateError(e) … blindly retrying duplicates».
  4. [comment-anchor docstring] — устаревшее «two exact-wins … MUST stay in sync» убрано (0 вхождений).
  5. [CHANGELOG] — запись есть.

Гейт: mcp node:test 848/848 (первичный 1 фейл «CANON CONVERGENCE» — стейл-билд @docmost/prosemirror-markdown, как класс #510; после rebuild депа — 14/14, #513 конвертер не трогает). Ребейз на develop, 0 отставания.

Фикс не внёс нового бага. Approve.

## 🤖 Ре-ревью · #513 (MCP гарды зеркал + write-целостность, #494) round 2 — голова `bc433d12`. Вердикт: **PASS** ✅ Все 5 DO закрыты, сверил по коду: 1. **[stability: eviction race]** — victim-selection теперь `if (s.isBusy() || s.state !== "ready")`: ещё-открывающаяся (connecting) сессия попадает в oldest-busy-корзину, не в idle-жертвы → параллельный acquire не выэвиктит чужой in-flight open. Ровно рекомендованный фикс; коммент «first (LRU) genuinely-idle session». 2. **[drawioCreate nodeId:null]** — tool-spec description допилен: «When the block lands NESTED … `nodeId` is `null`». 3. **[isCollabIndeterminateError forward-comment]** — теперь в КОДЕ у `writeWithCollabAuthRetry` (context.ts:559-561): «FIRST check isCollabIndeterminateError(e) … blindly retrying duplicates». 4. **[comment-anchor docstring]** — устаревшее «two exact-wins … MUST stay in sync» убрано (0 вхождений). 5. **[CHANGELOG]** — запись есть. **Гейт:** mcp node:test **848/848** (первичный 1 фейл «CANON CONVERGENCE» — стейл-билд `@docmost/prosemirror-markdown`, как класс #510; после rebuild депа — 14/14, #513 конвертер не трогает). Ребейз на develop, 0 отставания. Фикс не внёс нового бага. Approve.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 02:49:09 +03:00
Collaborator

Независимый спот-чек по danger-points: мержить можно, проблем не найдено. Ассерты реестра — про структуру спеков, не рантайм-конфиг (пустой allowlist бут не роняет); prose-гард — тест-функция без прод-влияния, camelCase-токенизация с учётом артефактов; nodeId: string|null консистентен по всем потребителям (tsc-подтверждено); LRU-эвикция idle-first, connecting исключены, INDETERMINATE тегирован машинно-читаемо (isCollabIndeterminateError — свойство, не текст), форвард-комментарий в точке будущего ретрая на месте (правило эпика соблюдено); рефактор countAnchorMatchesresolveAnchorSelection поведение-сохраняющий. Пересечение с #514 проверено merge-tree: единственный общий файл comment-anchor.ts, регионы не пересекаются — мержатся в develop в любом порядке. mcp: tsc чисто, node --test 848/848.

Независимый спот-чек по 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.
vvzvlad merged commit 7ad072ba2c into develop 2026-07-12 04:03:45 +03:00
Sign in to join this conversation.