«Найди все неявные конечные автоматы»: промпт, который находит баги

31 октября Стив Руис, основатель tldraw, написал в X одну строчку: good prompt "find all of the implicit state machines in my app" — хороший промпт: «найди все неявные конечные автоматы в моём приложении». Пост собрал около 8,5 тысячи лайков, а дальше начался мем: глава Shopify Тоби Лютке ответил двумя словами «It works», разработчик Яхья Бакур — «ненавижу, сколько багов это нашло, запускайте сейчас же», а Кент Си Доддс выложил свою версию промпта.

Это не только разговоры. Поиск по GitHub 11 ноября находит 847 пулреквестов, которые упоминают «implicit state machines» и открыты после поста Руиса. Среди них — Trezor Suite, библиотеки alloy-rs для Ethereum, Hyperlane, проекты Parity, десктопный клиент opencode. Ниже разбираем, что именно ищет этот промпт, почему он так хорошо находит баги и как запускать его, чтобы агент не переписал половину приложения.1

Что такое конечный автомат

Конечный автомат — это набор состояний и правила перехода между ними: «если мы в таком-то состоянии и произошло такое-то событие, мы переходим вот в такое». Дэвид Хуршид, автор библиотеки XState, в своём объяснении сравнивает это с тестовой формулой given, when, then.

Автоматы есть в любом приложении. Загрузка файла: ожидает, загружается, загружена, ошибка. Подписка: пробный период, активна, просрочена, отменена. Платёж: создан, оплачен, отклонён, возвращён. Задача агента: в очереди, выполняется, ждёт тул, готово, упало.

Автомат явный, когда его состояния и переходы записаны в одном месте: перечисление статусов, switch, редьюсер, таблица переходов или библиотека вроде XState. Автомат неявный, когда никто его не записал. Состояние живёт в наборе булевых флагов, nullable-полей, таймстемпов и проверок if, разбросанных по разным файлам, и о том, какие переходы возможны, знает только тот, кто всё это помнит.

Как выглядит неявный автомат

Вот типичный код загрузки, который мог написать и человек, и агент:

type Upload = {
  isUploading: boolean;
  progress: number | null;
  error: string | null;
  url: string | null;
};

async function start(upload: Upload, file: File) {
  upload.isUploading = true;
  upload.error = null;
  try {
    upload.url = await send(file, (p) => (upload.progress = p));
  } catch (e) {
    upload.error = String(e);
  }
  upload.isUploading = false;
}

Каждая строчка выглядит разумно. Но четыре поля дают десятки сочетаний, и большая часть из них бессмысленна: есть и url, и error; isUploading ложно, а progress застыл на 40%. Код не запрещает ни одно из них. Хуже того, если пользователь отменил загрузку и начал новую, ответ первой может прийти позже и записать свой url поверх второй.

Тот же автомат, записанный явно:

type Upload =
  | { status: "idle" }
  | { status: "uploading"; id: number; progress: number }
  | { status: "done"; url: string }
  | { status: "failed"; error: string };

function finish(state: Upload, id: number, url: string): Upload {
  // Ответ старой загрузки игнорируется: применяется только текущая.
  if (state.status !== "uploading" || state.id !== id) return state;
  return { status: "done", url };
}

Теперь «есть и url, и ошибка» просто нельзя записать: у каждого состояния свои поля. Переход uploading → done описан в одной функции, и в ней же видно правило, которое спасает от гонки: результат применяется, только если состояние всё ещё то, из которого запрос начался.2

Почему это находит баги

Каждый отдельный if в неявном автомате правильный, поэтому его не ловят ни линтер, ни ревью построчно, ни юнит-тест одной функции. Баг живёт в сочетании: в состоянии, до которого никто не думал дойти, или в переходе, который случается в неожиданном порядке. Особенно часто это поздние асинхронные ответы, повторы запросов и два параллельных действия над одним объектом.

Промпт работает потому, что просит агента не искать ошибки, а восстановить модель: какие у этой штуки бывают состояния и как она между ними ходит. Когда модель выписана, бессмысленные сочетания и недостающие переходы видны сразу. Агент здесь сильнее человека: он может прочитать все места, где меняется флаг, и не устать.

