Человек между агентом и кнопкой: почему слой подтверждения — это такая сложная инженерия

от автора

Мой агент дошёл до состояния, когда я перестал читать письма перед отправкой. Это меня и напугало. Между «агент решил» и «письмо ушло» не было ничего — ни паузы, ни человека, ни записи о том, что вообще произошло.

Решение очевидное: агент готовит действие, человек нажимает кнопку. Дальше все пишут один и тот же слой. Таблица pending_actions, эндпоинт «подтвердить», эндпоинт «отклонить». День работы от силы.

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

Двойной клик — двойная отправка

Два ревьюера подтверждают одновременно. Или один, но нервный) Наивный код читает статус, видит pending, исполняет, пишет done — и так оба. Обычный read‑modify‑write без блокировки.

Лечится атомарным переходом:

UPDATE actions SET status = 'approved', decided_by = ?WHERE id = ? AND status = 'pending'

Дальше смотрим на число затронутых строк. Ровно один из конкурентов получает rowcount = 1 — он и исполняет.

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

И ещё одно, про что легко забыть. Само действие исполняется вне транзакции. Внутри только смена статуса и запись результата. SMTP‑вызов под открытым write‑lock базы это способ узнать много нового про таймауты у всех остальных, кто в эту базу пишет.

Рестарт между «подтверждено» и «выполнено»

Процесс умер после UPDATE, но до вызова функции. Человек нажал «да», действие пропало, тишина.

Значит, нужен промежуточный статус между «одобрено» и «сделано», отметка «исполнение началось» — второй CAS, заявка на владение — и процедура восстановления при старте: всё, что одобрено, но не начато, можно безопасно доисполнить.

А вот упавшее посреди исполнения доисполнить, очевидно, нельзя. Побочный эффект то ли случился, то ли нет, и никакой код в умершем процессе этого уже не выяснит. Честный вариант выходит один: пометить «исход неизвестен» и отдать человеку. Повторный запуск — это осознанный выбор at‑least‑once, а не поведение по умолчанию.

Из‑за этого пришлось расшифровать слово exactly‑once тремя строчками вместо одной:

  • больше одного исполнения одновременно не бывает — всегда

  • ровно одно — если процесс пережил вызов

  • после падения посреди вызова — неопределённость, и она обязательно показывается, а не прячется.

Расшифровка выглядит слабее красивого «exactly‑once» на баннере, зато правда.

TTL истекает в момент подтверждения

«Подтверждение действительно 24 часа» звучит просто ровно до того момента, когда подтверждение и истечение попадают в одну миллисекунду. Кто выиграл? Если ответ зависит от того, какой поток проснулся первым — система недетерминированная, и однажды это всплывёт в самый неудобный день.

Срок вшивается прямо в предикат:

UPDATE actions SET status = 'approved'WHERE id = ?  AND status = 'pending'  AND (expires_at IS NULL OR expires_at > ?)

Подтвердить просроченное становится структурно невозможно. Вопрос «кто выиграл» перестаёт уже быть философским и становится вопросом к движку базы.

Отдельная история — кто вообще переводит действия в статус «истекло». Фоновый поток внутри библиотеки плохой сосед: форки, serverless, чужие event loop’ы, и всё это ломается по‑разному. Поэтому истечение ленивое: списки просроченное фильтруют, CAS его не пропускает, а финальный статус проставляет явный вызов, который приложение делает со своей каденцией.

Часы при этом инъектируемые. Без возможности подкрутить время в тесте ни один из этих сценариев детерминированно не проверить — будешь писать sleep(1.1) и ловить плавающие падения в CI.

Повторное предложение того же действия

Агент с ретраями предлагает одно и то же письмо три раза. Очередь не должна превращаться в три кнопки «отправить» — человек нажмёт первую, а потом будет думать, что делать с оставшимися.

Дедуп‑ключ решает, но с тремя небольшими тонкостями.

Первая: уникальность действует, только пока действие живое. Человек отклонил и агент вправе предложить снова. В SQLite это частичный уникальный индекс.

Вторая: держать уникальность обязана база, а не приложение. Проверка «а нет ли уже такого» проигрывает гонку двум одновременным вставкам, и проигрывает, к сожалению. тихо.

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

