Перейти к основному содержимому

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), являющееся требованием:

  1. сообщение, пришедшее во время идущего хода, попадает в сохраняемую очередь, которая переживает перезапуск;
  2. N сообщений = 1 ход: в конце идущего хода ожидающие читаются вместе, по порядку, в одном ходе;
  3. каждое может быть отменено, пока ожидает, тем, кто его отправил, с минимальной ролью, которую уже требует маршрут сообщения;
  4. потолок 10 ожидающих на агента и сессию, а выше — названный отказ;
  5. действует для СЕМИ разговорных агентов одним путём (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 этой сессии, что его актор — вызывающий (иначе 403 mensagem_de_outra_pessoa) и что сессия принимает беседу (RN-581). Решает, находится ли оно ещё в очереди, процесс агента, который сериализует гонку с доставкой: 409 mensagem_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 и проверены только парсером и форматтером.