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

ADR 0086 — План Dev Lead приостанавливает ход до одобрения

  • Статус: Принят
  • Дата: 2026-08-16
  • Контекст: аудит docs/fluxo.yml × код (находка A2)
  • Пересматривает часть: ADR 0053
  • Прямой прецедент: ADR 0052, ADR 0057

Контекст​

Сессия аудита, только чтение, сопоставила docs/fluxo.yml (целевую модель, объявленную ADR 0085) с реальным кодом (docs/explanation/auditoria-fluxo-vs-codigo.md). Находка A2 обнаружила расхождение высокой серьёзности: fluxo.yml объявляет выход plano-de-paralelismo у dev-lead как via: proposed_action, но код производил только простое событие (execution.plan_proposed), без какого-либо конвейера одобрения, — намеренное решение, задокументированное в комментарии dev_lead_tools.ex того времени:

O plano vira EVENTO no log, não proposed_action. A distinção não é cosmética: propor um plano não tem efeito externo nenhum — o gasto acontece quando os agentes sobem, e é lá que o teto da RN-083 cobra autorização. Transformar a proposta em ação a decidir faria o usuário decidir duas vezes a mesma coisa.

Этот урок не был ошибочным в 2026-08-07 (ADR 0053, ФАЗА 14d): proposed_action типа parallelize, который действительно существует, запускается ДЕЙСТВИЕМ ПОЛЬЗОВАТЕЛЯ в UI, запрашивающим подкрепление сверх потолка (POST /sessions/:sessionId/execution/parallelize), а не начальным выходом плана Dev Lead. Это два действительно разных механизма, и fluxo.yml слил их в один выход.

Владелец продукта, столкнувшись с расхождением, решил, что ошибается КОД: план Dev Lead — ПЕРВОЕ реальное решение о том, сколько сессия потратит на параллелизм, — пользователь сегодня лишь читает его в ленте и отдельно нажимает «Ativar execução», без настоящего одобрения посередине. План начинает рождаться как proposed_action, и пользователь решает, глядя на него, а не на строку лога.

Почему это структурно ново​

Четыре разговорных агента (Criativo, PO, Arquiteto, Dev Lead) выполняют СИНХРОННЫЙ ход через GenServer.call до 180 с, при посредничестве Engine.Agents.TurnoAssincrono (RN-122) — handle_call остаётся заблокированным в ожидании завершения Task, и вызывающий (HTTP-маршрут engine) ждёт вместе с ним. Образец приостановки на одобрении уже существовал дважды:

  • dev agent (ADR 0052), запускаемый через cast (work/2, correct/3);
  • gates QA/Infra (ADR 0057), тоже запускаемые через cast (run/2).

Ни одному из них не пришлось иметь дело с ожидающим синхронным from — у cast нет from, которому нужно ответить. Dev Lead — это call: кто-то ждёт HTTP-ответа, когда ход приостанавливается, и этот ответ должен прийти СРАЗУ (чтобы не блокировать маршрут до 180 с), даже если сам ход остаётся незавершённым гораздо дольше.

Решение​

План исполнения рождается как proposed_action (тип propose_execution_plan), и ход Dev Lead ПРИОСТАНАВЛИВАЕТСЯ — не завершаясь, — пока она в состоянии pending.

Детали​

  1. decide.ts получает тип propose_execution_plan (apps/api/src/domain/actions/decide.ts), минимальная роль maintainer (того же калибра, что parallelize: решение о том, сколько продукт потратит на параллелизм). Он НАМЕРЕННО оставлен ВНЕ блока абсолютных потолков (защищённый merge, instruction_patch, parallelize/raise_max_parallel) — его можно настроить на автоодобрение, как уже действует для open_adr_pr/open_infra_pr. Семантическая разница важна: абсолютные потолки существуют потому, что продукт отказывается позволить пользователю автоматизировать СОБСТВЕННОЕ решение даже при включённом «всегда разрешать» (parallelize — это ПРЕВЫШЕНИЕ уже разрешённого потолка, raise_max_parallel — продукт, поднимающий собственный предел). План Dev Lead — ПЕРВОЕ решение сессии, а не превышение, — и ничто в этой фиче не требует четвёртого абсолютного.
  2. Engine.Agents.DevLeadTools.run/2 вызывает EngineApiClient.propose_action/5 вместо append_event/3 (тот же клиент, который уже использует Engine.Dev.AgentIo) и возвращает контракт из трёх исходов: {:ok, texto} | {:pending, action_id} | {:error, texto}. validar/1 по-прежнему отсекает пустой план или план с модулем без агента ДО любого I/O — без изменений.
  3. Engine.Agents.DevLeadServer.run_turn/2 останавливается на первом :pending. Прежний Enum.reduce/булево становится Enum.reduce_while, который ОСТАНАВЛИВАЕТСЯ, не обрабатывая дальнейших вызовов и не уходя в рекурсию. Возвращаемый state несёт новый ключ :aguardando_aprovacao (%{action_id:, tool_call_id:, tool_name:, remaining:}) — remaining уже уменьшен, потому что приостановленная итерация засчитывается в потолок при возобновлении. Сообщение role: "tool" в этот момент НЕ попадает в state.messages: запись туда «pending» солгала бы модели, что команда уже так ответила (то же рассуждение, что у dev agent).
  4. Engine.Agents.TurnoAssincrono.tratar_resultado/2 получает ветку. GenServer.reply(from, :ok) по-прежнему происходит всегда, сразу же, — это то, что разрывает синхронную блокировку в нужный момент, с приостановкой или без. При наличии :aguardando_aprovacao (проверяется по ЗНАЧЕНИЮ, Map.get/2 truthy, — а не по наличию ключа, потому что Dev Lead несёт его nil начиная с init/1) вызывает suspender/1 вместо finalizar/1: только agent.status: awaiting_approval, без agent.done — ход не завершён.
  5. Engine.Sessions.LiveBroadcast.agent_status/4 получает новый статус в охране (["working", "idle", "awaiting_approval"]) — без этого agent.status из шага 4 даже не был бы сохранён (ADR 0021: это единственное событие, которое ДОЛЖНО быть долговременным, а не только разосланным).
  6. Возобновление. DevLeadServer подписывается на Engine.Dev.Wake.subscribe(project_id, "dev-lead") в init/1 — ТОТ ЖЕ модуль, который Engine.Gates.QaLeadServer уже переиспользует для субагентов QA, несмотря на имя «dev»: доставка {:action_settled, ...} идёт по АГЕНТУ, маршрутизируется по agentId из payload (DevAgentWakeWorker), а не по типу. По прибытии handle_info собирает сообщение role: "tool" с РЕАЛЬНЫМ результатом (texto_do_desfecho/1, тот же словарь, что у dev agent и QaLeadServer), обнуляет aguardando_aprovacao и возобновляет через TurnoAssincrono.iniciar(state, nil, fn -> run_turn(state, pendente.remaining) end).
  7. Второе user_message во время приостановки не начинает новый ход. Охрана в handle_call({:user_message, _text}, _from, %{aguardando_aprovacao: %{}}), проверяемая ДО общей клаузы, выпускает agent.error (источник politica) и отвечает {:reply, :ok, state} — HTTP-ответ этого маршрута контроллер engine уже отбрасывает для всех агентов.

