Работа над Agent Inbox показала мне, что передача управления между агентом и человеком — это конечный автомат: с явным владением состоянием, маркерами версии и чёткой семантикой подтверждения обработки.

Содержание

Первую версию Agent Inbox я набросал за один день. Таблица в SQLite, столбец для вопроса, столбец для ответа, флаг состояния. Когда агенту требовалось решение человека, он создавал строку. Когда человек отвечал, агент её считывал. Вроде всё просто и понятно.

А потом я попробовал одновременно запустить два CLI-процесса.

Дальше произошло то, что хорошо знакомо каждому, кто когда-либо строил очередь или планировщик. Простая версия отлично работает в идеальном сценарии, но начинает ломаться во всех действительно важных местах, как только среда становится хоть немного реальной. Между «сохранить вопрос» и «надёжно передать управление между агентом и человеком, когда есть несколько процессов ОС, долгоживущие MCP-серверы, соседние субагенты и два конкурирующих канала ответа» лежит пропасть. И проблема здесь не в хранении данных. Проблема во владении состоянием.

Главный инвариант, который проявился при разработке Agent Inbox: передача управления с человеком в контуре — это конечный автомат, а не цепочка сообщений.

У каждой строки в любой момент времени есть владелец.
Переходы выполняются через чётко определённые операции с маркерами версии. Подтверждение обработки отслеживается отдельно от доставки.

— Именно такой взгляд позволяет разобраться в системе, когда что-то идёт не так, а что-то обязательно пойдёт не так.

Конечный автомат передачи управления

Строка доски в Agent Inbox может находиться в одном из шести состояний. tracked, partial, missing, na и done описывают обычный ход работы. blocked — единственное состояние, которое требует внимания человека: оно означает, что строкой должен заняться человек и никто другой.

Здесь важно различать два события: человек завершил свою часть работы и агент изменил состояние строки. Это не одно и то же. handled_at фиксирует момент, когда человек отметил свою задачу выполненной. При этом status остаётся blocked, пока агент не прочитает ответ и не запишет непустое значение в outcome. Поэтому строка может остаться с заполненным handled_at и статусом blocked, если процесс агента завершился до того, как успел забрать ответ на обработку.

Так и задумано. Благодаря этому ответы без подтверждения обработки можно найти обычным запросом:

WHERE handled_at IS NOT NULL AND outcome = ''

Он вернёт все ответы, которые поступили в систему, но так и не были обработаны.

annotation_seen_at и handled_seen_at — это подтверждения доставки, а не признаки завершения. Они фиксируют, что какой-то экземпляр агента увидел ответ человека, но не закрывают строку. Забор ответа на обработку фактически имеет семантику «как минимум один раз». Если процесс, установивший annotation_seen_at, упадёт до того, как запишет результат, ту же строку сможет забрать другой процесс. Человек по-прежнему видит, что строка открыта, система остаётся согласованной, а ответ не пропадает бесследно.

Два канала ответа, одно правило приоритета

Issue #29 наглядно показал, почему одного канала ответа недостаточно. Агент может задать вопрос прямо в чате и получить ответ там же. В Agent Inbox при этом есть карточка, где человек может ответить напрямую. Оба пути записывают данные в одни и те же поля ответа, а reply_source показывает, откуда взялось текущее значение: из inbox или из чата, откуда его сохранил агент.

Правило приоритета намеренно асимметрично. Запись из inbox выполняется безусловно: она заменяет текущий ответ, устанавливает reply_source='inbox' и сбрасывает reply_seen_at, чтобы агенту пришлось заново забрать новый ответ на обработку. Для ответа, сохранённого из чата, используется один UPDATE с условием. Запись разрешена только в том случае, если ответа ещё нет или текущий ответ уже был забран на обработку:

WHERE id = ?
  AND kind = 'question'
  AND status = 'open'
  AND (reply IS NULL OR reply = '' OR reply_seen_at IS NOT NULL)

