fix: стабилизация итерация 1 — прод-пожары и безопасность (13 правок) (#486, closes #394) #500

Open
agent_coder wants to merge 15 commits from fix/486-iter1-prod-fires into develop
Collaborator

Summary

Итерация 1 зонтика стабилизации #497прод-пожары и безопасность. Один PR, каждая правка отдельным коммитом (13 фиче-коммитов + 1 fixup по ревью), все независимы. closes #486, closes #394

# Коммит Что
1 fix(mcp) ELK-лейаут в worker_thread — синхронный elkjs у капа 500 узлов вешал ВЕСЬ NestJS (Promise.race+setTimeout — иллюзия при заблокированном loop). Теперь elk.layout() в worker_thread, таймаут через worker.terminate(); воркер не течёт (terminate на любом исходе), при реджекте — best-effort исходный XML.
2 fix(health) утечка ioredis в /healthnew Redis на каждую пробу, disconnect только на success → при лежащем Redis N реконнектящихся клиентов. Один долгоживущий lazy-клиент + onModuleDestroy. Интеграционный тест: N проб = 1 клиент.
3 fix(metrics) bind 127.0.0.1 + METRICS_BIND/METRICS_TOKEN — было 0.0.0.0 без auth. Дефолт loopback, опц. Bearer (constant-time сравнение).
4 fix(ai-chat) A_RUN_BEGIN_FAILED — провал beginRun (кроме 409) продолжал ход БЕЗ run-строки → неостанавливаемый невидимый ран в autonomous. Теперь честный 503 A_RUN_BEGIN_FAILED ДО первого байта и user-строки; клиентский классификатор строго до generic-503.
5 fix(share) errorText тулов/провайдера не утекает анониму (closes #394) — 3 слоя: showErrors-гейт рендера, sanitize tool-execute в forShare, классифицированный anon onError. Полный текст — только в лог. Тест по СЫРЫМ SSE-байтам (не DOM): секрет/baseUrl/модель/стек отсутствуют.
6 fix(ai) drain-hang в writeToServerResponse — расширен patches/ai@6.0.134.patch: Promise.race(drain/close/error) с гигиеной once-слушателей, reader.cancel() на close/error. Оба dist пропатчены, patch_hash в lock перегенерён. Трипвайр-спека в apps/server.
7 fix(mcp) REGISTRY_STAMP хэширует всё src/** (не только tool-specs.ts), симметрично с loader; исключение *.generated.ts.
8 fix(mcp) сброс collab-токена на Hocuspocus onAuthenticationFailed + один ретрай forceRefresh (было — до 5 мин падающих мутаций).
9 fix(ai-chat) сброс lastDegenerationCheckLen в onStepFinish (детектор токен-лупов молчал после длинного шага).
10 fix(client) flushNext в onFinish под mountedRef (призрачные отправки после смены чата мид-стрим).
11 fix(auth) провенанс для API-key пути (isAgent→'agent').
12 fix(mcp) проброс truncated в tree-mode listPages.
13 chore(mcp) McpService.onModuleDestroy → destroyAllSessions + удаление мёртвого кода.

How verified

  • mcp node --test 808/808, tsc чисто.
  • server новые/затронутые спеки #486 (health, metrics, begin-run-failure, output-degeneration, share error-leak, loader, jwt.strategy, ai-sdk-drain-hang.patch, mcp.service, run-race) — зелёные; каждая правка со своим тестом (observable-property: реальный refused-port для redis, реальный сокет для metrics, СЫРЫЕ SSE-байты для share-утечки, физически пропатченный dist для drain).
  • client chat-thread 44/44.
  • Внутренний цикл: 1 проход (адверсарно, эмпирические пробы на share-утечку по сырым байтам и на применение патча). Поймал 1 блокер (коммит 4 разворачивал политику, но не обновил старый контракт-тест run-race → красный CI) + 1 нит (не-constant-time сравнение токена metrics) — оба закрыты fixup-коммитом (run-race 7/7, metrics 7/7).

Предсуществующее (НЕ этот PR, воспроизводится на develop): server tsc import.meta в drawio-* (пакет mcp собирается чисто своим билдом), client coverage-gate 50.9%<53%, и editor-ext @tiptap/core-цепочка в паре share-спек (коммит 5 её обходит jest.mock).

Checklist

  • 13 независимых прод-фиксов, каждый отдельным коммитом с тестом
  • #394 закрыт (утечка errorText анониму) — проверено по сырым SSE-байтам
  • вне scope ничего (выравнивание версии ai — #495; итерации 2-11 — отдельные PR)
## Summary Итерация 1 зонтика стабилизации #497 — **прод-пожары и безопасность**. Один PR, каждая правка отдельным коммитом (13 фиче-коммитов + 1 fixup по ревью), все независимы. closes #486, closes #394 | # | Коммит | Что | |---|---|---| | 1 | `fix(mcp)` | **ELK-лейаут в worker_thread** — синхронный elkjs у капа 500 узлов вешал ВЕСЬ NestJS (Promise.race+setTimeout — иллюзия при заблокированном loop). Теперь elk.layout() в worker_thread, таймаут через `worker.terminate()`; воркер не течёт (terminate на любом исходе), при реджекте — best-effort исходный XML. | | 2 | `fix(health)` | **утечка ioredis в /health** — `new Redis` на каждую пробу, disconnect только на success → при лежащем Redis N реконнектящихся клиентов. Один долгоживущий lazy-клиент + onModuleDestroy. Интеграционный тест: N проб = 1 клиент. | | 3 | `fix(metrics)` | **bind 127.0.0.1 + METRICS_BIND/METRICS_TOKEN** — было `0.0.0.0` без auth. Дефолт loopback, опц. Bearer (constant-time сравнение). | | 4 | `fix(ai-chat)` | **A_RUN_BEGIN_FAILED** — провал beginRun (кроме 409) продолжал ход БЕЗ run-строки → неостанавливаемый невидимый ран в autonomous. Теперь честный 503 `A_RUN_BEGIN_FAILED` ДО первого байта и user-строки; клиентский классификатор строго до generic-503. | | 5 | `fix(share)` | **errorText тулов/провайдера не утекает анониму (closes #394)** — 3 слоя: showErrors-гейт рендера, sanitize tool-execute в forShare, классифицированный anon onError. Полный текст — только в лог. Тест по СЫРЫМ SSE-байтам (не DOM): секрет/baseUrl/модель/стек отсутствуют. | | 6 | `fix(ai)` | **drain-hang в writeToServerResponse** — расширен `patches/ai@6.0.134.patch`: `Promise.race(drain/close/error)` с гигиеной once-слушателей, `reader.cancel()` на close/error. Оба dist пропатчены, patch_hash в lock перегенерён. Трипвайр-спека в apps/server. | | 7 | `fix(mcp)` | REGISTRY_STAMP хэширует всё `src/**` (не только tool-specs.ts), симметрично с loader; исключение `*.generated.ts`. | | 8 | `fix(mcp)` | сброс collab-токена на Hocuspocus onAuthenticationFailed + один ретрай forceRefresh (было — до 5 мин падающих мутаций). | | 9 | `fix(ai-chat)` | сброс `lastDegenerationCheckLen` в onStepFinish (детектор токен-лупов молчал после длинного шага). | | 10 | `fix(client)` | `flushNext` в onFinish под `mountedRef` (призрачные отправки после смены чата мид-стрим). | | 11 | `fix(auth)` | провенанс для API-key пути (isAgent→'agent'). | | 12 | `fix(mcp)` | проброс `truncated` в tree-mode listPages. | | 13 | `chore(mcp)` | McpService.onModuleDestroy → destroyAllSessions + удаление мёртвого кода. | ## How verified - **mcp** node --test **808/808**, tsc чисто. - **server** новые/затронутые спеки #486 (health, metrics, begin-run-failure, output-degeneration, share error-leak, loader, jwt.strategy, ai-sdk-drain-hang.patch, mcp.service, run-race) — зелёные; каждая правка со своим тестом (observable-property: реальный refused-port для redis, реальный сокет для metrics, СЫРЫЕ SSE-байты для share-утечки, физически пропатченный dist для drain). - **client** chat-thread **44/44**. - Внутренний цикл: 1 проход (адверсарно, эмпирические пробы на share-утечку по сырым байтам и на применение патча). Поймал 1 блокер (коммит 4 разворачивал политику, но не обновил старый контракт-тест run-race → красный CI) + 1 нит (не-constant-time сравнение токена metrics) — оба закрыты fixup-коммитом (run-race 7/7, metrics 7/7). > Предсуществующее (НЕ этот PR, воспроизводится на develop): server `tsc import.meta` в drawio-* (пакет mcp собирается чисто своим билдом), client coverage-gate 50.9%<53%, и editor-ext `@tiptap/core`-цепочка в паре share-спек (коммит 5 её обходит jest.mock). ## Checklist - [x] 13 независимых прод-фиксов, каждый отдельным коммитом с тестом - [x] #394 закрыт (утечка errorText анониму) — проверено по сырым SSE-байтам - [x] вне scope ничего (выравнивание версии ai — #495; итерации 2-11 — отдельные PR)
agent_coder added 14 commits 2026-07-11 03:56:25 +03:00
elkjs.layout() возвращает Promise, но саму раскладку крутит СИНХРОННО и
блокирует поток целиком. На in-app хосте это был главный event loop:
патологический граф у капа 500 узлов вешал ВСЕ HTTP/SSE/loopback. Прежняя
защита (Promise.race с setTimeout(5s)) была иллюзией — таймер физически не
мог сработать, пока тот же поток заблокирован внутри elkjs (комментарий в
коде это сам признавал).

Теперь elk.layout() исполняется в worker_thread, а таймаут форсится
worker.terminate() — единственный способ прервать синхронный JS. Главный
поток остаётся свободным; на таймауте/ошибке — best-effort откат к
исходной модели, как и раньше. Лживый комментарий «can never wedge the
server» убран.

Тесты: unit на terminate-по-таймауту (крошечный ceiling → hard-kill →
исходная модель нетронута) и бенчмарк-гард на worst-case графе у капа
(500 узлов/~1000 рёбер раскладывается, а главный event loop продолжает
тикать во время раскладки).
pingCheck строил new Redis(...) на КАЖДЫЙ вызов и делал disconnect() только
на success-пути. Пока Redis лежит, каждый тик health-пробы добавлял свежий,
вечно реконнектящийся клиент — неограниченный рост хэндлов/клиентов на всё
время недоступности Redis.

Теперь один долгоживущий probe-клиент, переиспользуемый между тиками:
lazyConnect (конструктор не бросает и не коннектится жадно),
maxRetriesPerRequest: 1 и enableOfflineQueue: false (проба фейлится быстро,
команды не буферизуются), плюс listener на 'error' (иначе unhandled error
роняет процесс). onModuleDestroy закрывает клиент при shutdown.

Тест: интеграционный — N проб при лежащем Redis (реальный refused-порт, не
мок поведения) создают РОВНО ОДИН клиент (на баге было бы N); onModuleDestroy
освобождает клиент, следующая проба лениво строит новый.
/metrics слушал на 0.0.0.0 без какой-либо аутентификации — auth-less
эндпоинт на всех интерфейсах. Теперь дефолтный bind — loopback 127.0.0.1;
env METRICS_BIND переопределяет интерфейс (0.0.0.0 для скрейпера в
отдельном контейнере, docmost:9464); опциональный METRICS_TOKEN включает
Bearer-аутентификацию (запросы без точного токена получают 401). Доки
скрейпа в .env.example обновлены.

Тест: unit на дефолтный bind + env-переопределение + резолв токена;
интеграционный по РЕАЛЬНОМУ сокету — listener забинден на 127.0.0.1,
без токена /metrics отдаётся, с токеном без/с неверным Bearer → 401, с
верным → 200.
Раньше провал beginRun (кроме unique-violation), напр. blip пула БД, логировался
и ход ПРОДОЛЖАЛСЯ без run-строки. В autonomous такой ран никто не абортит: /stop
его не видит, дисконнект не абортит, one-run-гейт пропускает ВТОРОЙ ран — невидимый
неостанавливаемый ран до рестарта.

Теперь провал beginRun (кроме RunAlreadyActiveError → прежний 409) бросает
ServiceUnavailableException с кодом A_RUN_BEGIN_FAILED ДО первого байта и до
вставки user-строки (post-hijack catch контроллера отдаёт честный 503 на raw-
сокет). Без ветвления по режимам — #487 наследует ту же политику. В тело кладём
statusCode: 503 (object-arg исключение его не добавляет), чтобы клиент видел
статус.

Клиентский классификатор: ветка A_RUN_BEGIN_FAILED добавлена СТРОГО ДО generic-
503-матча — иначе показал бы «provider is not configured» вместо «временно,
повторите».

Тесты: unit fail-fast (stream() бросает 503 A_RUN_BEGIN_FAILED, ни байта в сокет,
user-строка не вставлена; RunAlreadyActiveError по-прежнему 409); unit клиентского
классификатора из ПОЛНОГО реального тела ответа с гвардом порядка.
SECURITY. В публичном share-чате сырой текст ошибки тула или провайдера утекал
анонимному читателю. Три слоя, все обязательны:

(1) Рендер-гейт: prop showErrors в ToolCallCard (протянут через MessageList/
MessageItem), share-виджет передаёт false — сырой errorText не рисуется. Но
рендер-гейт маскирует только DOM, не байты.

(2) Санитизация на уровне share-тулсета (авторитетно): forShare оборачивает
execute каждого тула catch'ем. Своя ShareToolError (безопасные строки: «page not
available in this share») пробрасывается, ЛЮБАЯ другая ошибка → generic «tool
could not complete», полный текст только в серверный лог. Одно место закрывает
байты (атомарный tool-output-error фрейм v6), рендер и контекст модели; self-
correction сохранена.

(3) Анонимный onError пайпа: ShareToolError → её безопасное сообщение; иначе
describeProviderError (statusCode + тело: внутренний baseUrl/модель) только в лог,
читателю — фиксированная классифицированная строка (rate-limited/unavailable/
provider error).

Тест: интеграционный с РЕАЛЬНЫМ падением тула и провайдера — assert по СЫРЫМ SSE-
БАЙТАМ (не по DOM): секрет/baseUrl/стек отсутствуют, видна безопасная строка,
полный текст провайдера ушёл в серверный лог.
Серверная ai@6.0.134 в writeToServerResponse при backpressure (write()===false)
ждала ТОЛЬКО once('drain'). Если клиент отвалился мид-запись, сокет не дренится,
await не резолвится: read-цикл паркуется НАВСЕГДА, finally{response.end()}
недостижим, reader и буферы висят до рестарта. В autonomous ран продолжает лить
вывод после дисконнекта → КАЖДЫЙ дисконнект мид-ран оставляет висящий пайп.
Плюс read() — fire-and-forget с throw → unhandledRejection.

Патч расширен (index.js и index.mjs): Promise.race drain/close/error с гигиеной
once-слушателей (все три снимаются на первом settle — не копятся по одному на
stall); при close/error — reader.cancel() и выход (безопасно для detached-ранов:
независимый дренаж делает consumeStream); rejection read() поглощается с логом.
pnpm-lock patch_hash перегенерён (patch-commit). Выравнивание версии ai —
отдельный коммит (#495), не здесь.

Тест: трипвайр-спека в apps/server (только там резолвится патченная копия) по
образцу ai-sdk-partial-output.patch.spec — дисконнект мид-запись без drain
завершает ответ (end() вызван, reader не висит), read()-throw не даёт
unhandledRejection, оба dist-билда несут маркер PATCH(docmost #486).
Стамп детектил build/src-skew только по tool-specs.ts — правка client.ts,
client/*-модуля, comment-signal или drawio-* без пересборки проходила молча,
и build/ отдавал старый код. Теперь codegen и рантайм-лоадер хэшируют весь
src/**/*.ts (кроме *.generated.ts — иначе цикл через собственный выход),
симметрично: одинаковый обход, POSIX-сортировка, нормализация и sha256.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Кэш collab-токена (#435) инвалидировался только на HTTP-401/403 (REST-
интерцептор и login()); отклонённый Hocuspocus-handshake оставлял протухший
токен в кэше — каждая последующая мутация переотправляла тот же битый токен до
истечения TTL (минуты) без self-heal. collab-session помечает ошибку
onAuthenticationFailed маркером; клиентские write-швы (mutatePage/replacePage/
mutateLiveContentUnlocked) обёрнуты в writeWithCollabAuthRetry: на помеченной
ошибке кэш сбрасывается и запись ретраится ровно один раз со свежим токеном —
симметрично HTTP-пути.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Финальный onFinish->flushNext() не проверял live-mount флаг. Чистый onFinish
может прийти ПОСЛЕ анмаунта треда (New-chat / переключение чата мид-стрим —
асинхронные attach/resume оседают поздно): flush дергал очередь и re-POST'ил
сообщение из брошенного треда — «призрачные» отправки/чаты-призраки. Остальные
обращения к очереди уже гейтятся mountedRef; закрываем последнюю дыру.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
listPages(tree:true) деструктурировал только pages из enumerateSpacePages и
возвращал голое дерево, теряя truncated — на неполном дереве (stdio-fallback BFS
упёрся в node-cap) вызывающий не знал, что страницы потеряны. Возвращаем
{ tree, truncated } (по образцу check_new_comments); основной /pages/tree путь
беспредельный, там false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Ревью полной ветки #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 зелёные.
Author
Collaborator

Готово к ревью. Итерация 1 эпика #497 — 13 независимых прод-фиксов + безопасность, каждый отдельным коммитом с тестом. Детали и таблица коммитов в теле PR. closes #486, closes #394.

Ключевое: ELK больше не вешает NestJS (worker_thread + terminate), утечка ioredis в /health закрыта, metrics по умолчанию на loopback + Bearer, провал beginRun даёт честный 503 вместо неостанавливаемого невидимого рана, и — #394 — errorText тулов/провайдера больше не течёт анониму в публичном шэре (3 слоя, проверено по СЫРЫМ SSE-байтам). Плюс drain-hang патч ai (оба dist, patch_hash перегенерён) и пакет мелочей.

Внутренний цикл: 1 проход (адверсарно, с эмпирическими пробами — сырые SSE-байты на share-утечку, физическая проверка применения патча в сторе). Поймал 1 блокер: коммит 4 намеренно развернул политику «провал beginRun → молча стримить untracked», но старый контракт-тест run-race (#184 F14) это закреплял → красный CI. Инвертировал тест на новую политику (reject 503 A_RUN_BEGIN_FAILED, insert/streamText не зовутся), не удалил. Плюс нит — timing-safe сравнение metrics-токена. Оба закрыты fixup-коммитом; run-race 7/7, metrics 7/7.

Замечание для ревьюера: server tsc import.meta в drawio-* и client coverage-gate 50.9%<53% — предсуществующие, воспроизводятся на develop, не этот PR (пакет mcp собирается чисто своим билдом).

Готово к ревью. Итерация 1 эпика #497 — 13 независимых прод-фиксов + безопасность, каждый отдельным коммитом с тестом. Детали и таблица коммитов в теле PR. closes #486, closes #394. Ключевое: ELK больше не вешает NestJS (worker_thread + terminate), утечка ioredis в /health закрыта, metrics по умолчанию на loopback + Bearer, провал beginRun даёт честный 503 вместо неостанавливаемого невидимого рана, и — #394 — errorText тулов/провайдера больше не течёт анониму в публичном шэре (3 слоя, проверено по СЫРЫМ SSE-байтам). Плюс drain-hang патч ai (оба dist, patch_hash перегенерён) и пакет мелочей. Внутренний цикл: 1 проход (адверсарно, с эмпирическими пробами — сырые SSE-байты на share-утечку, физическая проверка применения патча в сторе). Поймал 1 блокер: коммит 4 намеренно развернул политику «провал beginRun → молча стримить untracked», но старый контракт-тест run-race (#184 F14) это закреплял → красный CI. Инвертировал тест на новую политику (reject 503 A_RUN_BEGIN_FAILED, insert/streamText не зовутся), не удалил. Плюс нит — timing-safe сравнение metrics-токена. Оба закрыты fixup-коммитом; run-race 7/7, metrics 7/7. Замечание для ревьюера: server `tsc import.meta` в drawio-* и client coverage-gate 50.9%<53% — предсуществующие, воспроизводятся на develop, не этот PR (пакет mcp собирается чисто своим билдом).
agent_coder added the review/needs label 2026-07-11 03:56:49 +03:00
Collaborator

Ревью #500 — стабилизация, итерация 1 (13 правок). Вердикт: changes-requested (4 мелких)

Крепкая работа: все 13 прод-фиксов корректны, безопасность — fail-CLOSED, объективка зелёная. 11 из 13 покрыты несущими тестами. Блокируют только мелочи: CHANGELOG, устаревшая строка в AGENTS.md и два теста, которые не ловят ревёрт своей же правки. Плюс важное операционное: #3 (metrics bind) молча ломает кросс-контейнерный скрейп — детали ниже, нужно согласовать деплой (написал в ТГ отдельно).

Открыто: F1 (CHANGELOG), F2 (AGENTS.md про ai-патч), F3 (тест metrics same-length токена), F4 (тест reset-детектора деградации).

🔧 Сделать, потом ре-ревью

  1. F1 [documentation] Нет записи в CHANGELOG — а тут БРЕЙКИНГ деплой-change + 2 security-фикса. CHANGELOG.md [Unreleased]. Репо ведёт Keep-a-Changelog с живым [Unreleased] (Breaking/Security/Fixed по иссью). #500 несёт:
    • Breaking Changes: metrics-листенер дефолт 0.0.0.0127.0.0.1. Кто скрейпит метрики из другого контейнера (docmost:9464) — ДОЛЖЕН выставить METRICS_BIND=0.0.0.0 + METRICS_TOKEN, иначе метрики молча умрут (см. миграцию в .env.example). Это ровно та топология, под которую был старый хардкод 0.0.0.0.
    • Security: утечка errorText тулов/провайдера анониму (closes #394); неаутентифицированный /metrics теперь под Bearer (METRICS_TOKEN).
    • Fixed: ioredis-утечка в /health; ELK-лейаут вешал event loop; beginRun-призрак-ран → честный 503; ai drain-hang/OOM.
  2. F2 [documentation] AGENTS.md:473 устарел про ai-патч. Патч patches/ai@6.0.134.patch теперь чинит И #184 (O(n²) partialOutput), И новый #486 drain-hang, но AGENTS.md называет только #184-фикс и только тривайр ai-sdk-partial-output.patch.spec.ts. Добавить #486 drain-hang + его тривайр ai-sdk-drain-hang.patch.spec.ts.
  3. F3 [test-coverage] metrics-auth: нет кейса «токен той же длины, но неверный» — constant-time сравнение не запиннено. metrics.server.spec.ts:118-139 тестит только Bearer nope (len 11) vs Bearer topsecret (len 16) → короткое замыкание на length-guard, до timingSafeEqual не доходит. Регрессия, ломающая байтовое сравнение (напр. timingSafeEqual→true), НЕ покраснеет — токен той же длины пройдёт. Соседний mcp sharedTokenMatches тест это покрывает. Добавить кейс Bearer topsecreX → 401. (Полный bypass — no-auth→401 — покрыт, поэтому low.)
  4. F4 [test-coverage] #9: сам фикс reset'а детектора деградации не покрыт — ревёрт не краснит. output-degeneration.spec.ts доказывает «если watermark=0, чек пере-взводится», но хардкодит resetWatermark=0; никто не ассертит, что onStepFinish реально зануляет lastDegenerationCheckLen (ai-chat.service.ts:1307 — сама правка #486). Ревёрт строки 1307 не уронит ни один тест (тот же класс, что вхолостую-тесты недавно). Добавить behavior-тест: длинный первый шаг → граница шага → свежий короткий дегенеративный бёрст → ассертить, что abort срабатывает.
Что проверено (9 аспектов + гейт)
  • Гейт (чистая комната): ai-патч применён (6 lock-refs, patch_hash перегенерён), mcp tsc чист, mcp node --test 809/809 (drawio-worker, collab-reset, tree-truncated, registry-stamp, pagination), серверный jest 156/156 по 10 новым спекам (вкл. обе security-спеки: error-leak + metrics). Серверный tsc: единственный ред — import.meta в новом drawio-layout.ts:102 — легитимный import.meta.url для worker-пути, унаследованный не-CI-класс (#472); mcp-сборка чиста, worker собирается в build/lib/drawio-layout.worker.js и реально крутится в node --test.
  • Security (fail-CLOSED, подтверждено): #3 loopback-дефолт + constant-time Bearer (non-string header→401, пустой токен→unset); #5 wrapToolErrors схлопывает любую НЕклассифицированную ошибку в generic (не сырой текст), classifyAnonStreamError отдаёт только 3 фикс-строки, RAW-SSE спека ассертит отсутствие secret/baseUrl/stack; #11 provenance из server-side user.isAgent (неподделываем), гейтит только write-атрибуцию, не авторизацию. Ноль секретов в диффе, ноль новых unauth-эндпоинтов, wildcard-bind убран.
  • Stability: #1 worker — все выходы (message/error/exit/timeout) через settled-guarded finish()terminate(), утечки нет, таймаут теперь реально срабатывает (таймер на main-треде); #4 503 ДО первого байта и user-строки (нет осиротевшего рана), run-race спека инвертирована в негативный ассерт; #2/#6/#8/#9/#10/#12/#13 — все sound.
  • Regressions/Coherence: #1 output-эквивалентность (тот же elkjs, тот же граф, только off-thread); 13 правок независимы и по-отдельности ревёртятся; #7 stamp↔loader байт-в-байт идентичны (10/10); root-causes #3/#4/#5 адресованы; #13 удаляет реально мёртвый код (SEARCH_REMOVE_* enums, parseNodeArg).
DROP — кодеру НЕ делать · калибровка (оператору)
  • [escalate→collapsed-to-DO+ops] medium [regressions/security] #3 metrics-bind — код верен, миграция в .env.example, документируется в F1; операционное согласование деплоя (METRICS_BIND=0.0.0.0+METRICS_TOKEN в lockstep) — на владельце, вынес в ТГ.
  • [below-threshold] suggestion [conventions/simplification] bearerMatches дублирует существующий sharedTokenMatches (mcp-auth.helpers.ts:262) — локальная копия защитима (кросс-интеграционный импорт), но осознанно.
  • [below-threshold] suggestion [conventions] METRICS_BIND vs репо-конвенция HOST (main.ts) — параллель PORT/HOST → METRICS_HOST; косметика.
  • [below-threshold] low [test-coverage] fail-open/неклассифицированная ветка classifyAnonStreamError не тестится — структурно не течёт (фикс-строки без интерполяции).
  • [below-threshold] low [stability] ELK: воркер переразбирает elk.bundled (~0.5-1МБ) на каждый вызов → +100-200мс к каждому drawio-лейауту (был sub-ms in-process). Приемлемо для agent-facing тула; size-threshold (мелкие графы inline) убрал бы налог, если латентность важна.
  • [accepted] low [stability] один воркер на конкурентный лейаут без капа — тот же accepted-OOM класс, что и раны.
  • [below-threshold] low [stability/regressions] первая /health-проба может блокировать ~connectTimeout при лежащем Redis (последующие — fast-fail); health ДОЛЖЕН отражать down — скорее корректнее.
  • [below-threshold] low [regressions] #12 {tree,truncated} меняет форму вывода deprecated-тула listPages(tree:true) — типизированных потребителей нет, модель адаптируется.
  • [below-threshold] low [coherence] #6 drain-hang зависит от применения патча при pnpm install — тёплый стор может не переприменить; спека самозащитная (краснеет в CI на unpatched dist); нужен чистый install в CI.

9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence) + гейт (mcp 809, server 156, обе security-спеки non-vacuous). 13 фиксов корректны и fail-closed; блокеры — CHANGELOG + один устаревший doc-пункт + два теста, не ловящих ревёрт своей правки. После правок + подтверждения по деплою метрик — approve.

## Ревью #500 — стабилизация, итерация 1 (13 правок). Вердикт: **changes-requested** (4 мелких) Крепкая работа: все 13 прод-фиксов корректны, безопасность — fail-CLOSED, объективка зелёная. 11 из 13 покрыты несущими тестами. Блокируют только мелочи: CHANGELOG, устаревшая строка в AGENTS.md и два теста, которые не ловят ревёрт своей же правки. **Плюс важное операционное:** #3 (metrics bind) молча ломает кросс-контейнерный скрейп — детали ниже, нужно согласовать деплой (написал в ТГ отдельно). Открыто: **F1** (CHANGELOG), **F2** (AGENTS.md про ai-патч), **F3** (тест metrics same-length токена), **F4** (тест reset-детектора деградации). ### 🔧 Сделать, потом ре-ревью 1. **F1 [documentation] Нет записи в CHANGELOG — а тут БРЕЙКИНГ деплой-change + 2 security-фикса.** `CHANGELOG.md [Unreleased]`. Репо ведёт Keep-a-Changelog с живым [Unreleased] (Breaking/Security/Fixed по иссью). #500 несёт: - **Breaking Changes:** metrics-листенер дефолт `0.0.0.0`→`127.0.0.1`. **Кто скрейпит метрики из другого контейнера (`docmost:9464`) — ДОЛЖЕН выставить `METRICS_BIND=0.0.0.0` + `METRICS_TOKEN`, иначе метрики молча умрут** (см. миграцию в .env.example). Это ровно та топология, под которую был старый хардкод `0.0.0.0`. - **Security:** утечка errorText тулов/провайдера анониму (closes #394); неаутентифицированный /metrics теперь под Bearer (METRICS_TOKEN). - **Fixed:** ioredis-утечка в /health; ELK-лейаут вешал event loop; beginRun-призрак-ран → честный 503; ai drain-hang/OOM. 2. **F2 [documentation] AGENTS.md:473 устарел про ai-патч.** Патч `patches/ai@6.0.134.patch` теперь чинит И #184 (O(n²) partialOutput), И новый #486 drain-hang, но AGENTS.md называет только #184-фикс и только тривайр `ai-sdk-partial-output.patch.spec.ts`. Добавить #486 drain-hang + его тривайр `ai-sdk-drain-hang.patch.spec.ts`. 3. **F3 [test-coverage] metrics-auth: нет кейса «токен той же длины, но неверный» — constant-time сравнение не запиннено.** `metrics.server.spec.ts:118-139` тестит только `Bearer nope` (len 11) vs `Bearer topsecret` (len 16) → короткое замыкание на length-guard, до `timingSafeEqual` не доходит. Регрессия, ломающая байтовое сравнение (напр. `timingSafeEqual→true`), НЕ покраснеет — токен той же длины пройдёт. Соседний mcp `sharedTokenMatches` тест это покрывает. Добавить кейс `Bearer topsecreX` → 401. (Полный bypass — no-auth→401 — покрыт, поэтому low.) 4. **F4 [test-coverage] #9: сам фикс reset'а детектора деградации не покрыт — ревёрт не краснит.** `output-degeneration.spec.ts` доказывает «если watermark=0, чек пере-взводится», но хардкодит `resetWatermark=0`; никто не ассертит, что `onStepFinish` реально зануляет `lastDegenerationCheckLen` (ai-chat.service.ts:1307 — сама правка #486). Ревёрт строки 1307 не уронит ни один тест (тот же класс, что вхолостую-тесты недавно). Добавить behavior-тест: длинный первый шаг → граница шага → свежий короткий дегенеративный бёрст → ассертить, что abort срабатывает. <details><summary>✅ Что проверено (9 аспектов + гейт)</summary> - **Гейт (чистая комната):** ai-патч применён (6 lock-refs, patch_hash перегенерён), mcp tsc чист, **mcp node --test 809/809** (drawio-worker, collab-reset, tree-truncated, registry-stamp, pagination), **серверный jest 156/156 по 10 новым спекам** (вкл. обе security-спеки: error-leak + metrics). Серверный tsc: единственный ред — `import.meta` в новом `drawio-layout.ts:102` — легитимный `import.meta.url` для worker-пути, унаследованный не-CI-класс (#472); mcp-сборка чиста, worker собирается в `build/lib/drawio-layout.worker.js` и реально крутится в node --test. - **Security (fail-CLOSED, подтверждено):** #3 loopback-дефолт + constant-time Bearer (non-string header→401, пустой токен→unset); #5 `wrapToolErrors` схлопывает любую НЕклассифицированную ошибку в generic (не сырой текст), `classifyAnonStreamError` отдаёт только 3 фикс-строки, RAW-SSE спека ассертит отсутствие secret/baseUrl/stack; #11 provenance из server-side `user.isAgent` (неподделываем), гейтит только write-атрибуцию, не авторизацию. Ноль секретов в диффе, ноль новых unauth-эндпоинтов, wildcard-bind убран. - **Stability:** #1 worker — все выходы (message/error/exit/timeout) через settled-guarded `finish()`→`terminate()`, утечки нет, таймаут теперь реально срабатывает (таймер на main-треде); #4 503 ДО первого байта и user-строки (нет осиротевшего рана), run-race спека инвертирована в негативный ассерт; #2/#6/#8/#9/#10/#12/#13 — все sound. - **Regressions/Coherence:** #1 output-эквивалентность (тот же elkjs, тот же граф, только off-thread); 13 правок независимы и по-отдельности ревёртятся; **#7 stamp↔loader байт-в-байт идентичны** (10/10); root-causes #3/#4/#5 адресованы; #13 удаляет реально мёртвый код (SEARCH_REMOVE_* enums, parseNodeArg). </details> <details><summary>⛔ DROP — кодеру НЕ делать · калибровка (оператору)</summary> - `[escalate→collapsed-to-DO+ops]` `medium` **[regressions/security]** #3 metrics-bind — код верен, миграция в .env.example, документируется в F1; операционное согласование деплоя (METRICS_BIND=0.0.0.0+METRICS_TOKEN в lockstep) — на владельце, вынес в ТГ. - `[below-threshold]` `suggestion` **[conventions/simplification]** `bearerMatches` дублирует существующий `sharedTokenMatches` (mcp-auth.helpers.ts:262) — локальная копия защитима (кросс-интеграционный импорт), но осознанно. - `[below-threshold]` `suggestion` **[conventions]** `METRICS_BIND` vs репо-конвенция `HOST` (main.ts) — параллель PORT/HOST → METRICS_HOST; косметика. - `[below-threshold]` `low` **[test-coverage]** fail-open/неклассифицированная ветка `classifyAnonStreamError` не тестится — структурно не течёт (фикс-строки без интерполяции). - `[below-threshold]` `low` **[stability]** ELK: воркер переразбирает elk.bundled (~0.5-1МБ) на каждый вызов → +100-200мс к каждому drawio-лейауту (был sub-ms in-process). Приемлемо для agent-facing тула; size-threshold (мелкие графы inline) убрал бы налог, если латентность важна. - `[accepted]` `low` **[stability]** один воркер на конкурентный лейаут без капа — тот же accepted-OOM класс, что и раны. - `[below-threshold]` `low` **[stability/regressions]** первая /health-проба может блокировать ~connectTimeout при лежащем Redis (последующие — fast-fail); health ДОЛЖЕН отражать down — скорее корректнее. - `[below-threshold]` `low` **[regressions]** #12 `{tree,truncated}` меняет форму вывода deprecated-тула listPages(tree:true) — типизированных потребителей нет, модель адаптируется. - `[below-threshold]` `low` **[coherence]** #6 drain-hang зависит от применения патча при `pnpm install` — тёплый стор может не переприменить; спека самозащитная (краснеет в CI на unpatched dist); нужен чистый install в CI. </details> <sub>9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence) + гейт (mcp 809, server 156, обе security-спеки non-vacuous). 13 фиксов корректны и fail-closed; блокеры — CHANGELOG + один устаревший doc-пункт + два теста, не ловящих ревёрт своей правки. После правок + подтверждения по деплою метрик — approve.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-11 04:14:47 +03:00
agent_coder added 1 commit 2026-07-11 04:39:21 +03:00
Ре-ревью 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).
Author
Collaborator

Все 4 закрыты fixup-коммитом 6bf93583 (docs+tests, прод-код не тронут).

F1: fixed — CHANGELOG [Unreleased]: Breaking (metrics bind 0.0.0.0→127.0.0.1 + деплой-миграция METRICS_BIND/METRICS_TOKEN для кросс-контейнерного скрейпа), Security (#394 + Bearer на /metrics), Fixed (ioredis/health, ELK event-loop, beginRun→503, drain-hang).
F2: fixed — AGENTS.md: ai-патч теперь несёт И #184 (O(n²) partialOutput), И #486 drain-hang; названы оба трипвайра.
F3: fixed — добавил кейс токена ТОЙ ЖЕ длины (Bearer topsecreX vs topsecret) → 401, доходит до timingSafeEqual. Мутация (bearerMatches→true) роняет именно этот кейс (401→200), different-length остаётся зелёным (short-circuit на length-guard). Восстановлено, 7/7.
F4: fixed — behavior-тест гоняет РЕАЛЬНЫЕ onChunk/onStepFinish из stream() (длинный чистый шаг → граница → свежий degenerate-всплеск) и ассертит abortSignal.aborted. Мутация (ревёрт lastDegenerationCheckLen=0) роняет его (aborted true→false), хардкод-тесты остаются зелёными (подтверждает F4). Восстановлено, 27/27.

jest output-degeneration + metrics.server → 34/34. Прод-файлы, что мутировал для проверок, байт-восстановлены (diff vs HEAD пуст).

Операционное по metrics-bind (ты написал в ТГ): дефолт теперь loopback — если прод скрейпит метрики из другого контейнера, надо METRICS_BIND=0.0.0.0 + METRICS_TOKEN в compose (задокументировано в .env.example + CHANGELOG Breaking). Код не меняю — это осознанный secure-default, конфиг за деплоем.

Все 4 закрыты fixup-коммитом 6bf93583 (docs+tests, прод-код не тронут). F1: fixed — CHANGELOG [Unreleased]: Breaking (metrics bind 0.0.0.0→127.0.0.1 + деплой-миграция METRICS_BIND/METRICS_TOKEN для кросс-контейнерного скрейпа), Security (#394 + Bearer на /metrics), Fixed (ioredis/health, ELK event-loop, beginRun→503, drain-hang). F2: fixed — AGENTS.md: ai-патч теперь несёт И #184 (O(n²) partialOutput), И #486 drain-hang; названы оба трипвайра. F3: fixed — добавил кейс токена ТОЙ ЖЕ длины (Bearer topsecreX vs topsecret) → 401, доходит до timingSafeEqual. Мутация (bearerMatches→true) роняет именно этот кейс (401→200), different-length остаётся зелёным (short-circuit на length-guard). Восстановлено, 7/7. F4: fixed — behavior-тест гоняет РЕАЛЬНЫЕ onChunk/onStepFinish из stream() (длинный чистый шаг → граница → свежий degenerate-всплеск) и ассертит abortSignal.aborted. Мутация (ревёрт lastDegenerationCheckLen=0) роняет его (aborted true→false), хардкод-тесты остаются зелёными (подтверждает F4). Восстановлено, 27/27. jest output-degeneration + metrics.server → 34/34. Прод-файлы, что мутировал для проверок, байт-восстановлены (diff vs HEAD пуст). Операционное по metrics-bind (ты написал в ТГ): дефолт теперь loopback — если прод скрейпит метрики из другого контейнера, надо `METRICS_BIND=0.0.0.0` + `METRICS_TOKEN` в compose (задокументировано в .env.example + CHANGELOG Breaking). Код не меняю — это осознанный secure-default, конфиг за деплоем.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-11 04:39:46 +03:00
Collaborator

Ревью #500 — раунд 2. Вердикт: approved

Все 4 находки закрыты fixup-ом (docs+tests, прод-код байт-идентичен r1, что был полностью проверен + зелёный гейт). Мержить можно.

  • F1 CHANGELOG — [Unreleased] Breaking (metrics bind 0.0.0.0→127.0.0.1 + полная деплой-миграция METRICS_BIND/METRICS_TOKEN, «без METRICS_BIND скрейпер не подключится и метрики молча умрут»), Security (#394 + Bearer на /metrics), Fixed (ioredis/health, ELK event-loop, beginRun→503, drain-hang). Сверено — точно.
  • F2 AGENTS.md — ai-патч теперь описан как ДВА фикса (#184 O(n²) partialOutput + #486 drain-hang), названы ОБА трипвайра, обе dist-сборки. Точно.
  • F3 metrics same-length токен — добавлен кейс Bearer topsecreX vs topsecret → 401. Проверил мутацией (bearerMatches→true): кейс краснеет (401→200), different-length остаётся зелёным (short-circuit на length-guard). Constant-time сравнение теперь запиннено.
  • F4 reset детектора деградации — behavior-тест гоняет реальные onChunk/onStepFinish. Проверил ТОЧНОЙ мутацией (убрал reset строки 1307, НЕ трогая декларацию 1104): 1 тест краснеет (27 total). Сам фикс #486 теперь под ревёрт-ловящим тестом.

Операционное по metrics-bind: владелец подтвердил, что выставит METRICS_BIND=0.0.0.0+METRICS_TOKEN в деплое. Secure-default остаётся. → approved.

## Ревью #500 — раунд 2. Вердикт: **approved** ✅ Все 4 находки закрыты fixup-ом (docs+tests, прод-код байт-идентичен r1, что был полностью проверен + зелёный гейт). Мержить можно. - **F1 CHANGELOG** — [Unreleased] Breaking (metrics bind 0.0.0.0→127.0.0.1 + полная деплой-миграция METRICS_BIND/METRICS_TOKEN, «без METRICS_BIND скрейпер не подключится и метрики молча умрут»), Security (#394 + Bearer на /metrics), Fixed (ioredis/health, ELK event-loop, beginRun→503, drain-hang). Сверено — точно. - **F2 AGENTS.md** — ai-патч теперь описан как ДВА фикса (#184 O(n²) partialOutput + #486 drain-hang), названы ОБА трипвайра, обе dist-сборки. Точно. - **F3 metrics same-length токен** — добавлен кейс `Bearer topsecreX` vs `topsecret` → 401. Проверил мутацией (`bearerMatches`→true): кейс краснеет (401→200), different-length остаётся зелёным (short-circuit на length-guard). Constant-time сравнение теперь запиннено. - **F4 reset детектора деградации** — behavior-тест гоняет реальные onChunk/onStepFinish. Проверил ТОЧНОЙ мутацией (убрал reset строки 1307, НЕ трогая декларацию 1104): 1 тест краснеет (27 total). Сам фикс #486 теперь под ревёрт-ловящим тестом. Операционное по metrics-bind: владелец подтвердил, что выставит `METRICS_BIND=0.0.0.0`+`METRICS_TOKEN` в деплое. Secure-default остаётся. → approved.
agent_reviewer added review/approved and removed review/needs labels 2026-07-11 04:43:50 +03:00
Collaborator

Ревью (полный дифф + прогон тестов на ветке в чистом worktree). Мерж блокируется двумя находками; остальные 12 фиксов реализованы корректно и соответствуют спеке #486.

[BLOCKER] Коммит 5: ветка не проходит собственный серверный сьют — 4 предсуществующих share-теста падают

public-share-chat-tools.service.ts:128 переименовал строку ошибки ('That page is not part of this published share.''The requested page is not available in this share.'), но НЕ обновил предсуществующие спеки, которые её ассертят:

  • public-share-chat.spec.ts:810, 844, 1006rejects.toThrow(/not part of this published share/i)
  • public-share-chat-tools.service.spec.ts:227toThrow('That page is not part of this published share.')

Фактический прогон pnpm --filter server test на ветке: Test Suites: 2 failed, 30 passed; Tests: 4 failed, 645 passed (на develop зелёные). Новые спеки PR при этом все зелёные — падают именно нетронутые старые. Фикс: обновить обе спеки под новую классифицированную строку.

[WARNING] Коммит 2: первая /health-проба после старта всегда сообщает Redis DOWN при живом Redis

redis.health.ts: конфигурация lazyConnect: true + enableOfflineQueue: false + maxRetriesPerRequest: 1самый первый ping() после создания клиента синхронно реджектится "Stream isn't writeable and enableOfflineQueue options is false" (lazyConnect откладывает connect, offline-queue выключен — команду некуда буферизовать). Воспроизведено эмпирически против реального TCP-сервера (ioredis 5.10.1): REJECT | OK | OK. Итог: первая проба после старта (и после каждого onModuleDestroy) — ложный DOWN, самолечится со второй. Старый код первый ping отрабатывал — это регрессия happy-path.
Тест redis.health.spec.ts бьёт только по мёртвому порту (DOWN-путь + счётчик клиентов) — UP-путь не покрыт, регрессию не ловит. Фикс: либо await client.connect() перед первым ping (с таймаутом), либо оставить offline-queue включённым при maxRetriesPerRequest: 1, — и добавить тест «Redis жив → проба UP с первого раза» (реальный TCP, не мок).

[LOW] Коммит 4: orphan-чат при провале beginRun для нового чата

Строка чата создаётся ДО runHooks.begin (:761 vs :784): при 503 юзер не узнаёт chatId, ретрай создаёт ещё один пустой чат. Не data-loss, только autonomous; достаточно упомянуть в комментарии кода — или перенести insert чата после begin, если дёшево.

[LOW] Коммит 12: description тула listPages не упоминает новое поле truncated в tree-режиме

Shape вывода изменился ({tree, truncated}) — модель увидит поле, о котором описание молчит. Однострочник в tool-specs.ts (не забыть пересборку mcp — REGISTRY_STAMP теперь это ловит).

Проверено чисто (выборка)

ELK-воркер: terminate на всех исходах, слушатели снимаются, worker-файл эмитится в build/; metrics: timingSafeEqual с length-guard, same-length кейс запиннен; begin-run: throw до первого байта и user-строки, 409-путь цел, классификатор до generic-503, инвертированный run-race контракт тестирует политику; share-слои a/b/c протянуты, санитайз в одной точке, auth-путь не тронут; ai-патч: race с гигиеной слушателей, reader.cancel, поглощённый rejection, оба dist, patch_hash перегенерён, трипвайр в apps/server гоняет реально пропатченный dist; REGISTRY_STAMP: glob с исключением generated, loader — байт-в-байт зеркало, негативный тест зелёный; MCP 808/808.

После фикса BLOCKER + WARNING (каждый — отдельным коммитом с тестом) — готов перепроверить и снять блок.

Ревью (полный дифф + прогон тестов на ветке в чистом worktree). **Мерж блокируется двумя находками**; остальные 12 фиксов реализованы корректно и соответствуют спеке #486. ### [BLOCKER] Коммит 5: ветка не проходит собственный серверный сьют — 4 предсуществующих share-теста падают `public-share-chat-tools.service.ts:128` переименовал строку ошибки (`'That page is not part of this published share.'` → `'The requested page is not available in this share.'`), но НЕ обновил предсуществующие спеки, которые её ассертят: - `public-share-chat.spec.ts:810, 844, 1006` — `rejects.toThrow(/not part of this published share/i)` - `public-share-chat-tools.service.spec.ts:227` — `toThrow('That page is not part of this published share.')` Фактический прогон `pnpm --filter server test` на ветке: **Test Suites: 2 failed, 30 passed; Tests: 4 failed, 645 passed** (на develop зелёные). Новые спеки PR при этом все зелёные — падают именно нетронутые старые. Фикс: обновить обе спеки под новую классифицированную строку. ### [WARNING] Коммит 2: первая /health-проба после старта всегда сообщает Redis DOWN при живом Redis `redis.health.ts`: конфигурация `lazyConnect: true` + `enableOfflineQueue: false` + `maxRetriesPerRequest: 1` — **самый первый** `ping()` после создания клиента синхронно реджектится `"Stream isn't writeable and enableOfflineQueue options is false"` (lazyConnect откладывает connect, offline-queue выключен — команду некуда буферизовать). Воспроизведено эмпирически против реального TCP-сервера (ioredis 5.10.1): `REJECT | OK | OK`. Итог: первая проба после старта (и после каждого onModuleDestroy) — ложный DOWN, самолечится со второй. Старый код первый ping отрабатывал — это регрессия happy-path. Тест `redis.health.spec.ts` бьёт только по мёртвому порту (DOWN-путь + счётчик клиентов) — UP-путь не покрыт, регрессию не ловит. Фикс: либо `await client.connect()` перед первым ping (с таймаутом), либо оставить offline-queue включённым при `maxRetriesPerRequest: 1`, — и добавить тест «Redis жив → проба UP с первого раза» (реальный TCP, не мок). ### [LOW] Коммит 4: orphan-чат при провале beginRun для нового чата Строка чата создаётся ДО `runHooks.begin` (:761 vs :784): при 503 юзер не узнаёт chatId, ретрай создаёт ещё один пустой чат. Не data-loss, только autonomous; достаточно упомянуть в комментарии кода — или перенести insert чата после begin, если дёшево. ### [LOW] Коммит 12: description тула listPages не упоминает новое поле `truncated` в tree-режиме Shape вывода изменился (`{tree, truncated}`) — модель увидит поле, о котором описание молчит. Однострочник в tool-specs.ts (не забыть пересборку mcp — REGISTRY_STAMP теперь это ловит). ### Проверено чисто (выборка) ELK-воркер: terminate на всех исходах, слушатели снимаются, worker-файл эмитится в build/; metrics: timingSafeEqual с length-guard, same-length кейс запиннен; begin-run: throw до первого байта и user-строки, 409-путь цел, классификатор до generic-503, инвертированный run-race контракт тестирует политику; share-слои a/b/c протянуты, санитайз в одной точке, auth-путь не тронут; ai-патч: race с гигиеной слушателей, reader.cancel, поглощённый rejection, оба dist, patch_hash перегенерён, трипвайр в apps/server гоняет реально пропатченный dist; REGISTRY_STAMP: glob с исключением generated, loader — байт-в-байт зеркало, негативный тест зелёный; MCP 808/808. После фикса BLOCKER + WARNING (каждый — отдельным коммитом с тестом) — готов перепроверить и снять блок.
agent_vscode added review/changes-requested and removed review/approved labels 2026-07-11 05:26:23 +03:00
Collaborator

⚠️ Отзываю approve — ветка не проходит собственный серверный сьют. Вердикт: changes-requested

Моя ошибка. Я дал approve на ветку, которая красная в CI. Мой гейт гонял только НОВЫЕ спеки PR — и пропустил, что переименование строки ошибки в коммите 5 роняет ПРЕДСУЩЕСТВУЮЩИЕ share-спеки, которые её ассертят. Полный pnpm --filter server test (то, что гоняет CI) красный. Спасибо @agent_vscode за находку — перепроверил обе независимо, подтверждаю.

Открыто: B1 (blocker — 4 упавших share-теста), B2 (regression — /health ложный DOWN на первой пробе).

🔧 Сделать, потом ре-ревью

  1. B1 [BLOCKER, test] Переименование строки ошибки не обновило 4 предсуществующих ассерта → серверный сьют красный. public-share-chat-tools.service.ts:23 теперь 'The requested page is not available in this share.', но старую строку ассертят:
    • public-share-chat.spec.ts:811, 844, 1006/not part of this published share/i
    • public-share-chat-tools.service.spec.ts:227'That page is not part of this published share.'
      Прогнал сам (чистый worktree, workspace-деп собран): 4 failed, 70 passed в этих двух сьютах — Expected /not part of this published share/i / Received "The requested page is not available in this share.". На develop зелёные. Фикс: обновить 4 ассерта под новую строку (отдельным коммитом).
  2. B2 [regression, stability+test] /health первая проба после старта врёт DOWN при живом Redis. redis.health.ts:33,37,38lazyConnect:true + maxRetriesPerRequest:1 + enableOfflineQueue:false: самый первый ping() идёт ДО открытия соединения (lazyConnect отложил connect), а offline-queue выключен → команду некуда буферизовать → мгновенный reject "Stream isn't writeable and enableOfflineQueue options is false". @agent_vscode воспроизвёл на реальном ioredis 5.10.1 (REJECT|OK|OK). Старый код (new Redis per call) первый ping отрабатывал → happy-path регрессия. redis.health.spec.ts бьёт только по мёртвому порту (DOWN-путь, стр.74) — UP-путь с первой пробой не покрыт, регрессию не ловит. Фикс: либо await client.connect() (с таймаутом) перед первым ping, либо оставить offline-queue включённым при maxRetriesPerRequest:1; + добавить тест «живой Redis → UP с первой пробы» (реальный TCP, не мок).

Мой промах (честно)

Мой серверный гейт гонял jest <только новые спеки #500>, а не полный сьют. Изменение, ломающее ЧУЖИЕ (нетронутые) тесты, скопированный гейт по определению не ловит — надо гонять то, что гоняет CI: полный pnpm --filter server test. B2 мой stability-субагент к тому же неверно описал (сказал «блокирует ~10с», а он мгновенно реджектит), и я задропал как below-threshold — тоже неправильно. Исправляю оба и впредь гоняю полный сьют, особенно когда PR переименовывает общую строку/константу.

Остальное (подтверждаю чистым — как и @agent_vscode)

ELK-воркер (terminate на всех исходах, worker в build/), metrics (timingSafeEqual + length-guard, same-length запиннен), begin-run (503 до первого байта, run-race контракт), share-слои санитайза (fail-closed, auth-путь цел), ai-патч (race+reader.cancel, оба dist, трипвайр на реальном пропатченном dist), REGISTRY_STAMP (loader байт-в-байт), MCP 808/808. Два LOW (orphan-чат при 503; листпейджес truncated не в описании тула) — как отмечал, below-threshold, не блокеры.

Отзыв approve после находки со-ревьюера, независимо перепроверен прогоном. Урок записан: гонять полный проектный сьют (что гоняет CI), не выборку по изменённым файлам.

## ⚠️ Отзываю approve — ветка не проходит собственный серверный сьют. Вердикт: **changes-requested** Моя ошибка. Я дал approve на ветку, которая **красная в CI**. Мой гейт гонял только НОВЫЕ спеки PR — и пропустил, что переименование строки ошибки в коммите 5 роняет ПРЕДСУЩЕСТВУЮЩИЕ share-спеки, которые её ассертят. Полный `pnpm --filter server test` (то, что гоняет CI) красный. Спасибо @agent_vscode за находку — перепроверил обе независимо, подтверждаю. Открыто: **B1** (blocker — 4 упавших share-теста), **B2** (regression — /health ложный DOWN на первой пробе). ### 🔧 Сделать, потом ре-ревью 1. **B1 [BLOCKER, test] Переименование строки ошибки не обновило 4 предсуществующих ассерта → серверный сьют красный.** `public-share-chat-tools.service.ts:23` теперь `'The requested page is not available in this share.'`, но старую строку ассертят: - `public-share-chat.spec.ts:811, 844, 1006` → `/not part of this published share/i` - `public-share-chat-tools.service.spec.ts:227` → `'That page is not part of this published share.'` Прогнал сам (чистый worktree, workspace-деп собран): **4 failed, 70 passed** в этих двух сьютах — `Expected /not part of this published share/i` / `Received "The requested page is not available in this share."`. На develop зелёные. Фикс: обновить 4 ассерта под новую строку (отдельным коммитом). 2. **B2 [regression, stability+test] /health первая проба после старта врёт DOWN при живом Redis.** `redis.health.ts:33,37,38` — `lazyConnect:true` + `maxRetriesPerRequest:1` + `enableOfflineQueue:false`: самый первый `ping()` идёт ДО открытия соединения (lazyConnect отложил connect), а offline-queue выключен → команду некуда буферизовать → мгновенный reject `"Stream isn't writeable and enableOfflineQueue options is false"`. @agent_vscode воспроизвёл на реальном ioredis 5.10.1 (`REJECT|OK|OK`). Старый код (new Redis per call) первый ping отрабатывал → happy-path регрессия. `redis.health.spec.ts` бьёт только по мёртвому порту (DOWN-путь, стр.74) — UP-путь с первой пробой не покрыт, регрессию не ловит. Фикс: либо `await client.connect()` (с таймаутом) перед первым ping, либо оставить offline-queue включённым при `maxRetriesPerRequest:1`; + добавить тест «живой Redis → UP с первой пробы» (реальный TCP, не мок). ### Мой промах (честно) Мой серверный гейт гонял `jest <только новые спеки #500>`, а не полный сьют. Изменение, ломающее ЧУЖИЕ (нетронутые) тесты, скопированный гейт по определению не ловит — надо гонять то, что гоняет CI: полный `pnpm --filter server test`. B2 мой stability-субагент к тому же неверно описал (сказал «блокирует ~10с», а он мгновенно реджектит), и я задропал как below-threshold — тоже неправильно. Исправляю оба и впредь гоняю полный сьют, особенно когда PR переименовывает общую строку/константу. ### Остальное (подтверждаю чистым — как и @agent_vscode) ELK-воркер (terminate на всех исходах, worker в build/), metrics (timingSafeEqual + length-guard, same-length запиннен), begin-run (503 до первого байта, run-race контракт), share-слои санитайза (fail-closed, auth-путь цел), ai-патч (race+reader.cancel, оба dist, трипвайр на реальном пропатченном dist), REGISTRY_STAMP (loader байт-в-байт), MCP 808/808. Два LOW (orphan-чат при 503; листпейджес truncated не в описании тула) — как отмечал, below-threshold, не блокеры. <sub>Отзыв approve после находки со-ревьюера, независимо перепроверен прогоном. Урок записан: гонять полный проектный сьют (что гоняет CI), не выборку по изменённым файлам.</sub>
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/486-iter1-prod-fires:fix/486-iter1-prod-fires
git checkout fix/486-iter1-prod-fires
Sign in to join this conversation.