Ревью #682 r1, Medium: перенос добавляет документу уровень вложенности (`docs/reviews/X.md` → `legacy/reviews/<тег>/X.md`, `docs/specs/` → `legacy/specs/`), а относительные ссылки внутри перенесённых документов и в соседях, ссылавшихся на них, никто не пересчитывал — на `97d19268` 53 битые ссылки в 46 файлах (заявление «все 26 резолвятся» в `7feb6177` было верно только до переноса документов ревью). Гейты архив не смотрят. `reviews-archive.mjs`: `repairLinks` пересчитывает ссылку, если она не резолвится от нового места, а цель находится от нового или старого места через карту переносов; битая и до переноса ссылка не трогается. `--apply` делает это само, `--repair-links=<rev>` — для всех переименований `<rev>..HEAD`, `--check-links` печатает битые. Этим коммитом `--repair-links=origin/dev` переписал ровно 53 ссылки в 46 файлах; остались две прежние «...»-заглушки в CODE-REVIEW-448-r2 (битые и на dev). Тесты: перенесённый документ, сосед со ссылкой в архив, ТЗ со ссылкой на позже перенесённое ревью, битая-до-переноса не трогается, в `legacy/` битых нет; мутант `reviews-archive-links-from-new-place-only`. PROCESS §2.10 и DEVELOPMENT › Release называют переписывание и `--check-links`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
22 KiB
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— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
76d0e202bdc79141e49c499710b7ae826be6b8d6git log --all --format='%H %T' | grep 76d0e202bdc7 - ТЗ
docs/specs/495-import-commit-and-route-runs-durability.md, блобa6fb6bbe17da6cc4ca7579279397f40730c235fdgit log --all --find-object=a6fb6bbe17da6cc4ca7579279397f40730c235fd -- docs/specs/495-import-commit-and-route-runs-durability.md - Вердикт конвейера:
green· High 0