Если в inbox уже ждёт непрочитанный ответ, обновление из чата не затронет ни одной строки и вернёт unread_inbox_answer вместе с ответом, который получил приоритет. Новый ответ из inbox всегда может заменить ответ, записанный из чата. Но после того как агент забрал ответ из inbox на обработку, более свежий ответ из чата уже может его заменить. Это не last-write-wins по временной метке и не обычный first-writer-wins. Здесь действует приоритет inbox до подтверждения обработки.

Ветка reply = '' нужна для обратной совместимости со старым представлением пустого ответа, а не для обозначения непрочитанного ответа из inbox. Текущий интерфейс просмотра преобразует пустой ответ в NULL, очищает его источник и разрешает такую очистку только до того, как ответ будет забран на обработку. Поэтому настоящий ответ из inbox всегда непустой и имеет reply_seen_at IS NULL, а значит, обновление из чата не может его перезаписать.

Важно, что всё это делается одним UPDATE с условием, потому что интерфейс просмотра пишет данные из отдельного процесса ОС. Если сначала выполнить SELECT, а затем UPDATE, между этими двумя операциями останется окно, в котором человек может успеть отправить ответ, а последующий UPDATE его перезапишет. Проверка условия и запись должны выполняться одной атомарной операцией.

Один вопрос — одно место взаимодействия

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

Поэтому в Agent Inbox действует инвариант, прямо зафиксированный в контрактах инструментов: один вопрос — одно место взаимодействия. Если зависимость принадлежит строке доски, то именно эта строка со статусом blocked и является вопросом — отдельный дублирующий элемент с вопросом создавать нельзя. Стабильные метки доски служат идентификатором для человека, а поле revision у строки — версией для системы. Если агенту нужно изменить сам вопрос, потому что поменялся контекст, а не потому что пришёл ответ, он обновляет существующую строку и увеличивает revision. Вторую строку он не создаёт.

board_advance как CAS-маркер

За перевод доски отслеживания на следующий шаг отвечает операция board_advance. Она принимает два маркера версии: expected_revision — текущую ревизию строки, которую нужно закрыть, и board_version — текущее поколение доски. Если с момента последнего чтения любое из этих значений изменилось, операция завершается ошибкой: вызывающая сторона должна заново прочитать состояние и только потом повторить попытку.

По сути, это compare-and-swap (CAS) на уровне приложения. Такой механизм защищает от целого класса ошибок, когда два агента одновременно считают, что переводят одну и ту же доску на следующий шаг, а один из них в итоге записывает устаревшее состояние. board_advance в рамках одной транзакции атомарно архивирует предыдущий шаг для человека и устанавливает следующий. Состояния, в котором доска обновлена лишь наполовину, не возникает.

В тестах эти два механизма проверяются по-разному. Причём используются реальные временные базы SQLite, а не моки. Защиту по ревизии строки доски проверяют последовательно: клиент вызывает board_advance, board_row или board_upsert с устаревшим expected_revision либо board_version, а явная проверка ревизии внутри транзакции отклоняет вызов. Второе соединение, чтобы доказать корректность этого механизма, не требуется.

А вот принудительно воспроизводимая гонка между двумя соединениями используется для проверки ответа на элемент и его забора на обработку. В тесте одновременно работают два реальных SQLite-соединения, а порядок выполнения жёстко задаётся так, чтобы одна запись попала в окно между чтением и записью другой операции. Именно здесь процессы интерфейса просмотра и MCP действительно могут выполняться вперемешку.

И test/store.test.ts, и test/mcp.integration.test.ts выполняют реальные операции с хранилищем. Интеграционный тест вдобавок прогоняет настоящий цикл запрос-ответ через MCP-сервер, работающий через stdio, потому что само время жизни MCP-сервера создаёт состояние, которое тоже стоит проверять напрямую.

Время жизни MCP-сервера и контекст на уровне процесса

MCP-сервер, работающий через stdio, живёт всё время работы CLI-процесса. Субагент, запущенный внутри того же процесса, использует то же MCP-соединение. Поэтому передача контекста на уровне процесса — это лишь оптимизация, а не гарантия корректности. Субагент с новым соединением или процесс после перезапуска начинает без локального контекста.

