«Найди все неявные конечные автоматы»: промпт, который находит баги
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 неявных автоматов в работе.
Почему это стало проблемой именно сейчас
У этой идеи давняя история. В 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 записала похожее в свой стайлгайд:
## Состояние
- Никаких неявных конечных автоматов. Если что-то меняет состояние
со временем (подписка, загрузка, задача, сессия), храни его как одно
поле статуса с записанным списком допустимых переходов и проверяй
этот список везде, где состояние меняется.
- Не добавляй новый булев флаг или таймстемп, который незаметно
создаёт новое состояние.Лорен Тан предложила следующий шаг: найденные автоматы формально проверить. В её наборе pstack есть скил /create-formal-verification-skill, который выбирает самое рискованное место, описывает его модель и проверяет инвариант — для гонок по умолчанию на TLA+. А скил /statechart прогоняет такую модель через проверяльщик TLC и рисует диаграмму состояний, на которой каждое нарушение правила показано как кратчайший путь к нему. Сам набор мы разбирали в отдельной статье.
Как проектировать состояния и переходы до того, как агент напишет код, где в системе чаще всего прячутся гонки и как проверять архитектурные решения в коде агента — разбираем на курсе «Системный дизайн».
Курсы по теме
У каждого курса есть бесплатная часть: оставьте почту и начните сразу.
Основы TypeScript
Стандарт фронтендаСредний уровень17 уроков бесплатно →Основы Claude Code
Стандарт агентной работыС нуля14 уроков бесплатно →