compactToolOutput делает JSON.stringify каждого output на КАЖДОМ flush. Т.к.
onStepFinish на шаге N перестраивает всю assistant-строку по всем N накопленным
шагам, а каждый output — 50–200 KB, это O(N²) stringify за ход.
Мемоизация по identity шага: finished-шаг в capturedSteps неизменен и держит
стабильную ссылку между flush'ами, поэтому его parts (и дорогой stringify output)
строятся ровно раз за ход. buildStepParts вынесен в чистую функцию; assistantParts
принимает опциональный StepPartsCache (WeakMap<step, parts>), flushAssistant
пробрасывает его, stream() заводит один WeakMap на ход и передаёт во все flush'и.
Промах кэша (или его отсутствие в тестах/легаси-вызовах) просто пересобирает —
байтового расхождения нет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Каждый tool-output хранился ДВАЖДЫ: в metadata.parts (assistantParts) И в
tool_calls (serializeSteps). При 50-шаговом ране с outputs по 50–200 KB это
127–510 МБ записи в Postgres за ход (+WAL/TOAST/dead tuples), т.к. onStepFinish
переписывает всю строку. Копия в parts — та, что реально реплеится модели и
рендерится UI/markdown-экспортом, так что копия в трейсе была чистым дублем.
Новый формат элементов tool_calls (v2), парно на каждый вызов:
{toolName, input} — вызов
{toolName, ok: true} — успех (БЕЗ output)
{toolName, error, kind: 'thrown'} — брошенный tool-error
{toolName, error, kind: 'interrupted'} — прерван mid-step (abort/restart)
kind обязателен: синтетический «Tool call did not complete.» при прерывании иначе
неотличим от реального hard-fail и загрязняет error-rate. Различие структурное
(errorsById-хит против синтетической ветки), НЕ per-tool классификатор — soft-
маркеры в трейс не выносятся (остаются в metadata.parts).
metadata.toolTraceVersion: 2 — маркер эры; старые строки НЕ мигрируются
(перезапись гигантских jsonb — тот самый WAL-чарн). serializeSteps пейрит
результаты/ошибки по toolCallId (как assistantParts); общая константа
TOOL_CALL_INCOMPLETE_TEXT держит текст реплея и трейса в синхроне.
docs/reading-ai-logs.md переписан dual-shape: ветвление по toolTraceVersion,
soft-анализ v2 через metadata.parts, правило «не сравнивать агрегаты через границу
эр». UI action-log и markdown-экспорт читают только parts — не затронуты.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревью #516: fakeDb.execute всегда пушил commit — моделировался только commit-путь,
несущий негатив (тело кидает → tx реджектит → дренаж после awaited-tx не бежит →
хук НЕ фаerr) не был заперт. Исходник корректен, это была дыра в тесте.
Добавлен тест: тело бросает → executeTx реджектит, commit не случился, хук не
побежал. Mutation-verify: перенос дренажа в finally → тест краснеет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревью #516: фича была мёртвой-при-рождении ровно на целевом кейсе. Все три
CONCURRENT_INDEXES используют LOWER(f_unaccent(col)), но index-инлайнящаяся
1-арг форма f_unaccent (SELECT public.unaccent($1)) создаётся ВНУТРИ миграции
20260705, а ensureConcurrentIndexes зовётся ПЕРЕД мигратором. На существующем
тенанте живой f_unaccent — ещё старая 2-арг форма из 20250729, которая НЕ
инлайнится: CREATE INDEX CONCURRENTLY падает («function unaccent(unknown, text)
does not exist … during inlining»), best-effort глотает, и мигратор строит
индексы НЕ-concurrently под тем самым SHARE-локом (эмпирически подтверждено
ревьюером на живом pg).
Фикс: перед циклом идемпотентно переопределяем f_unaccent в 1-арг форму
(output-identical, в lockstep с 20260705), в том же best-effort try/swallow.
Плюс честный докстринг: убрал ложное «worst case = previous behaviour».
Прерванный CONCURRENTLY оставляет INVALID-индекс, который name-based IF NOT
EXISTS не чинит — новый режим отказа (старый in-tx build такого не оставлял).
Тест: переопределялка вызывается ПЕРВОЙ (order), затем индексы CONCURRENTLY вне
транзакции; провал переопределялки не рвёт цикл. Mutation-verify: убрать
pre-loop redef → order-тест краснеет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 4 закрыл утечку currentPageId на анонимном /availability, но
эквивалентная (и более широкая) дыра оставалась на POST /aliases/set (reassign):
при занятом алиасе и confirmReassign=false 409 отдавал currentPageId И
currentPageTitle ЦЕЛЕВОЙ страницы, а контроллер гейтил только validateCanEdit на
ИСХОДНОЙ странице. Любой участник с одной редактируемой+расшаренной страницей мог
перебирать имена алиасов и мапить их на (id, title) чужих страниц без права
просмотра — тот же класс перечисления, плюс ещё и заголовок.
Фикс: setAlias теперь гейтит раскрытие. currentPageId НЕ отдаётся никогда
(клиент им не пользуется, это перечислимая идентичность). currentPageTitle —
только если validateCanView(целевая, user) проходит; иначе голый «занят» (клиент
и так показывает generic confirm-модалку без заголовка — UX не ломается).
Гейт живёт в сервисе, где строится раскрытие (PageAccessService — @Global, без
цикла); контроллер прокидывает user. Поправлен неточный коммент checkAvailability.
Тесты: viewer → 409 с title, БЕЗ id; не-viewer → 409 без title и без id.
Mutation-verify: вернул утечку (id + безусловный title) → оба теста краснеют.
Контроллер-спек и int-spec обновлены под новую сигнатуру; tsc чист.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Аспект A — идентичность прогона реиндекса эмбеддингов.
У статуса/поллинга реиндекса не было идентичности КОНКРЕТНОГО прогона, из-за
чего класс багов «это тот же прогон или новый?» чинили дважды. Теперь каждый
прогон получает свой runId (crypto.randomUUID в start()), он хранится в Redis-
хэше рядом с total/done/startedAt и возвращается в ReindexProgress. Эндпоинт
статуса (getMasked -> MaskedAiSettings) отдаёт runId и reindexStartedAt. Клиент
кеит поллинг на (runId, startedAt): смена runId = НОВЫЙ прогон (сбрасываем
залатанное per-run состояние поллинга), тот же runId = тот же прогон. Всё
best-effort/косметика как и остальной код прогресса: пустой/отсутствующий runId
деградирует мягко и никогда не ломает реиндекс.
Аспект B — брендированные PageId/SlugId в MCP-клиенте + валидация формата в
серверных DTO (семейство инцидентов #435: двойная идентичность страницы —
внутренний id и slugId — гонялась как голая строка и молча путалась).
- packages/mcp/src/lib/page-id.ts: номинальные типы PageId/SlugId + валидирующие
конструкторы asPageId/asSlugId и гварды isPageId/isSlugId (формат UUID и
10-символьного slugId). PageId протянут через единственный узел канонизации и
записи: resolvePageId() теперь возвращает PageId, а ключ per-page лока
(withPageLock) и точки записи в collab (mutatePageContent/replacePageContent/
updatePageContentRealtime) требуют бренд — сырой slugId/непроверенный id больше
не проходит проверку типов (инвариант #260 «resolve-then-lock» теперь на уровне
компилятора). Публичный вход методов остаётся строкой (это legitimно UUID ИЛИ
slugId), брендируется каноническое значение.
- apps/server core/page/dto: PageIdDto.pageId получает валидацию формата
(@Matches, UUID или 10-символьный slugId), так что кривая/подменённая
идентичность отклоняется на границе, а не падает в repo голой строкой.
Тесты (все зелёные, с mutation-verify каждого):
- server: getMasked отдаёт runId/reindexStartedAt; стор пишет/читает runId и
мягко деградирует его до ''; DTO отклоняет кривой pageId/slugId и принимает
валидный.
- client: чистый хелпер reindexRunKey/isNewReindexRun (кеинг поллинга на runId).
- mcp: конструкторы/гварды PageId/SlugId (валидация формата).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 GIN trigram-индекса (pages.title, pages.text_content) + users.name строились
plain CREATE INDEX внутри Kysely-транзакции миграции: SHARE-lock блокирует записи
на pages/users на минуты при автодеплое.
Kysely гоняет миграцию в транзакции, а CONCURRENTLY внутри транзакции нельзя.
Поэтому ensureConcurrentIndexes (concurrent-indexes.ts) пре-строит эти индексы
через CREATE INDEX CONCURRENTLY (raw, вне транзакции) ДО миграатора — на
существующей БД миграционный CREATE INDEX IF NOT EXISTS становится no-op и лок не
берётся. Best-effort: на свежей БД (нет pages/f_unaccent) пре-build молча
пропускается, а миграция строит индекс на пустой таблице. Худший случай = прежнее
поведение, лучший — без лока.
CONCURRENT_INDEXES — канонические определения; drift-guard тест сверяет их с
выражениями в миграциях. Тест раннера: CONCURRENTLY+IF NOT EXISTS, вне
транзакции, best-effort (падение одного не рвёт остальные).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Две дырки свипера временных заметок:
- Не было свипа на старте: заметки, протухшие во время простоя, ждали до часа
до первого тика @Interval. Добавил onApplicationBootstrap (best-effort, не
блокирует boot).
- Гонка без блокировки: re-check и removePage не были атомарны, «Сделать
постоянной» могла проскочить между ними. Теперь re-check идёт в транзакции с
FOR UPDATE SKIP LOCKED: сериализуется с toggleTemporary (тот же row-lock) и
пропускает строки, захваченные другим воркером/инстансом (без двойной
обработки). Удаление идёт ВНУТРИ этой транзакции.
removePage получил опциональный trx: чтобы удалять под блокировкой без deadlock
на вложенной независимой транзакции. Броадкаст PAGE_SOFT_DELETED при переданном
trx отложен на commit через registerAfterCommit (откат больше не рассылает
фантомное удаление); без trx поведение прежнее.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/telemetry/vitals анонимный, а route/attr не сверялись со словарём:
любой мог писать свободный текст в client_metrics (высокая кардинальность,
инъекция текста/PII/разметки).
- route: экспортировал полный словарь шаблонов из клиентского route-template.ts
(KNOWN_ROUTE_TEMPLATES — канонический источник), сервер валидирует по зеркалу
ALLOWED_ROUTE_TEMPLATES: нет в словаре → drop (null), событие остаётся.
- attr: это web-vitals attribution target (CSS-селектор), а не enum — ограничил
консервативным charset CSS-селектора; всё вне набора → drop.
Клиентский self-consistency тест: templateRoute выдаёт ТОЛЬКО значения из
словаря (иначе легитимные метрики отбрасывались бы). Серверные тесты: raw-путь
и инъекция в route отброшены, PII/разметка в attr отброшены.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bustWorkspaceCache звался сразу после write, но ВНУТРИ переданной транзакции
(до коммита). Окно: параллельный читатель промахивается мимо инвалидированного
ключа, читает ещё НЕ закоммиченную (старую) строку и репопулирует кэш старым
значением; после коммита кэш держит устаревшее до TTL (15 c).
Добавил post-commit-хук в executeTx: registerAfterCommit(trx, fn) регистрирует
side-effect, который дренится ТОЛЬКО после коммита транзакции — причём внешним
executeTx, владеющим trx (проброшенный existingTrx срабатывает на настоящей
границе коммита, а не во вложенном вызове). WeakMap по trx — без утечки.
bustWorkspaceCache теперь: без trx — del сразу (уже автокоммит); с trx —
регистрирует del на post-commit. Ошибка хука не валит уже закоммиченный write.
Тест: порядок body→commit→hook, дренаж на внешней границе, глушение падения хука.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MovePageDto.position — это fractional-indexing ключ (generateJitteredKeyBetween,
тот же генератор, что в page.service). @MinLength(5)/@MaxLength(12) не совпадали
с реальным диапазоном генератора: плотные between-вставки в глубоком дереве
растят ключ далеко за 12 символов (замерено >40), и валидный ключ, который
сервер сам сгенерил, отклонялся 400 (Gitea #139, п.6).
Теперь валидируем по charset — base-62 алфавит [0-9A-Za-z] — плюс щедрый
@MaxLength(256) как чистый DoS-guard, сильно выше любого реального ключа.
test.failing (bug-lock) распинен в обычный it; добавлен кейс на отклонение
символов вне алфавита (control/separator/инъекция/пустая строка).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
api_key-принципал стал полноправным REST-принципалом → мог начеканить 24-ч
COLLAB-токен, который AuthenticationExtension не сверял с api_keys, и ревокация
не доставала websocket-редактирование. Блокировать /collab-token для api_key
нельзя (это правки агентов). Решение fail-closed:
- JwtCollabPayload расширен полем principal ('session'|'api_key') + apiKeyId.
generateCollabToken штампует его в КАЖДЫЙ токен: 'api_key'+apiKeyId, когда
чеканит api-key-принципал (внешний MCP-агент), иначе 'session'. Дискриминатор
ключуется на ОРИГИНЕ (наличии api-key), НЕ на actor:'agent' — внутренний
session-backed AI-агент остаётся 'session' и на no-check пути.
- /auth/collab-token читает подписью-выведенные req.raw.authType/apiKeyId
(не тело) и прокидывает origin через getCollabToken → generateCollabToken.
- doAuthenticate: при principal='api_key' на connect прогоняет
ApiKeyService.validate (отозванный ключ → новых collab-подключений нет; инфра
пробрасывается). api_key без apiKeyId — reject. Claimless-токен доверяется
только В ПРЕДЕЛАХ 24-ч grace-окна роллаута (легаси session-токен, api_key его
начеканить не мог); после окна всякий валидный токен обязан нести
дискриминатор, поэтому claimless — баг и отвергается (не молча-доверие 24ч).
CollaborationModule импортирует ApiKeyModule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/mcp-Bearer больше не пинит тип к ACCESS. Новый framework-free примитив
verifyMcpBearer верифицирует подпись ОДИН раз (bindMcpBearerVerifier пинит
allowlist {ACCESS, API_KEY}) и роутит:
- API_KEY → сперва биндинг к воркспейсу инстанса (чужой воркспейс — отказ), затем
общий row-check ApiKeyService.validate. HMAC-верификация (микросекунды) вместо
bcrypt, это НЕ попытка логина — Basic-путь и анти-брутфорс-лимитер не
затрагиваются (фикс удушения параллельных чтений агентов).
- ACCESS → прежний verifyBearerAccess (session-active + not-disabled), которому
передаётся замыкание над уже проверенным payload, так что «проверить один раз»
и «helper без изменений» сосуществуют.
Bearer-нога resolveMcpSessionConfig униформизирована: любой отказ авторизации →
единый generic-401 (анти-enumeration, класс отказа не течёт в тело), а
непредвиденная (инфра) ошибка ПРОБРАСЫВАЕТСЯ (→5xx), не маскируется под 401.
McpService получает ApiKeyService; McpModule импортирует ApiKeyModule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Форк не несёт EE-модуль ee/api-key, поэтому api_key-токен сегодня отвергается на
любом /api/*. Вводим core-модуль:
- ApiKeyRepo (kysely): insert/findById/findByCreator/findAllInWorkspace/
softDelete/touchLastUsed, фильтр deleted_at IS NULL.
- ApiKeyService.create — mint-then-insert: сперва uuid7 id, затем чеканка JWT
без exp, затем строка (сбой чеканки → строки нет; потерянный ответ →
осиротевшая строка видна в list и самолечится). Токен отдаётся один раз, в
таблице не хранится.
- ApiKeyService.validate — единый валидатор для jwt.strategy И /mcp. Владелец
истины о сроке/отзыве — строка api_keys, не JWT. Любой определённый негатив
(нет строки / expired / disabled / workspace mismatch / kill-switch off) →
единый bare-401 (анти-enumeration); инфра-ошибка пробрасывается (→5xx), не
маскируется. Без кэша — немедленная ревокация. last_used_at троттлится 1ч,
fire-and-forget.
- REST create/list/revoke: create — self + UserThrottlerGuard; list/revoke —
admin (CASL Manage на API) видит/отзывает все ключи воркспейса, член — только
свои. api_key-принципал отвергается на всех трёх (токен не управляет
токенами — GitHub-PAT-семантика). Аудит API_KEY_CREATED/DELETED + структурный
лог с apiKeyId и IP.
- jwt.strategy: прямой вызов ApiKeyService.validate вместо динамического
EE-require; штампует req.raw.authType='api_key' и apiKeyId для гварда выше.
- Kill-switch API_KEYS_ENABLED: дефолт ON (деплой без переменной не убивает
агентов), строгий парс @IsIn(['true','false']) (=0/=off падают на boot, а не
молча значат «включено»), boot-лог состояния. off → validate deny и
эндпоинты 404.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Проба доступности алиаса возвращала currentPageId — id страницы, на которую
алиас уже указывает — ЛЮБОМУ аутентифицированному участнику воркспейса без
проверки прав на просмотр этой страницы. Перебором имён алиасов можно было
смапить их на id страниц, к которым доступа нет.
Теперь checkAvailability отдаёт только {alias, valid, available}. Бита
taken/free достаточно для пробы; заголовок целевой страницы всплывает лишь
ПОСЛЕ реальной попытки setAlias (путь 409 ALIAS_REASSIGN_REQUIRED), который
проверяет права. Клиент currentPageId нигде не использовал — убран из типа,
стейта и теста. Серверный спек утверждает отсутствие currentPageId в ответе.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Три хвоста детектора петель (#444):
- Пороги детектора дегенерации теперь конфигурируются из env (по образцу
AI_CHAT_FINAL_STEP_LOCKDOWN): AI_CHAT_DEGENERATION_REPEATED_LINES /
_PERIOD_MAX_LEN / _PERIOD_MIN_REPEATS / _CHECK_STEP. Резолвер читает сырую
строку (пусто = unset → компилируемый дефолт), требует ≥1, иначе безопасный
откат к дефолту (0/отриц. сломал бы детектор). Оператор перенастраивает
анти-babble-guard без редеплоя.
- Единый маркер: при дегенерации live-стрим показывает нейтральное «Response
stopped.» (клиент не отличает от ручного Stop), а персист-баннер после refetch
падал в дженерик. Классифицировал OUTPUT_DEGENERATION_ERROR на клиенте под тот
же заголовок «Response stopped.» + деталь про петлю — live и refetch больше не
расходятся.
- STEP_LIMIT_NO_ANSWER_MARKER: вместо хардкодной русской строки в content —
локаль-нейтральная английская (базовая локаль = i18n-ключ этого репо, читаема
моделью на реплее); не-русские юзеры больше не видят русский текст.
Тесты: env-пороги гоняются против реальных форм повторов с mutation-проверкой
(поднятый checkStep глушит burst; сниженный repeatedLines триггерит короткий
ран); клиентская классификация — новый кейс в error-message.test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Провайдер-фабрики gemini и ollama (chat-путь) шли на глобальном undici-fetch:
без keep-alive recycle, без ретраев на pre-response reset, с дефолтным
(безграничным по паузе) таймаутом. Классы инцидентов #140/#175/#310 для них
воспроизводимы так же, как для openai. Прокинул this.aiProviderFetch (одна
строка на провайдера) — тот же слоёный instrumented streaming fetch, что уже
стоит на openai.
Плюс явно закрепил maxRetries=2 в обоих streamText-вызовах (authenticated и
public-share): совпадает с дефолтом SDK, но фиксирует потолок против дрейфа
дефолта. Арифметика коннектов на ход: (1 + maxRetries=2) × (1 +
AI_STREAM_PRE_RESPONSE_RETRIES) — два слоя ретраев композируются.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TokenService получает примитив type-routing`verifyJwtOneOf(token, allowed[])`:
подпись проверяется один раз, тип обязан входить в явный allowlist, иначе — тот
же generic-throw, что и у verifyJwt. Нужен для /mcp-Bearer, который принимает и
ACCESS, и API_KEY на одном слоте, но без переиспользуемого footgun'а
«verify-and-return-any-type».
generateApiToken теперь чеканит api-key токен БЕЗ клейма exp. Единственный
источник истины о сроке/отзыве ключа — строка api_keys (проверяется на каждом
запросе), не JWT. Общий jwtService зарегистрирован с глобальным
signOptions.expiresIn ('90d'), который мержится в любой sign() — даже
sign(payload, {}) — а {expiresIn: undefined} бросает; поэтому «бессрочный» ключ
через общий signer молча получал бы exp=now+90d. Чеканка идёт через выделенный
signer без expiresIn (issuer 'Docmost' для паритета клеймов). Живой баг
подтверждён эмпирически и покрыт тестом.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ребейз ветки на текущий gitea/develop (устраняет mergeable:false).
Конфликты разрешены вручную:
- editor-atoms.ts: сохранён type-only импорт Editor (сплит-код с develop),
добавлен import type HocuspocusProvider из #370.
- collaboration/constants.ts: оставлен EMBED_DEBOUNCE_MS (embed-дебаунс develop),
убраны более не используемые HISTORY_* (их единственный потребитель — старая
эвристика computeHistoryJob — удалён в #370), добавлены idle-константы и
PageHistoryKind.
- persistence.extension.ts: 3-way смёржены метрики/#348/#402 develop поверх
idle-конвейера #370; computeHistoryJob остаётся idle-версией без остатков.
Миграция переименована 20260705T120000 -> 20260707T120000 (класс #361):
таймстамп был занят perf-indexes на develop; новый строго позже свежайшей
на develop (20260706T120000-search-lookup-trgm). Содержимое не менялось,
внешних ссылок на имя файла нет (Kysely находит по директории).
Документирование (findings 1-2):
- remove-vs-active гонка в idiome remove()->add() enqueuePageHistory: окно,
где отложенная job уходит в active между remove (проглатывается на active) и
add (BullMQ отбрасывает add с существующим jobId); ограничено и
самовосстанавливается (следующий store перевзводит), кроме худшего случая —
гонящий store был ПОСЛЕДНИМ в сессии: хвостовые правки без trailing-снапшота
до следующей правки. Явно указано, почему нельзя «унифицировать» с соседним
embed-дебаунсом (стабильный jobId, без remove).
- допущение single-process у Map idleBurstStart: в памяти процесса, рестарт
collab теряет метки начала всплеска -> непрерывный всплеск через рестарт может
ждать до 2x cap. Ограничено и безопасно.
Тест (finding 3): интеграционный тест idle-конвейера против реального BullMQ
(короткие интервалы через jest.mock констант): непрерывный всплеск в неск. cap
-> периодические idle-снапшоты не реже cap и не по одному на store;
прерывистый всплеск -> ровно один trailing-снапшот. Жёсткий teardown
(force-close + settle), чтобы фоновый BullMQ не влиял на соседние suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The round-1 F3 fix (wrapping the processor's find+save in a locked tx) itself
introduced two regressions:
F7 [CRITICAL] addPageWatchers ran WITHOUT trx inside the tx holding FOR UPDATE on
pages[pageId]. The watcher insert's FK check takes FOR KEY SHARE on the same row,
but on a DIFFERENT pool connection — a true self-deadlock (our tx connection sits
idle-in-transaction awaiting the JS await, the insert connection blocks on the
lock). Now passes trx (addPageWatchers already accepts it and routes it through
insertMany), so the FK lock is taken on the connection that already holds FOR
UPDATE — no self-conflict.
F8 [WARNING] popContributors is a destructive Redis SPOP; the inner catch only
restores on a throw INSIDE the callback. A COMMIT failure throws OUTSIDE it,
rolling the snapshot back while the pop is gone → a retry writes an unattributed
version. Now tracks the popped set and restores it in an outer catch (idempotent
SADD), leaving BullMQ to retry with attribution intact.
F9 [WARNING] The spec asserted saveHistory args with a loosened objectContaining
that stopped verifying trx, and never pinned withLock/trx on findById or the trx
on addPageWatchers — which is exactly why F7 slipped. Restored the exact
saveHistory(trx) assertion and added findById({withLock,trx}) + addPageWatchers
trx assertions (the latter would have caught F7), plus a commit-failure test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 [BLOCKER] persistence-store.spec used Array.prototype.at(-1) (ES2022) but the
server targets ES2021, so server tsc failed (TS2550) and ts-jest could not
compile the suite — 22 core manual-save/idle/boundary tests silently did not run
in CI. Replaced with [length - 1] index access.
F2 [WARNING] The idle burst-reset used a hardcoded IDLE_MAX_WAIT_USER for both
tiers, but computeHistoryJob's ceiling is source-specific. On a continuously
agent-edited page the burst marker stayed stale for 5..10m, forcing delay=0 on
every store and writing one idle row per store — the exact per-store bloat the
debounce prevents. The reset now uses the same source-specific max-wait.
F3 [WARNING] The processor did an unlocked findPageLastHistory -> saveHistory,
which TOCTOU-races a concurrent manual-save (that runs under a page-row lock),
producing two page_history rows with identical content (one idle, one manual) and
defeating promote-not-dup. The snapshot decision is now wrapped in executeTx with
the same page-row lock, so the second writer observes the first's committed row
and the isDeepStrictEqual gate collapses the duplicate.
F4 [WARNING] The risky client filtered-index -> full-list mapping had no tests.
Extracted it to a pure resolvePrevSnapshotId(fullItems, id) helper (diff/restore
baseline against the true previous snapshot in the FULL list, never the previous
visible version) and unit-tested it; removed the now-vestigial index threading.
F5/F6 [low] Renamed the misleading ceiling test + fixed its comment; added a
CHANGELOG entry for the user-facing versioning feature.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR-1 'core' of #370: introduces page_history.kind ('manual'|'agent'|'idle'|
'boundary'; legacy null = autosave) and rebuilds the snapshot triggers around a
three-tier intentionality model. Draft durability (pages/ydoc hocuspocus
autosave) is unchanged; only the frequency and labelling of history points change.
- Migration 20260705T120000: page_history.kind nullable varchar(20), no default.
- Manual Save: one stateless 'save-version' path for human AND agent; kind is
derived SERVER-SIDE from the signed context.actor (never the payload), readOnly
connections rejected, the fresh ydoc runs through the existing store path (no
REST race), then broadcasts version.saved.
- Idle-flush: trailing debounce (one BullMQ job per page, remove-then-readd) with
IDLE_INTERVAL_USER=60m / AGENT=15m AND a max-wait ceiling
(IDLE_MAX_WAIT_USER=10m / AGENT=5m) so a continuous editing session can't starve
the autosnapshot (review round-1 WARNING).
- Boundary: generalized from the user→agent special-case to ANY lastUpdatedSource
transition (user↔agent↔git), same isDeepStrictEqual gate — covers git-sync free.
- Removed the agent delay=0 fast path and the old HISTORY_FAST_* constants; the
agent joins the common idle pipeline.
- Promote-not-dup: a manual save on unchanged content promotes the latest
autosave's kind in place (or no-ops if already manual) instead of duplicating a
heavy content row.
- Client: mod+S hotkey + menu button (hidden when readOnly), history-panel kind
badges, dimmed autosaves, a 'versions only' filter (indices map to the full
list so diff/restore still target the true previous snapshot), live refresh on
version.saved.
Internal review: APPROVE-with-suggestions; the round-1 WARNING (idle starvation)
is fixed here via the max-wait ceiling, and the generalized-boundary + ceiling
behaviours are pinned with new tests (115 collab/repo specs green, server tsc 0).
Deferred to later PRs: shares.published_mode (PR-2), the save_page_version MCP
tool + role prompts (PR-3), actor='git' wiring into #359 (PR-4).
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>
Коммит 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>
DO1: тест audit-swallow был вакуумен (log() — fire-and-forget void persist(),
.not.toThrow() зелен независимо от try/catch). Теперь: после failInsert()
шпионим Logger.warn + слушаем unhandledRejection, флашим микро/макротаски,
ассертим warn=1 и 0 floating-rejection. Mutation: убрать try/catch у persist
→ красный.
DO2: dominant-run тест не отличал longest от first (самый длинный ран был и
первым). Перестроено: короткий plain ран впереди, длинный bold — следом →
ассерт bold:true держится только при genuine longest; +тест tie→first.
Mutation: reduce→segments[0] → красный.
DO3: CHANGELOG [Unreleased] — 3 записи по #496.
DO4: убран no-op override insertAttrs.comment (=dominant.markAttrs, а он и есть
attributes['comment'] — сегменты фильтруются по нему; {...attributes,comment:
attributes.comment}={...attributes}); коммент про 'defensive re-assert' исправлен.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
expectedText брался из debounced REST-снапшота (getAnchoredText по
pages/info), а метка ставилась в live-доке — при расхождении (док ушёл
вперёд за окно дебаунса) apply строго сравнивал текст под меткой с
устаревшим stored selection и давал 409 на каждый вызов.
MCP-клиент теперь в transform-фазе (та же версия live-дока, где ставится
метка) перечитывает фактическую подстроку под меткой и, если она
отличается от сохранённого selection, синкает её через новый эндпоинт
POST /comments/resync-suggestion-anchor. Best-effort: сбой синка не
откатывает уже заякоренный комментарий, а лишь выдаёт мягкое
предупреждение. Совпадающий снапшот не делает лишнего round-trip.
Сервер: resyncSuggestionAnchor правит только stored selection
незаселённой suggestion своего автора (guards: top-level, есть
suggestedText, не applied/resolved, отличается от suggestedText),
идемпотентно, без ws-бродкаста.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deleteEphemeralSuggestion docstring обещал инвариант «метка снимается
FIRST and FATALLY» синхронно — после #399 это уже не так: fatal только
ENQUEUE снятия метки, сама операция идёт в воркере с ретраями. Docstring
переписан под фактическое поведение.
Reconcile: воркер COMMENT_MARK_UPDATE на resolve/unresolve, обнаружив что
строки комментария больше нет (hard-delete гонкой с ephemeral apply/
dismiss), теперь СНИМАЕТ осиротевшую метку вместо тихого return. Это
самозаживляет тихую дивергенцию и закрывает fire-and-forget resolve/
unresolve enqueue из resolveComment. Операция идемпотентна.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
finalizeAppliedSuggestion на ветке «есть ответы» вызывал resolveComment
(бродкаст commentResolved с обогащённой строкой), а затем сам слал ещё и
commentUpdated — клиент получал два события на один apply. Теперь
commentUpdated шлётся только когда resolveComment НЕ вызывался (редкий
повторный вход по уже разрешённому треду), иначе один бродкаст.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
replaceYjsMarkedText вставлял замену только с меткой `{comment}`, молча
теряя bold/italic/code/link исходного run'а. Теперь захватываем полный
набор атрибутов доминирующего (самого длинного) сегмента заменяемого
диапазона и применяем его к вставке: однородное форматирование
сохраняется точно, для смешанного run'а берётся преобладающий стиль
вместо полной потери. Метка комментария при этом гарантированно
сохраняется (переутверждается явно).
Клиентское предупреждение в превью диффа не добавлялось: у клиента нет
марок из документа (только plain selection/suggestedText), а фикс на
сервере уже сохраняет форматирование, так что баннер был бы неточным.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Событие comment.suggestion_applied/dismissed раньше улетало в
NoopAuditService и молча терялось; при childless-ветке apply/dismiss
комментарий hard-delete'ится, поэтому восстановить, кто и что решил,
было невозможно.
- DatabaseAuditService пишет в уже существующую таблицу `audit`
(миграция 20260228T223532); actor/workspace/ip берутся из CLS
AuditContext, вне HTTP — из явного контекста (logWithContext).
Аудит — побочная запись: сбой записи не ломает исходный запрос,
EXCLUDED_AUDIT_EVENTS отбрасываются.
- Биндинг AUDIT_SERVICE переключён с Noop на DatabaseAuditService.
- payload apply/dismiss дополнен suggestedText/selection/commentAuthor/
decidedBy — на childless-ветке это единственная уцелевшая запись.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Нормализация чужого markdown (GFM reference-сноски [^id] -> инлайн ^[body],
срез ведущего YAML front-matter) жила только в apps/server, поэтому MCP-путь
записи страницы (updatePageMarkdown -> markdownToProseMirrorCanonical) тот же
ввод обрабатывал ИНАЧЕ, чем серверный импорт: front-matter и [^id] утекали
как литеральный текст / битая ссылка.
Перенёс normalizeForeignMarkdown в @docmost/prosemirror-markdown и вызвал его
первым шагом в MCP markdownToProseMirrorCanonical — теперь агентский
updatePageMarkdown нормализуется точно как серверный импорт. Серверные
импортёры (import.service, file-import-task.service, page.service) берут
функцию из пакета. Тест-корпус перенесён в пакет (foreign-markdown.test.ts).
Проверено: пакет (17 тестов corpus) + весь MCP unit-suite (685) зелёные,
включая reference-footnote/fence-кейсы на canonical-пути.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retrospective of 22.06-10.07 merges showed one recurring miss class: local
logic verified, integration property never checked (#361, #353, #452, #172,
#435). This lands four gates so each of those classes fails BEFORE the
:develop image is pushed:
1. Image boot-smoke in the publish job (develop.yml + scripts/ci/image-smoke.sh):
the exact image watchtower pulls is booted against postgres/redis services
before the push — /api/health (startup migrator, #361-boot/#353), auth/setup,
client dist served, hashed assets immutable + brotli (#452).
2. migration-order gate now also runs on push (test.yml): direct pushes used to
bypass the PR-only gate; base = event.before, zero-SHA skips, force-push
fails closed.
3. External-MCP tool allowlist fails closed (#172 class): corrupt stored value
now reads as [] (deny-all) with an error log instead of null (allow-all);
[] round-trips as jsonb [] via jsonbBind({preserveEmpty}) and means deny-all
in the toolset filter. The settings form sends null for an empty tag field
so existing "unrestricted" servers are not silently narrowed.
4. Property tests for the silent-degradation classes: converter fixpoint
through the live server path (mcp e2e), and CollabSession cache-key
stability under per-call fresh tokens (#435/#439 lesson) incl. a negative
control with the token cache disabled.
Closes#476
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Коммит 1 (#489) прогоняет загрузку истории + convertToModelMessages ДО insert
user-строки (convert-before-insert, чтобы ретрай не плодил дубли), поэтому
stream() теперь зовёт реальный метод репо findAllByChat перед insert. Хэнд-роллед
мок в тесте «exception after beginRun → settled to error» стабил только insert,
и тест ловил «findAllByChat is not a function» вместо «insert boom».
Порядок инварианта НЕ изменился: и findAllByChat, и insert идут ПОСЛЕ beginRun
(reconcileChat между ними — best-effort, глотает ошибку), так что бросок по-
прежнему происходит ПОСЛЕ начала рана и обязан сеттлить ран в error. Застабил
findAllByChat → [] (реальный репо-метод, см. ai-chat.controller.ts), тест снова
доходит до insert boom и проверяет settle-to-error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MEDIUM (реальная брешь): SHARED_TOOL_WRITE_CLASS — имена ТУЛОВ Docmost, но
mergeNamespaced применял мапу к тулам ЛЮБОГО стороннего MCP-сервера по совпадению
rawName. Сторонний WRITE-тул, названный как Docmost-read (getPage/listPages/…),
наследовал readOnly → авто-ретрай при транспортной ошибке → double-apply (класс
#435). Гарантия «unknown third-party → write → не ретраится» была ЛОЖНОЙ при
коллизии имён.
Фикс: write-class мапа применяется ТОЛЬКО к серверу, про который известно, что
это внутренний Docmost-MCP (isInternalDocmostServer). Сейчас это ВСЕГДА false —
в этом пути нет встроенного/доверенного Docmost-сервера: все ai_mcp_servers суть
сторонние admin-конфиги, а собственные тулы Docmost идут отдельным in-app путём
(docmostTools), не через mcp-clients. Значит НИ ОДИН сторонний тул не получает
readOnly по коллизии и не авто-ретраится (undefined → трактуется как write).
Мапа грузится лениво только если есть доверенный сервер (иначе ESM-импорт
пропускается). isInternalDocmostServer — метод-сим, флипается при появлении
доверенного сервера (kind/isBuiltin-колонка или сконфигурённый self-MCP URL).
LOW: reconnect не наблюдает composed abort во время 5-сек handshake — задокумен-
тировано (окно поздней отмены ≤5s, сокет закрывается на turn-end); проброс
composed в общий CAS-дедуплицированный reconnect намеренно НЕ сделан (отменил бы
реконнект, нужный конкурентному живому вызову).
Тест: сторонний WRITE-тул с именем getPage при транспортной ошибке НЕ ретраится;
mutation-verify — форсирование trusted делает тест красным (чужой getPage ретраится).
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>
Юзер-сообщение персистилось в 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>
Клаузы (a-d) тестировались по отдельности, но сам оркестратор reconcile()
(ПОРЯДОК клауз + пер-клаузная try/catch-изоляция «одна упавшая не блокирует
остальные») не проверялся, а reconcileChat() (старт каждого хода) не был
покрыт вовсе. Добавляю: тест порядка a→b→c→d; тест изоляции (клауза b кидает
— c и d всё равно отрабатывают, reconcile не реджектит); тест reconcileChat
(скоуп по чату, bound 50, failed→error / прочее→aborted).
MUTATION-VERIFY: снятие try/catch у клаузы (b) делает тест изоляции красным
(исключение пробрасывается, c+d пропускаются).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CHANGELOG [Unreleased]: серверный supersede + три кода (SUPERSEDE_INVALID /
SUPERSEDE_TARGET_MISMATCH / SUPERSEDE_TIMEOUT) в Added; смена поведения
(легаси вторая вкладка теперь → 409 A_RUN_ALREADY_ACTIVE вместо второго
параллельного стрима; каждый ход — ран в обоих режимах) в Changed.
- .env.example: три новых AI_CHAT_* (SUPERSEDE_TIMEOUT_MS / RECONCILE_INTERVAL_MS
/ INAPP_TOOL_CALL_CAP_MS, дефолты 10000/120000/120000, связь cap↔staleness).
- AGENTS.md:458: ран теперь универсален (оба режима), флаг autonomousRuns =
только disconnect-семантика; плюс упоминание supersede.
- ai-chat.service.ts:1136: устаревший коммент про «legacy → socket signal» →
оба режима на run-signal, guard runId защищает лишь no-handle fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stream() был единственным эндпоинтом ai-chat без assertOwnedChat: участник
того же воркспейса (НЕ владелец чата) мог послать POST /stream
{chatId:<чужой>, supersede:{runId:<любой>}} и (а) выудить активный runId
жертвы из ответа 409 SUPERSEDE_TARGET_MISMATCH, затем (б) requestStop чужого
рана. Добавляю owner-check в начале stream() (когда есть body.chatId), ровно
как в /stop и соседях — pre-hijack, чистый 403. Это заодно закрывает и
негейченную кросс-юзерную запись через тот же stream() (база #500).
Тест: не-владелец POST /stream с чужим chatId → ForbiddenException,
runId не утёк, supersede/requestStop не вызваны. MUTATION-VERIFY: снятие
assertOwnedChat делает тест красным (возвращается путь утечки).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раньше finalizeAssistant ставил finalized=true ДО записи и не ретраил → один
неудачный UPDATE = строка вечно 'streaming'; свип был boot-only; run-свип
безусловный — асимметрия «run succeeded / message streaming навсегда».
- finalizeAssistant: bounded-ретраи; once-гейт закрывается ТОЛЬКО после успешной
записи; возвращает ok. Правило owner-write: терминальная запись owner'а
условна на status='streaming' OR metadata.finalizeFailed (repo.finalizeOwner) —
перетирает reconcile-штамп, но не проставленный терминал. ВСЕ status-only
штампы reconcile (stampTerminalIfStreaming, sweepStreaming) пишут строго
onlyIfStreaming И мёржат metadata.finalizeFailed:true (иначе поздний owner-write
не перетрёт).
- Порядок: попытка message-finalize → ран финализируется ВСЕГДА; при провале
message onFinish помечает ран 'error' (не 'completed'). Ран не гейтится на
message.
- Периодический reconcile-джоб (setInterval, env-tunable) клаузами по порядку:
(a) пере-драйв зомби; (b) message streaming + ран терминален → штамп по статусу
рана (succeeded-ран + зависшая строка → 'aborted'+finalizeFailed, НЕ
'completed'-empty); (c) run running + НЕТ entry И НЕТ zombie + staleness →
aborted (гейт «нет entry» первичный, staleness от last-progress updatedAt,
X=max(2×per-call cap,15мин)+boot-warn); (d) message streaming + возраст>X + нет
активной run-строки → aborted (двойной гейт). isInterruptResume исключает
finalizeFailed-строки. Оппортунистический одно-чатовый reconcile при старте хода
(best-effort, не фейлит ход). sweepStreaming boot-only → периодический.
Тесты (реальная БД): owner finalizeOwner чистит finalizeFailed; штамп не перетирает
терминал; поздний owner-write перетирает aborted-штамп; клаузы b/c/d (+живой entry
не трогать, двойной гейт d); «убить БД на finish → после восстановления ни строка,
ни ран не застряли». Юниты: finalizeFailed исключает interrupt-resume;
reconcileStaleRuns «нет entry».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раньше в legacy-режиме (дефолт!) гейта конкурентности НЕ было вообще — проверка
409 сидела внутри if (autonomousRuns && chatId): два таба = два параллельных
стрима на один чат (интерливинг истории, падения convertToModelMessages).
Серверная часть (клиентская лестница ретраев — отдельной клиентской итерацией
FSM, см. ниже):
- Run-строка ОБЯЗАТЕЛЬНА в ОБОИХ режимах: legacy тоже проходит beginRun/
finalizeRun (гейт партиального уникального индекса + runId в start-метаданных).
Различие режимов сведено к семантике abort: legacy onClose при дисконнекте
зовёт requestStop(runId) вместо аборта сокет-сигнала (которого streamText уже
не потребляет). Второй таб в ЛЮБОМ режиме → 409 A_RUN_ALREADY_ACTIVE.
- Supersede CAS POST /stream { supersede: { runId: X } } (AiChatRunService
.supersede): валидация X.chatId===body.chatId (иначе 400 SUPERSEDE_INVALID);
нет активного рана → degrade в обычный send; активный ≠ X → 409
SUPERSEDE_TARGET_MISMATCH + текущий runId; активный = X → requestStop →
awaitSettled (таймаут W=10c) → «записано/сдался» (сдался → settleZombie
применяет intended условным UPDATE) → ready; таймаут → 409 SUPERSEDE_TIMEOUT,
ничего не персистится. W обоснован race-on-abort'ом коммита 1; DB-brownout →
TIMEOUT штатен, W не увеличивать (env-tunable).
- Задокументированы ограничения: нет квиесценции сайд-эффектов (в промпт нового
рана добавлена строка SUPERSEDE_NOTE «предыдущий ран прерван, его последние
операции могли примениться с задержкой»); кража слота между освобождением и
beginRun (→ MISMATCH с новым runId, бэкстоп — уникальный индекс).
Тесты: два параллельных старта (оба режима) → строго 409 второму; supersede при
живом долгом ТУЛЕ (не UPDATE-задержке) settle'ится быстро; все CAS-ветки; дубль
supersede-POST → degrade; зомби-путь через settleZombie; HTTP-маппинг ветвей.
Кейс «beginRun ok → insert user-msg упал → ран settled, слот свободен» покрыт
lifecycle-спекой против реального safety-net catch.
КЛИЕНТ (deferred, точно flagged): удаление лестницы SUPERSEDE_RETRY_DELAYS_MS/
isRunAlreadyActive/supersedeRetryRef и переход на supersede-в-body требуют
адопции runId из start-метаданных (сейчас клиент runId не трекает вовсе) — это
и есть та «итерация клиентского FSM», которую данный серверный контракт
разблокирует. Не трогаю фрагильный клиентский FSM без возможности E2E-валидации
в браузере, чтобы не внести непроверяемую регрессию.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раньше give-up-путь finalizeRun ВОССТАНАВЛИВАЛ entry (зомби неотличим от живого
рана), а терминальный runRepo.update был БЕЗУСЛОВНЫМ (последний писатель перетирал
терминальный статус).
- Все терминальные UPDATE ранов теперь УСЛОВНЫЕ: новый repo.finalizeIfActive
пишет только пока строка pending|running (зеркально onlyIfStreaming у сообщений)
→ двойной settle схлопывается в benign no-op, терминальный статус не перетереть.
- При исчерпании ретраев finalizeRun НЕ восстанавливает entry, а оставляет
ЗОМБИ-запись { terminalWriteFailed, intended:{status,error} } в отдельной map;
settleZombie пере-драйвит intended условным UPDATE (зовётся reconcile/supersede/
boot sweep). Зомби удаляется после успешного UPDATE или обнаружения строки уже
терминальной.
- Per-run settledPromise в ОТДЕЛЬНОЙ map runId→deferred (переживает active.delete),
создаётся в beginRun, резолвится ровно один раз исходом (записано/сдался); поздний
подписчик через ранее взятую ссылку получает резолвленный; peekSettled: live
deferred → зомби-синтез → undefined (читать строку). Обе map bounded.
- Задокументирована потеря (single-process): рестарт до пере-драйва → boot sweep
пишет 'aborted' поверх фактического intended — неустранимо в phase 1.
Тесты: юниты give-up-пути (зомби вместо restore + notifier terminalWriteFailed +
settleZombie), двойного settle, позднего подписчика; интеграционные против реальной
Б, что условный finalizeIfActive не перетирает терминал и двойной settle схлопывается.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-app тул-обёртки отбрасывали второй аргумент options с abortSignal — это был
единственный класс тулов без отмены и без wall-clock cap. Контент-мутации идут
через collab-WS (mutatePageContent), не через axios, поэтому «прокинуть signal в
axios» главный write-путь не покрывал.
- Переиспользован race-паттерн wrapToolWithCallTimeout: каждый in-app тул гонится
против композитного сигнала (Stop + per-call cap); на abort — реджект
немедленно, проигравший промис отбрасывается (латентность мс, не сетевой
teardown — от этого зависит таймаут supersede в коммите 3).
- Safe-point проверки сигнала между последовательными вызовами paginateAll и
пре-коммитная проверка в mutatePageContent (+ реентрантный близнец) через
DocmostClient.setToolAbortSignal, который обёртка публикует перед каждым вызовом.
- Per-call cap покрывает весь вызов, env-tunable (AI_CHAT_INAPP_TOOL_CALL_CAP_MS,
дефолт 2 мин).
- Задокументировано ограничение (#487): abort между вызовами многовызовного
write-тула оставляет частично применённую операцию — отмена гарантирует «новый
вызов не стартует», не «запись не доехала».
Тесты (честное свойство «после Stop не стартует новый HTTP/WS-вызов»): юнит
wrapInAppToolWithCap (реджект-на-abort, отсутствие новых вызовов, per-call cap,
публикация сигнала) + mock-HTTP тест реального paginateAll safe-point.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>