Запасной механизм восстановления — параметр full:true при чтении контекста: с ним текущее состояние загружается из базы данных, а не берётся из кэша процесса. Его стоит использовать при любой передаче управления, где нельзя допустить рассинхронизации. Кэш на уровне процесса нужен для снижения задержек, а full:true остаётся источником истины.

SQLite на практике

SQLite в режиме WAL с busy_timeout=5000 справляется с характерным для агентных систем потоком множества небольших операций записи лучше, чем можно было бы ожидать. В обычном случае WAL позволяет читателям и одному писателю работать параллельно, но не устраняет ошибки SQLITE_BUSY и SQLITE_BUSY_SNAPSHOT. Тайм-аут позволяет ждать освобождения блокировки до пяти секунд там, где такое ожидание допустимо. Но вызывающая сторона всё равно должна уметь обрабатывать ошибку busy, если SQLite не может безопасно ждать или продвинуть снимок состояния. На практике конкуренция возникает редко, а тайм-аут сглаживает большинство коротких всплесков.

Но WAL не спасает от логических гонок. Если два процесса сначала проверяют условие, а потом выполняют запись, между ними всё равно возможна гонка: WAL — это механизм обеспечения долговечности данных, а не механизм сериализации операций на уровне приложения. От нарушения логической целостности защищают описанная выше условная проверка при записи ответа и транзакция board_advance. WAL делает надёжную запись быстрой, а схема данных — безопасной при конкурентных записях.

Представление Plans в Agent Inbox с двумя досками отслеживания и выделенным решением по аутентификации
Представление Plans в Agent Inbox с двумя досками отслеживания и выделенным решением по аутентификации

На скриншоте выше используются синтетические демонстрационные данные, чтобы показать, как на практике выглядят стабильные строки плана. У каждой строки есть понятный человеку статус, видимый прогресс и только одно решение, за которое в данный момент отвечает человек. Метка доски остаётся неизменной между ревизиями — отображение не меняется, пока агент обновляет внутреннее состояние. Разницу между описательным статусом и настоящей блокирующей зависимостью, когда строка не может перейти дальше без действия человека, видно сразу. Показанная строка с удалённым режимом приведена только для иллюстрации: этот сценарий пока не реализован.

Что пока не решено

Стоит отдельно назвать два пробела.

Удалённый режим, в котором Agent Inbox работает с общей базой данных, а не с локальным файлом SQLite, пока не реализован. Модель данных к нему готова, а сетевой слой и аутентификация — нет (issue #8). С этим связана и другая пока нерешённая задача: разбудить и возобновить локальную сессию агента, когда приходит удалённый ответ (issue #51).

Остальные недоработки, обнаруженные в начале эксплуатации, удалось исправить, и это кое-что говорит о самой модели. Раньше аннотации строк доски не попадали в pending(): агент мог исправно опрашивать систему и всё равно пропустить заметку человека в строке. В issue #37 это исправили: теперь pending() возвращает {items, rows} вместе.

Раньше ответ, сохранённый из чата, после записи уже нельзя было исправить. Issues #34 и #69 решили эту проблему, добавив тип ответа clarify: теперь агент закрывает исходный элемент и создаёт вместо него исправленную версию, а не переписывает историю задним числом. Ни одно из этих изменений не затронуло описанное выше правило приоритета — они лишь расширили его.

Практический взгляд на задачу

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

Если вы строите систему с человеком в контуре управления, один из первых вопросов, который стоит задать: кто отвечает за каждый переход и какой маркер подтверждает, что этот переход допустим. Всё остальное — интерфейс, промпт, уведомления — строится поверх этого контракта.

Инварианты и тесты лежат в открытом репозитории github.com/shariqh/agent-inbox. Сам конечный автомат реализован в src/store.ts, а его поведение проверяется в test/store.test.ts и test/mcp.integration.test.ts. Начните с них.

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

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

  • 1 октября в 20:00. «Продуктивность разработчика и Agent Skills». Записаться

  • 19 октября в 20:00. «ИИ-агенты в реальной разработке: что умеют делать вместо вас». Записаться

Комментарии (0)