feat(deploy): version-coherence — WS-анонс версии + guarded reload устаревших вкладок (#481) #499

Open
agent_coder wants to merge 3 commits from feat/481-version-coherence into develop
Collaborator

Summary

SPA держат открытым часами; сервер за это время передеплоивают → старый index.html вкладки ссылается на content-hashed чанки, которых в новом образе нет → ленивый import() → catch-all отдаёт index.html как text/html → WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Сейчас предохранитель только реактивный (ловит уже случившуюся ошибку чанка). Делаем сигнал проактивным: сервер сообщает версию по существующему WS, клиент сравнивает со вшитой и делает guarded reload ДО битого чанка. UX (выбор владельца): баннер + reload в фоне, видимую вкладку не дёргаем. closes #481

Единый источник версии

vite.config.ts вычисляет resolveAppVersion РОВНО раз → и в define.APP_VERSION, и в плагине, пишущем dist/version.json. Бандл и файл тождественны by construction. Сервер читает client/dist/version.json при старте (client-version.ts; resolveClientDistPath вынесен из static.module — единый источник пути; ошибка чтения → '', fail-safe). Никаких Docker/ENV-изменений.

WS-контракт + доставка

Отдельное socket.io-событие app-version {version} (НЕ в message/WebSocketEvent). ws.gateway: версия читается в onModuleInit + boot-лог ACTIVE/DISABLED; emit в handleConnection (success-ветка). Только per-connect анонс, БЕЗ broadcast (broadcast в кластере → thundering-herd reload всего флота; естественный реконнект после редеплоя покрывает и single-container, и кластер).

Клиент — guarded reload

Listener навешан СИНХРОННО в том же useEffect, что создаёт сокет (до connect — иначе гонка с немедленным emit). Чистая decideVersionAction (reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка: hidden→немедленный reload, visible→баннер + reload при уходе в фон. Общий one-shot флаг (reload-guard.ts над существующим chunk-reload-attempted; boundary переведён на него): проактивный + реактивный reload делят ОДИН счётчик → максимум одна авто-перезагрузка за сессию; permanent skew / oscillation / disabled storage → после первого reload только баннер, петли нет.

How verified

  • Server jest client-version 7/7; client vitest version-coherence+reload-guard+guarded-reload 16/16 (coverage 97.9%).
  • Build-proof единого источника: APP_VERSION=test-A vite builddist/version.json = {"version":"test-A"} + test-A вшито в бандл.

⚠️ Требует staging-проверки (вне автостенда) — шаги приёмки #481

  1. docker build --build-arg APP_VERSION=test-A: cat dist/version.json={"version":"test-A"}; шапка=test-A; DevTools WS-кадр app-version=test-A — три совпадают.
  2. Вкладка A → пересобрать test-B, docker compose up -d → в секунды после реконнекта: видимая вкладка показывает баннер, фоновая перезагружается; DevTools: новый connect + app-version test-B.
  3. Клик «Обновить» → reload → APP_VERSION==test-B.
  4. Permanent skew (прокси отдаёт старый бандл) → РОВНО одна авто-перезагрузка (chunk-reload-attempted выставлен), дальше баннер; петли нет.

Checklist

  • Сервер анонсирует версию по WS per-connect; клиент сравнивает и делает guarded reload
  • Единый источник (version.json == бандл); fail-safe при отсутствии файла
  • Максимум один авто-reload за сессию (общий флаг с реактивным boundary); петле-безопасно
  • Staging-приём (docker/мульти-таб/WS-кадры) — за ревьюером/владельцем
## Summary SPA держат открытым часами; сервер за это время передеплоивают → старый `index.html` вкладки ссылается на content-hashed чанки, которых в новом образе нет → ленивый `import()` → catch-all отдаёт `index.html` как `text/html` → WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Сейчас предохранитель только **реактивный** (ловит уже случившуюся ошибку чанка). Делаем сигнал **проактивным**: сервер сообщает версию по существующему WS, клиент сравнивает со вшитой и делает *guarded reload* ДО битого чанка. UX (выбор владельца): **баннер + reload в фоне**, видимую вкладку не дёргаем. closes #481 ### Единый источник версии `vite.config.ts` вычисляет `resolveAppVersion` РОВНО раз → и в `define.APP_VERSION`, и в плагине, пишущем `dist/version.json`. Бандл и файл тождественны by construction. Сервер читает `client/dist/version.json` при старте (`client-version.ts`; `resolveClientDistPath` вынесен из static.module — единый источник пути; ошибка чтения → `''`, fail-safe). **Никаких Docker/ENV-изменений.** ### WS-контракт + доставка Отдельное socket.io-событие `app-version {version}` (НЕ в `message`/`WebSocketEvent`). `ws.gateway`: версия читается в `onModuleInit` + boot-лог ACTIVE/DISABLED; `emit` в `handleConnection` (success-ветка). **Только per-connect анонс, БЕЗ broadcast** (broadcast в кластере → thundering-herd reload всего флота; естественный реконнект после редеплоя покрывает и single-container, и кластер). ### Клиент — guarded reload Listener навешан СИНХРОННО в том же `useEffect`, что создаёт сокет (до connect — иначе гонка с немедленным emit). Чистая `decideVersionAction` (reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка: `hidden`→немедленный reload, `visible`→баннер + reload при уходе в фон. **Общий one-shot флаг** (`reload-guard.ts` над существующим `chunk-reload-attempted`; boundary переведён на него): проактивный + реактивный reload делят ОДИН счётчик → **максимум одна авто-перезагрузка за сессию**; permanent skew / oscillation / disabled storage → после первого reload только баннер, петли нет. ## How verified - Server jest `client-version` **7/7**; client vitest `version-coherence`+`reload-guard`+`guarded-reload` **16/16** (coverage 97.9%). - Build-proof единого источника: `APP_VERSION=test-A vite build` → `dist/version.json` = `{"version":"test-A"}` + `test-A` вшито в бандл. ### ⚠️ Требует staging-проверки (вне автостенда) — шаги приёмки #481 1. `docker build --build-arg APP_VERSION=test-A`: `cat dist/version.json`=`{"version":"test-A"}`; шапка=`test-A`; DevTools WS-кадр `app-version`=`test-A` — три совпадают. 2. Вкладка A → пересобрать `test-B`, `docker compose up -d` → в секунды после реконнекта: видимая вкладка показывает баннер, фоновая перезагружается; DevTools: новый connect + `app-version` `test-B`. 3. Клик «Обновить» → reload → `APP_VERSION==test-B`. 4. Permanent skew (прокси отдаёт старый бандл) → РОВНО одна авто-перезагрузка (`chunk-reload-attempted` выставлен), дальше баннер; петли нет. ## Checklist - [x] Сервер анонсирует версию по WS per-connect; клиент сравнивает и делает guarded reload - [x] Единый источник (version.json == бандл); fail-safe при отсутствии файла - [x] Максимум один авто-reload за сессию (общий флаг с реактивным boundary); петле-безопасно - [ ] Staging-приём (docker/мульти-таб/WS-кадры) — за ревьюером/владельцем
agent_coder added 1 commit 2026-07-11 02:14:52 +03:00
SPA — долгоживущий клиент: вкладку держат часами, сервер за это время
передеплоивают. Старый index.html ссылается на content-hashed чанки, которых в
новом образе нет → ленивый import() → catch-all отдаёт index.html как text/html →
WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Реактивный
предохранитель (chunk-error-boundary) ловит УЖЕ случившуюся ошибку. Делаем сигнал
ПРОАКТИВНЫМ: сервер сообщает версию по существующему WS, клиент сравнивает и
делает guarded reload ДО битого чанка. closes #481

- Единый источник версии: vite.config.ts вычисляет resolveAppVersion РОВНО раз →
  и в define.APP_VERSION, и в плагине, пишущем dist/version.json. Бандл и файл
  тождественны by construction. Сервер читает client/dist/version.json при старте
  (client-version.ts: resolveClientDistPath вынесен из static.module — единый
  источник пути; readClientBuildVersion → версия или '' на любой ошибке,
  fail-safe). НИКАКИХ Docker/ENV-изменений.
- WS: отдельное событие app-version {version} (НЕ в message/WebSocketEvent —
  член без operation сломал бы union). ws.gateway читает версию в onModuleInit +
  boot-лог ACTIVE/DISABLED; emit в handleConnection (success-ветка, после join).
  ТОЛЬКО per-connect анонс, БЕЗ broadcast (broadcast в кластере → thundering-herd
  reload флота; естественный реконнект покрывает и single-container, и кластер).
- Клиент: listener навешан СИНХРОННО в том же useEffect, что создаёт сокет (до
  connect — иначе гонка с немедленным emit сервера). Чистая decideVersionAction
  (reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка
  triggerGuardedReload: модульный latch, hidden→немедленный reload, visible→баннер
  + reload-on-hidden {once}, фикс-id закрываемый баннер с кнопкой «Обновить».
- Общий one-shot флаг: reload-guard.ts (hasAutoReloaded/markAutoReloaded над
  существующим chunk-reload-attempted); chunk-load-error-boundary переведён на
  него. Проактивный + реактивный reload делят ОДИН счётчик → максимум одна
  авто-перезагрузка за сессию суммарно; permanent skew / oscillation / disabled
  storage → после первого reload только баннер, петли нет (проверено трассировкой).

Проверка: server jest client-version 7/7; client vitest version-coherence+
reload-guard+guarded-reload 16/16 (coverage 97.9%). Build-proof единого источника:
APP_VERSION=test-A → dist/version.json {"version":"test-A"} + вшито в бандл.
Полный staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб) — вне
автостенда, шаги приёмки #481 (крит.1-4) на ручную проверку.

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

Готово к ревью. Проактивная version-coherence: сервер анонсирует версию по WS per-connect, клиент делает guarded reload устаревших вкладок до битого чанка. Единый источник (version.json == бандл), максимум один авто-reload за сессию (общий флаг с реактивным boundary), петле-безопасно.

Внутренний цикл: 1 проход (адверсарно, фокус на два опасных сценария). (1) Петля reload прослежена исчерпывающе: permanent skew (клиент навсегда A, сервер B) даёт РОВНО один reload, дальше только баннер; disabled-storage → авто-reload вообще не вызывается; oscillation нод → после первого reload баннер. (2) Гонка emit↔listener закрыта на connect И reconnect (listener навешан синхронно до connect; socket.io хранит обработчики между reconnect — реконнект и есть механизм доставки после редеплоя). Path-хелпер, decideVersionAction (4 ветки), visibility, scope (без broadcast, не в WebSocketEvent) — чисто. Блокеров нет; единственный nit — не покрыта тестом строка onClick ручной кнопки (зовёт уже протестированный performAutoReload).

Staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб visible/hidden) — вне автостенда, шаги в теле PR (крит.1-4). Прошу прогнать на стенде.

Готово к ревью. Проактивная version-coherence: сервер анонсирует версию по WS per-connect, клиент делает guarded reload устаревших вкладок до битого чанка. Единый источник (version.json == бандл), максимум один авто-reload за сессию (общий флаг с реактивным boundary), петле-безопасно. Внутренний цикл: 1 проход (адверсарно, фокус на два опасных сценария). (1) Петля reload прослежена исчерпывающе: permanent skew (клиент навсегда A, сервер B) даёт РОВНО один reload, дальше только баннер; disabled-storage → авто-reload вообще не вызывается; oscillation нод → после первого reload баннер. (2) Гонка emit↔listener закрыта на connect И reconnect (listener навешан синхронно до connect; socket.io хранит обработчики между reconnect — реконнект и есть механизм доставки после редеплоя). Path-хелпер, decideVersionAction (4 ветки), visibility, scope (без broadcast, не в WebSocketEvent) — чисто. Блокеров нет; единственный nit — не покрыта тестом строка onClick ручной кнопки (зовёт уже протестированный performAutoReload). Staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб visible/hidden) — вне автостенда, шаги в теле PR (крит.1-4). Прошу прогнать на стенде.
agent_coder added the review/needs label 2026-07-11 02:15:18 +03:00
Collaborator

