Commit Graph

1032 Commits

Author SHA1 Message Date
agent_coder 798a81abfe perf(ai-chat): дельта-эндпоинт поллинга + run-факт в ответе (#491)
Degraded-poll рефетчил ВСЕ страницы infinite-query каждые 2.5 c с полными
parts. Вводим дельта-эндпоинт «строки, изменённые после курсора»:

- POST /ai-chat/messages/delta → { rows, cursor, run: {id,status}|null }.
  Курсор — таймстамп часов БД (now()), клиент эхом возвращает его каждый
  поллинг. Окно перекрытия now()−5s ловит строку, закоммиченную с updatedAt
  чуть раньше момента снятия предыдущего курсора на другом autocommit-
  соединении (одиночные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ
  повторы → контракт: клиентский merge идемпотентен по id (mergeById).
- Run-факт едет В дельте (отдельный /run-поллинг удвоил бы QPS — отвергнуто).
- Все дельта-релевантные записи (message update/finalizeOwner/reconcile/sweep,
  run update/finalizeIfActive/markStopRequested/sweepRunning) стампуют
  updatedAt через SQL now(), а не app-clock new Date(): единая монотонная ось
  курсора, смешанные источники часов были независимым источником пропусков.
- Клиент: сервис-функция getAiChatMessagesDelta + фиксация контракта
  идемпотентности mergeById тестом (свап degraded-поллинга на дельту — в
  коммите 3, вместе с re-seed-путём attach).

Тесты (observable-property на живом Postgres, не моки):
- дельта-семантика + монотонность курсора;
- RACE «коммит позже с updatedAt раньше курсора» — ловится окном перекрытия
  (наивный updatedAt > cursor пропустил бы);
- окно гарантирует повторы → идемпотентный merge;
- updatedAt стампуется часами БД, а не app-clock (фейк системных часов в 2099
  — стамп остаётся на времени БД);
- контроллер: owner-gate, форма ответа, run-факт только {id,status}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder 6dad309b51 fix(ai-chat): ревью #510 — Опция A бюджета + 4 DO ревьюера
Эскалация (владелец) — Опция A: 100k только фолбэк для НЕсконфигурированных
инсталляций; при заданном chatContextWindow бюджет = floor(0.7×window) БЕЗ капа
(бюджетер — защита от брика об контекст-окно, не эконом-лимитер). Спек репиннут
resolveReplayBudget(1_000_000)→700_000.

DO1: агрессивный next-turn recovery ×0.5 вынесен в чистую resolveEffectiveReplayThreshold
+ тест линковки replayOverflow→0.5×бюджет (mutation-verified).
DO2: checkNewComments partial-failure — per-page reject скипается (→null), скан
резолвится, порядок выживших сохранён; тест #7 (mutation-verified).
DO3: ai-chat.write-volume.spec.ts → .int-spec.ts (WAL-гард не бежал НИ в одном
CI-lane) + маппер @docmost/token-estimate в jest-integration.json; реальный WAL
на pg:5432 зелёный (трейс v1 140MB→v2 0.04MB).
DO4: CHANGELOG [Unreleased] по #490.
Follow-up: issue #520 (эскалация агрессивной доли при незаданном окне + малом
реальном контексте).

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:06:24 +03:00
agent_coder b038d96708 perf(ai-chat): snapshotOpenPage fast-path (#490)
snapshotOpenPage делал полный экспорт Markdown + upsert каждый ход. Fast-path: если
снапшот уже существует на ТЕКУЩЕЙ версии страницы (тот же instant updated_at), его
контент уже актуален — пропускаем экспорт+upsert целиком. Ход, не тронувший
открытую страницу (частый случай), больше не делает работы по снапшоту.

Зеркалит read-side fast-path в detectPageChange (sameInstant): оба доверяют, что
правка страницы двигает updated_at. Когда агент/человек ПРАВИЛ страницу этим ходом,
updated_at продвинулся → не совпадает → экспортируем как раньше (правки агента
запекаются в снапшот, инвариант #274 сохранён).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder f5bbfdb2d4 perf(ai-chat): deferred-активация тулов в metadata чата (#490)
Активированный set сбрасывался каждый ход → модель заново гоняла loadTools, чтобы
переактивировать те же тулы (лишний round-trip на каждом ходу). Теперь набор
персистится в metadata чата и сидируется на следующем ходу.

- Миграция: jsonb-колонка metadata на ai_chats (default '{}'); db.d.ts дополнен
  вручную (AiChats.metadata: Generated<Json>).
- seedActivatedTools(metadata, validDeferredNames): читает сохранённый набор,
  ПЕРЕСЕКАЯ с актуальными validDeferredNames — смена allowlist/ролей не воскресит
  несуществующий тул (иначе prepareAgentStep получил бы фантомное активное имя).
  Сид только при deferredEnabled.
- Персист на завершении хода (once-guard, во всех терминальных ветках рядом со
  snapshotTurnEnd): детерминированно отсортированный набор, merge в существующий
  bag (другие ключи сохраняются), запись пропускается если ничего нового не
  активировано (обычный ход не даёт лишней записи).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder 875f6ba9c5 feat(ai-chat): токен-бюджет реплея истории + реактивная ветка (#490)
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат
рано или поздно упирается в контекстное окно и получает провайдерский 400 на
каждом ходу — навсегда (чат «кирпичится»). Бюджетер ограничивает РЕПЛЕЙ (никогда
не мутирует персист — в БД остаётся полная запись), детерминированно и byte-stable
(обрезанный префикс идентичен от хода к ходу → дружелюбно к prompt-cache).

Единый оценщик chars/2.5 (кириллица; chars/4 занижает вдвое) вынесен в shared-пакет
packages/token-estimate; клиентский count-stream-tokens.ts переведён на него ТЕМ ЖЕ
коммитом (два расходящихся оценщика = «бейдж 60%, а бюджет уже режет»).

history-budget.ts (чистый, покрыт тестами):
- resolveReplayBudget(raw): min(100k, 0.7×window) при заданном окне; флэт 100k при
  незаданном (именно эти инсталляции ловят терминальный overflow — warn-лог); 0 =
  явный off-switch. Читается СЫРОЙ chatContextWindow, т.к. parsePositiveInt схлопывает
  0 и unset в undefined (новое поле ResolvedAiConfig.chatContextWindowRaw).
- trimHistoryForReplay: первичный сигнал — провайдерский факт metadata.contextTokens
  прошлого хода; chars-оценка — дельта/раскройка/фолбэк. Порядок: обрезка tool-outputs
  старых ходов (head+tail+маркер) → механическое схлопывание старейших ходов
  (конкатенация, НЕ LLM) → текущий + последние N ходов всегда полные. Пейринг
  tool-call/result сохраняется (схлопывание убирает ОБЕ части).
- isContextOverflowError: классификация провайдерского 400 (статус + паттерны).

Реактивная ветка: превентивная оценка не даёт инварианта (первый переполняющий ход
не имеет usage). onError классифицирует context-overflow → пишет различимую причину
и штампует metadata.replayOverflow; следующий ход бюджетер режет агрессивно
(0.5×), что и раскирпичивает чат. Наблюдаемость: metadata.replayTrimmedToTokens.

ПРИМЕЧАНИЕ по реактивной ветке (форк, требует решения ревьюера): истинный in-turn
re-pipe (перезапуск streamText в тот же ответ) архитектурно несовместим с текущим
пайпом — pipeUIMessageStreamToResponse пишет writeHead СИНХРОННО (подтверждено в
ai@6.0.207), а suite ожидает await stream() c моком, не дёргающим колбэки, — так что
отложенный пайп/ожидание сигнала повесит тесты. Поэтому реализована реактивная
рекавери «классификация → штамп → агрессивный ре-трим на следующем ходу», что даёт
тот же инвариант (чат не кирпичится) без рискованного рефактора стрима.

Тесты (наблюдаемые свойства): объём записи через дельту pg_current_wal_lsn() на живой
gitmost-test-pg вокруг 50-шагового прогона (несжимаемые payload'ы) — trace-колонка
v1=140МБ → v2=0.04МБ (в 3206× меньше), полная строка 289МБ → 140МБ (−51%); dual-shape
не нужен здесь; «окно не задано → бюджет применяется»; реактивная классификация на
реальном 400-шейпе; parity клиент/сервер оценщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder 7741821ee3 perf(ai-chat): кэш compactToolOutput по identity шага (#490)
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>
2026-07-12 04:05:48 +03:00
agent_coder 48a074e0d2 perf(ai-chat): формат трейса tool_calls v2 — outputs только в parts (#490)
Каждый 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>
2026-07-12 04:05:48 +03:00
vvzvlad 17e3b3d882 Merge pull request 'fix(converter): block-escape + attribute contract + dedup mirrors (#493)' (#514) from feat/493-converter into develop
Reviewed-on: #514
2026-07-12 04:04:24 +03:00
vvzvlad 801add63e8 Merge pull request 'fix(ai-chat+security+cache): хвосты стабилизации — транспорт, share-alias, temp-notes, guards (#495)' (#516) from feat/495-tails into develop
Reviewed-on: #516
2026-07-12 04:03:55 +03:00
vvzvlad 7ad072ba2c Merge pull request 'feat(mcp): гарды зеркал реестра + write-целостность (#494)' (#513) from feat/494-mcp-registry-guards into develop
Reviewed-on: #513
2026-07-12 04:03:44 +03:00
agent_coder b041944a23 Merge remote-tracking branch 'gitea/develop' into feat/493-converter
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:28:31 +03:00
agent_coder 401d62119c Merge remote-tracking branch 'gitea/develop' into feat/493-converter
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:23:14 +03:00
agent_coder e3ca2dc1d5 test(db): executeTx — покрыть rollback-ветку after-commit-хука
Ревью #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>
2026-07-12 01:02:13 +03:00
agent_coder f919ced8c9 fix(db): concurrent-pre-build индексов — переопределять f_unaccent ПЕРЕД циклом
Ревью #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>
2026-07-12 01:02:13 +03:00
agent_coder 4109b2ef7f fix(security): share-alias reassign 409 — гейт раскрытия по view-праву (доведение #495 к.4)
Коммит 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>
2026-07-12 01:02:13 +03:00
agent_coder 6b27ff0652 feat(ai,mcp): идентичность прогона реиндекса + брендированные PageId/SlugId (#495 item 14)
Аспект 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>
2026-07-12 01:02:13 +03:00
agent_coder a96ca8e26b perf(db): GIN trigram-индексы строятся CONCURRENTLY вне транзакции
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>
2026-07-12 01:02:13 +03:00
agent_coder f84386f24a fix(temp-notes): свип на старте + FOR UPDATE SKIP LOCKED в транзакции
Две дырки свипера временных заметок:
- Не было свипа на старте: заметки, протухшие во время простоя, ждали до часа
  до первого тика @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>
2026-07-12 01:02:13 +03:00
agent_coder c0c44fddb9 fix(security): client-vitals — whitelist route/attr на анонимном эндпоинте
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>
2026-07-12 01:02:13 +03:00
agent_coder 91e58e3c9f fix(cache): bustWorkspaceCache — после коммита, не внутри транзакции
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>
2026-07-12 01:02:13 +03:00
agent_coder 8d062728e5 fix(page): move-position — валидация charset вместо длины
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>
2026-07-12 01:02:13 +03:00
agent_coder f1cffc2d0f fix(security): share-alias availability не отдаёт currentPageId
Проба доступности алиаса возвращала 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>
2026-07-12 00:53:44 +03:00
agent_coder bb9a6fd765 chore(ai-chat): guard-пороги в env + единый маркер дегенерации
Три хвоста детектора петель (#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>
2026-07-12 00:53:44 +03:00
agent_coder 52763998d3 fix(ai): gemini/ollama через aiStreamingFetch + явный maxRetries
Провайдер-фабрики 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>
2026-07-12 00:50:35 +03:00
agent_coder be433d40f0 refactor(mcp): гарды остальных зеркал реестра — проза, ярлыки, зонды, счётчик (#494)
Коммит 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>
2026-07-11 23:46:37 +03:00
agent_coder e56a05926d fix(mcp): registration-time assert — каждый не-inline спек регистрируем (#494)
Коммит 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>
2026-07-11 23:46:37 +03:00
agent_coder eae7640f30 test(comments): не-вакуумные тесты audit-swallow + dominant-run; CHANGELOG (#512 ревью)
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>
2026-07-11 23:44:27 +03:00
agent_coder 0099ba272d fix(comments): suggestion с вечным 409 (#496)
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>
2026-07-11 23:43:42 +03:00
agent_coder 826bb491ca fix(comments): orphan-anchor reconcile + docstring (#496)
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>
2026-07-11 23:43:42 +03:00
agent_coder 7be49c1280 fix(comments): дедуп двойного WS-broadcast при apply треда с ответами (#496)
finalizeAppliedSuggestion на ветке «есть ответы» вызывал resolveComment
(бродкаст commentResolved с обогащённой строкой), а затем сам слал ещё и
commentUpdated — клиент получал два события на один apply. Теперь
commentUpdated шлётся только когда resolveComment НЕ вызывался (редкий
повторный вход по уже разрешённому треду), иначе один бродкаст.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:43:42 +03:00
agent_coder c7073b62d1 fix(comments): apply не стирает форматирование (#496)
replaceYjsMarkedText вставлял замену только с меткой `{comment}`, молча
теряя bold/italic/code/link исходного run'а. Теперь захватываем полный
набор атрибутов доминирующего (самого длинного) сегмента заменяемого
диапазона и применяем его к вставке: однородное форматирование
сохраняется точно, для смешанного run'а берётся преобладающий стиль
вместо полной потери. Метка комментария при этом гарантированно
сохраняется (переутверждается явно).

Клиентское предупреждение в превью диффа не добавлялось: у клиента нет
марок из документа (только plain selection/suggestedText), а фикс на
сервере уже сохраняет форматирование, так что баннер был бы неточным.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:43:42 +03:00
agent_coder 11b2c55485 feat(audit): DB-backed audit trail вместо Noop (#496)
Событие 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>
2026-07-11 23:43:42 +03:00
agent_coder 9e95412695 refactor(converter): normalizeForeignMarkdown -> в пакет, единый import-boundary
Нормализация чужого 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>
2026-07-11 18:05:47 +03:00
agent_coder 60205398bb test(ai-chat): застабить findAllByChat в lifecycle-моке под convert-before-insert (#489)
Коммит 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>
2026-07-11 08:25:29 +03:00
agent_coder 38a09c5ca1 fix(ai-chat): write-class-ретрай только для доверенного внутреннего Docmost-сервера (#489 ревью)
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>
2026-07-11 08:01:33 +03:00
agent_coder 1b05224b27 fix(ai-chat): MCP-кэш — in-run восстановление, ретраи только для readOnly (#489)
Кэш внешних 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>
2026-07-11 08:01:33 +03:00
agent_coder b50b32bf64 fix(ai-chat): валидация клиентских parts + insert после конвертации (#489)
Юзер-сообщение персистилось в 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>
2026-07-11 08:01:33 +03:00
agent_coder 2cb07ef7fb test(ai-chat): покрыть оркестраторы reconcile()/reconcileChat() (#487, F3)
Клаузы (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>
2026-07-11 07:49:30 +03:00
agent_coder 9ba97b6d2b docs(ai-chat): задокументировать наблюдаемую поверхность #487 (F2)
- 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>
2026-07-11 07:49:14 +03:00
agent_coder 0b9de2c25e fix(ai-chat): owner-gate stream() до supersede — закрыть утечку чужого runId (#487, F1)
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>
2026-07-11 07:49:02 +03:00
agent_coder 002c931c6b feat(ai-chat): ретраи finalizeAssistant + owner-write приоритет + двусторонний reconcile (#487)
Раньше 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>
2026-07-11 07:26:01 +03:00
agent_coder 307ad49324 feat(ai-chat): единый гейт конкурентности + серверный supersede (CAS) (#487)
Раньше в 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>
2026-07-11 07:26:01 +03:00
agent_coder 1031ed1ae8 feat(ai-chat): зомби-механика finalizeRun + settledPromise + условные терминальные UPDATE (#487)
Раньше 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>
2026-07-11 07:26:01 +03:00
agent_coder 3b6634c6be fix(ai-chat): in-app тулы — race-on-abort + safe-points + per-call cap (#487)
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>
2026-07-11 07:26:01 +03:00
agent_coder fdc37de3e8 fix(ai-chat): ревью-раунд #500 — красный серверный сьют + регрессия Redis-health
B1: переименование строки ошибки (#394) не обновило 4 предсуществующих
share-ассерта → полный серверный сьют был красный. Обновлены ассерты в
public-share-chat.spec.ts и public-share-chat-tools.service.spec.ts под
новую классифицированную строку.

B2: /health первая проба после старта врала DOWN при живом Redis
(lazyConnect + enableOfflineQueue:false + maxRetriesPerRequest:1 → первый
ping до открытия сокета). Добавлен ensureConnected() с bounded-таймаутом
перед первым ping; покрывает и путь пересоздания после onModuleDestroy.
Тест UP-с-первой-пробы против реального ioredis (mutation-verified).

Устранён open-handle leak в redis.health.spec (drain ioredis
force-destroy-таймера в teardown; без forceExit) и окно ложного DOWN при
конкурентных пробах (мемоизированный connectingPromise).

B3: комментарий про orphan-чат при провале beginRun (insert до begin).
B4: описание listPages упоминает поле truncated в tree-режиме.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:19:14 +03:00
agent_coder 4a750c1e7f fix(#486): ревью — CHANGELOG + AGENTS.md + два теста
Ре-ревью PR #500 (changes-requested, 4 мелких, все doc/test):

F1 [CHANGELOG] CHANGELOG.md [Unreleased]:
- Breaking Changes: metrics-листенер 0.0.0.0→127.0.0.1 — кросс-контейнерный
  скрейп (docmost:9464) молча умрёт без METRICS_BIND=0.0.0.0 + METRICS_TOKEN
  (миграция в .env.example).
- Security: утечка errorText тулов/провайдера анониму (closes #394);
  /metrics под Bearer (METRICS_TOKEN).
- Fixed: ioredis-утечка в /health; ELK вешал event loop; beginRun-призрак →
  честный 503 A_RUN_BEGIN_FAILED; ai drain-hang.

F2 [AGENTS.md] строка про ai-патч: теперь он несёт ДВА фикса (#184 O(n²)
partialOutput И #486 drain-hang), оба тривайра названы.

F3 [test] metrics.server.spec: добавлен кейс токена ТОЙ ЖЕ длины
(Bearer topsecreX) → 401 — пиннит constant-time сравнение (прежние кейсы
коротили на length-guard, до timingSafeEqual не доходили).

F4 [test] output-degeneration.spec: behavior-тест, гоняющий РЕАЛЬНЫЕ
onChunk/onStepFinish из stream() — длинный чистый шаг → граница → свежий
дегенеративный бёрст → ассерт abortSignal.aborted (было хардкодом
resetWatermark=0, ревёрт правки не краснил).

Мутационные пруфы (non-vacuous): F3 — форс compare→true роняет same-length
кейс (401→200); F4 — ревёрт lastDegenerationCheckLen=0 роняет behavior-тест
(aborted true→false). Оба восстановлены, специи зелёные (34/34).
2026-07-11 07:19:14 +03:00
agent_coder ea7c4d7cd2 fix(#486): ревью — run-race контракт под новую политику + timing-safe metrics-токен
Ревью полной ветки #486 нашло два пункта в моих коммитах (3 и 4):

- BLOCKER (коммит 4): ai-chat.service.run-race.spec.ts (#184 F14) пинил СТАРУЮ
  политику «plain begin() failure → swallow + стрим UNTRACKED» (resolves
  toBeUndefined). Коммит 4 её развернул → тест падал (1 failed/6). Кейс
  ИНВЕРТИРОВАН под новую политику: plain begin-failure теперь REJECTS с 503
  A_RUN_BEGIN_FAILED, до первого байта, user-строка не вставлена, streamText не
  вызван — путь остаётся явно запинён, а не удалён.

- NIT (коммит 3): metrics-токен сравнивался наивным !== (единственный слой auth
  эндпоинта) → тайминг-утечка токена. Заменено на crypto.timingSafeEqual с
  length-guard (разная длина → reject), семантика 401/200 без изменений.

Внесено отдельным fixup-коммитом (rebase -i недоступен в окружении; ветка не
запушена). Тесты: run-race 7/7 + metrics.server 7/7 зелёные.
2026-07-11 07:19:14 +03:00
agent_coder 255024fbe2 chore(mcp): McpService.onModuleDestroy → destroyAllSessions + удаление мёртвого кода (#486)
onModuleDestroy чистил только sweep-таймер; loopback-collab-сессии держали доки
открытыми на collab-сервере до идла — рестарт мог гонять с доком, запиненным
умирающим воркером. Теперь дергает destroyAllSessions() через переопределяемый
шов (для юнит-теста без ESM-пакета), best-effort. Плюс удалён мёртвый код:
неиспользуемый импорт parseNodeArg в mcp/index.ts и мёртвые enum-члены
SEARCH_REMOVE_* в queue.constants (подтверждено grep'ом — ни диспетчера, ни
процессора).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:19:14 +03:00
agent_coder 14da295185 fix(auth): провенанс для API-key пути (#486)
validateApiKey возвращал результат до резолва провенанса — REST-записи по
is_agent API-ключу не получали маркер 'agent'. Перенести «выше» нельзя: payload
API-ключа не несёт подписанный actor-клейм, а user (с isAgent) неизвестен до
валидации ключа. Резолвим провенанс от возвращённого user: isAgent -> 'agent',
иначе 'user'; aiChatId у API-ключа всегда null (нет ai_chats-строки). Загрузка
EE ApiKeyService вынесена в переопределяемый шов resolveApiKeyService для юнит-
теста без EE-бандла.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:19:14 +03:00
agent_coder 8ebdfe156f fix(ai-chat): сброс lastDegenerationCheckLen в onStepFinish (#486)
onStepFinish обнулял inProgressText, но НЕ lastDegenerationCheckLen — а это
смещение В аккумулятор. После первого длинного шага протухшая (большая) отметка
делала throttle-условие отрицательным, и детектор токен-лупов молчал весь
следующий шаг, пока текст не перерастал старую отметку. Обнуляем отметку в
onStepFinish. Throttle-предикат вынесен в output-degeneration
(shouldCheckDegeneration + DEGENERATION_CHECK_STEP), чтобы юнит гонял ту же
логику, что и стрим.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:19:14 +03:00