ADR 0163 — Клик отвечает при ПРИНЯТИИ команды, а ход продолжается через канал
Статус
Accepted. 2026-09-18 (AT-089). Обобщает разрыв синхронной
блокировки, который ADR 0086
сделал только для приостановленного Dev Lead. ADR 0086 не редактируется: то, что он решил
(ход, который ПРИОСТАНАВЛИВАЕТСЯ, сразу отвечает from и испускает
agent.status: awaiting_approval вместо agent.done), продолжает действовать —
этот документ лишь переносит момент ответа раньше для КАЖДОГО хода, и тем самым
его случай перестаёт быть единственным. Правило: RN-578.
Контекст
В реальной установке v6.1.0 (14.09) три клика висели весь ход агента, который они запустили:
| Маршрут api | Время запроса |
|---|---|
POST …/agents/criativo/structured-question/:id/answer | 97,3 с |
POST …/sessions/:id/readiness | 97,3 с |
POST …/agents/arquiteto/handoff-infra | 51,8 с |
Event log той же сессии совпадает со всеми тремя до десятой доли секунды (от события
пользователя до сохранённого agent.status: idle агента): 97,2 с, 97,3 с и 51,7 с.
Причина одна: каждый пользовательский маршрут, говорящий с разговорным агентом,
проходит через EngineWeb.AgentCommandController, который делает синхронный
GenServer.call (120 с у Criativo, 180 с у остальных), — а handle_call агента
со времён RN-122 откладывает ответ до завершения Task хода
(TurnoAssincrono.iniciar/3 хранил from и отвечал в
tratar_resultado/2). Controller уже возвращал 202 и уже отбрасывал
возврат («esta resposta é só o aceite»), а OpenAPI …/message уже говорила
«the response is just the acknowledgment». Принятие лишь приходило в конце
хода.
Три последствия, все измерены:
- Кнопка «не отвечает». Для человека клик, занимающий 97 с, — это клик, который не удался.
- Ход сверх потолка становится ошибкой в сработавшей команде.
GenServer.callвыходит по:timeoutв процессе controller, Phoenix возвращает 500, api становится 500, и экран показывает ошибку — пока ход продолжает работать и завершается успешно. Kickoff той же сессии (которые являютсяcastи поэтому не страдают) длились 7,5 мин (PO), 6,6 мин (Arquiteto) и 8,4 мин (Infra): ход сверх 120 с — не гипотеза. - Молчаливый отказ. Сообщение, пришедшее при уже идущем ходе, получает
{:error, :turno_em_andamento}, который controller отбрасывал, отвечая 202. В той же сессии «Continue», набранное через 2,5 мин после начала kickoff Arquiteto, было принято успешно и никогда не прочитано — ни одногоagent.status: workingпосле него, ни одногоagent.error. Хуже: экран оставлял composer свободным во время этого kickoff (принятый handoff взводит полосу безstreaming), так что это был естественный путь.
Решение
TurnoAssincrono.iniciar/3отвечаетfromПРИ ПРИНЯТИИ. Когда Task поднята, аagent.status: workingуже сохранён,handle_callвозвращает{:reply, :ok, state}.fromперестаёт путешествовать вstate.turno_assincrono;tratar_resultado/2иcancelar/1больше никому не отвечают. Это ТОТ ЖЕ механизм для шести разговорных агентов — никакого второго пути, — и ADR 0086 становится частным случаем: приостановленный Dev Lead по-прежнему безagent.done, просто он больше не единственный, кто отвечает рано.- Из запроса уходит только ожидание; отказ остаётся в нём. То, что агент отклоняет
ДО подъёма Task, остаётся синхронным и получает собственный статус
вместо молчаливого 202:
- ход уже идёт → 409 (
turno_em_andamento), и теперь с устойчивымagent.error(источникpolitica), говорящим, что сообщение зарегистрировано и не прочитано; - Dev Lead с ожидающим планом в Одобрениях → 409
(
aguardando_aprovacao, уже существовавшийagent.error); - Criativo без зафиксированного бизнес-правила → 422
(
sem_regra_de_negocio, то же). api передаёт эти два статуса какConflictException/UnprocessableEntityExceptionс фразой engine. Проверка, уже бывшая синхронной в api (409 уже отвеченной формы, 400 отсутствующего ответа, 400 RN-160), не меняет места.
- ход уже идёт → 409 (
- HTTP-статус api НЕ меняется. Маршруты по-прежнему
201 { ok: true }. Тело и статус всегда означали «принято» — изменилось то, КОГДА они приходят. Замена на 202 изменила бы сгенерированный контракт, не изменив никакого смысла, а событие пользователя (chat.message,readiness.confirmed,architecture.readiness_confirmed) записывается до ответа, поэтому 201 по-прежнему верен. - Сбой хода остаётся устойчивым, и только там. Всё, что уже было
agent.error(crash, неверный формат, отмена, сбой, описанный api), остаётся таковым, с тем же источником. Исчезает его отражение в HTTP — которое никогда не читалось: controller его отбрасывал. - Экран отслеживает конец через канал, а страховочная сеть меняет
сигнал. Четыре вызывающих в web считали «вызов разрешился»
«ход закончился» (страховочная сеть RN-131 против потерянного
agent.done). Этот сигнал перестал существовать. Вместо негоacompanharTurnoPeloLog(вlib/session-turno.ts): пока полоса включена, каждые 4 с читает хвост event log и закрывает ход, когда САМЫЙ СВЕЖИЙ сохранённыйagent.statusсоответствующего агента — неworking. Он надёжен благодаря порядку из пункта 1:workingнового хода сохраняется ДО принятия, поэтому старыйidleникогда не бывает самым свежим после него. - Предложение Dev Lead по-прежнему идёт ПОСЛЕ завершающего хода
Arquiteto.
OfferInfraHandoffUseCaseвызываетofferInfraHandoff, а затемofferDevHandoff, и порядок двух handoffs в event log определялся ожиданием. Без него handoff Dev родился бы раньше handoff Infra.ArquitetoServerначинает ОТКЛАДЫВАТЬ:offer_dev_handoff, пришедший при идущем ходе, и выполняет его, когда ход закрывается, — по успеху, сбою, crash или отмене, тот же набор исходов, при которых он предлагался раньше.
Последствия
- Время клика становится временем принятия (запись события пользователя +
подъём Task + сохранение
working), и длинный ход перестаёт становиться 500. ПотолокGenServer.callпо-прежнему существует, но начинает измерятьhandle_call, а не ход. - Отказ, который был молчаливым, становится видимой ошибкой. Кто печатает во время хода
(включая kickoff), начинает видеть 409 с причиной, а лента получает
agent.error.chat.message, который api записала ДО того, как спросить engine, остаётся в log — сирота, как раньше, но теперь объяснённый. Отказ до записи потребовал бы, чтобы api спрашивала engine, есть ли ход, и ответ устарел бы доPOST; это не было сделано. - Возврат истории (
ReturnStoryUseCase) остаётся best-effort: 409 там логируется и проглатывается, как любой другой сбой уведомления PO, — отказ уже записан. Разница в том, чтоagent.errorтеперь существует. - Страховочная сеть экрана стоит одного чтения хвоста log каждые 4 с
ВО ВРЕМЯ хода — периода, когда 3-секундный опрос экрана приостановлен (находки
2/7). Если engine перезапустится посреди хода, ни один
idleне придёт, и полоса останется включённой до «Parar» (которая закрывает локально) или перезагрузки: раньше тот же сценарий становился 500 на клике. Объявлено, не решено. - Сообщение для
infraчерез composer попадает в последнюю клаузуAgentCommandController.message/2, которая направляет к Criativo. Предсуществующее, измерено здесь и НЕ исправлено — это другое направление.