Ревью #499 — version-coherence (авто-перезагрузка устаревших вкладок). Вердикт: needs-human (нужно решение владельца)

Фича сделана крепко и безопасно: анти-петля перезагрузок держится на всех путях (проверил исчерпывающе), версия клиента и сервера тождественны by construction (подтвердил реальной сборкой), auth не обходится, race «слушатель до connect» закрыт. Корректных багов нет, объективка зелёная. Но один вопрос — продуктовый, и он твой: выбранное поведение «перезагружать скрытую вкладку в фоне» молча теряет несохранённый ввод (черновик комментария, незакрытая форма) — а это не контент страницы (он-то переживает перезагрузку).

Петля/лейблы: ставлю needs-human, снимаю review/needs. Кодер и я ждём твоего ответа. После — раскатаю в задачи (F1, F2 ниже) в любом случае.

🙋 Решение, которое нужно от тебя

Что происходит. После того как в течение сессии произошёл редеплой (то есть ровно сценарий, ради которого фича и делается), клиент знает, что версия устарела. Ты выбрал: видимую вкладку не дёргать, показать баннер; а вкладку, которая ушла в фон, — молча перезагрузить («никто не смотрит»). Проблема в том, что «никто не смотрит» ≠ «нет несохранённой работы»: пользователь мог начать писать комментарий и переключиться на другую вкладку (скопировать ссылку, глянуть почту). В этот момент вкладка становится скрытой → срабатывает фоновая перезагрузка → черновик комментария/незаполненная форма пропадают без предупреждения (нет диалога «несохранённые изменения», нет спасения черновика). Контент самой страницы НЕ теряется — редактор синхронит в IndexedDB и переживает reload; страдает только ввод вне редактора (комментарии, настройки, share-форма).

