ADR 0191 — Сообщение, пришедшее во время идущего хода, попадает в сохраняемую очередь и читается в конце хода
Статус
Accepted. 2026-10-01 (AT-267, решение владельца от 01.10). Пересматривает, только для
СООБЩЕНИЯ пользователя, синхронный отказ turno_em_andamento из
ADR 0163. ADR 0163 не редактируется:
немедленное принятие, которое он решил, остаётся в силе, и названный отказ остаётся
в силе для команд, не являющихся репликой (ревью истории PO,
готовность Criativo, предложение handoff от Arquiteto). Правило:
RN-673.
Контекст
Со времён ADR 0163 сообщение, пришедшее разговорному агенту посреди
хода, отклонялось с 409 turno_em_andamento и устойчивым agent.error.
Это закрыло молчание — раньше 409 был молчаливым 202, — но не
дефект: api записывает chat.message ДО обращения к engine
(SendAgentMessageUseCase), поэтому сообщение оставалось в ленте как сказанное и
никогда не доходило до модели. Экран просил «отправьте снова, когда он закончит».
Реальное использование 2026-09-29 (сессия be70) зафиксировало три таких случая за один день, по одному в
каждом агенте: seq 100 (PO, 06:28), seq 268 (Infra Lead, 06:39 — «infra ainda
está executando») и seq 312 (Dev Lead, 06:46 — «ok»). Все три записаны
как chat.message, и ни одно не было прочитано. Воспроизведено в коде этого дерева:
TurnoAssincrono.iniciar/3 отвечал {:error, :turno_em_andamento} при
from и идущем ходе, а AgentCommandController.responder_ao_aceite/2
превращал это в 409 — тест agent_command_controller_test.exs («turno já em
curso: 409 nomeado») закреплял именно этот исход.
Единственным прецедентом очереди была очередь Infra Lead (correcoes_pendentes, RN-617):
В ПАМЯТИ, для исправления gate, никогда для реплики пользователя — и терялась при
перезапуске engine.
Решение владельца (01.10), являющееся требованием:
- сообщение, пришедшее во время идущего хода, попадает в сохраняемую очередь, которая переживает перезапуск;
- N сообщений = 1 ход: в конце идущего хода ожидающие читаются вместе, по порядку, в одном ходе;
- каждое может быть отменено, пока ожидает, тем, кто его отправил, с минимальной ролью, которую уже требует маршрут сообщения;
- потолок 10 ожидающих на агента и сессию, а выше — названный отказ;
- действует для СЕМИ разговорных агентов одним путём (
TurnoAssincrono), никогда не на уровне сервера.
Решение
Очередь живёт в TurnoAssincrono, а её состояние живёт в event log.
TurnoAssincrono.receber_mensagem/4заменяет прямойiniciar/3в семиhandle_call({:user_message, …}). Без хода и без очереди ход поднимается как всегда (:ok, 202). При идущем ходе и очереди ниже 10 сообщение ставится в очередь, записываетсяchat.message_queued, и ответ —{:ok, :enfileirada, posicao}— тоже 202, с телом{entrega: "enfileirada", posicao}. При очереди на потолке —{:error, :fila_de_mensagens_cheia}: названный 409 и устойчивыйagent.error(сообщение записано и НЕ будет прочитано — та же честность, которую ADR 0163 потребовал от старого отказа).- Сервер лишь говорит, как реплика становится ходом. Каждый из семи получил
turno_de_mensagem/2(та же сборка, которуюhandle_callделал inline) и защиту в state (:montar_turno_de_mensagem).TurnoAssincronoрешает КОГДА; сервер — КАК. Ни один сервер не получил собственной очереди. - В конце хода —
finalizar/1, никогдаsuspender/1— очередь становится ОДНИМ ходом. Доставка — это сообщение самому себе (:entregar_fila_de_mensagens), а не вызов внутри завершения, потому что у сервера ещё есть собственное завершение в том жеhandle_info(Infra Lead сливает исправление gate, Arquiteto — handoff к Dev Lead) и он не должен найти уже поднятый ход из очереди. Ожидающие становятся одним текстом (FilaDeMensagens.texto_do_turno/1: одно — это оно само; несколько — пронумерованы по порядку, со строкой, говорящей, что их несколько), аchat.message_deliveredперечисляет id. Язык хода — язык автора САМОГО СВЕЖЕГО сообщения (RN-622). Важна очередь ТЕКУЩЕГО state, никогда очередь из результата Task, которая захватила state начала хода. - Сообщение, пришедшее между концом хода и доставкой, встаёт в КОНЕЦ очереди, и вся очередь доставляется сразу — самое новое никогда не обгоняет ожидавшие.
- Три неизменяемых события, все с id
chat.message.chat.message_queued(агент;mensagemId,texto,idioma,posicao— текст идёт вместе, потому что именно его очереди нужно перечитать после перезапуска),chat.message_delivered(агент;mensagemIds) иchat.message_cancelled(акторuser, тот, кто отменил;mensagemId,agente). Ожидающее — это поставленное в очередь и ни доставленное, ни отменённое — один и тот же вывод в engine (FilaDeMensagens.pendentes/2) и на экране (estadosNaFila). Никакой таблицы. - Перезапуск.
init/1семи агентов восстанавливает очередь из log (TurnoAssincrono.fila_ao_subir/3) и планирует доставку;Reidratacaoубирает из истории отменённое (оно никогда не было сказано) и ожидающее этого агента (оно придёт через ход очереди — без этого вошло бы дважды). При boot, после закрытия осиротевшего хода (RN-586),Rehydratorбудит агента, у которого есть ожидающая очередь (FilaDeMensagens.acordar_pendentes/2): без этого очередь того, кто был посреди хода, когда engine упал, ждала бы, пока кто-то напишет снова. Прерванный ход по-прежнему НИКОГДА не переделывается — читается сообщение, которое не прочитал ни один ход. - Отмена.
POST …/agents/:agent/messages/:messageId/cancel(developer, как у маршрута сообщения). api проверяет, что сообщение —chat.messageэтой сессии, что его актор — вызывающий (иначе 403mensagem_de_outra_pessoa) и что сессия принимает беседу (RN-581). Решает, находится ли оно ещё в очереди, процесс агента, который сериализует гонку с доставкой: 409mensagem_fora_da_fila, когда оно уже прочитано или отменено. Когда агент не работает, очереди в памяти нет — очередь — это log, — и engine записывает отмену напрямую. - Ход, приостановленный в ожидании одобрения (Dev Lead, RN-284), по-прежнему отказывает, с
aguardando_aprovacao. Использование той же очереди было рассмотрено и отвергнуто: у приостановленного хода нет предсказуемого конца (он зависит от решения человека в Одобрениях), и постановка в очередь означала бы обещать чтение «в конце хода» без конца в поле зрения. Сообщение, уже стоявшее в очереди, когда ход приостановился, ждёт, пока ВОЗОБНОВЛЕНИЕ завершится, — доставка пропускает приостановленное состояние. - Экран. Composer перестаёт блокироваться во время хода, когда сообщение адресовано
агенту: кнопка становится «Pôr na fila», сообщение не взводит новый ход (идущий
остаётся отслеживаемым) и появляется в ленте со значком «na fila de
», а для отправителя — с кнопкой отмены. Отменённое остаётся зачёркнутым. Чат без агента (SSE) по-прежнему блокируется: там очереди нет.
Последствия
- Отказ
turno_em_andamentoне исчез. Он остаётся для ревью истории, готовности и предложения handoff, которые вызываютiniciar/3сfrom. Тот, кто прочитает ADR 0163 отдельно, решит, что сообщение всё ещё отклоняется; RN-578 указывает сюда. - Текст сообщения оказывается в log дважды, когда оно попадает в очередь (в
chat.messageи вchat.message_queued). Принятая цена: без него очередь, чейchat.messageвышел за окно в 200 событий, не восстановилась бы. - Чтение очереди в
init/1— это ещё один запрос на каждый запуск агента (по типу, с потолком 200 из ADR 0060). Более 200 событий очереди в одной сессии заставили бы восстановление видеть только самые свежие — объявлено, не измерено; потолок в 10 ожидающих делает это маловероятным, но не невозможным. - Пробуждение при boot тратит токены без чьего-либо клика — от имени сообщений, которые пользователь отправил и которые не прочитал ни один ход. Это намеренное отличие от RN-586, которая не переделывает ход: прерванный ход мог иметь эффект; ожидающее сообщение — никакого.
- У отмены при неработающем агенте есть окно: агент может запускаться (читая очередь из log) в тот же момент, когда отмена записывается. Блокировки нет; сообщение может быть прочитано. Объявлено.
- Сообщения другому агенту при идущем ходе на экране. Экран отслеживает
один ход за раз; отправка ПРОСТАИВАЮЩЕМУ агенту, пока другой работает, поднимает
его ход (
entrega: lida), и экран начинает отслеживать его по log. Селектор получателя по-прежнему заблокирован во время хода, как раньше. - Закрытая сессия. Агенты останавливаются (RN-581), и очередь умирает вместе с ними;
сообщение, которое ожидало, навсегда остаётся «в очереди» в log, а экран не
предлагает отмену в неактивной сессии. Если сессия будет открыта снова (ADR 0183) и
агент снова поднимется, его
init/1доставит очередь — принятое следствие, не спроектированное отдельно. - Engine доказывает только CI. В этом дереве
repo.hex.pmотвечает 403, и набор ExUnit не запускается локально; новые тесты (fila_de_mensagens_test.exs) и скорректированные были написаны против существующих fakes и проверены только парсером и форматтером.