fix(mcp): ревью #513 — гонка LRU-эвикции + доводки контракта/доков

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>
This commit is contained in:
2026-07-12 00:03:59 +03:00
parent 141ebb4864
commit bc433d12e6
6 changed files with 125 additions and 21 deletions
+7
View File
@@ -553,6 +553,13 @@ export abstract class DocmostClientContext {
try {
return await write(collabToken);
} catch (e) {
// INVARIANT (#494/#435): this auto-retry MUST stay auth-only. A collab
// write can fail INDETERMINATE — its update may already have reached and
// persisted on the server (e.g. an LRU eviction of a busy session, tagged
// via isCollabIndeterminateError). Blindly retrying such a write duplicates
// it (the #435 double-apply class). If this gate is ever widened to retry a
// broader error class, FIRST check `isCollabIndeterminateError(e)` and do
// NOT retry an indeterminate write — re-read and verify before any retry.
if (!isCollabAuthFailedError(e)) throw e;
// The WS handshake rejected our token: drop it from the cache so it can't
// be reused for the rest of the TTL, mint a fresh one (forceRefresh bypasses
+18 -6
View File
@@ -732,20 +732,32 @@ export async function acquireCollabSession(
// until there is room. PREFER an IDLE victim (#494): a session with an in-flight
// mutate may have already sent (and persisted) its update, so evicting it would
// reject that write as a FALSE failure → a retry-prone agent re-issues it →
// DUPLICATE write (the #435 class). So walk LRU order and skip busy sessions,
// evicting the oldest IDLE one. Only when EVERY cached session is busy (eviction
// unavoidable to admit this write) do we evict the LRU busy one — via
// evictForCap(), which rejects its in-flight op with a tagged INDETERMINATE
// DUPLICATE write (the #435 class). So walk LRU order and skip non-idle sessions,
// evicting the oldest IDLE one. Only when EVERY cached session is non-idle (eviction
// unavoidable to admit this write) do we evict the LRU non-idle one — via
// evictForCap(), which rejects an in-flight op with a tagged INDETERMINATE
// "verify before retry" error rather than a plain failure.
//
// "Non-idle" = busy OR still opening. `sessions.set(key)` below runs BEFORE the
// `await session.open()` that resolves it, so a `connecting` session is in the
// map while its handshake is still in flight. Such a session is NOT busy yet
// (isBusy() needs an in-flight mutate), but it is ALSO not a legitimate idle
// victim: in multi-user HTTP two acquires for DIFFERENT pages interleave across
// that await, and under saturation the second acquire would otherwise pick the
// first's freshly-inserted `connecting` session as an "idle" victim and destroy
// it, rejecting the first's pending open() as "evicted (LRU cap)" — a spurious
// failure of a write that never even started. So exclude `state !== "ready"`
// from the idle scan; a connecting session falls into the last-resort bucket and
// is evicted ONLY when every other entry is busy-or-connecting too.
while (sessions.size >= cfg.maxEntries) {
let idleKey: string | undefined;
let oldestBusyKey: string | undefined;
for (const [k, s] of sessions) {
if (s.isBusy()) {
if (s.isBusy() || s.state !== "ready") {
if (oldestBusyKey === undefined) oldestBusyKey = k;
continue;
}
idleKey = k; // first (LRU) idle session
idleKey = k; // first (LRU) genuinely-idle session
break;
}
const victimKey = idleKey ?? oldestBusyKey;
+8 -8
View File
@@ -22,14 +22,14 @@
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
* match the document's plain text. Exactly like editPageText's json-edit
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
* `resolveAnchorSelection`. `countAnchorMatches` keeps its OWN parallel exact-wins
* implementation (it needs a raw match COUNT, not a single resolved locator), kept
* deliberately in sync with `resolveAnchorSelection`: raw match ⇒ use raw, else fall
* back to the stripped count. All four therefore agree on which locator matched —
* the suggestion-uniqueness gate depends on count and can/get never disagreeing, so
* these two exact-wins implementations MUST stay in sync if either is changed.
* in the whole document, retry with `stripInlineMarkdown` applied. All four entry
* points — `canAnchorInDoc`, `getAnchoredText`, `applyAnchorInDoc` and
* `countAnchorMatches` — share this exact-wins / strip-fallback decision through the
* SINGLE resolver `resolveAnchorSelection`; there is no second copy of the control
* flow. `countAnchorMatches` just asks the resolver which selection form wins and
* returns the raw occurrence count of that winning form. Because count and anchor
* derive from the same resolver, the suggestion-uniqueness gate (which depends on
* count) can never disagree with what actually anchors.
*/
import { stripInlineMarkdown } from "./text-normalize.js";
+11 -7
View File
@@ -1963,13 +1963,17 @@ export const SHARED_TOOL_SPECS = {
'value escaping) — a violation returns a structured error naming the rule ' +
'and cellId so you can fix and retry. `where` positions the block like ' +
'insertNode: position before/after (with exactly one of anchorNodeId or ' +
'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' +
'returned `nodeId` is an index-based "#<index>" handle (drawio nodes carry ' +
'no attrs.id): it addresses the new top-level block and can be fed straight ' +
'back into drawioGet / drawioUpdate for THIS document. It is positional, ' +
'so if you add or remove blocks before it, re-resolve via getOutline. The ' +
'diagram is editable in the draw.io editor and can be re-read with ' +
'drawioGet.' +
'anchorText) or append. Returns { nodeId, attachmentId, warnings }. On a ' +
'top-level insert the returned `nodeId` is an index-based "#<index>" handle ' +
'(drawio nodes carry no attrs.id): it addresses the new block and can be fed ' +
'straight back into drawioGet / drawioUpdate for THIS document. It is ' +
'positional, so if you add or remove blocks before it, re-resolve via ' +
'getOutline. When the block lands NESTED (e.g. anchored inside a callout or ' +
'table cell), "#<index>" cannot address it, so `nodeId` is `null` — the ' +
'write STILL SUCCEEDED (success:true, a warning explains this); do NOT ' +
're-create it. Re-read or edit that nested diagram by locating it via ' +
'getOutline / getPageJson using the returned attachmentId. The diagram is ' +
'editable in the draw.io editor and can be re-read with drawioGet.' +
DRAWIO_HARD_RULES,
tier: 'deferred',
catalogLine: