# CODE-REVIEW-491-r1 Issue: [#491](https://github.com/Matysh/houseplan-card/issues/491) — «Optimize/Undo: не терять незавершённую парную транзакцию при следующей записи». Материал ревью: ветка `issue/491-optimize-undo-pair-recovery`, **точный SHA `4c08b8abfca4a81240f98d654cc4766faae8ec58`** (рабочая копия уже на нём). Заход: r1. Блокирующих циклов израсходовано: 0/4. ТЗ: `docs/specs/491-optimize-undo-pair-recovery.md`, ревью ТЗ зелёное (`SPEC-REVIEW-491-r1`, `S5-ready`). ## Скоуп диффа `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/SPEC-REVIEW-491-r1.md | 165 +++++++++++ docs/specs/491-optimize-undo-pair-recovery.md | 385 ++++++++++++++++++++++++++ docs/specs/README.md | 1 + scripts/mutation-gate.mjs | 87 ++++++ tests_backend/test_ha_import_export.py | 31 +++ tests_backend/test_ha_websocket.py | 357 ++++++++++++++++++++++++ 16 files changed, 1461 insertions(+), 141 deletions(-) ``` Изменение backend-only (класс A: `custom_components/houseplan/*.py`; класс B: `scripts/mutation-gate.mjs`, `tests_backend/**`; класс C: `docs/**`). `src/**` не тронут — фронтенд-гейты (typecheck, smoke, golden, check-docs) вне обязательного набора для этого диффа. 4 коммита на ветке, все с `Issue: #491`; коммит `ba52a10f` (`User-Visible: yes`) несёт весь код и правки обоих changelog в одном коммите — трейлеры и правило «документация в том же коммите» соблюдены. ## Как проверялось ### Дешёвые гейты — прогнаны сам, на `4c08b8ab` | Гейт | Команда | Результат | |---|---|---| | Typecheck | `npx tsc --noEmit` | зелёный, без вывода | | JS unit | `npm test` | **2318 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 файлах — diff не по TS) | | `smoke-select` | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет... браузер-smoke этим диффом не выбираются» — прогон смоков не нужен | | `process-gate` | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 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 voluptuous==0.15.2`) | 395 passed, 4 skipped — **не проверяет новый код**: `test_ha_*.py` пропущен, `conftest.py` игнорирует эти файлы без `homeassistant` | Не прогонялись и почему: - `golden:verify`, `check-docs.mjs`, browser-smoke — не нужны: `src/**` не менялся, видимый рендер не меняется, `smoke-select` подтвердил пустой выбор. - `npm run invariants` — диффа геометрии/ссылок (`layout`, `marker.space`, `open_spans`, записи толщины) нет; меняется только протокол персистентности уже существующих config/layout документов, не их геометрическое содержимое. - `python -m pytest tests_backend -q` **с реальным Home Assistant** — недоступно в этом окружении. `tests_backend/requirements.txt` пинует `pytest-homeassistant-custom-component==0.13.357`, который требует Python **>=3.14**; в песочнице стоит Python 3.12.3, и других версий на машине нет (проверено — `python3.13`/`python3.14` отсутствуют, `.venv-backend` не создан). Установка `pip install -r tests_backend/requirements.txt` падает на резолвере с этой причиной. Все AC1–AC9 этой задачи доказываются исключительно HA-harness тестами — этот гейт я не смог исполнить и не подтверждаю его результат экспериментально. ### Зелёный CI на этом SHA По условиям раунда — не найден. Разработчик в хендоффе ссылается на `exact-SHA CI 98c1923e` и «полный HA backend harness в WSL: 728 passed» — оба прогона сделаны **до** ребейза на `dev` (конфликт был только в `docs/specs/README.md`, попытка слияния без ревью отклонена ботом, issue-комментарий от `2026-09-08T22:06:57Z`). Ни тот прогон CI, ни WSL-прогон не относятся к точному SHA `4c08b8ab`, который является материалом этого ревью. Ребейз, по описанию владельца, тронул только строку в `docs/specs/README.md` (список ТЗ, класс C) — не код; я не могу пере-проверить это побайтово, потому что предребейзный SHA `98c1923e` уже не резолвится локально (обычное дело после ребейза, PROCESS §2.10), но `git diff origin/dev...HEAD` для файлов класса A/B, который я разбирал ниже, не содержит ничего, что выглядело бы как след мержа/конфликта. **Вывод:** ни один AC этой задачи не подтверждён исполнением HA-harness ни мной, ни зелёным CI на этом точном SHA. Все AC1–AC10 ниже доказаны **чтением, не исполнением** — с указанием, что именно я проследил по коду и какому сценарию из существующих тестов это соответствует. ## Разбор по коду (проверено чтением) Прослежены построчно: `custom_components/houseplan/store.py` (новые `ResolvedStorePair`, `_pending_target`, `async_converge_store_pair`, `async_resolve_pending_pair`), `custom_components/houseplan/websocket_api.py` (`_resolved_write_pair`, `PairCommitFailure`, `_persist_pair_intent`, `_converge_pair`→`_commit_pair`, все точки вызова в `ws_import_apply`, `ws_layout_set/update/delete`, `ws_geometry_repair`, `ws_config_set`, `ws_space_delete`, `ws_plan_optimize`, `ws_plan_optimize_undo`) и `custom_components/houseplan/__init__.py` (перенос recovery до миграции square-canvas). Ключевые находки чтения (все — «работает корректно», не дефекты): - **Разрешение конфликта write fence vs auth.** Во всех восьми writer'ах `_check_write`/`_runtime` вызываются **до** `_resolved_write_pair` — предположение ТЗ №5 («проверка permissions остаётся до fence») выполнено (проверено на `ws_layout_set` L710, `ws_config_set` L1588, `ws_plan_optimize` L1963 и т.д.). - **Совместимость со старым форматом pending.** `async_converge_store_pair` ветвится по `isinstance(pending.get("final_metadata"), dict)`. Ветка `replace_metadata=False` — дословно перенесённая из старого `custom_components/houseplan/__init__.py` (был: `if not replace_metadata and not pending.get("clear_backup") and "optimize_backup" in lay_stored: ...`) логика сохранения backup по `clear_backup`; существующий `test_setup_recovers_exact_optimize_storage_roundtrip_pair` (`tests_backend/test_ha_import_export.py:2282`, не тронут этим диффом) по-прежнему использует старый формат (`clear_backup: False`, без `final_metadata`) и по коду обязан пройти тот же путь — AC9 для setup подтверждён без изменений в самом тесте, только в реализации, которая теперь общая. - **Идемпотентность recovery.** Для нового формата (`replace_metadata=True`) запись config пропускается, если `config_data.get("config") == target_config and config_rev == target_config_rev`; запись layout выполняется всегда (это и есть операция, которая в последний момент снимает `optimize_pending`) — соответствует §8 ТЗ («target уже записан в обе половины... повтор удаляет intent без нового смыслового состояния»). - **Постоянный порядок операций в setup** (`__init__.py`): resolver теперь вызывается **до** square-canvas миграции (раньше — после), с явным комментарием о причине; после миграции `optimize_revs` пересчитывается свежим чтением обоих stores перед `hass.bus.async_fire`, так что событие не описывает уже устаревшую (домиграционную) ревизию. - **`_commit_pair`** — прямое переименование бывшего `_commit_import_pair` (intent → converge → retry once → rollback intent → converge rollback → `PairCommitFailure`), протокол не менялся, только обобщено имя и убраны специфичные для импорта строки лога; поведение для Import/`space/delete` не отличается от `dev` кроме сообщения в логе. - **Мутационный трейс AC6 вручную** (пояснение ниже, в разделе находок): прочитан код `_resolved_write_pair`, при отказе `async_resolve_pending_pair` функция шлёт `commit_failed` и возвращает `None`; вызывающий writer делает ранний `return`. Гипотетическая мутация «проглотить исключение и продолжить со свежим (не сведённым) состоянием» превратила бы `test_issue_491_failed_fence_blocks_point_write_and_keeps_intent` в проходящий *успешный* ответ вместо `commit_failed` — то есть тест по факту умеет её ловить. Это прослежено по коду, не исполнено. Дефектов, ломающих AC1–AC5, AC7–AC10, чтением не найдено. ## Находки ### Medium (в скоупе, чинится в этой же задаче) — 1 **AC6 не имеет названного мутанта, хотя защита живёт в продуктовом коде и проверяется исключительно дорогим HA-harness гейтом.** - Файл: `custom_components/houseplan/websocket_api.py`, функция `_resolved_write_pair` (обработка исключения `async_resolve_pending_pair` → `commit_failed`, без записи и без удаления pending/backup). - Доказательство AC6 в хендоффе: `test_issue_491_failed_fence_blocks_point_write_and_keeps_intent`. Третий столбец таблицы «чем краснеет» в хендоффе — «exact Store pair и pending сравниваются до/после»: это описание проверок самого теста, а не название мутации/снятой защиты/отдельной отрицательной пробы с результатом прогона. - PROCESS.md §2.7: «Мутант ... обязателен, когда защита живёт в продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там ревьюер не воспроизведёт отрицательный прогон второй раз... Пустой третий столбец — находка Medium, а не примечание». AC6 — ровно такая защита (backend, дорогой гейт, я сам не смог исполнить HA-harness в этом окружении и не могу подтвердить «падает» без названного мутанта — см. раздел «Чего не проверял»). - Сценарий отказа без исправления: если протокол recovery когда-нибудь сломают правкой рядом (например, вернут `return ResolvedStorePair(...)` вместо `return None` в except-блоке), ни один существующий мутант в `scripts/mutation-gate.mjs` этого не поймает — 4 добавленных мутанта (`pair-recovery-config-writer-skips-fence`, `pair-recovery-point-writer-skips-fence`, `optimize-skips-pair-retry-rollback`, `optimize-undo-skips-pair-retry-rollback`) целятся в отсутствие вызова fence, а не в то, что происходит, когда fence **сам возвращает ошибку**. - Что требуется: мутант в `scripts/mutation-gate.mjs`, патчащий `_resolved_write_pair` так, чтобы отказ `async_resolve_pending_pair` игнорировался и writer продолжал со свежим (не гарантированно сведённым) состоянием, с guard на `test_issue_491_failed_fence_blocks_point_write_and_keeps_intent` (или новый более специфичный тест) и подтверждённым результатом прогона — либо эквивалентная запись «снял защиту руками, прогнал, вот вывод» в документации задачи. Без High-находок это влечёт жёлтый вердикт раунда; находка в скоупе issue #491 (сам мутационный гейт для этой задачи, п.5 «HA-harness fault-injection тесты» из раздела 4 ТЗ) и правится в этой же ветке, отдельный issue не заводится. ### Low — 0 Не найдено. ## Что проверено и корректно - **AC1/AC2 (Optimize/Undo — общий retry/rollback).** Прочитаны `ws_plan_optimize`/`ws_plan_optimize_undo` целиком: обе теперь строят `pending`/`rollback` с `final_metadata` и идут через `_commit_pair`, тот же примитив, что Import/`space/delete`. Тесты `test_issue_491_optimize_failure_restores_before_pair`, `test_issue_491_optimize_undo_failure_restores_pre_undo_pair`, `test_issue_491_optimize_fail_after_final_write_is_success` монтируют реальный `monkeypatch` на `Store.async_save` (не подмену чистой функции) и сверяют **точное** содержимое обоих store после отказа — структурно корректный дизайн теста, ловит и «до записи», и «после durable записи» сбой. Названные мутанты `optimize-skips-pair-retry-rollback`, `optimize-undo-skips-pair-retry-rollback` целятся именно в замену `_commit_pair` на голый `_persist_pair_intent + _converge_pair` — точное попадание в защищаемую строку. - **AC3/AC4 (ordinary writers резолвят pending до CAS).** Все шесть ordinary-путей (`config/set`, `layout/set`, `layout/update`, `layout/delete`, `geometry/repair`) вызывают `_resolved_write_pair` до чтения revision/CAS; `layout/update` — единственный без CAS — применяет дельту к `resolved.layout_data` (восстановленному), не к сырому. Мутанты `pair-recovery-config-writer-skips-fence` и `pair-recovery-point-writer-skips-fence` целятся именно в удаление вызова fence на этих двух путях. - **AC5 (paired writers не стартуют вторую пару поверх первой).** Общий `_resolved_write_pair` вызывается идентично во всех четырёх paired writer'ах (`import/apply`, `space/delete`, `plan/optimize`, `plan/optimize_undo`) — не дублированная логика, а один и тот же вызов. Для `import/apply` и `space/delete` есть прямые тесты (`test_issue_491_import_apply_fences_an_older_pending_pair`, `test_issue_491_space_delete_fences_before_pair_revisions`); для `plan/optimize`/`plan/optimize_undo` — только чтение кода (тот же вызов на той же позиции), что соответствует принятому в ТЗ доказательству («тесты и чтение кода общего входа»). - **AC7 (setup завершает недоделанное).** `__init__.py` использует тот же `async_resolve_pending_pair`; порядок операций (resolver → geometry migration → пересчёт revs → событие) не даёт события со старой ревизией. Существующий `test_setup_recovers_durable_import_pair` (не изменён, но проходит по общему коду) и `test_setup_recovers_exact_optimize_storage_roundtrip_pair` закрывают одновременно новый и legacy формат pending. - **AC8 (one-deep семантика).** `_discard_optimizer_snapshot` по-прежнему вызывается в конце `config/set` после успешной обычной правки (инвалидирует backup); `layout/set`/`layout/update` по-прежнему передают `remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)`. Логика инвалидации backup обычным edit'ом не менялась. - **AC9 (форматы и совместимость).** Persisted-схема не меняется: новых ключей нет, `final_metadata`/`kind`/`clear_backup` — уже существовавшие поля, используемые как общий протокол. Legacy pending без `final_metadata` обрабатывается веткой совместимости (см. выше). - **AC10 (документация).** `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`, `docs/USER-GUIDE(.ru).md` описывают ровно реализованный протокол (write fence до CAS/no-op, retry→rollback, порядок setup, отсутствие новых persisted-полей) — сверено построчно с кодом, расхождений нет. `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` в одном коммите с кодом (`ba52a10f`, `User-Visible: yes`). - **Трейлеры и процесс.** 4 коммита, каждый с `Issue: #491`; ветка `issue/491-optimize-undo-pair-recovery`; `process-gate.mjs` зелёный локально. ## Чего не проверял - **Исполнение HA-harness backend тестов** (`test_ha_*.py`, включая все 8 новых `test_issue_491_*`) — недоступно в этом окружении: `pytest-homeassistant-custom-component==0.13.357` требует Python ≥3.14, доступен только 3.12.3, других интерпретаторов на машине нет. Соответственно не проверены исполнением и 4 новых мутанта в `scripts/mutation-gate.mjs` (`node scripts/backend-test-guard.mjs ...` тоже требует HA). Все выводы по AC1–AC9 в разделе выше — чтение кода и сопоставление с существующими (не тронутыми) тестами, не запуск. - **Зелёный CI на точном SHA `4c08b8ab`** — не существует на момент ревью (условие раунда). Прогон, на который ссылается хендофф разработчика, сделан на предребейзном SHA `98c1923e`, недоступном для сверки. - **Golden/визуальные гейты, браузерные smoke, `check-docs`, `model-invariants`** — сознательно не прогонялись, обоснование в разделе «Как проверялось» (нет `src/**`, нет геометрии, `smoke-select` вернул пустой список). - **Ручное тестирование в HA** — вне цикла по PROCESS §2; не проводилось никем. ## Вердикт Один Medium в скоупе задачи (отсутствие мутационного свидетеля для AC6), High нет. По PROCESS §2.7/§4 это жёлтый вердикт: цикл возврата автору, без отдельного issue — правка (мутант или эквивалентная запись прогона со снятой защитой) делается в той же ветке `issue/491-optimize-undo-pair-recovery`. Основная реализация (общий crash-resumable commit-протокол, write fence, setup recovery, обратная совместимость) при чтении кода выглядит корректной и хорошо согласованной с ТЗ; дешёвые гейты зелёные. Возврат вызван узким процессным требованием к доказательству одного защитного AC, а не сомнением в самом протоколе. --- ## Материал раунда - Ветка: `issue/491-optimize-undo-pair-recovery`, коммит `4c08b8abfca4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала: `697d7e922c8b91078551d280f65add13c92cb79f` ``` git log --all --format='%H %T' | grep 697d7e922c8b ``` - ТЗ `docs/specs/491-optimize-undo-pair-recovery.md`, блоб `c73fde225d4f284d917879b5c4a7a54d1ff7994e` ``` git log --all --find-object=c73fde225d4f284d917879b5c4a7a54d1ff7994e -- docs/specs/491-optimize-undo-pair-recovery.md ``` - Вердикт конвейера: `yellow` · High 0