Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. 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
Executable File
#495 — Результат Import согласован с commit; удаление маршрутов робота доходит до Store
- Issue: https://github.com/Matysh/houseplan-card/issues/495
- Тип / приоритет: bug / P2
- Трек: полный — две поверхности с независимыми контрактами durability (эндпоинт
houseplan/import/applyи рекордер следов), критерий §5 «одна поверхность» не проходит - Оценка: пользовательская ценность 6/10; ценность для разработки 5/10; сложность 3/10; риск 3/10
- Связано: аудит 2026-09-08 §11 п.6 (B2/B3); #491 (фехтование pending-pair, S8); #335 (откат памяти при сбое Store); #162 (маршруты и
drop_unknown_routes); #265 (Apply применяет ровно материализованный кандидат);docs/SCOPE.mdJ6
1. Проблема
Два места, где ответ пользователю или память процесса расходятся с тем, что записано на диск. Оба подтверждены чтением dev@abbca50c.
B2. Import: ложный отказ после успешного commit. ws_import_apply берёт кандидат get_candidate(rt, token, owner) при входе под write_lock, проверяет ревизии, материализует пару, пишет её _commit_pair и после этого вызывает get_candidate(..., consume=True) — ту же функцию с проверками TTL, владельца и дайджеста. Если TTL (IMPORT_PREVIEW_TTL_S, 10 мин) истёк за время prepare_apply + двух записей Store, или если параллельный POST /api/houseplan/import/preview того же пользователя вытеснил токен по лимиту (create_preview меняет runtime.import_previews из executor, вне write_lock), повторная выборка бросает ImportFailure("preview_expired"). Обработчик отвечает ошибкой; houseplan_config_updated/houseplan_layout_updated не уходят, _purge_trail_recorder/_refresh_trail_recorder не выполняются, send_result нет. План при этом уже заменён (rev +1 в обоих Store). Карточка показывает ошибку в диалоге импорта и держит старую модель до чужого события или перезагрузки.
B3. Маршруты робота: удаление не сохраняется. TrailRecorder.async_purge_orphans вызывается после каждого commit конфига. Он чистит из памяти прогоны, чей route_id больше не существует у маркера (book.drop_unknown_routes), но делает это вне _refresh_lock и отбрасывает результат. Запись Store есть только в _async_delete_many, и только когда найдены маркеры-сироты. Удаление или перенацеливание одного маршрута у живого маркера → сирот нет → return 0, записи нет, события нет. Отложенная запись _schedule_save возникает лишь от новой точки робота; для стоящего робота её не будет, и после рестарта HA Store.async_load возвращает удалённые прогоны. Карточка их не рисует (runRoute не находит маршрут), но обещание «удалить пути» не выполнено, а при повторном создании маршрута с тем же id прогоны воскресают.
1.1. Сценарий
Импорт. Редактор загружает резервную копию, читает preview, отвлекается, нажимает «Применить» на девятой минуте. Медленный диск (SD-карта, NAS) или долгая материализация полного плана — commit заканчивается уже после десятой. Ответ: «Import preview expired». Человек делает preview заново и применяет ещё раз — и получает конфликт ревизий, потому что план уже заменён первым Apply; либо накатывает второй раз поверх, создавая дубли пространств при duplicate_policy=virtual.
Маршруты. Владелец робота удаляет маршрут карты (или переносит его на другое пространство) в настройках маркера и сохраняет. Следы пропадают с плана. Робот стоит на базе до вечера. После рестарта HA — или при повторном добавлении маршрута с тем же id — старые пути снова в памяти рекордера.
1.2. Что человек увидит до и после
До: см. §1.1. После: Apply, дошедший до записи обеих половин, всегда отвечает ok: true с новыми ревизиями и рассылает оба события — независимо от того, что за время записи случилось с preview-токеном. Удалённый маршрут забирает свои прогоны с диска в ту же секунду, что и из памяти; рестарт их не возвращает; остальные прогоны маркера целы.
2. Скоуп
- Apply определяется durable-транзакцией (§4): после успешного
_commit_pairни одна проверка кандидата не может превратить успех в отказ; токен снимается безусловно. - Снятие прогонов по исчезнувшим маршрутам — durable (§5): выполняется под
_refresh_lock, записывается в Store одной транзакцией с удалением сирот либо немедленной записью без них; при сбое записи память откатывается (#335). - Регрессионные тесты (§6): HA-харнесс для Apply (TTL истёк во время commit; вытеснение во время commit), стаб-тесты рекордера (drop → Store, рестарт-модель через новый
TrailBookиз сохранённых данных, сбой записи → откат), мутанты на оба контракта.
3. Не-скоуп
- Алгоритмы импорта, материализации, калибровки маршрутов — не меняются (граница issue).
- Продление TTL preview или «продление при Apply» — не нужно: валидность решается один раз при входе.
- Защита токена от вытеснения до commit (пометка «applying») — не нужна: локальная копия кандидата уже в руках обработчика, а после commit токен снимается в любом случае; вытесненный до commit токен при неудачном Apply даёт
preview_expiredна повторе, что верно (кандидат мог устареть). - #491-фехтование, Optimize/Undo — сделано, не трогается.
- Удаление прогонов без
route_id(легаси, pre-#162) — по-прежнему не трогаются (усыновляются карточкой). - Файлы пользователя (планы, вложения) — не затрагиваются.
4. Apply: результат определяется commit
4.1. Контракт
Под write_lock, до записи, как сейчас: get_candidate (TTL, владелец, дайджест), сверка ревизий, политика дубликатов, материализация, проверка файлов. Это единственная точка, где preview может отклонить Apply.
После await _commit_pair(rt, pending, rollback) — вместо get_candidate(..., consume=True):
# Both halves are durable: the token is spent no matter what happened to
# the preview registry meanwhile (TTL, eviction by a newer preview).
rt.import_previews.pop(msg["token"], None)
dict.pop с умолчанием не бросает. В блоке try после commit не остаётся ни одного выражения, способного поднять ImportFailure; путь «commit прошёл → ответ ошибка» становится недостижимым по построению, а не по удаче.
4.2. Семантика повторного Apply тем же токеном
После успешного Apply — preview_expired (токен снят). После Apply, упавшего на conflict/missing_* до commit — токен остаётся (как сейчас, тест test_import_apply_conflict_preserves_state_and_preview_token). После PairCommitFailure — токен остаётся; план восстановлен, повтор возможен после нового preview либо тем же токеном, если ревизии сошлись (без изменений).
4.3. Вытеснение во время Apply
create_preview продолжает работать вне write_lock (CPU-работа до 8 МиБ, HA-правило «>50 мс не в loop»). Вытеснение применяемого токена не влияет на исход: кандидат — локальная переменная обработчика. Финальный pop идемпотентен.
5. Маршруты: снятие прогонов доходит до Store
5.1. Контракт async_purge_orphans(config) -> int
Возвращает, как и раньше, число удалённых маркеров; снятые прогоны маршрутов в счётчик не входят (обратная совместимость с вызовами и тестами #335).
Порядок под _refresh_lock:
dropped = self._drop_unknown_routes(config)— для каждого живого маркера с записью в книге:effective_routes(...)→book.drop_unknown_routes(marker_id, ids). Возвращает{marker_id: {slot: run}}снятых прогонов (копии для отката).orphan_ids = set(book.data) - live_marker_ids.- Если нет ни сирот, ни
dropped→return 0(ни записи, ни события — как сейчас). - Если есть сироты → существующий путь
_async_delete_many(внутренняя версия без повторного захвата lock): книга, пары, подписка,store.async_save(book.data)— снятые прогоны уезжают той же записью. При исключении — восстановление сирот иdropped, повторное_schedule_saveесли ждала отложенная,raise→ внешнийexceptлогирует и возвращает 0 (как сейчас). - Если сирот нет, но
droppedне пуст → отменить отложенную запись (_unsub_save),await store.async_save(book.data); при исключении — вернутьdroppedв книгу, вернуть отложенную запись, если ждала, залогировать, вернуть 0. - После успешной записи (п.4 или п.5) —
hass.bus.async_fire("houseplan_trail_updated", {})вне lock, один раз.
5.2. Что не меняется
TrailBook.drop_unknown_routes — семантика та же (только прогоны с непустым route_id; легаси не трогаются); возвращаемое bool сохраняется для test_trails.py. async_delete (одиночное удаление маркера) — без изменений. _schedule_save при новой точке — без изменений.
5.3. Рестарт-модель в тестах
Рекордер HA-независим в test_trail_recorder.py (стабы). «Рестарт» моделируется так: Store-стаб запоминает последнее async_save(data); новый TrailBook() заполняется этими данными; в нём отсутствуют снятые прогоны и присутствуют остальные. Live-HA проверка эндпоинта/рекордера — test_ha_websocket.py по образцу test_config_set_purges_tombstoned_and_absent_trails_durably (#335): config/set с маркером, у которого из map_routes убран маршрут → trail/get без прогона этого маршрута → recorder.store.async_load() без него, остальные прогоны маркера на месте.
6. Тесты и мутанты
6.1. HA-харнесс, tests_backend/test_ha_import_export.py
test_issue_495_apply_result_follows_the_commit_when_the_preview_expires_meanwhile: обёртка надwsapi._commit_pairпосле реального commit ставитrt.import_previews[token]["expires"] = 0. Ожидание:connection.error is None,result["ok"],config_rev == layout_rev == 2, оба события отправлены (по образцуtest_success_events_are_emitted_only_after_both_target_writes), токена нет вrt.import_previews, Store с rev 2.test_issue_495_apply_result_survives_eviction_by_a_newer_preview: та же обёртка делаетcreate_previewтем жеowner_idMAX_IMPORT_PREVIEWS_PER_USERраз (лимит понижен monkeypatch) — применяемый токен вытеснен. Ожидание то же; новые токены живы.- Существующие тесты Apply (conflict сохраняет токен; commit_failed откатывает) остаются зелёными без правок.
6.2. Стабы рекордера, tests_backend/test_trail_recorder.py
test_issue_495_dropped_route_runs_reach_the_store_without_orphans: книга{"live": {"current": {route_id: "vr_old"}, "previous": {route_id: "vr_keep"}}}, конфиг с маркеромliveиmap_routes=[{id: "vr_keep"}]; purge →0, Store записан один раз, в записиlive.previousесть,live.currentнет; событие одно; новыйTrailBookиз записи не содержитvr_old.test_issue_495_dropped_route_runs_roll_back_when_the_store_write_fails: FailingStore →0, прогонvr_oldснова в памяти, событий нет; затем WorkingStore → записано безvr_old.test_issue_495_dropped_route_runs_share_the_orphan_transaction: сирота + снятый маршрут у живого маркера →1, одна запись без сироты и безvr_old, одно событие.- Существующие тесты purge (#335) без правок.
6.3. Мутанты (реестр scripts/mutation-gate.mjs, гард backend-test-guard.mjs)
import-apply-rechecks-preview-after-commit:rt.import_previews.pop(msg["token"], None)→get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True); свидетель — 6.1 первый тест.import-apply-ignores-eviction-after-commit: тот же патч, свидетель — 6.1 второй тест (регистрируется отдельно только если селектор различает; иначе один мутант с обоими тестами в-k).trail-purge-drops-routes-in-memory-only: в п.5 убрать немедленную запись (await self.store.async_save(...)→pass); свидетель — 6.2 первый тест.trail-purge-keeps-dropped-runs-after-failed-write: в откате п.5 убрать восстановлениеdropped; свидетель — 6.2 второй тест.trail-purge-forgets-routes-when-orphans-exist:_drop_unknown_routesвызывается после вычисления сирот, но результат не попадает в запись (например,droppedвосстанавливается перед сохранением); свидетель — 6.2 третий тест.
Каждый мутант проверяется отрицательным прогоном штатным раннером до перевода в S7.
7. Совместимость и откат
Формат Store следов не меняется; формат ответа Apply не меняется. Откат — revert одного коммита; данных, требующих миграции, нет. Рестарт после отката вернёт прежнее поведение без побочных эффектов.
8. Критерии приёмки
- AC1. Apply, у которого обе половины записаны, отвечает
ok: trueс новыми ревизиями и отправляет оба события, даже если TTL preview истёк во время записи (тест 6.1-1). - AC2. То же при вытеснении токена новым preview того же пользователя во время записи (тест 6.1-2); токен после Apply снят.
- AC3. Удаление/перенацеливание маршрута у живого маркера без сирот → одна запись Store без снятых прогонов, одно событие,
return 0; новыйTrailBookиз записи не содержит удалённых прогонов, остальные целы (тест 6.2-1). - AC4. Сбой записи → снятые прогоны возвращаются в память, событие не уходит; следующий успешный purge записывает без них (тест 6.2-2).
- AC5. Сироты и снятые маршруты уезжают одной записью (тест 6.2-3).
- AC6. Live-HA:
config/setс удалённым маршрутом →trail/getиStore.async_loadбез прогона (§5.3). - AC7. Мутанты §6.3 пойманы штатным раннером; существующие тесты Apply и purge без правок зелёные.
8.1. UX, модель данных, i18n
Не затрагиваются. Changelog: пункт user-visible (ложная ошибка импорта; удалённые маршруты не возвращаются после рестарта).
8.2. Риски и меры
- Немедленная запись при каждом commit конфига с изменённым набором маршрутов — редкое событие (правка маркера), не горячий путь; при отсутствии изменений записи нет.
- Расширение
_refresh_lockнаdrop_unknown_routes— чистая работа с памятью, миллисекунды; deadlock исключён (lock не захватывается повторно: удаление сирот вызывается внутренней версией без lock).
9. Release-артефакты
docs/CHANGELOG.md / docs/CHANGELOG.ru.md — пункт в разделе следующей беты. Документация подсистем не меняется: docs/VACUUM.md («changing the target space is a NEW route identity … the recorded runs … are dropped») и docs/ARCHITECTURE.md (Apply как одна crash-resumable пара) уже описывают поведение после фикса; фикс приводит код к тексту.
10. Затронутые файлы
custom_components/houseplan/websocket_api.py (Apply), custom_components/houseplan/trails.py (async_purge_orphans, _async_delete_many → lock-обёртка + внутренняя версия, _drop_unknown_routes), tests_backend/test_ha_import_export.py, tests_backend/test_trail_recorder.py, tests_backend/test_ha_websocket.py (AC6), scripts/mutation-gate.mjs (мутанты §6.3), docs/CHANGELOG*.md, docs/specs/README.md.
11. Принятые предположения
- Validity кандидата решается ровно один раз — при входе под
write_lock; истечение TTL за время записи не отменяет уже принятый Apply. Альтернатива «продлевать TTL при входе» не нужна: после commit токен всё равно снимается. - Счётчик возврата
async_purge_orphansостаётся числом маркеров; вызывающие его места (websocket_api,__init__recovery) значение не используют кроме логов/тестов. - Событие
houseplan_trail_updatedпри снятых маршрутах уходит один раз после записи — карточка и так перечитывает следы поconfig_updated, лишнего трафика нет.