# CODE-REVIEW-491-r2 Issue: [#491](https://github.com/Matysh/houseplan-card/issues/491) — «Optimize/Undo: не терять незавершённую парную транзакцию при следующей записи». Материал ревью: ветка `issue/491-optimize-undo-pair-recovery`, **точный SHA `1787e505a192f400c363666b549dbcf46834d90f`** (рабочая копия уже на нём). Заход: r2. Блокирующих циклов израсходовано: 1/4. ТЗ: `docs/specs/491-optimize-undo-pair-recovery.md`, ревью ТЗ зелёное (`SPEC-REVIEW-491-r1`). ## Почему разбор полный, а не по дельте Поверх материала r1 (`4ee83716`, ныне осиротевший после ребейза — SHA умер уже после публикации отчёта r1, это обычное дело, PROCESS §2.10) легли 2 коммита `dev` (`e0e68f25` #496, `9bfe7885` #503). После ребейза на ушедший вперёд `dev` это другой код (§7.2), поэтому разбор — полный, не по дельте, как и предписано в постановке раунда. Проверка содержимым, а не доверием: `git diff 4ee83716 1787e505 --stat` показывает изменения ровно в трёх файлах — `.github/workflows/process.yml`, `scripts/mutation-gate.mjs`, `test/mutation-gate.test.mjs` — все три из несвязанных с #491 коммитов `dev` (CI-инфраструктура ревью-экшна и `package.json`-релевантность гварда). Ни один продуктовый Python-файл (`store.py`, `websocket_api.py`, `__init__.py`), ни один `tests_backend/*.py`, ни один файл документации #491 между r1 и r2 не менялся ни байтом. Это не основание сократить объём разбора (диктует §2.10 полноту при ребейзе), но объясняет, почему полный независимый разбор ниже приходит к тем же выводам, что и r1, а не к новым дефектам в самом протоколе. ## Скоуп диффа `git diff origin/dev...HEAD --stat`: ``` custom_components/houseplan/__init__.py | 86 ++---- custom_components/houseplan/store.py | 151 ++++++++++ custom_components/houseplan/websocket_api.py | 251 ++++++++++++----- docs/ARCHITECTURE.md | 26 +- docs/CHANGELOG.md | 5 + docs/CHANGELOG.ru.md | 7 + docs/CONFIG-COMPATIBILITY.md | 20 ++ docs/TESTING.md | 15 + docs/USER-GUIDE.md | 7 + docs/USER-GUIDE.ru.md | 8 + docs/reviews/CODE-REVIEW-491-r1.md | 304 ++++++++++++++++++++ docs/reviews/SPEC-REVIEW-491-r1.md | 165 +++++++++++ docs/specs/491-optimize-undo-pair-recovery.md | 385 ++++++++++++++++++++++++++ docs/specs/README.md | 1 + scripts/mutation-gate.mjs | 111 ++++++++ tests_backend/test_ha_import_export.py | 31 +++ tests_backend/test_ha_websocket.py | 357 ++++++++++++++++++++++++ 17 files changed, 1789 insertions(+), 141 deletions(-) ``` Backend-only (класс A: `custom_components/houseplan/*.py`; класс B: `scripts/mutation-gate.mjs`, `tests_backend/**`; класс C: `docs/**`). `src/**` не тронут — фронтенд браузерные гейты (smoke, golden, check-docs) вне обязательного набора. 6 коммитов на ветке относительно `origin/dev`, все с `Issue: #491`; коммит `488f70c4` (`User-Visible: yes`) несёт код и правки обоих changelog в одном коммите. ## Закрытие раунда r1 | Находка r1 | Чем закрыта | Где это видно | |---|---|---| | Medium (в скоупе): AC6 («неразрешимый pending блокирует новый write») — защита в продуктовом коде, проверяется только дорогим HA-harness гейтом, не имела названного мутанта | Добавлен мутант `pair-recovery-fence-ignores-resolution-failure` в `scripts/mutation-gate.mjs`, патчащий `except Exception:` в `_resolved_write_pair` так, чтобы отказ `async_resolve_pending_pair` игнорировался и writer продолжал со свежим несведённым `ResolvedStorePair` вместо `commit_failed`; guard — `test_issue_491_failed_fence_blocks_point_write_and_keeps_intent` | Коммит `1787e505` (тот же код, что был в `4ee83716` до ребейза), `scripts/mutation-gate.mjs:7795-7818`. Проверено мной исполнением: `node scripts/mutation-gate.mjs --check --id=pair-recovery-fence-ignores-resolution-failure` → `ok pair-recovery-fence-ignores-resolution-failure` (якорь патча найден ровно один раз в текущем дереве, патч синтаксически валиден — `ResolvedStorePair(config_data=…, layout_data=…)` соответствует сигнатуре dataclass). Сам guard-тест (реальный HA-harness прогон, покрасит ли мутант тест) не воспроизведён ни мной, ни r1 — окружение по-прежнему без Python ≥3.14/HA (см. «Чего не проверял») | ## Унаследовано из r1 Поскольку разбор полный (см. выше), ниже перечислено не «пропущено», а то, что я подтвердил **тем же способом, что r1** (чтение, не исполнение) и пришёл к тем же выводам независимо, без изменений в самом коде между раундами: - **Совместимость со старым форматом pending** (ветка `replace_metadata=False` в `async_converge_store_pair`, `store.py`) — код идентичен тому, что читал r1 (`docs/reviews/CODE-REVIEW-491-r1.md`, раздел «Разбор по коду», материал r1 `4c08b8ab`/`4ee83716`). Я перечитал эту ветку самостоятельно (см. ниже) и подтверждаю тот же вывод. - **AC9 (форматы и совместимость) и AC7 (setup)** через существующие, нетронутые этим диффом тесты `test_setup_recovers_durable_import_pair` и `test_setup_recovers_exact_optimize_storage_roundtrip_pair` — я не перечитывал их текст заново построчно (r1 это сделал), полагаюсь на вывод r1: код `__init__.py`, который они покрывают, я перечитал сам и он не менялся между раундами. - **Документация ARCHITECTURE/CONFIG-COMPATIBILITY/USER-GUIDE построчно сверена с кодом на материале r1** — я самостоятельно перепроверил `ARCHITECTURE.md` и `CONFIG-COMPATIBILITY.md` против кода (см. «Разбор по коду» ниже) и подтверждаю; `TESTING.md` перепроверил и нашёл новое расхождение (Low, см. «Находки»), которого в материале r1 быть не могло, так как мутант, о котором идёт речь, появился только в фиксе r1→r2. Всё остальное — гейты, весь код `websocket_api.py`/`store.py`/`__init__.py`, все девять точек `_resolved_write_pair`, тесты `test_issue_491_*` — я прочитал заново сам в этом раунде (результаты ниже), а не принял на веру. ## Как проверялось ### Дешёвые гейты — прогнаны сам, на `1787e505` | Гейт | Команда | Результат | |---|---|---| | Typecheck | `npx tsc --noEmit` | зелёный, без вывода | | JS unit | `npm test` | **2341 passed, 1 skipped, 0 failed** | | Build + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | зелёный, побайтовое совпадение (ожидаемо: `src/**` не менялся) | | `no-new-any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (0 строк в 0 файлах) | | `smoke-select` | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «исполняемого frontend-диффа нет» — смоки не выбираются | | `process-gate` (локально, свежий `origin/dev`) | `node scripts/process-gate.mjs --issues` | «диапазон origin/dev..HEAD, коммитов 6; гейт пройден, предупреждений 0» | | Синтаксис Python | `python3 -m py_compile custom_components/houseplan/{store,websocket_api,__init__}.py` | OK | | Python pure-subset | `python3 -m pytest tests_backend -q` (после `pip install pytest pytest-asyncio voluptuous`) | 395 passed, 4 skipped — **не проверяет новый код**: все `test_issue_491_*` живут в `test_ha_*.py`, который `conftest.py` пропускает без `homeassistant` | | Мутант-якоря #491 (самостоятельная проверка, дешёвая) | `node scripts/mutation-gate.mjs --check --id=<5 id>` (все пять: `pair-recovery-config-writer-skips-fence`, `pair-recovery-point-writer-skips-fence`, `pair-recovery-fence-ignores-resolution-failure`, `optimize-skips-pair-retry-rollback`, `optimize-undo-skips-pair-retry-rollback`) | все пять: `ok` — патч-якорь найден в текущем дереве ровно один раз, синтаксически валиден | Не прогонялись и почему: - `golden:verify`, `check-docs.mjs`, browser-smoke — не нужны: `src/**` не менялся, `smoke-select` подтвердил пустой выбор. - `npm run invariants` — диффа геометрии/ссылок нет. Проверил сам (не только со слов r1): `canonicalize_config_geometry`/`canonicalize_layout_geometry` в новой функции `async_converge_store_pair` (`store.py:282-283`) — это те же вызовы, что уже существовали в `layout_store_payload`/`async_save_config_state` (`store.py:172,220`, не тронуты этим диффом); новой геометрической трансформации диф не добавляет, меняется только протокол персистентности. - `python -m pytest tests_backend -q` **с реальным Home Assistant** — недоступно: `pytest-homeassistant-custom-component==0.13.357` требует Python ≥3.14, в этом окружении только 3.12.3, других интерпретаторов нет, `.venv-backend` не создан. Ограничение то же, что было у r1 — окружение с тех пор не изменилось. ### Зелёный CI на этом SHA **Не существует — Validate на `1787e505` красный**, а не просто «не найден»: [прогон](https://github.com/Matysh/houseplan-card/actions/runs/34288755708), job «Предполётные проверки» упал на `node scripts/process-gate.mjs --github-range --issues`: ``` process-gate: диапазон 71aeb860791709b3011d280f65add13c92cb79f1..1787e505..., коммитов 8 FAIL п.8 статус issue - issue #496: статус не проставлен, а нужен один из S5-ready / S6-in-progress / S7-code-review / S8-merged ``` Разобрано, а не списано на «гейт не тот»: issue #496 закрыт корректно (без `S*`-метки — это правильное состояние закрытого issue). Диапазон `71aeb860..` на 2 коммита шире, чем `origin/dev..HEAD` (`9bfe7885..HEAD`, 6 коммитов) — `71aeb860` предшествует обоим приземлившимся на `dev` коммитам `e0e68f25` (#496) и `9bfe7885` (#503, hotfix конвейера ревью). В `--github-range` режиме `process-gate.mjs` резолвит базу через `mergeBaseWithDev`, вычисляемый по `git fetch origin dev` **в момент самого CI-джоба** (23:01:53 UTC); похоже, в эту секунду `origin/dev` на стороне GitHub ещё не был виден job'у с учётом `e0e68f25`/`9bfe7885` (гонка с параллельным push хотфикса #503 в `dev`, случившимся в разгар этого же цикла ревью — см. комментарии issue про неотработавший конвейер 22:28–22:39). В диапазон попали два уже смёрженных, не относящихся к #491 коммита, один из которых ссылается на закрытый issue — и правило 8 фейлится по чужому, легитимно закрытому issue. Проверено, а не предположено: тот же чек **сейчас**, со свежим `git fetch origin dev` (`origin/dev` = `9bfe7885`, уже включает оба коммита): ``` $ node scripts/process-gate.mjs --issues process-gate: диапазон origin/dev..HEAD, коммитов 6 гейт пройден, предупреждений 0 ``` **Вывод:** красный Validate на `1787e505` — не дефект диффа #491, а гонка в CI-инфраструктуре ревью (`scripts/process-gate.mjs` в режиме `--github-range` чувствителен к моменту `git fetch` относительно параллельного push в `dev`). Дефект не в скоупе #491 (класс B, чужая подсистема — CI-гейт, не `custom_components/houseplan/*.py`) и заведён отдельно: [#504](https://github.com/Matysh/houseplan-card/issues/504). ## Разбор по коду (проверено чтением, самостоятельно в этом раунде) Перечитаны построчно `custom_components/houseplan/store.py` (весь diff: `ResolvedStorePair`, `_pending_target`, `async_converge_store_pair`, `async_resolve_pending_pair`), весь diff `custom_components/houseplan/websocket_api.py` (`_resolved_write_pair`, `PairCommitFailure`, `_persist_pair_intent`, `_converge_pair`/`_commit_pair` и все девять точек вызова) и diff `custom_components/houseplan/__init__.py` (перенос resolver'а перед square-canvas миграцией). Ключевые проверки, которые я провёл сам (не переписывание вывода r1): - **Порядок auth → fence проверен по всем девяти вызовам, не по выборке.** `grep` по файлу подтверждает: каждый вызов `_resolved_write_pair(hass, connection, msg["id"], rt)` (строки 532, 732, 794, 881, 1365, 1591, 1858, 1978, 2167) стоит **после** ближайшего предшествующего `_check_write` (521, 725, 783, 868, 1357, 1574, 1845, 1963, 2158 соответственно) — во всех девяти writer'ах (5 ordinary + 4 paired по счёту ТЗ §5) permissions проверяются раньше recovery, ни разу наоборот. - **`ws_layout_update` (point writer, AC4).** `data = resolved.layout_data` (восстановленный после fence документ), дельта `{**layout, msg["device_id"]: msg["pos"]}` применяется поверх него, `new_rev = int(data.get("rev", 0)) + 1` — считается от восстановленной ревизии, не от сырой. Соответствует ТЗ §7 п.4 буквально. - **`ws_layout_set` (CAS writer, AC3/AC4).** `current_rev` берётся из `resolved.layout_data` **до** сравнения с `expected_rev` — устаревший клиент после recovery получает `conflict` по свежей ревизии, не тихий merge. - **`_commit_pair` — идентичен бывшему `_commit_import_pair` по протоколу** (intent → converge → один retry → rollback intent → converge rollback → `PairCommitFailure`), переименован и обобщён, поведение Import/`space/delete` не меняется — только сообщение в логе. - **`async_converge_store_pair` reload-vs-guess.** Оба try/except (config, layout) ловят исключение и **перечитывают** стор, сравнивая с точным ожидаемым payload (`if config_data != expected_config: raise` / `if layout_data != expected_layout: raise`) — решение об «успело записаться или нет» принимается по факту на диске, а не по типу исключения, буквально требование ТЗ §6.2. - **`__init__.py` setup**: resolver (`async_resolve_pending_pair`) вызывается **до** square-canvas миграции; после миграции `optimize_revs` пересчитывается свежим чтением обоих store перед `hass.bus.async_fire`, событие не описывает устаревшую (домиграционную) ревизию. - **Мутант-якоря — исполнено, не только прочитано.** Все пять патчей #491 в `scripts/mutation-gate.mjs` синтаксически валидны и уникально адресуют текущий код (`--check`, вывод выше). Это не доказывает, что HA-harness guard краснеет (недоступно в этом окружении — см. «Чего не проверял»), но исключает класс дефектов «мутант ссылается на текст, которого уже нет». Дефектов, ломающих AC1–AC5, AC7–AC9, чтением не найдено — согласуется с выводом r1, полученным независимо мной, а не переписанным с его документа. ## Находки ### High — 0 ### Medium в скоупе — 0 Единственная Medium-находка r1 закрыта (см. «Закрытие раунда r1»). ### Medium вне скоупа — 1, заведена отдельным issue **`process-gate.mjs --github-range` может дать ложный красный по правилу 8, когда параллельный push в `dev` обгоняет `git fetch` внутри CI-джоба ревьюемой ветки.** Разобрано выше в разделе «Зелёный CI на этом SHA». Не в скоупе #491 (чужая подсистема — CI-инфраструктура, класс B в `scripts/process-gate.mjs`, не `custom_components/houseplan/*.py`). Заведено: [#504](https://github.com/Matysh/houseplan-card/issues/504) с метками `process`, `P2`, `S1-new`, ссылкой на #491. ### Low — 1 **`docs/TESTING.md` не называет пятый мутант, добавленный фиксом r1.** - Файл: `docs/TESTING.md`, пункт «An unfinished config/layout pair survives the next writer (#491)» (добавлен в исходном коммите `488f70c4`). - Список `mutations:` перечисляет 4 идентификатора (`pair-recovery-config-writer-skips-fence`, `pair-recovery-point-writer-skips-fence`, `optimize-skips-pair-retry-rollback`, `optimize-undo-skips-pair-retry-rollback`), но не пятый — `pair-recovery-fence-ignores-resolution-failure` — добавленный **после** этого коммита, в фиксе r1→r2 (`1787e505`, ранее `4ee83716`), именно для закрытия AC6. Сама реализация и сам мутант корректны (см. «Закрытие раунда r1»); расходится только текст канонического списка доказательств в `TESTING.md`. - Влияние: AC10 требует, чтобы Testing «фиксировал… точные команды доказательства» — сейчас читатель `TESTING.md` не узнает о существовании пятого, самого свежего мутанта, закрывающего именно AC6 (историю которого ТЗ и оба код-ревью подробно обсуждают). Функционально ничего не сломано: мутант существует, зарегистрирован в `scripts/mutation-gate.mjs`, проверен `--check`. - Решение ревьюера: **снимается с записью**, без возврата на цикл. Причина — находка чисто текстовая (список из пяти строк, где не хватает одной), не затрагивает ни один AC по существу, её цена правки тривиальна и не соразмерна очередному циклу ревью при нулевых High. Если браться за неё — одна строка в `docs/TESTING.md`, добавляющая `` `pair-recovery-fence-ignores-resolution-failure` `` в список `mutations:` того же пункта; отдельный issue не требуется (Low, §2.7). ## Что проверено и корректно - **AC1/AC2 (Optimize/Undo — общий retry/rollback).** Код идентичен проверенному в r1 материалу; я перечитал `ws_plan_optimize`/ `ws_plan_optimize_undo` целиком заново — оба строят `pending`/`rollback` с `final_metadata` и идут через `_commit_pair`. Мутанты `optimize-skips-pair-retry-rollback`/`optimize-undo-skips-pair-retry-rollback` целятся именно в замену `_commit_pair` на голый `_persist_pair_intent + _converge_pair` — точное попадание в защищаемую строку (подтверждено якорем `--check`). - **AC3/AC4 (ordinary writers резолвят pending до CAS/точечной записи).** Проверено самостоятельно по всем пяти ordinary-путям (`config/set`, `layout/set`, `layout/update`, `layout/delete`, `geometry/repair`): `_resolved_write_pair` вызывается до чтения revision/CAS/дельты; `layout/update` применяет дельту к `resolved.layout_data`, не к сырому. - **AC5 (paired writers не начинают вторую пару поверх первой).** Общий `_resolved_write_pair` вызывается идентично во всех четырёх paired writer'ах — не дублированная логика. Мутанты AC3/AC4 покрывают тот же вход (`_resolved_write_pair`/fence), которым защищено и AC5; отдельного мутанта для AC5 ТЗ не требует (§13 AC5: «тесты и чтение кода общего входа») — это решение принято ещё на этапе спек-ревью, не пересматриваю. - **AC6 (неразрешимый pending блокирует новый write).** Закрыто в этом раунде — см. «Закрытие раунда r1». - **AC7 (setup завершает недоделанное).** `__init__.py`: resolver вызывается до square-canvas миграции, ревизии для события пересчитываются после миграции по свежему чтению — проверено самостоятельно, см. «Разбор по коду». - **AC8 (one-deep семантика).** `_discard_optimizer_snapshot` по-прежнему вызывается в `config/set`; `layout/set`/`layout/update` по-прежнему передают `remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)`. Логика не менялась этим диффом (тот же код, что видел r1). - **AC9 (форматы и совместимость).** Новых persisted-полей нет; `final_metadata`/`kind`/`clear_backup` — существовавшие поля. Legacy pending без `final_metadata` обрабатывается веткой `replace_metadata=False` — код идентичен материалу r1. - **AC10 (документация).** `ARCHITECTURE.md`, `CONFIG-COMPATIBILITY.md`, `USER-GUIDE(.ru).md` сверены мной построчно с кодом заново, расхождений нет; `docs/CHANGELOG.md`/`.ru.md` — в том же коммите `488f70c4` (`User-Visible: yes`), формулировки на пользовательском языке, без терминов реализации. `TESTING.md` — расхождение найдено, см. «Находки» (Low). - **Трейлеры и процесс.** 6 коммитов, каждый с `Issue: #491`; ветка `issue/491-optimize-undo-pair-recovery`; `process-gate.mjs --issues` зелёный на актуальном `origin/dev` (см. «Как проверялось»); документов ревью на issue — 2 существующих (`SPEC-REVIEW-491-r1`, `CODE-REVIEW-491-r1`) плюс этот, лимит §10.2 п.7 не превышен. ## Чего не проверял - **Исполнение HA-harness backend тестов** (`test_ha_*.py`, все 9 новых `test_issue_491_*`, все 5 мутантов #491 через реальный `backend-test-guard.mjs`) — недоступно в этом окружении: та же причина, что у r1, `pytest-homeassistant-custom-component==0.13.357` требует Python ≥3.14, доступен только 3.12.3. Проверено самостоятельно и переподтверждено сейчас, не только со слов r1. Все выводы по AC1–AC9 выше — чтение кода и сопоставление с тестами, не запуск; мутант-якоря проверены исполнением (`--check`), но не сам факт «гвард краснеет». - **Зелёный CI на точном SHA `1787e505`** — существует, но красный, причина разобрана выше и не относится к #491 (заведено #504). - **Golden/визуальные гейты, браузерные smoke, `check-docs`, `model-invariants`** — сознательно не прогонялись, обоснование в «Как проверялось» (нет `src/**`, нет геометрической трансформации, `smoke-select` вернул пустой список). - **Ручное тестирование в HA** — вне цикла по PROCESS §2; не проводилось никем. ## Вердикт Ноль High, ноль Medium в скоупе — единственная находка r1 закрыта (подтверждено исполнением `--check`, не только чтением). Одна Medium-находка вне скоупа заведена отдельным issue #504 и не возвращает #491 автору. Одна Low-находка (устаревшая строка в `TESTING.md`) снята решением ревьюера с записью — не соразмерна возврату на цикл. Полный независимый разбор (обязателен из-за ребейза на ушедший вперёд `dev`, §7.2/§2.10) не нашёл новых дефектов в протоколе: девять точек входа fence, порядок auth→fence, retry/rollback и setup recovery проверены самостоятельно и подтверждают выводы r1. Красный Validate на этом SHA разобран и отнесён к гонке CI-инфраструктуры, не к диффу #491. **Зелёный.** --- ## Материал раунда - Ветка: `issue/491-optimize-undo-pair-recovery`, коммит `1787e505a192f400c363666b549dbcf46834d90f`. - Вердикт этого документа: `green` · High 0 · Medium 0 в скоупе / 1 вне скоупа (#504) · Low 1 (снята) --- ## Материал раунда - Ветка: `issue/491-optimize-undo-pair-recovery`, коммит `4ee837168efd` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала: `f8e79336179590bc03fa49c01388f34918e43436` ``` git log --all --format='%H %T' | grep f8e793361795 ``` - ТЗ `docs/specs/491-optimize-undo-pair-recovery.md`, блоб `c73fde225d4f284d917879b5c4a7a54d1ff7994e` ``` git log --all --find-object=c73fde225d4f284d917879b5c4a7a54d1ff7994e -- docs/specs/491-optimize-undo-pair-recovery.md ``` - Вердикт конвейера: `green` · High 0