Files
houseplan-card/docs/reviews/SPEC-REVIEW-495-r1.md
2026-09-09 05:33:25 +00:00

22 KiB
Raw Permalink Blame History

SPEC-REVIEW-495-r1 — #495: результат Import согласован с commit; удаление маршрутов робота доходит до Store

  • Issue: #495
  • Этап: spec (PROCESS.md §2.4)
  • ТЗ под ревью: docs/specs/495-import-commit-and-route-runs-durability.md
  • Материал: HEAD 70a394d2 (detached), содержит коммит ТЗ 70a394d2 docs: spec for #495 — import result follows the commit, dropped route runs reach the store
  • Заход: r1, блокирующих циклов израсходовано 0/4 (первый раунд — раздел «Унаследовано из r0» не нужен)
  • Трек: полный (issue корректно не помечен small; в issue названо, какой критерий §5 не проходит — «одна поверхность»: два независимых durability-контракта, Apply-эндпоинт и рекордер следов)
  • Вердикт: зелёный

Скоуп ревью

Диапазон git diff abbca50c..70a394d2 (беспосредственно предыдущий коммит dev) содержит только:

  • docs/specs/495-import-commit-and-route-runs-durability.md (новый файл);
  • вход в issue #495 не менялся сверх комментария S2-аналитики.

