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.
Детали
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 — ПЕРВОЕ решение сессии, а не превышение, — и ничто в этой фиче не требует четвёртого абсолютного.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 — без изменений.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).Engine.Agents.TurnoAssincrono.tratar_resultado/2получает ветку.GenServer.reply(from, :ok)по-прежнему происходит всегда, сразу же, — это то, что разрывает синхронную блокировку в нужный момент, с приостановкой или без. При наличии:aguardando_aprovacao(проверяется по ЗНАЧЕНИЮ,Map.get/2truthy, — а не по наличию ключа, потому что Dev Lead несёт егоnilначиная сinit/1) вызываетsuspender/1вместоfinalizar/1: толькоagent.status: awaiting_approval, безagent.done— ход не завершён.Engine.Sessions.LiveBroadcast.agent_status/4получает новый статус в охране (["working", "idle", "awaiting_approval"]) — без этогоagent.statusиз шага 4 даже не был бы сохранён (ADR 0021: это единственное событие, которое ДОЛЖНО быть долговременным, а не только разосланным).- Возобновление.
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). - Второе
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.exapps/engine/lib/engine/sessions/live_broadcast.exapps/api/src/domain/actions/decide.ts