Почему спрашиваю тебя, а не решаю сам. До этого PR переключение вкладки НИКОГДА не перезагружало страницу — это новая поверхность потери данных. Убрать потерю можно тремя разными способами с разной ценой, и они меняют выбранный тобой UX. «Никогда молча не терять несохранённое» — принцип почти всегда верный, но здесь он борется с простотой твоего решения, и размен (редкий короткий кейс vs усложнение) — твой.

Вариант Суть Плюсы Минусы
A. Оставить как есть Скрытая вкладка перезагружается всегда Ноль работы, максимально простой код Черновик комментария/формы после редеплоя может молча пропасть при уходе со вкладки
B. Не перезагружать «грязную» вкладку Проверять несохранённый ввод (dirty comment/form), при наличии — отложить reload, оставить баннер UX «фон» сохранён, данные не теряются Нужен сигнал «есть несохранённое» — сейчас его нет, надо завести (заметное усложнение)
C. Отложить reload до навигации Не reload по visibilitychange; перезагрузить при следующем переходе внутри приложения Проще B, данные целы Устаревшая вкладка живёт дольше (но реактивный предохранитель чанков всё равно подстрахует)

Рекомендация: если черновики для тебя реальный кейс — C (дёшево и данные целы, скрытую вкладку просто не трогаем, а перезагружаем на следующем клике-переходе; реактивный ловец битых чанков остаётся бэкапом). B — если хочешь именно фоновый reload и готов на усложнение с детекцией «грязного» состояния. A — только если считаешь, что окно (редеплой + недописанный коммент + уход со вкладки) достаточно редкое, чтобы принять потерю.

Что проверено (объективка + верификация)
  • Гейт (чистая комната): серверный jest client-version 7/7; клиентский vitest version-coherence+reload-guard+guarded-reload 16/16; клиентская сборка ок; version.json ↔ APP_VERSION тождественны эмпирическиversion.json={"version":"v0.94.1-540-g1e492500"} и эта же строка вшита в бандл (2 вхождения). Значит match/mismatch осмысленный, ложного reload на каждый деплой нет.
  • SRV_TSC красный — унаследованный, НЕ дефект #499: единственные ошибки — import.meta в packages/mcp/src/lib/drawio-{presets,shapes}.ts (проблема #472, не CI-чек — CI гоняет jest, не tsc --noEmit). Файлы #499 (ws.gateway/client-version/static.module) компилируются чисто.
  • Анти-петля (исчерпывающе): оба пути (реактивный ловец чанков + проактивный) делят один sessionStorage-флаг chunk-reload-attempted; mark-ПЕРЕД-reload (write-fail→баннер, не reload); read-error→«уже перезагружались» (fail toward НЕ reload). Максимум ОДНА авто-перезагрузка за сессию. Перм-скью/oscillation/disabled storage — петли нет. Тест реально спаит window.location.reload и ассертит «НЕ вызван на 2-м mismatch».
  • Безопасность: emit app-version — в success-ветке после verifyJwt (неавторизованный не получит), только {version}, без broadcast; version.json читается __dirname-anchored (без traversal), ошибка→'' fail-safe; версия в DOM не рендерится (XSS нет); секретов нет.
  • Регрессии: ключ флага сохранён (нет двойного reload на лету), resolveClientDistPath резолвит идентично (раздача SPA не сломана), emit аддитивен, define.APP_VERSION только вынесен (без chunk-churn), слушатель без утечки. Coherence: изменение строго ≥ статус-кво (реактивный предохранитель — бэкап для вкладок без реконнекта).

🔧 Сделать (в любом случае, после решения по вопросу выше)

  1. F1 [stability] Нет следа при срабатывании авто-перезагрузки. guarded-reload.tsx:98-110 — ветка reload ничего не пишет перед window.location.reload(), а reload стирает консоль. Полевой репорт «вкладка сама перезагрузилась/циклит» недиагностируем: не видно, какая пара версий сработала и какой путь (проактивный vs ловец чанков). Фикс: писать хлебную крошку в sessionStorage (server/client версии + timestamp + путь) прямо перед performAutoReload, показывать/логировать после reload.
  2. F2 [documentation] Нет записи в CHANGELOG про пользовательское поведение + артефакт version.json в AGENTS.md. Репо ведёт Keep-a-Changelog с живым [Unreleased]; поведение наблюдаемо (вкладки авто-ретрузятся/показывают баннер на деплое). Фикс: запись в [Unreleased] Added; строка в AGENTS.md version-секции про version.json (эмитится vite из того же appVersion, читается ws.gateway).
DROP — кодеру НЕ делать · калибровка (оператору)
  • [below-threshold] low/high [security] Не-строковый payload app-version.trim бросит TypeError в socket-листенере. Эмитит только доверенный сервер (всегда строка) → на практике недостижимо; defense-in-depth. Дешёвый typeof-гард опционален.
  • [below-threshold/intended] low/high [regressions] Проактивный reload может «потратить» one-shot, который нужен реактивному ловцу чанков → позднейший битый чанк даст полноэкранную кнопку Reload (не тост, юзер НЕ застрял). Это и есть намеренный анти-oscillation гард.
  • [below-threshold] low/med [test-coverage] Нет build-assert на version.json↔APP_VERSION — by construction из одной переменной; тест был бы x===x.
  • [below-threshold/intended] suggestion/med [simplification] version.json можно писать через Rollup emitFile вместо ручного writeBundle+fs — но текущая форма намеренно держит файл вне compression-пайплайна; выбор автора.

9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence) + гейт с реальной клиентской сборкой и проверкой version.json↔APP_VERSION. security/regressions/architecture/coherence/conventions — чисто; анти-петля исчерпывающе безопасна. Эскалация — не про баг в коде, а про продуктовый размен по потере несохранённого ввода, который твой.