Продуктовый код (custom_components/houseplan/**), тесты, бандлы — не тронуты. Это ожидаемо: этап spec меняет только документацию.

Прочитано до вердикта: docs/SCOPE.md, AGENTS.md, PROCESS.md (§1–§9, §12), тело issue #495 и комментарий S2-аналитики, docs/VACUUM.md, docs/ARCHITECTURE.md (таблица WS-команд и раздел про crash-resumable паттерн), сам код: custom_components/houseplan/websocket_api.py (обработчик ws_import_apply, целиком блок write_lock…send_result), custom_components/houseplan/import_export.py (get_candidate, create_preview, лимиты вытеснения), custom_components/houseplan/trails.py (TrailBook.drop_unknown_routes, TrailRecorder.async_purge_orphans, _async_delete_many, _refresh_lock), три файла тестов (test_trails.py, test_trail_recorder.py, test_ha_websocket.py, test_ha_import_export.py) на предмет существования упомянутых в ТЗ тестов-образцов.

Как проверялось

Ревью ТЗ, не код-ревью: гейты npm test/npm run build/tsc/check-docs не запускались — диапазон не содержит изменений src/** или custom_components/**, только новый файл в docs/specs/. Запускать их означало бы проверять пустое множество.

Основная работа ревью — верификация каждого технического утверждения ТЗ против реального кода, поскольку у полного трека (в отличие от small) риск «описал не тот код» выше, а ТЗ делает много конкретных построчных утверждений о websocket_api.py и trails.py.

Проверка Результат
Обязательные разделы ТЗ (PROCESS.md §7.1) все присутствуют — таблица ниже
AC1–AC7: однозначность + способ доказательства у каждого назван тест/раунд мутантов — см. «AC» ниже
Построчные утверждения о websocket_api.py / import_export.py / trails.py сверено с кодом на 70a394d2 (эквивалентен dev) — см. «Верификация фактов»
Ссылки на связанные issue (#491, #335, #162, #265) тематика совпадает; #491 закрыт (S8), не дублируется
Ссылки на существующие тесты-образцы найдены все три — test_import_apply_conflict_preserves_state_and_preview_token, test_success_events_are_emitted_only_after_both_target_writes, test_config_set_purges_tombstoned_and_absent_trails_durably
Признак «догадка выдана за решение» без пометки целевой поиск — не найдено, см. ниже
Продуктовые вопросы владельцу, не решённые самим ТЗ не найдено — ТЗ решает всё внутри §11

Обязательные разделы ТЗ (PROCESS.md §7.1)

Раздел Есть Где
Сценарий да, две персоны/сценария (Import, Маршруты) §1.1
Что человек увидит до/после да, одной фразой на каждую половину §1.2
Проблема да, с построчными ссылками на код §1
Скоуп и не-скоуп да §2, §3
Контракт поведения да, раздельно по подсистемам §4, §5
UX да («не затрагивается», обоснованно) §8.1
Модель данных и миграция да («формат не меняется») §7
i18n да (не затрагивается) §8.1
AC1…ACn с доказательством да, 7 штук, у каждого назван тест §8
План автотестов да, подробный, включая мутанты §6
Риски да §8.2
Откат да §7
Release-артефакты да (changelog RU+EN) §9

Все разделы на месте — расхождений с §7.1, требующих находки, нет.

Верификация фактов

ТЗ утверждает поведение конкретных строк кода на dev; каждое из этих утверждений проверено чтением того же кода на материале ревью, поскольку неверное построчное утверждение — тот дефект, который должен убить полный трек:

  • B2 (Import). ws_import_apply (websocket_api.py:526–596): кандидат берётся get_candidate(rt, msg["token"], owner) (без consume) в начале под write_lock — подтверждено, строка 538. После await _commit_pair(rt, pending, rollback) идёт get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True) — подтверждено, строка 634 (текущий код), это и есть точка, которую §4.1 ТЗ предлагает заменить на rt.import_previews.pop(msg["token"], None). Между _commit_pair и этим вызовом никакого другого выражения, способного бросить ImportFailure, нет — заявление ТЗ «путь commit→ошибка становится недостижимым по построению» после замены верно.
  • get_candidate (import_export.py:1999–2011): при consume=False (на входе) проверяет expires, owner_id, digest; при consume=True (после commit, до правки) — те же проверки плюс безусловный pop. Подтверждает §1 B2 буквально.
  • Вытеснение (import_export.py:1956–1965): mine — токены владельца в порядке вставки (Python dict сохраняет порядок), эвикшн mine.pop(0) — действительно «старейший первым». Применяемый токен создан раньше нового preview того же владельца (preview → потом apply), значит он вытесняется первым при достижении лимита — тестовый план AC2 (создать MAX_IMPORT_PREVIEWS_PER_USER новых preview тем же владельцем, чтобы вытеснить применяемый токен) технически корректен.
  • PairCommitFailure / §4.2. _commit_pair (websocket_api.py:384) бросает PairCommitFailure(recovery_pending=...) при неудаче — до строки с pop, которая находится уже после await _commit_pair(...). Значит на пути PairCommitFailure вызов get_candidate(..., consume=True)/новый pop вообще не выполняется — заявление §4.2 «после PairCommitFailure токен остаётся» верно без правки этого пути.
  • B3 (маршруты). TrailRecorder.async_purge_orphans (trails.py:314–341): цикл по живым маркерам вызывает effective_routes(...) → self.book.drop_unknown_routes(marker_id, ids), результат (bool) никуда не сохраняется; запись Store происходит только внутри _async_delete_many(orphan_ids), и только если orphan_ids непусто (if not orphan_ids: return 0 — строка 338). Подтверждено построчно; ровно то, что описывает §1 B3.
  • asyncio.Lock неренетерабелен, и _async_delete_many уже сам берёт self._refresh_lock (trails.py:351) — если бы async_purge_orphans тоже взял этот лок на весь свой корпус (как требует §5.1) и затем вызвал публичную _async_delete_many как есть, был бы deadlock. ТЗ (§5.1 п.4, §8.2) корректно называет это и предписывает «внутреннюю версию без повторного захвата lock» — риск замечен автором ТЗ, а не пропущен.
  • drop_unknown_routes возвращает bool, не снятые прогоны (trails.py:152–172, rec.pop(slot) без возврата значения) — §5.1 описывает НОВУЮ обёртку self._drop_unknown_routes(config) (метод рекордера, не путать с методом книги того же почти-имени), которая обязана сама снять копию current/previous до вызова book.drop_unknown_routes, чтобы получить {marker_id: {slot: run}} для отката. §5.2 явно говорит, что сигнатура TrailBook.drop_unknown_routes (включая bool) не меняется — тест test_drop_unknown_routes_touches_only_runs_that_name_a_route (test_trails.py:195) подтверждён существующим и совместимым.
  • Тесты-образцы существуют: test_import_apply_conflict_preserves_state_and_preview_token (test_ha_import_export.py:2217), test_success_events_are_emitted_only_after_both_target_writes (test_ha_import_export.py:2483), test_config_set_purges_tombstoned_and_absent_trails_durably (test_ha_websocket.py:409) — все три подтверждены существующими, ссылки ТЗ корректны.
  • «Существующие тесты Apply без правок»: тест на строке 1208 test_ha_import_export.py (runtime.import_previews[expiring]["expires"] = 0 → get_candidate(...) бросает preview_expired) — это unit-тест на саму функцию get_candidate, а не на обработчик ws_import_apply; правка ТЗ не трогает get_candidate, только убирает её повторный вызов в обработчике. Тест остаётся зелёным без правок, как и заявлено.
  • docs/VACUUM.md / docs/ARCHITECTURE.md уже описывают целевое поведение (§9 ТЗ): VACUUM.md:98 — «changing the target space is a NEW route identity … recorded runs … are dropped»; ARCHITECTURE.md:1178 — houseplan/import/apply описан как «crash-resumable paired config/layout commit». Оба подтверждены; заявление «фикс приводит код к тексту, документы не меняются» корректно.
  • Вызовы _purge_trail_recorder/async_purge_orphans после каждого config-commit — подтверждено 4 точками вызова (websocket_api.py:654,1757,2278, __init__.py:193), согласуется с §1 «вызывается после каждого commit конфига».

Не найдено ни одного построчного утверждения о коде, которое разошлось бы с материалом ревью.

Догадка, выданная за решение — целевой поиск

Не найдено. Единственные более-менее «решательные» технические детали — имя новой приватной обёртки _drop_unknown_routes и точный протокол отката (что именно копируется для восстановления) — прямо не диктуют пользователю видимое поведение и относятся к классу «агенты решают между собой» (PROCESS.md §7.1); ТЗ не выдаёт их за нормативный факт о продукте, а описывает как техническую реализацию контракта §5.1, который сам сформулирован как обязательный результат («результат обязан дойти до Store»), а не как гадание о существующем коде.

Критерии приёмки (AC1–AC7)

Каждый пронумерован, для каждого назван ровно один тест или группа тестов — проверяемость есть у всех:

AC Доказывается Проверка ревьюера
AC1 test_issue_495_apply_result_follows_the_commit_when_the_preview_expires_meanwhile (backend) сценарий (обёртка над _commit_pair, expires=0) технически реализуем на верифицированном коде §4.1
AC2 test_issue_495_apply_result_survives_eviction_by_a_newer_preview (backend) эвикшн-механизм подтверждён «старейший первым» — сценарий воспроизводим
AC3 test_issue_495_dropped_route_runs_reach_the_store_without_orphans (backend/stub) воспроизводимо на подтверждённом _async_delete_many/drop_unknown_routes
AC4 test_issue_495_dropped_route_runs_roll_back_when_the_store_write_fails (backend/stub) откат по образцу существующего отката в _async_delete_many (строки 373–383), симметрия описана в §5.1 п.4/5
AC5 test_issue_495_dropped_route_runs_share_the_orphan_transaction (backend/stub) комбинация путей п.4/п.5 §5.1, не противоречит текущей структуре _async_delete_many
AC6 новый тест test_ha_websocket.py по образцу test_config_set_purges_tombstoned_and_absent_trails_durably (backend/HA) образец существует и подтверждён (строка 409)
AC7 мутанты §6.3, scripts/mutation-gate.mjs + backend-test-guard.mjs пять мутантов, у каждого назван ровно один свидетель-тест; отрицательный прогон — требование самого ТЗ (§6.3, «до перевода в S7»)

Ни один AC не оставляет открытого вопроса о способе доказательства.

Соответствие SCOPE.md

Задача — про J6 («Keep the plan true as the home evolves»): расхождение ответа/памяти с диском — именно та ложь плана, которую J6 запрещает. Both half (Import-ответ и маршруты робота) относятся к этому job. Продуктового расширения скоупа нет: §3 (не-скоуп) явно исключает алгоритмы импорта, продление TTL, защиту токена до commit, #491-фехтование — все обоснованно, и все совпадают с тем, что реально не нужно трогать по прочитанному коду.

Что проверено и корректно

  • Обязательные разделы §7.1 все присутствуют.
  • Все 7 AC пронумерованы, однозначны, у каждого назван способ доказательства и конкретный тест/группа тестов.
  • Каждое построчное техническое утверждение о websocket_api.py, import_export.py, trails.py подтверждено чтением кода на материале ревью (см. «Верификация фактов») — ни одного расхождения.
  • Технически рискованное место (расширение _refresh_lock, неренетерабельность asyncio.Lock) не просто не упущено — ТЗ прямо формулирует решение («внутренняя версия без повторного захвата lock») и называет риск в §8.2.
  • «Принятые предположения» (§11) — три штуки, все нормативно нейтральны (не меняют видимое поведение), явно помечены как предположения, а не факт.
  • Продуктовых вопросов владельцу, требующих ответа, не найдено — контракт «результат определяется commit» и «удаление доходит до Store» не оставляет места для персонального/UX-выбора; это баг-фикс с уже согласованным на уровне документации (VACUUM.md, ARCHITECTURE.md) целевым поведением.
  • Ссылки на #491, #335, #162, #265 — не дублируются, использованы как прецеденты паттернов (откат памяти, crash-resumable commit).
  • Откат (§7) полный: revert одного коммита, без миграции данных.
  • Release-артефакты (§9): changelog RU+EN назван; изменение документации подсистем корректно признано ненужным (текст уже соответствует целевому поведению).

Чего не проверял

  • Гейты npm test/npm run build/tsc/check-docs/python -m pytest tests_backend не запускались — диапазон ревью не содержит изменений кода или тестов, только новый файл в docs/specs/; прогон был бы проверкой пустого множества для этого раунда.
  • Не проверялась реализуемость точного протокола отката §5.1 п.4/5 построчно как будущий diff (мутанты §6.3 и тесты §6 ещё не написаны) — это по определению предмет code-этапа и будущего code-review-раунда этой же задачи, а не ревью ТЗ.
  • Не проверялось поведение _drop_unknown_routes-обёртки на конкурентных вызовах async_refresh во время purge — сценарий не упомянут ни в ТЗ, ни в issue как открытый риск, и _refresh_lock по конструкции сериализует оба пути; отдельного продуктового риска здесь не просматривается.
  • Мутанты и HA-харнесс AC6 не прогонялись (кода ещё нет) — асимметрично этапу; будет предметом code-review.

Вердикт

Зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0 → в задаче.

ТЗ полное по §7.1, каждый AC однозначен и доказуем, все построчные технические утверждения о существующем коде подтверждены чтением того же кода на материале ревью, риск неренетерабельности lock замечен и решён самим автором ТЗ, продуктовых вопросов владельцу не осталось. Следующий статус — «Готово к разработке» (S5-ready).


Материал раунда

  • Ветка: issue/495-import-commit-and-route-runs-durability, коммит 70a394d22821 — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 76d0e202bdc79141e49c499710b7ae826be6b8d6
    git log --all --format='%H %T' | grep 76d0e202bdc7
    
  • ТЗ docs/specs/495-import-commit-and-route-runs-durability.md, блоб a6fb6bbe17da6cc4ca7579279397f40730c235fd
    git log --all --find-object=a6fb6bbe17da6cc4ca7579279397f40730c235fd -- docs/specs/495-import-commit-and-route-runs-durability.md
    
  • Вердикт конвейера: green · High 0