devs_key = ",".join(developers) — список джойнился как пришёл, а приходит он прямо
из тела запроса (`developers` в admin_scrape, порядок произвольный). Один и тот же
набор в другом порядке давал ДРУГОЙ ключ: синглтон молча переставал быть
синглтоном, два свипа шли параллельно по одним и тем же разработчикам.
Второе следствие того же корня: force_release_lock строит ключ этой же функцией.
Оператор, снимающий залипший лок и перечисливший разработчиков в ином порядке,
молча не снимал ничего.
sorted(set(...)) закрывает оба случая: порядок и повторы ('A','A' ≡ 'A').
Про переход: формат ключа меняется, поэтому лок, удерживаемый ПРЯМО СЕЙЧАС под
старым ключом, осиротеет и истечёт по TTL. Практического риска нет — kn-свип
не отрабатывает с 28.06 (DOM.РФ отдаёт страницу блокировки, см. #2443).
Тесты сравнивают ЗНАЧЕНИЯ ключей, поэтому красное на origin/main — про разошедшиеся
ключи, а не про отсутствующую функцию:
один набор в двух порядках → два разных ключа → падает
повтор в списке меняет ключ → падает
разные наборы остаются разными — контроль, зелёный с обеих сторон
регион остаётся в ключе — контроль, зелёный с обеих сторон
None/[] по-прежнему дают '*' — контроль, зелёный с обеих сторон
Первый контроль не для симметрии: ловит «починку» через огрубление ключа
(отбросить developers вовсе) — тогда свипы по разным разработчикам блокировали бы
друг друга.
Прогоны: -k "scrape_kn or lock" — 114 passed rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Деплой делает `git reset --hard origin/main`, поэтому любой site-блок,
вписанный руками в /opt/gendesign/Caddyfile, живёт до первого пуша в main.
16.08 так и вышло с garmin.gendsgn.ru: контейнер остался healthy, а хост
исчез из конфига — TLS-хендшейк стал падать `alert internal error`,
потому что сертификат для него Caddy больше не держит.
Класть такой блок в git нельзя: у него единственный рубеж аутентификации —
секрет в пути URL (апстрим-MCP аутентификации не имеет, а claude.ai
connector кастомные заголовки не шлёт).
Развязка: в репозитории только `import caddy/local/*.caddy` и сам каталог
(держится .gitignore'ом), файлы с секретами лежат на VPS untracked —
reset --hard их не трогает. Пустой glob для Caddy не ошибка,
`caddy validate` проходит, на машинах без локальных блоков import — no-op.
Проверено `caddy validate` 2.11.4 на VPS: и с пустым каталогом, и с
реальным блоком garmin — Valid configuration.
Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.
Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).
- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
(иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
= False внутри replay_fixture (было только в самом гейте) — иначе штатная
регенерация baseline (--from-fixture --update-baseline) писала baseline,
который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.
Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене.
## 1. Город предлагали выбрать, но отвечать по нему не умели
Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия
(`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в
двух местах. Они разошлись в обе стороны:
предлагали, но не отвечали: Серов
отвечали, но не предлагали: Берёзовский, Среднеуральск, Ревда
Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал:
«Этот адрес вне области, по которой мы собираем данные.
Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько
городов вокруг.»
Про город в той же самой области. Серов при этом покрыт данными: 363 активных
объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в
жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт.
Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением
7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест
есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс
проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый,
город по координатам не резолвится.
## 2. Подсказки не слушались выбранного города
`city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только
екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир
пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает.
Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н,
пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом —
ровно баг #2576, ради которого город и спрашивают.
Публичная ручка теперь подставляет город в саму строку запроса. Проверено на
проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга
подстановка безвредна — три разных адреса дали тот же результат с префиксом и
без, поэтому правило одно на все города, без исключения для основного трафика.
Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит
поведение закрытого контура (`target_city_ambiguous`).
## Фикстура теста
`_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от
всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до
ближайшего центроида.
## Мутации
убрать Серов из покрытия (состояние прода) → падает сверка списков
не подставлять город в строку → падает проверка ручки
откат → 21 passed
Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard.
`city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков
делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы
не запускала — то есть ровно тот путь, которым списки и разошлись.
Замер на проде сразу после выкатки #2913: страница `/estimate` отдаёт 200 и
выглядит правильно, но после гидратации монтируется `GuardedRoute` —
404 /trade-in/api/v1/me ×3
404 /trade-in/api/v1/trade-in/support/anon/unread
плюс виджет чата поддержки из закрытого контура и экран «Не удалось проверить
доступ» внизу страницы. Форма при этом переставала работать ВОВСЕ: разметка с
сервера видна, поле принимает текст, а обработчики не подключены — человек
печатает адрес и не получает ни подсказок, ни ошибки. Проверено, что дело не в
API: ручной `fetch` из той же страницы отдаёт 200 и восемь подсказок.
Причина ровно та же, что чинил PR #2910 для `/`: список публичных путей в
гварде и матчер `@meraPages` в Caddyfile — одно множество, записанное в двух
местах. Тогда разошлись на `/`, теперь на `/estimate`. Оба раза страница
отдавала 200, ломалась только в браузере, и ни один тест этого не видел.
Комментарий в шапке гварда инцидент не предотвратил (он там был), поэтому
совпадение теперь запинено ТЕСТОМ: он читает Caddyfile и требует, чтобы каждый
путь из `@meraPages` проходил `isPublicPath`. Сверяемся с источником, а не
дублируем список третий раз.
Мутация — вернуть список к состоянию, которое уехало на прод:
2 failed / 3 passed
× пропускает короткие адреса публичного домена
× совпадает с тем, что публичный домен реально раздаёт (Caddyfile)
откат → 5 passed.
Плюс vitest 56 passed, tsc, lint, isolation guard 41 файл.
`Caddyfile` уже в paths-фильтре фронтового лэйна (#2913), так что правка одного
лишь конфига теперь тоже гоняет этот гейт.
Первая версия шага смонтировала `$PWD` внутрь caddy-контейнера и упала на
`open /etc/caddy/Caddyfile: no such file or directory`.
Причина: job сам исполняется внутри контейнера, а `docker run` создаёт
КОНТЕЙНЕР-БРАТ на том же демоне. Путь в `-v` резолвится на ХОСТЕ, тогда как
`$PWD` — путь внутри job-контейнера, которого на хосте нет. Классическая
ловушка docker-in-docker, и она не зависит от содержимого конфига — смонтируй
так что угодно, монтирования просто не произойдёт.
Заменено на `docker create -w /work` + `docker cp` + `docker start -a`: копия
не зависит от того, как смонтирован workspace. Копируется и каталог `caddy/` —
Caddyfile делает `import caddy/users.caddy.snippet`, без него validate падает
на импорте.
Проверено локально обе стороны: валидный конфиг → `Valid configuration`,
exit 0; конфиг с незакрытой скобкой → `unexpected EOF`, exit 1. Без второй
проверки гейт мог бы оказаться вечно-зелёным.