ADR 0086 — The Dev Lead's plan suspends the turn for approval
- Status: Accepted
- Date: 2026-08-16
- Context:
docs/fluxo.yml× code audit (finding A2) - Revises part of: ADR 0053
- Direct precedent: ADR 0052, ADR 0057
Context
A read-only audit session cross-checked docs/fluxo.yml (the target model
declared by ADR 0085) against the real code
(docs/explanation/auditoria-fluxo-vs-codigo.md). Finding A2 found a
high-severity divergence: fluxo.yml declares the dev-lead's
plano-de-paralelismo output as via: proposed_action, but the code
only produced a simple event (execution.plan_proposed), with no
approval pipeline at all — a deliberate decision, documented in the
dev_lead_tools.ex comment itself at the time:
The plan becomes an EVENT in the log, not a
proposed_action. The distinction isn't cosmetic: proposing a plan has no external effect at all — the spend happens when the agents come up, and that's where the RN-083 cap requires authorization. Turning the proposal into an action to be decided would make the user decide the same thing twice.
That lesson wasn't wrong on 2026-08-07 (ADR 0053,
PHASE 14d): the parallelize-type proposed_action that does exist is
triggered by a USER ACTION in the UI requesting reinforcement above the
cap (POST /sessions/:sessionId/execution/parallelize), not by the Dev
Lead's initial plan output. They are two genuinely distinct mechanisms,
and fluxo.yml merged them into a single output.
Faced with the divergence, the product owner decided that the CODE is
wrong: the Dev Lead's plan is the FIRST real decision about how much the
session will spend on parallelism — today the user only reads it narrated
in the feed and clicks "Activate execution" separately, with no real
approval in between. The plan now becomes a proposed_action, and the
user decides by looking at it, not at a log line.
Why this is structurally new
The four conversational agents (Criativo, PO, Arquiteto, Dev Lead) run a
SYNCHRONOUS turn via GenServer.call of up to 180s, mediated by
Engine.Agents.TurnoAssincrono (RN-122) — the handle_call stays blocked
waiting for the Task to finish, and the caller (the engine's HTTP route)
waits along with it. The suspend-for-approval pattern already existed
twice:
- the dev agent (ADR 0052),
triggered by
cast(work/2,correct/3); - the QA/Infra gates (ADR 0057),
also triggered by
cast(run/2).
Neither of them had to deal with a pending synchronous from — cast
has no from to respond to. The Dev Lead IS call: someone is waiting
for an HTTP response when the turn suspends, and that response needs to
come RIGHT AWAY (so as not to block the route for up to 180s), even
though the turn itself remains pending for much longer than that.
Decision
The execution plan is born as a proposed_action (type
propose_execution_plan), and the Dev Lead's turn SUSPENDS — without
finishing — while it is pending.
The pieces
decide.tsgains thepropose_execution_plantype (apps/api/src/domain/actions/decide.ts), minimum rolemaintainer(same tier asparallelize: a decision about how much the product will spend on parallelism). It is DELIBERATELY left OUTSIDE the absolute-cap block (protected merge,instruction_patch,parallelize/raise_max_parallel) — it can be configured to auto-approve, as already holds foropen_adr_pr/open_infra_pr. The semantic difference matters: the absolute caps exist because the product refuses to let the user automate the DECISION ITSELF even with "always allow" on (parallelizeis an OVERRUN of a cap already authorized,raise_max_parallelis the product raising its own limit). The Dev Lead's plan is the session's FIRST decision, not an overrun — and nothing in this feature calls for a fourth absolute cap.Engine.Agents.DevLeadTools.run/2callsEngineApiClient.propose_action/5instead ofappend_event/3(the same clientEngine.Dev.AgentIoalready uses), and returns a three-outcome contract:{:ok, text}|{:pending, action_id}|{:error, text}.validar/1continues blocking an empty plan or one with a module lacking an agent BEFORE any I/O — unchanged.Engine.Agents.DevLeadServer.run_turn/2stops at the first:pending. TheEnum.reduce/boolean from before becomesEnum.reduce_while, which HALTS without processing further calls or recursing. The returnedstatecarries the new:aguardando_aprovacaokey (%{action_id:, tool_call_id:, tool_name:, remaining:}) —remainingalready decremented, because the suspended iteration counts against the cap once resumed. Therole: "tool"message is NOT added tostate.messagesat this point — recording "pending" there would lie to the model that the command already answered it (same reasoning as the dev agent).Engine.Agents.TurnoAssincrono.tratar_resultado/2gains a branch.GenServer.reply(from, :ok)still happens always, at the same time — it's what breaks the synchronous block at the right moment, suspended or not. When:aguardando_aprovacaois present (checked by VALUE,Map.get/2truthy — not by key presence, because the Dev Lead carries it asnilsinceinit/1), it callssuspender/1instead offinalizar/1: onlyagent.status: awaiting_approval, noagent.done— the turn hasn't finished.Engine.Sessions.LiveBroadcast.agent_status/4gains the new status in the guard (["working", "idle", "awaiting_approval"]) — without this, step 4'sagent.statuswouldn't even be persisted (ADR 0021: it is the only event that MUST be durable, not just broadcast).- The resumption.
DevLeadServersubscribes toEngine.Dev.Wake.subscribe(project_id, "dev-lead")ininit/1— the SAME moduleEngine.Gates.QaLeadServeralready reuses for QA subagents, despite the "dev" name: delivery of{:action_settled, ...}is by AGENT, routed by the payload'sagentId(DevAgentWakeWorker), not by type. On arrival, ahandle_infobuilds therole: "tool"message with the REAL outcome (texto_do_desfecho/1, the same vocabulary as the dev agent andQaLeadServer), clearsaguardando_aprovacao, and resumes withTurnoAssincrono.iniciar(state, nil, fn -> run_turn(state, pendente.remaining) end). - A second
user_messageduring suspension does not start a new turn. A guard onhandle_call({:user_message, _text}, _from, %{aguardando_aprovacao: %{}}), tested BEFORE the generic clause, emitsagent.error(originpolitica) and replies{:reply, :ok, state}— the HTTP response for that route is already discarded by the engine controller for every agent.
propose_execution_plan has no dedicated execute-* pipeline
Unlike parallelize/raise_max_parallel (which actually DO bring agents
up on approval), approving the plan has no effect to apply — bringing the
agents up remains a SEPARATE act, when the user clicks "Activate
execution." That's why manual approval never leaves status: "approved"
(the action-state-machine.ts state machine models
approved -> executed | failed as open, but nothing calls that
transition, and it shouldn't — there's nothing to execute). The engine
treats "executed", "auto_approved", and "approved" equally as
success.
Consequences
For
- Behavior now matches what
docs/fluxo.ymlalready declared — finding A2's divergence closes without editing the flow. - The user decides the plan by looking at a real approval (phrase in pt-BR, verb, payload — RN-096), not at a feed line they might not read.
TurnoAssincrono's suspension mechanism becomes generic enough that the NEXT conversational agent that needs to suspend doesn't have to reinvent anything — it just returns the:aguardando_aprovacaokey.
Against
- Accepted, declared gap: restart during the wait. Unlike the dev
agent (which rehydrates
laco_pendenteviahandle_continueininit/1), the Dev Lead does NOT rehydrateaguardando_aprovacao— only in memory. If the engine restarts while it's suspended, the decision remains recorded and visible in Approvals (it's durable in the api), but the Dev Lead doesn't narrate the outcome automatically: the process that subscribed toWakedied, and the next restart brings up a new Dev Lead, with no subscription for that action. Closing this would require the same persistence mechanism as ADR 0052 (dev_lead_states, or equivalent) — out of scope for this change, which only fixes the misalignment between the declared flow and the code. - One extra turn of waiting before "Activate execution" becomes
available, when the plan isn't auto-approved — the same cost any
proposed_actionalready imposes, now here too.
Alternatives considered
Just fix docs/fluxo.yml to match the code (via: evento).
Rejected by explicit decision of the product owner: the divergence wasn't
a documentation error, it was the code not yet having the pipeline that
the product decision (making the user really decide the plan) already
called for.
Add the type to decide.ts's absolute-cap block. Rejected — see
the "The pieces" section, item 1. The plan is neither a cap overrun nor
the product raising its own limit; it's the INITIAL decision, and the
user can legitimately want to configure "always allow" for it without
that becoming the same hole the three absolute caps exist to close.
Don't suspend — keep the simple event, and open a second
proposed_action in parallel (fire-and-forget). Rejected: fluxo.yml
declares that the output ITSELF is the proposal, not an event
accompanied by an orphan proposal. And letting the turn continue without
waiting would reintroduce the same flaw ADR 0052 closed for the dev
agent — the model would "learn" the plan was accepted before knowing
whether it was.
References
docs/explanation/auditoria-fluxo-vs-codigo.md— finding A2, the origin of this change- ADR 0053 — creates the Dev Lead and the parallelism cap (RN-083) that motivates its existence
- ADR 0052 — the
precedent: the dev agent suspends mid-loop, via
cast - ADR 0057 — the second precedent:
the QA/Infra gates suspend, also via
cast - ADR 0021 — why
agent.statusneeds to be persisted, not just broadcast 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