Аудит, который врёт

Журнал «кто что подтвердил» обычно пишут вторым запросом после смены статуса. Падение между ними — и получается либо действие без записи, либо запись про действие, которого не было.

Для обычного логирования это просто неприятно. Для слоя, весь смысл которого в «потом разберёмся, кто это одобрил» — жесткая дисквалификация.

Смена статуса и её журнальная строка обязаны коммититься одной транзакцией. А сам журнал должен быть append‑only на уровне движка, а не на уровне обычных обещаний в документации. В SQLite это делается триггерами:

CREATE TRIGGER audit_no_update BEFORE UPDATE ON auditBEGIN SELECT RAISE(ABORT, 'audit log is append-only'); END;CREATE TRIGGER audit_no_delete BEFORE DELETE ON auditBEGIN SELECT RAISE(ABORT, 'audit log is append-only'); END;

Три строки, после которых «неизменяемый журнал» перестаёт быть маркетингом)

Бонус: правка аргументов

Ревьюер — не кнопка «да/нет». По моему (скромному) опыту тему письма правят чаще, чем письмо отклоняют.

И патч аргументов тянет за собой длинную нитку. Что делать, если в патче ключ, которого нет в сигнатуре функции? Что если сигнатура успела измениться, пока действие лежало в очереди неделю? Что показывать в аудите, если один из аргументов это токен?

Мои ответы получились такими. Сериализация строгая и без pickle: человек должен видеть ровно то, что исполнится, а не результат работы reduce. Валидация патча — до любых смен статуса, чтобы сломанный патч не оставлял действие в подвешенном виде. Редакция секретов — только в журнале, потому что исполнению нужны настоящие значения.

Как это тестировать

Это оказалась самая полезная часть проекта, и я к ней, если честно, не был готов.

Покрытие строк здесь почти ничего не значит. Строка исполнялась — супер. А заметил бы тест, если бы она сломалась?

Мутационное тестирование ответило неприятно. Первый прогон: 81,5% убитых мутантов при 100% покрытия по ветвям. То есть примерно каждый пятый способ испортить мой код тесты не замечали вообще.

Разбор выживших мутантов стал лучшим код‑ревью, которое получил проект. Нашлись:

  • мёртвое поле, которое никто никогда не читал

  • мёртвая ветка, тихо глотавшая патч, если ключ совпадал с именем **kwargs параметра

  • опечатка в экранировании внутри TOML, из‑за которой один из паттернов исключений не применялся — то есть кусок конфига молча не работал, и об этом не сообщал никто.

Дальше пришлось нагенерить написать около 120 тестов‑контрактов: точные аудит‑трейлы вместо «хоть что‑то записалось», проверка атрибутов исключений, арифметика дедлайнов на скриптованных часах. Счёт дошёл до 93,1%. Оставшиеся выжившие разобраны по категориям и в основном эквивалентны — текст меток, регистр SQL, порядок в сообщениях.

Если вы пишете что‑то, где цена ошибки = необратимое действие, потратьте час и запустите mutmut на своём коде. Час будет неприятный, но отрезвляющий.

Что получилось

Всё вместе собралось в маленькую библиотеку — holdpoint. Декоратор @hold.guard превращает вызов функции в отложенное действие, дальше pending()approve(patch=...)reject(). Состояние в одном SQLite‑файле, рантайм‑зависимостей нет.

Честно про границы. Если вы уже на LangGraph — его родной interrupt() композится внутри фреймворка лучше, чем внешний слой. Если нужен кластер — это Temporal, а не файл на диске. Если хочется готовый веб‑инбокс и уведомления из коробки — есть HumanLayer, он как раз про это. В README лежит отдельный раздел «когда вам это не нужно» и таблица сравнения, где есть колонка с тем, в чём holdpoint проигрывает.

А если у вас всего один питоновский процесс и три страшные функции — возможно, хватит и этого.

Все равно остается вопрос: как поступать с падением посреди исполнения? Я остановился на «исход неизвестен, зовите человека», но подозреваю, что у кого‑то есть решение изящнее

ссылка на оригинал статьи https://habr.com/ru/articles/1073978/