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

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/answer97,3 с
POST …/sessions/:id/readiness97,3 с
POST …/agents/arquiteto/handoff-infra51,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». Принятие лишь приходило в конце хода.

Три последствия, все измерены:

  1. Кнопка «не отвечает». Для человека клик, занимающий 97 с, — это клик, который не удался.
  2. Ход сверх потолка становится ошибкой в сработавшей команде. GenServer.call выходит по :timeout в процессе controller, Phoenix возвращает 500, api становится 500, и экран показывает ошибку — пока ход продолжает работать и завершается успешно. Kickoff той же сессии (которые являются cast и поэтому не страдают) длились 7,5 мин (PO), 6,6 мин (Arquiteto) и 8,4 мин (Infra): ход сверх 120 с — не гипотеза.
  3. Молчаливый отказ. Сообщение, пришедшее при уже идущем ходе, получает {:error, :turno_em_andamento}, который controller отбрасывал, отвечая 202. В той же сессии «Continue», набранное через 2,5 мин после начала kickoff Arquiteto, было принято успешно и никогда не прочитано — ни одного agent.status: working после него, ни одного agent.error. Хуже: экран оставлял composer свободным во время этого kickoff (принятый handoff взводит полосу без streaming), так что это был естественный путь.

Решение​

  1. 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, просто он больше не единственный, кто отвечает рано.
  2. Из запроса уходит только ожидание; отказ остаётся в нём. То, что агент отклоняет ДО подъёма 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), не меняет места.
  3. HTTP-статус api НЕ меняется. Маршруты по-прежнему 201 { ok: true }. Тело и статус всегда означали «принято» — изменилось то, КОГДА они приходят. Замена на 202 изменила бы сгенерированный контракт, не изменив никакого смысла, а событие пользователя (chat.message, readiness.confirmed, architecture.readiness_confirmed) записывается до ответа, поэтому 201 по-прежнему верен.
  4. Сбой хода остаётся устойчивым, и только там. Всё, что уже было agent.error (crash, неверный формат, отмена, сбой, описанный api), остаётся таковым, с тем же источником. Исчезает его отражение в HTTP — которое никогда не читалось: controller его отбрасывал.
  5. Экран отслеживает конец через канал, а страховочная сеть меняет сигнал. Четыре вызывающих в web считали «вызов разрешился» «ход закончился» (страховочная сеть RN-131 против потерянного agent.done). Этот сигнал перестал существовать. Вместо него acompanharTurnoPeloLog (в lib/session-turno.ts): пока полоса включена, каждые 4 с читает хвост event log и закрывает ход, когда САМЫЙ СВЕЖИЙ сохранённый agent.status соответствующего агента — не working. Он надёжен благодаря порядку из пункта 1: working нового хода сохраняется ДО принятия, поэтому старый idle никогда не бывает самым свежим после него.
  6. Предложение 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. Предсуществующее, измерено здесь и НЕ исправлено — это другое направление.