---
name: inmemory-state-plus-gate-deadlock
description: Гейт «доделай начатое» + кнопка с состоянием в памяти процесса = вечный тупик после рестарта. Проверять любой новый гейт вопросом «а выход переживёт перезапуск?»
metadata: 
  node_type: memory
  type: feedback
  originSessionId: d0c6c5aa-4d05-42e2-a1f9-034482b88603
  modified: 2026-08-03T05:45:39.288Z
---

**Любой гейт вида «сначала доделай начатое» проверяй вопросом: переживёт ли выход из него перезапуск процесса?**

**Случай (ЗБР, 02.08.2026).** Гейт не давал заказать новый экспертный отчёт, пока не выкуплена предыдущая предоплаченная проверка, и отправлял «закажите кнопкой под результатом». Кнопка жила в `PENDING_ANALYSIS_REQUESTS` — словаре в памяти бота. После рестарта:

- старая кнопка → «Запрос устарел, отправьте номер ещё раз»
- отправка номера → «проверка уже готова, жмите кнопку под результатом»

Круг. Пользователь не мог купить **ни одного** отчёта, пока кто-то не влезет в базу. Нашлось только живым прогоном в Telegram — `py_compile`, юнит-тесты и вызовы функций такого не видят, тупик рождается на стыке «рестарт + состояние в памяти + новый гейт».

**Как чинили:** тот же самый идентификатор (кадастровый номер) гейт пропускает — повтор того же объекта не обход, а ровно то, чего гейт добивается; долг закрывается по объекту, а не по строке-заявке, иначе брошенная строка запирает вечно; текст отказа называет рабочее действие, а не кнопку, которой может не быть.

**Правило на будущее:** запрещающая ветка обязана содержать действие, выполнимое из текущего состояния мира. «Нажмите кнопку в старом сообщении» — не действие, если кнопка зависит от RAM процесса.

**Причину тоже убрали (03.08.2026, D-73).** Состояние переехало в таблицу `pending_analysis_requests`, TTL подняли с 10 минут до суток — при прежнем сроке переезд почти ничего бы не дал, рестарт случается в произвольный момент. Проверка, доказывающая уход из памяти: **записать в одном процессе, прочитать в другом**; сверху живой прогон с реальным `launchctl kickstart` между отправкой сообщения и нажатием кнопки. Зонд подбирать так, чтобы он ничего не расходовал: годится любая проверка, которая идёт ПОСЛЕ поиска состояния и даёт свой, отличимый отказ.

**Третий слой той же ошибки (03.08.2026, D-74): одна операция исполняла две роли.** Запись гасилась при первом нажатии — это была и защита от двойного клика, и расход кнопки. Из-за второй роли отказ по балансу убивал кнопку насмерть: «нет платных отчётов» → купил пакет → та же кнопка мертва. Ровно в точке, где человек уже заплатил.

Лечение — не «перенести гашение ниже» (это открывает двойной запуск), а **развести роли на три операции**: занять (атомарный `UPDATE … SET launched_at=now WHERE id=? AND launched_at IS NULL`, `rowcount==1` — из двух нажатий выигрывает одно), освободить (обратно в NULL на каждой ветке отказа), погасить (только перед реальным стартом). Побочно это ещё и честнее по гонке: «прочитал-удалил» настоящей идемпотентностью не был.

**Правило:** если одна строка кода защищает от гонки И тратит ресурс — это два разных решения, слипшихся в одно. Разлепить дешевле, чем чинить последствия.

Связано: [[deployed_is_not_running]], [[verify_with_production_interpreter]], [[handler_order_catchall_eats_commands]].

**Грабли Playwright в TG-прогонах** (три штуки за один вечер, все — в тесте, не в продукте):
1. `show_alert` бота = модалка Telegram; Playwright пишет «popup-confirmation intercepts pointer events», и это читается как таймаут кнопки. Модалку надо прочитать (там полезный текст!) и закрыть.
2. Ждать «новое сообщение» нельзя: бот сначала шлёт прогресс. Ждать надо сообщение **с нужной кнопкой**.
3. Строка результата в БД может создаваться пустой и заполняться в конце — её появление не равно готовности. Условие готовности = непустой контент.