У propose_execution_plan нет собственного конвейера execute-*​

В отличие от parallelize/raise_max_parallel (которые действительно ПОДНИМАЮТ агентов при одобрении), одобрение плана не имеет эффекта, который нужно применить, — подъём агентов остаётся ОТДЕЛЬНЫМ действием, когда пользователь нажимает «Ativar execução». Поэтому ручное одобрение никогда не выходит из status: "approved" (машина состояний action-state-machine.ts моделирует approved -> executed | failed как открытый переход, но ничто этот переход не вызывает, и не должно — исполнять нечего). Engine трактует "executed", "auto_approved" и "approved" одинаково как успех.

Последствия​

За

  • Поведение начинает совпадать с тем, что уже объявлял docs/fluxo.yml, — расхождение находки A2 закрывается без правки fluxo.
  • Пользователь решает о плане, глядя на настоящее одобрение (фраза на pt-BR, глагол, payload — RN-096), а не на строку в ленте, которую он мог не прочитать.
  • Механизм приостановки TurnoAssincrono становится достаточно общим, чтобы СЛЕДУЮЩЕМУ разговорному агенту, которому понадобится приостановка, не пришлось ничего изобретать заново — лишь вернуть ключ :aguardando_aprovacao.

Против

  • Принятый, объявленный пробел: перезапуск во время ожидания. В отличие от dev agent (который регидрирует laco_pendente через handle_continue в init/1), Dev Lead НЕ регидрирует aguardando_aprovacao — только в памяти. Если engine перезапустится, пока он приостановлен, решение остаётся записанным и видимым в Одобрениях (оно долговременно в api), но Dev Lead не озвучит исход автоматически: процесс, подписавшийся на Wake, умер, а следующий перезапуск поднимает новый Dev Lead без подписки на это действие. Закрытие этого потребовало бы того же механизма персистентности, что в ADR 0052 (dev_lead_states или эквивалент), — вне рамок этого изменения, которое лишь исправляет рассогласование между объявленным fluxo и кодом.
  • Одно лишнее ожидание хода, прежде чем «Ativar execução» станет доступной, когда план не автоодобряется, — та же цена, которую уже накладывает любой proposed_action, теперь и здесь.

Рассмотренные альтернативы​

Только исправить docs/fluxo.yml, чтобы он совпадал с кодом (via: evento). Отвергнуто явным решением владельца продукта: расхождение было не ошибкой документации, а тем, что в коде ещё не было конвейера, которого уже требовало продуктовое решение (заставить пользователя по-настоящему решать о плане).

Добавить тип в блок абсолютных потолков decide.ts. Отвергнуто — см. раздел «Детали», пункт 1. План — это не превышение потолка и не продукт, поднимающий собственный предел; это НАЧАЛЬНОЕ решение, и пользователь может законно захотеть настроить для него «всегда разрешать», не превращая это в ту же дыру, которую существуют закрывать три абсолютных потолка.

Не приостанавливать — оставить простое событие и открыть параллельно второй proposed_action (fire-and-forget). Отвергнуто: fluxo.yml объявляет, что выход САМ ПО СЕБЕ является предложением, а не событием, сопровождаемым осиротевшим предложением. А позволить ходу продолжаться без ожидания вернуло бы тот же отказ, который ADR 0052 закрыл для dev agent, — модель «узнала» бы, что план принят, раньше, чем узнала бы, принят ли он.

Ссылки​

  • docs/explanation/auditoria-fluxo-vs-codigo.md — находка A2, источник этого изменения
  • ADR 0053 — создаёт Dev Lead и потолок параллелизма (RN-083), который мотивирует его существование
  • ADR 0052 — прецедент: dev agent приостанавливается посреди цикла, через cast
  • ADR 0057 — второй прецедент: gates QA/Infra приостанавливаются, тоже через cast
  • ADR 0021 — почему agent.status нужно сохранять, а не только рассылать
  • apps/engine/lib/engine/agents/dev_lead_tools.ex, dev_lead_server.ex, turno_assincrono.ex
  • apps/engine/lib/engine/sessions/live_broadcast.ex
  • apps/api/src/domain/actions/decide.ts