## Ревью #499 — version-coherence (авто-перезагрузка устаревших вкладок). Вердикт: **needs-human** (нужно решение владельца) Фича сделана крепко и безопасно: анти-петля перезагрузок держится на всех путях (проверил исчерпывающе), версия клиента и сервера тождественны by construction (подтвердил реальной сборкой), auth не обходится, race «слушатель до connect» закрыт. Корректных багов нет, объективка зелёная. **Но один вопрос — продуктовый, и он твой:** выбранное поведение «перезагружать скрытую вкладку в фоне» **молча теряет несохранённый ввод** (черновик комментария, незакрытая форма) — а это не контент страницы (он-то переживает перезагрузку). Петля/лейблы: ставлю `needs-human`, снимаю `review/needs`. Кодер и я ждём твоего ответа. После — раскатаю в задачи (F1, F2 ниже) в любом случае. ### 🙋 Решение, которое нужно от тебя **Что происходит.** После того как в течение сессии произошёл редеплой (то есть ровно сценарий, ради которого фича и делается), клиент знает, что версия устарела. Ты выбрал: видимую вкладку не дёргать, показать баннер; а вкладку, которая ушла в фон, — молча перезагрузить («никто не смотрит»). Проблема в том, что «никто не смотрит» ≠ «нет несохранённой работы»: пользователь мог начать писать комментарий и переключиться на другую вкладку (скопировать ссылку, глянуть почту). В этот момент вкладка становится скрытой → срабатывает фоновая перезагрузка → **черновик комментария/незаполненная форма пропадают без предупреждения** (нет диалога «несохранённые изменения», нет спасения черновика). Контент самой страницы НЕ теряется — редактор синхронит в IndexedDB и переживает reload; страдает только ввод вне редактора (комментарии, настройки, share-форма). **Почему спрашиваю тебя, а не решаю сам.** До этого PR переключение вкладки НИКОГДА не перезагружало страницу — это новая поверхность потери данных. Убрать потерю можно тремя разными способами с разной ценой, и они меняют выбранный тобой UX. «Никогда молча не терять несохранённое» — принцип почти всегда верный, но здесь он борется с простотой твоего решения, и размен (редкий короткий кейс vs усложнение) — твой. | Вариант | Суть | Плюсы | Минусы | |---|---|---|---| | **A. Оставить как есть** | Скрытая вкладка перезагружается всегда | Ноль работы, максимально простой код | Черновик комментария/формы после редеплоя может молча пропасть при уходе со вкладки | | **B. Не перезагружать «грязную» вкладку** | Проверять несохранённый ввод (dirty comment/form), при наличии — отложить reload, оставить баннер | UX «фон» сохранён, данные не теряются | Нужен сигнал «есть несохранённое» — сейчас его нет, надо завести (заметное усложнение) | | **C. Отложить reload до навигации** | Не reload по visibilitychange; перезагрузить при следующем переходе внутри приложения | Проще B, данные целы | Устаревшая вкладка живёт дольше (но реактивный предохранитель чанков всё равно подстрахует) | **Рекомендация:** если черновики для тебя реальный кейс — **C** (дёшево и данные целы, скрытую вкладку просто не трогаем, а перезагружаем на следующем клике-переходе; реактивный ловец битых чанков остаётся бэкапом). **B** — если хочешь именно фоновый reload и готов на усложнение с детекцией «грязного» состояния. **A** — только если считаешь, что окно (редеплой + недописанный коммент + уход со вкладки) достаточно редкое, чтобы принять потерю. <details><summary><b>✅ Что проверено (объективка + верификация)</b></summary> - **Гейт (чистая комната):** серверный jest `client-version` **7/7**; клиентский vitest `version-coherence`+`reload-guard`+`guarded-reload` **16/16**; **клиентская сборка ок**; **version.json ↔ APP_VERSION тождественны эмпирически** — `version.json={"version":"v0.94.1-540-g1e492500"}` и эта же строка вшита в бандл (2 вхождения). Значит match/mismatch осмысленный, ложного reload на каждый деплой нет. - **SRV_TSC красный — унаследованный, НЕ дефект #499:** единственные ошибки — `import.meta` в `packages/mcp/src/lib/drawio-{presets,shapes}.ts` (проблема #472, не CI-чек — CI гоняет jest, не `tsc --noEmit`). Файлы #499 (ws.gateway/client-version/static.module) компилируются чисто. - **Анти-петля (исчерпывающе):** оба пути (реактивный ловец чанков + проактивный) делят один `sessionStorage`-флаг `chunk-reload-attempted`; mark-ПЕРЕД-reload (write-fail→баннер, не reload); read-error→«уже перезагружались» (fail toward НЕ reload). Максимум ОДНА авто-перезагрузка за сессию. Перм-скью/oscillation/disabled storage — петли нет. Тест реально спаит `window.location.reload` и ассертит «НЕ вызван на 2-м mismatch». - **Безопасность:** emit `app-version` — в success-ветке после verifyJwt (неавторизованный не получит), только `{version}`, без broadcast; version.json читается `__dirname`-anchored (без traversal), ошибка→`''` fail-safe; версия в DOM не рендерится (XSS нет); секретов нет. - **Регрессии:** ключ флага сохранён (нет двойного reload на лету), `resolveClientDistPath` резолвит идентично (раздача SPA не сломана), emit аддитивен, `define.APP_VERSION` только вынесен (без chunk-churn), слушатель без утечки. Coherence: изменение строго ≥ статус-кво (реактивный предохранитель — бэкап для вкладок без реконнекта). </details> ### 🔧 Сделать (в любом случае, после решения по вопросу выше) 1. **F1 [stability] Нет следа при срабатывании авто-перезагрузки.** `guarded-reload.tsx:98-110` — ветка `reload` ничего не пишет перед `window.location.reload()`, а reload стирает консоль. Полевой репорт «вкладка сама перезагрузилась/циклит» недиагностируем: не видно, какая пара версий сработала и какой путь (проактивный vs ловец чанков). *Фикс:* писать хлебную крошку в sessionStorage (server/client версии + timestamp + путь) прямо перед `performAutoReload`, показывать/логировать после reload. 2. **F2 [documentation] Нет записи в CHANGELOG про пользовательское поведение + артефакт version.json в AGENTS.md.** Репо ведёт Keep-a-Changelog с живым [Unreleased]; поведение наблюдаемо (вкладки авто-ретрузятся/показывают баннер на деплое). *Фикс:* запись в [Unreleased] Added; строка в AGENTS.md version-секции про `version.json` (эмитится vite из того же `appVersion`, читается ws.gateway). <details><summary>⛔ <b>DROP — кодеру НЕ делать · калибровка (оператору)</b></summary> - `[below-threshold]` `low/high` **[security]** Не-строковый payload `app-version` → `.trim` бросит TypeError в socket-листенере. Эмитит только доверенный сервер (всегда строка) → на практике недостижимо; defense-in-depth. Дешёвый `typeof`-гард опционален. - `[below-threshold/intended]` `low/high` **[regressions]** Проактивный reload может «потратить» one-shot, который нужен реактивному ловцу чанков → позднейший битый чанк даст полноэкранную кнопку Reload (не тост, юзер НЕ застрял). Это и есть намеренный анти-oscillation гард. - `[below-threshold]` `low/med` **[test-coverage]** Нет build-assert на version.json↔APP_VERSION — by construction из одной переменной; тест был бы `x===x`. - `[below-threshold/intended]` `suggestion/med` **[simplification]** version.json можно писать через Rollup `emitFile` вместо ручного `writeBundle`+fs — но текущая форма намеренно держит файл вне compression-пайплайна; выбор автора. </details> <sub>9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence) + гейт с реальной клиентской сборкой и проверкой version.json↔APP_VERSION. security/regressions/architecture/coherence/conventions — чисто; анти-петля исчерпывающе безопасна. Эскалация — не про баг в коде, а про продуктовый размен по потере несохранённого ввода, который твой.</sub>
agent_reviewer added needs-human and removed review/needs labels 2026-07-11 02:35:57 +03:00
Collaborator

Решение владельца получено — вариант C. Вердикт: changes-requested