Что нашли в публичных пулреквестах:

  • Trezor Suite (#34610). В библиотеке connect восемь мест отслеживали жизненный цикл через разрозненные флаги, промисы, счётчики и параллельные словари. Например, сессию с устройством описывали четыре поля, а стало одно из четырёх состояний: released | acquiring | acquired | releasing. У обновления прошивки фаз не было вовсе, теперь их шесть — от подготовки до проверки. Описание пулреквеста прямо говорит, что каждый из найденных багов возник из сочетания флагов, которое не должно было быть возможным.
  • alloy-rs (core#1267). Режим проверки при декодировании хранился как Option<bool>, то есть три режима, закодированные через «нет значения», false и true. Теперь это перечисление Off | Words | Full. Попутно нашлось четыре бага, и на каждый добавлен тест, который падает на старом коде.
  • Hyperlane (#9971). Аудит описал 133 рабочих процесса. Из-за состояния, размазанного по флагам, таймерам повторов и очередям, остановленные сервисы могли «воскреснуть», а уже подтверждённые изменения — примениться второй раз.
  • opencode. Люк Паркер из команды opencode написал 4 ноября, что уже влил 15 пулреквестов в десктопное приложение, и ещё около 250 неявных автоматов в работе.

3

Почему это стало проблемой именно сейчас

У этой идеи давняя история. В 2011 году Ярон Мински из Jane Street в лекции для студентов Гарварда, пересказанной в блоге компании, сформулировал правило «make illegal states unrepresentable» — сделайте недопустимые состояния невыразимыми. Его пример — ровно тот же: состояние соединения с пачкой необязательных полей превращается в варианты, у каждого из которых свои поля.

Новым стал масштаб. Агент обычно добавляет одну фичу за раз, и самый короткий путь — завести ещё один флаг или ещё одно nullable-поле. Через пару недель таких фич в коде появляется бизнес-логика, которую никто не проектировал целиком. Промпт Руиса — способ попросить того же агента собрать эту логику обратно в модель.4

Как запускать

Короткий промпт работает и сам по себе: вставьте его в Claude Code, Codex или Cursor в корне репозитория. Но у формулировки Доддса и её популярного пересказа в X есть продолжение вроде «прими лучшее решение для UX, DX и опыта агента» — то есть разрешение сразу всё переделать. На большом проекте это значит гигантский дифф, который невозможно проверить.

Надёжнее разделить работу на два шага. Сначала — аудит без правок:

Найди в этом проекте явные и неявные конечные автоматы:
процессы, у которых есть состояния и переходы, но они закодированы
через булевы флаги, nullable-поля, таймстемпы, статусы-строки,
проверки if или асинхронные события.

Для каждого:
1. Перечисли состояния и допустимые переходы.
2. Укажи файлы и строки, где состояние читается и меняется.
3. Перечисли недопустимые сочетания и переходы, до которых код
   реально может дойти, с доказательством: путь в коде,
   включая повторы, поздние ответы и параллельные вызовы.
4. Отсортируй находки по риску реального бага.
5. Для каждого скажи, нужен ли явный автомат или хватит
   простых условий.

Код не меняй. Сохрани отчёт в docs/state-machines.md.

Похожую версию, с таблицами переходов и инвариантами, выложил Тревин Чоу — по его словам, она находит у него массу багов.

Потом — исправления по одному автомату за раз, начиная с верха отчёта. Хороший образец задают пулреквесты alloy-rs: на каждый найденный баг сначала тест, который падает на старом коде, потом переделка, а в описании — почему поведение остальной системы не изменилось.5

Где промпт ошибается

Не каждый флаг — это автомат, и не каждый автомат нужно переписывать. Чекбокс «запомнить меня» или переключатель тёмной темы — просто булево поле. Агент, которого попросили найти автоматы, найдёт их и там, где их нет, потому что вы попросили.

Поэтому полезно, когда отчёт сам отделяет важное. В пулреквесте Parity dotli-community#455 каждую находку перед исправлением проверяли двумя независимыми прочтениями, а находки без реального сценария сбоя оставили как есть. Там же в стайлгайд добавили правило: у жизненного цикла одно значение состояния, и меняет его одно место в коде.

Второй риск — объём. Переписать сотню мест в одном пулреквесте значит получить ревью, которое никто не сделает. Брать по одному автомату, по одной подсистеме, с тестами — медленнее, но каждое изменение можно проверить и откатить.

Как не плодить новые

Аудит находит автоматы, которые уже появились. Чтобы агент не создавал новые, правило можно записать в AGENTS.md или CLAUDE.md. Такое правило предлагает подборка god-tier-prompts, а Parity записала похожее в свой стайлгайд:

## Состояние
- Никаких неявных конечных автоматов. Если что-то меняет состояние
  со временем (подписка, загрузка, задача, сессия), храни его как одно
  поле статуса с записанным списком допустимых переходов и проверяй
  этот список везде, где состояние меняется.
- Не добавляй новый булев флаг или таймстемп, который незаметно
  создаёт новое состояние.

6

Лорен Тан предложила следующий шаг: найденные автоматы формально проверить. В её наборе pstack есть скил /create-formal-verification-skill, который выбирает самое рискованное место, описывает его модель и проверяет инвариант — для гонок по умолчанию на TLA+. А скил /statechart прогоняет такую модель через проверяльщик TLC и рисует диаграмму состояний, на которой каждое нарушение правила показано как кратчайший путь к нему. Сам набор мы разбирали в отдельной статье.

Как проектировать состояния и переходы до того, как агент напишет код, где в системе чаще всего прячутся гонки и как проверять архитектурные решения в коде агента — разбираем на курсе «Системный дизайн».

Курсы по теме

У каждого курса есть бесплатная часть: оставьте почту и начните сразу.

Рассылка

Раз в месяц — новые курсы, обновления программ и платформы. Отписаться можно в один клик из любого письма.

Начните учиться сегодня

Все курсы за 1 500 ₽ в месяц. Доступ откроется сразу после оплаты, отменить можно в любой момент.

Для компаний — 5 000 ₽ в месяц за сотрудника