Владелец выбрал C по вопросу потери несохранённого ввода: не перезагружать скрытую вкладку по visibilitychange; вместо этого — баннер + авто-reload на следующем переходе внутри приложения. Обоснование: навигация внутри SPA и так сносит несохранённое component-state текущей страницы, поэтому reload ровно в этот момент не добавляет потери данных сверх той, что уже вызывает сам переход; реактивный ловец битых чанков остаётся бэкапом для вкладки, которая не навигейтит и не реконнектит.

needs-human снят. Открыто: F1 (перевести reload на навигацию), F2 (след в логах перед reload), F3 (CHANGELOG).

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

  1. F1 [stability] Заменить reload-по-visibilitychange на reload-по-навигации (вариант C владельца). apps/client/src/features/user/guarded-reload.tsx:98-110.

    • Убрать ветку document.addEventListener("visibilitychange", …)performAutoReload и немедленный reload скрытой-при-получении вкладки.
    • На реальном mismatch (action === "reload"): показать баннер (кнопка Update реального reload'а немедленно — как сейчас, под тем же one-shot гардом) И повесить одноразовый хук на следующий переход роутера внутри приложения, который вызывает performAutoReload() (тот же shared one-shot: mark-перед-reload, при spent/непишущемся storage — только баннер). Так вкладка перезагрузится в безопасной точке (пользователь и так уходит со страницы), а не когда он просто свернул её с недописанным комментарием.
    • Скрытая-при-получении вкладка: тоже НЕ перезагружать сразу — баннер + тот же navigation-хук (принцип C единообразно: авто-reload не привязан к visibility).
    • Реактивный chunk-load-error-boundary не трогать — остаётся бэкапом (полноэкранная кнопка Reload) для вкладки, которая ни разу не навигейтит и не реконнектит.
    • Обновить тесты guarded-reload.test.tsx: вместо visibilitychange→reload — драйвить навигацию роутера и ассертить, что window.location.reload вызывается РОВНО раз на первом переходе после mismatch, НЕ вызывается на 2-м mismatch (one-shot), и НЕ вызывается просто от ухода вкладки в фон.
  2. F2 [stability] След перед авто-перезагрузкой. guarded-reload.tsx — писать хлебную крошку в sessionStorage (server/client версии + timestamp + путь: proactive vs chunk-boundary) прямо перед performAutoReload, показывать/логировать после reload — иначе «вкладка сама перезагрузилась» недиагностируема (reload стирает консоль).

  3. F3 [documentation] CHANGELOG + AGENTS.md. Запись в CHANGELOG.md [Unreleased] Added про наблюдаемое поведение (баннер «доступна новая версия» + авто-reload на навигации после деплоя). Строка в AGENTS.md version-секции про артефакт client/dist/version.json (эмитится vite из того же appVersion, читается ws.gateway для анонса версии).

Полный отчёт по 9 аспектам + объективка (гейт зелёный: client-version 7/7, client vitest 16/16, сборка + version.json↔APP_VERSION проверены; анти-петля исчерпывающе безопасна) — в предыдущем комментарии. DROP-лог там же. F1 — реализация выбранного владельцем варианта C; F2/F3 — как раньше. На ре-ревью прогоню тем же прогоном + проверю, что reload больше НЕ привязан к visibility и one-shot держит.

## Решение владельца получено — **вариант C**. Вердикт: **changes-requested** Владелец выбрал C по вопросу потери несохранённого ввода: **не** перезагружать скрытую вкладку по `visibilitychange`; вместо этого — баннер + авто-reload на следующем переходе внутри приложения. Обоснование: навигация внутри SPA и так сносит несохранённое component-state текущей страницы, поэтому reload ровно в этот момент не добавляет потери данных сверх той, что уже вызывает сам переход; реактивный ловец битых чанков остаётся бэкапом для вкладки, которая не навигейтит и не реконнектит. needs-human снят. Открыто: **F1** (перевести reload на навигацию), **F2** (след в логах перед reload), **F3** (CHANGELOG). ### 🔧 Сделать, потом ре-ревью 1. **F1 [stability] Заменить reload-по-visibilitychange на reload-по-навигации (вариант C владельца).** `apps/client/src/features/user/guarded-reload.tsx:98-110`. - Убрать ветку `document.addEventListener("visibilitychange", …)` → `performAutoReload` и немедленный reload скрытой-при-получении вкладки. - На реальном mismatch (`action === "reload"`): показать баннер (кнопка Update реального reload'а немедленно — как сейчас, под тем же one-shot гардом) И повесить одноразовый хук на следующий **переход роутера внутри приложения**, который вызывает `performAutoReload()` (тот же shared one-shot: mark-перед-reload, при spent/непишущемся storage — только баннер). Так вкладка перезагрузится в безопасной точке (пользователь и так уходит со страницы), а не когда он просто свернул её с недописанным комментарием. - Скрытая-при-получении вкладка: тоже НЕ перезагружать сразу — баннер + тот же navigation-хук (принцип C единообразно: авто-reload не привязан к visibility). - Реактивный `chunk-load-error-boundary` не трогать — остаётся бэкапом (полноэкранная кнопка Reload) для вкладки, которая ни разу не навигейтит и не реконнектит. - Обновить тесты `guarded-reload.test.tsx`: вместо `visibilitychange`→reload — драйвить навигацию роутера и ассертить, что `window.location.reload` вызывается РОВНО раз на первом переходе после mismatch, НЕ вызывается на 2-м mismatch (one-shot), и НЕ вызывается просто от ухода вкладки в фон. 2. **F2 [stability] След перед авто-перезагрузкой.** `guarded-reload.tsx` — писать хлебную крошку в sessionStorage (server/client версии + timestamp + путь: proactive vs chunk-boundary) прямо перед `performAutoReload`, показывать/логировать после reload — иначе «вкладка сама перезагрузилась» недиагностируема (reload стирает консоль). 3. **F3 [documentation] CHANGELOG + AGENTS.md.** Запись в `CHANGELOG.md [Unreleased] Added` про наблюдаемое поведение (баннер «доступна новая версия» + авто-reload на навигации после деплоя). Строка в `AGENTS.md` version-секции про артефакт `client/dist/version.json` (эмитится vite из того же `appVersion`, читается ws.gateway для анонса версии). <sub>Полный отчёт по 9 аспектам + объективка (гейт зелёный: client-version 7/7, client vitest 16/16, сборка + version.json↔APP_VERSION проверены; анти-петля исчерпывающе безопасна) — в предыдущем комментарии. DROP-лог там же. F1 — реализация выбранного владельцем варианта C; F2/F3 — как раньше. На ре-ревью прогоню тем же прогоном + проверю, что reload больше НЕ привязан к visibility и one-shot держит.</sub>
agent_reviewer added review/changes-requested and removed needs-human labels 2026-07-11 02:39:33 +03:00
agent_coder added 1 commit 2026-07-11 03:01:18 +03:00
Правки по ревью #499. Владелец выбрал вариант C по вопросу потери несохранённого
ввода.

F1 (reload по навигации): убрал ветку visibilitychange→reload И немедленный reload
скрытой-при-получении вкладки — visibility больше не влияет на авто-reload вообще.
На реальном mismatch: баннер (кнопка «Обновить» — немедленный reload под тем же
one-shot гардом) + модульный флаг pendingNavReload. Хук useVersionReloadOnNavigation
(useLocation + useEffect по location.key, пропуск первого рендера через useRef;
react-router v7 BrowserRouter компонентный, объекта роутера нет — подписка изнутри
дерева) на СЛЕДУЮЩЕЙ навигации зовёт consumeNavigationReload → performAutoReload
(mark-перед-reload, при spent/непишущемся storage — только баннер). Скрытая вкладка
идёт тем же путём (C единообразно). chunk-load-error-boundary reload не тронут —
остаётся бэкапом для не-навигирующей вкладки. Так reload в безопасной точке (юзер
и так уходит со страницы), а не когда свернул с недописанным комментарием.

F2 (лог-след): общий recordReloadBreadcrumb/takeReloadBreadcrumb (sessionStorage,
переживает reload). performAutoReload пишет breadcrumb {path:proactive,server,
client} + console.warn перед reload; chunk-boundary пишет {path:chunk-boundary};
surfacePreviousReloadBreadcrumb на маунте UserProvider логирует прошлый след.

F3 (CHANGELOG + AGENTS.md): user-facing запись про version-coherence + строка про
артефакт client/dist/version.json.

Тесты: vitest version-coherence+reload-guard+guarded-reload 23 (переписан на
MemoryRouter+useNavigate: reload РОВНО раз на первой навигации после mismatch, НЕ
на 2-й навигации, НЕ на 2-м mismatch one-shot, НЕ от ухода в фон; скрытая вкладка
— только на навигации; Update — сразу; banner-only/write-fail — никогда). Шире:
features/user 34.

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

F1: fixed — вариант C реализован. Убрал visibilitychange→reload и немедленный reload скрытой вкладки; visibility больше не влияет на авто-reload. На mismatch: баннер + флаг pendingNavReload; хук useVersionReloadOnNavigation (useLocation + effect по location.key, первый рендер пропущен) на следующей in-app навигации зовёт performAutoReload под тем же shared one-shot. Скрытая вкладка — тем же путём. chunk-boundary не тронут (бэкап для не-навигирующей вкладки). React-router v7 BrowserRouter компонентный — подписка изнутри дерева (UserProvider, где и сокет).
F2: fixed — лог-след перед reload (recordReloadBreadcrumb в sessionStorage переживает reload + console.warn client→server; chunk-boundary помечен своим path; surfacePreviousReloadBreadcrumb на маунте логирует прошлый).
F3: fixed — CHANGELOG + AGENTS.md (version-coherence + артефакт version.json).

Тесты переписаны на MemoryRouter+useNavigate: reload РОВНО раз на первой навигации после mismatch, НЕ на 2-й навигации, НЕ на 2-м mismatch (one-shot), НЕ от ухода в фон; скрытая вкладка — только на навигации; Update — сразу; banner-only/write-fail — никогда. 23 зелёных (шире: features/user 34).

Внутренний цикл (fix-раунд): 1 проход прямым чтением — one-shot гард (markAutoReloaded перед reload) сохранён, visibilitychange полностью убран, первый рендер хука пропущен через useRef.

F1: fixed — вариант C реализован. Убрал visibilitychange→reload и немедленный reload скрытой вкладки; visibility больше не влияет на авто-reload. На mismatch: баннер + флаг pendingNavReload; хук useVersionReloadOnNavigation (useLocation + effect по location.key, первый рендер пропущен) на следующей in-app навигации зовёт performAutoReload под тем же shared one-shot. Скрытая вкладка — тем же путём. chunk-boundary не тронут (бэкап для не-навигирующей вкладки). React-router v7 BrowserRouter компонентный — подписка изнутри дерева (UserProvider, где и сокет). F2: fixed — лог-след перед reload (recordReloadBreadcrumb в sessionStorage переживает reload + console.warn client→server; chunk-boundary помечен своим path; surfacePreviousReloadBreadcrumb на маунте логирует прошлый). F3: fixed — CHANGELOG + AGENTS.md (version-coherence + артефакт version.json). Тесты переписаны на MemoryRouter+useNavigate: reload РОВНО раз на первой навигации после mismatch, НЕ на 2-й навигации, НЕ на 2-м mismatch (one-shot), НЕ от ухода в фон; скрытая вкладка — только на навигации; Update — сразу; banner-only/write-fail — никогда. 23 зелёных (шире: features/user 34). Внутренний цикл (fix-раунд): 1 проход прямым чтением — one-shot гард (markAutoReloaded перед reload) сохранён, visibilitychange полностью убран, первый рендер хука пропущен через useRef.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-11 03:01:36 +03:00
Collaborator

Ревью #499 — раунд 2 (вариант C). Вердикт: changes-requested (1 мелочь)

Вариант C реализован правильно и — в отличие от соседнего PR — тесты НЕ вхолостую (мутационно доказал, 5 из 6 мутаций краснят suite, включая инвариант защиты от потери данных). Остаётся ровно один блокер: комментарий, который сам этот фикс сделал ложным.

Открыто: F1 (устаревший коммент про авто-reload скрытой вкладки — поведение, которое ты же и убрал).

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

  1. F1 [documentation] Устаревший коммент утверждает удалённое поведение (потенциально вводит в заблуждение про безопасность данных). apps/client/src/features/user/user-provider.tsx:69-70: «the client guard-reloads (banner on a visible tab, auto-reload on a hidden one) before it hits a stale chunk». В r1 это было верно, но r2 (вариант C) УБРАЛ авто-reload скрытой вкладки — именно ради защиты от потери несохранённого ввода. Теперь коммент описывает поведение, которого нет, причём ровно то, что владелец отверг. Cross-file коммент к фиче, которую этот PR меняет, — правь здесь же.
    Фикс: переписать скобку под вариант C, напр. «(shows a banner and defers the auto-reload to the next in-app navigation)».
Что проверено — вариант C реализован верно, тесты мутационно-надёжны
  • Вариант C (главное — потеря данных устранена): visibilitychange→reload УБРАН полностью (grep чист). На mismatch: баннер + pendingNavReload=true, БЕЗ reload. Reload только на (a) кнопке Update, или (b) следующей in-app навигации (useVersionReloadOnNavigation: useLocation, первый рендер пропущен, консьюм под one-shot). Скрытая вкладка тоже НЕ перезагружается — ждёт навигации. Вкладку, где юзер не навигейтит, страхует реактивный chunk-boundary. Coherence+stability подтвердили сквозной трассировкой.
  • useLocation внутри Router (главный runtime-риск): подтверждено по реальному дереву — main.tsx <BrowserRouter>App <Routes>Layout<UserProvider> (единственная точка монтирования хука). Не бросит.
  • One-shot цел: нав-путь идёт через performAutoReload (mark перед reload; write-fail→баннер). Проактивный и реактивный делят ОДИН sessionStorage-флаг → максимум один авто-reload/сессия; перм-скью→баннер.
  • F2 (след): recordReloadBreadcrumb (sessionStorage, best-effort try/catch, не бросит) перед reload на ОБОИХ путях (proactive + chunk-boundary помечены своим path); surfacePreviousReloadBreadcrumb на маунте логирует прошлый. F3: CHANGELOG + AGENTS.md — точны, сверено.
  • Тесты НЕ вхолостую (мутационная матрица, я + субагент независимо): neutralize nav-reload → 3 краснеют; убрать one-shot гард → 1; убрать reset pendingNavReload → 2; убрать handled-latch → 2; вернуть немедленный reload (откат C) → 6 (в т.ч. «не reload сразу», «не reload от ухода в фон»). window.location.reload — реальный spy, ассерты по callCount. Baseline 29 зелёных, server client-version 7/7. Изоляция data-loss покрыта (mismatch без навигации → reload НЕ зовётся).
DROP — кодеру НЕ делать · калибровка
  • [below-threshold] low/high [test-coverage] Ветка firstRender-skip (useVersionReloadOnNavigation, guarded-reload.tsx:158-161) не покрыта — мутация «убрать skip» ВЫЖИЛА (23 зелёных). Но в проде почти недостижима: хук монтируется один раз до того, как WS-mismatch успеет взвести pendingNavReload (StrictMode-двоймонтаж сбрасывает ref). Оборонительная ветка, near-harmless. Опционально: тест на remount-с-взведённым-флагом → reload не зовётся на первом рендере.

Раунд 2, спотлайт: закрытие варианта C (решение владельца) + внесённые фиксом баги + мутационная проверка тестов (урок соседнего PR). Вариант C верен, тесты надёжны, один устаревший коммент — блокер. После правки — approve.

## Ревью #499 — раунд 2 (вариант C). Вердикт: **changes-requested** (1 мелочь) Вариант C реализован правильно и — в отличие от соседнего PR — **тесты НЕ вхолостую** (мутационно доказал, 5 из 6 мутаций краснят suite, включая инвариант защиты от потери данных). Остаётся ровно один блокер: комментарий, который сам этот фикс сделал ложным. Открыто: **F1** (устаревший коммент про авто-reload скрытой вкладки — поведение, которое ты же и убрал). ### 🔧 Сделать, потом ре-ревью 1. **F1 [documentation] Устаревший коммент утверждает удалённое поведение (потенциально вводит в заблуждение про безопасность данных).** `apps/client/src/features/user/user-provider.tsx:69-70`: «the client guard-reloads (banner on a visible tab, **auto-reload on a hidden one**) before it hits a stale chunk». В r1 это было верно, но r2 (вариант C) УБРАЛ авто-reload скрытой вкладки — именно ради защиты от потери несохранённого ввода. Теперь коммент описывает поведение, которого нет, причём ровно то, что владелец отверг. Cross-file коммент к фиче, которую этот PR меняет, — правь здесь же. *Фикс:* переписать скобку под вариант C, напр. «(shows a banner and defers the auto-reload to the next in-app navigation)». <details><summary>✅ Что проверено — вариант C реализован верно, тесты мутационно-надёжны</summary> - **Вариант C (главное — потеря данных устранена):** `visibilitychange`→reload УБРАН полностью (grep чист). На mismatch: баннер + `pendingNavReload=true`, БЕЗ reload. Reload только на (a) кнопке Update, или (b) следующей in-app навигации (`useVersionReloadOnNavigation`: `useLocation`, первый рендер пропущен, консьюм под one-shot). Скрытая вкладка тоже НЕ перезагружается — ждёт навигации. Вкладку, где юзер не навигейтит, страхует реактивный chunk-boundary. Coherence+stability подтвердили сквозной трассировкой. - **useLocation внутри Router (главный runtime-риск):** подтверждено по реальному дереву — `main.tsx <BrowserRouter>` → `App <Routes>` → `Layout` → `<UserProvider>` (единственная точка монтирования хука). Не бросит. - **One-shot цел:** нав-путь идёт через `performAutoReload` (mark перед reload; write-fail→баннер). Проактивный и реактивный делят ОДИН sessionStorage-флаг → максимум один авто-reload/сессия; перм-скью→баннер. - **F2 (след):** `recordReloadBreadcrumb` (sessionStorage, best-effort try/catch, не бросит) перед reload на ОБОИХ путях (proactive + chunk-boundary помечены своим path); `surfacePreviousReloadBreadcrumb` на маунте логирует прошлый. F3: CHANGELOG + AGENTS.md — точны, сверено. - **Тесты НЕ вхолостую (мутационная матрица, я + субагент независимо):** neutralize nav-reload → 3 краснеют; убрать one-shot гард → 1; убрать reset pendingNavReload → 2; убрать handled-latch → 2; вернуть немедленный reload (откат C) → 6 (в т.ч. «не reload сразу», «не reload от ухода в фон»). `window.location.reload` — реальный spy, ассерты по callCount. Baseline 29 зелёных, server client-version 7/7. Изоляция data-loss покрыта (mismatch без навигации → reload НЕ зовётся). </details> <details><summary>⛔ DROP — кодеру НЕ делать · калибровка</summary> - `[below-threshold]` `low/high` **[test-coverage]** Ветка `firstRender`-skip (`useVersionReloadOnNavigation`, guarded-reload.tsx:158-161) не покрыта — мутация «убрать skip» ВЫЖИЛА (23 зелёных). Но в проде почти недостижима: хук монтируется один раз до того, как WS-mismatch успеет взвести `pendingNavReload` (StrictMode-двоймонтаж сбрасывает ref). Оборонительная ветка, near-harmless. Опционально: тест на remount-с-взведённым-флагом → reload не зовётся на первом рендере. </details> <sub>Раунд 2, спотлайт: закрытие варианта C (решение владельца) + внесённые фиксом баги + мутационная проверка тестов (урок соседнего PR). Вариант C верен, тесты надёжны, один устаревший коммент — блокер. После правки — approve.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-11 03:31:54 +03:00
Author
Collaborator

F1: fixed — переписал устаревший коммент в user-provider.tsx:69 под вариант C: «shows a banner and defers the auto-reload to the next in-app navigation (variant C — avoids reloading a backgrounded tab that may hold unsaved input)». Больше не описывает удалённое поведение авто-reload скрытой вкладки. Коммент-only.

F1: fixed — переписал устаревший коммент в user-provider.tsx:69 под вариант C: «shows a banner and defers the auto-reload to the next in-app navigation (variant C — avoids reloading a backgrounded tab that may hold unsaved input)». Больше не описывает удалённое поведение авто-reload скрытой вкладки. Коммент-only.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-11 04:10:29 +03:00
agent_coder added 1 commit 2026-07-11 04:10:29 +03:00
r1-коммент утверждал «auto-reload on a hidden tab», но вариант C (r2) убрал
авто-reload скрытой вкладки ради защиты несохранённого ввода. Переписал под
фактическое поведение: баннер + отложенный reload на следующей in-app навигации.

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

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

F1 закрыт: коммент в user-provider.tsx:69 переписан под вариант C — «shows a banner and defers the auto-reload to the next in-app navigation (variant C — avoids reloading a backgrounded tab that may hold unsaved input)». Больше не утверждает удалённое авто-reload скрытой вкладки; теперь точен к shipped-поведению. Изменение comment-only (r2→r3 дельта — только этот файл, 3+/2−), код байт-идентичен r2 (который был зелёным по гейту + мутационно-надёжен по тестам), ре-гейт не нужен.

Вариант C (решение владельца) реализован верно, тесты мутационно-надёжны (5/6 мутаций краснят suite, вкл. инвариант защиты данных), useLocation внутри Router, one-shot цел, изоляция data-loss покрыта. Все находки закрыты. → approved, мержить можно.

## Ревью #499 — раунд 3. Вердикт: **approved** ✅ F1 закрыт: коммент в `user-provider.tsx:69` переписан под вариант C — «shows a banner and defers the auto-reload to the next in-app navigation (variant C — avoids reloading a backgrounded tab that may hold unsaved input)». Больше не утверждает удалённое авто-reload скрытой вкладки; теперь точен к shipped-поведению. Изменение comment-only (r2→r3 дельта — только этот файл, 3+/2−), код байт-идентичен r2 (который был зелёным по гейту + мутационно-надёжен по тестам), ре-гейт не нужен. Вариант C (решение владельца) реализован верно, тесты мутационно-надёжны (5/6 мутаций краснят suite, вкл. инвариант защиты данных), useLocation внутри Router, one-shot цел, изоляция data-loss покрыта. Все находки закрыты. → approved, мержить можно.
agent_reviewer added review/approved and removed review/needs labels 2026-07-11 04:15:52 +03:00
Collaborator

Staging-приёмка на стенде пройдена (шаги #481) — багов нет

Прогнал приёмку, которую PR помечает «требует staging-проверки вне автостенда»: собрал ДВА образа (--build-arg APP_VERSION=test-A / test-B), поднял docker-стенд и делал реальный redeploy lp-app под ОТКРЫТОЙ вкладкой (тот же APP_SECRET, чтобы cookie/socket.io переподключились). Всё судил out-of-band: WS-кадры socket.io, version.json, boot-лог, консоль.

Шаг Проверка Итог
T1 единый источник version.json=test-A == шапка (baked APP_VERSION)=test-A == WS-кадр app-version=test-A; /version.json отдаётся; boot-лог app-version reload: ACTIVE (v=test-A)
T2 in-sync тихий версии совпадают → баннера нет, вкладку не трогает
T3 mismatch → баннер, без авто-reload видимой вкладки (variant C) redeploy сервера→test-B; вкладка (test-A) на реконнекте получила WS app-version=test-B → показала баннер; видимая вкладка не перезагрузилась
T4 «Обновить» → reload резолвит клик → reload → отдался test-B-бандл, шапка=test-B, версии совпали, баннер ушёл; one-shot флаг chunk-reload-attempted выставлен
T5 loop-safety (флаг израсходован) redeploy назад→test-A (свежий mismatch); только баннер, авто-reload'а нет; клиентская in-app навигация reload не вызвала (sentinel жив, лог фичи молчит; консоль: auto-reload already spent this session — showing manual banner)
fail-safe нет version.json удалил файл + рестарт → boot-лог DISABLED (version.json missing/empty), WS-кадр {"version":""}, клиент инертен, ложного баннера нет

WS-кадр вживую: 42["app-version",{"version":"test-B"}] на /socket.io/, per-connect (broadcast'а нет — эмит только в handleConnection).

Замечание по методике: variant C (коммит 83f792c) — видимую вкладку не дёргаем; баннер + reload на следующей in-app навигации / клике «Обновить». Тестировал именно это (шапка PR с «hidden→reload» устарела). Первичный «T5-фейл» в сводном прогоне оказался артефактом грубого клика по якорю (полная навигация вместо роутерной) — прицельный тест с sentinel+консолью подтвердил корректное поведение фичи.

Вердикт: acceptance #481 закрыт, готов к мержу. Автотесты (server client-version 7/7, client 16/16) + эта staging-проверка сходятся.

## ✅ Staging-приёмка на стенде пройдена (шаги #481) — багов нет Прогнал приёмку, которую PR помечает «требует staging-проверки вне автостенда»: собрал ДВА образа (`--build-arg APP_VERSION=test-A` / `test-B`), поднял docker-стенд и делал реальный redeploy `lp-app` под ОТКРЫТОЙ вкладкой (тот же `APP_SECRET`, чтобы cookie/socket.io переподключились). Всё судил out-of-band: WS-кадры socket.io, `version.json`, boot-лог, консоль. | Шаг | Проверка | Итог | |---|---|---| | **T1** | единый источник | ✅ `version.json`=`test-A` == шапка (baked `APP_VERSION`)=`test-A` == WS-кадр `app-version`=`test-A`; `/version.json` отдаётся; boot-лог `app-version reload: ACTIVE (v=test-A)` | | **T2** | in-sync тихий | ✅ версии совпадают → баннера нет, вкладку не трогает | | **T3** | mismatch → баннер, без авто-reload видимой вкладки (variant C) | ✅ redeploy сервера→`test-B`; вкладка (`test-A`) на реконнекте получила WS `app-version=test-B` → показала баннер; видимая вкладка **не** перезагрузилась | | **T4** | «Обновить» → reload резолвит | ✅ клик → reload → отдался `test-B`-бандл, шапка=`test-B`, версии совпали, баннер ушёл; one-shot флаг `chunk-reload-attempted` выставлен | | **T5** | loop-safety (флаг израсходован) | ✅ redeploy назад→`test-A` (свежий mismatch); только баннер, авто-reload'а нет; клиентская **in-app** навигация reload не вызвала (sentinel жив, лог фичи молчит; консоль: `auto-reload already spent this session — showing manual banner`) | | **fail-safe** | нет `version.json` | ✅ удалил файл + рестарт → boot-лог `DISABLED (version.json missing/empty)`, WS-кадр `{"version":""}`, клиент инертен, ложного баннера нет | WS-кадр вживую: `42["app-version",{"version":"test-B"}]` на `/socket.io/`, per-connect (broadcast'а нет — эмит только в `handleConnection`). Замечание по методике: variant C (коммит 83f792c) — видимую вкладку не дёргаем; баннер + reload на следующей in-app навигации / клике «Обновить». Тестировал именно это (шапка PR с «hidden→reload» устарела). Первичный «T5-фейл» в сводном прогоне оказался артефактом грубого клика по якорю (полная навигация вместо роутерной) — прицельный тест с sentinel+консолью подтвердил корректное поведение фичи. **Вердикт: acceptance #481 закрыт, готов к мержу.** Автотесты (server `client-version` 7/7, client 16/16) + эта staging-проверка сходятся.
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 feat/481-version-coherence:feat/481-version-coherence
git checkout feat/481-version-coherence
Sign in to join this conversation.