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 把 dev-lead 的输出 plano-de-paralelismo 声明为 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,FASE 14d)并没有错:实际存在的 parallelize 类型 proposed_action 是由用户在 UI 中请求超出上限的增援这一用户操作触发的(POST /sessions/:sessionId/execution/parallelize),而不是由 Dev Lead 计划的初始输出触发的。这是两个真正不同的机制,而 fluxo.yml 把它们合并成了一个输出。
产品所有者面对这一分歧,认定是代码错了:Dev Lead 的计划是会话在并行度上要花多少钱的第一个真正决定——如今用户只是在会话流中读到它的叙述,然后单独点击"Ativar execução",中间没有真正的审批。计划改为以 proposed_action 的形式诞生,用户看着它做决定,而不是看着一行日志。
为什么这在结构上是新的
四个对话式 agent(Criativo、PO、Arquiteto、Dev Lead)通过最长 180 秒的 GenServer.call 运行同步轮次,由 Engine.Agents.TurnoAssincrono 居间(RN-122)——handle_call 一直阻塞等待 Task 结束,调用方(engine 的 HTTP 路由)也一起等待。挂起等待审批的模式此前已出现过两次:
两者都不需要处理一个悬而未决的同步 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同一量级:决定产品在并行度上要花多少)。它被有意排除在绝对上限块(受保护的合并、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 之前拦下空计划或含有无 agent 模块的计划——没有改变。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/2为真——而不是按键是否存在,因为 Dev Lead 从init/1起就带着值为nil的这个键),调用suspender/1而不是finalizar/1:只有agent.status: awaiting_approval,没有agent.done——轮次没有结束。Engine.Sessions.LiveBroadcast.agent_status/4在守卫中加入新状态(["working", "idle", "awaiting_approval"])——没有这一点,第 4 步的agent.status根本不会被持久化(ADR 0021:它是唯一必须持久化、而不仅仅广播的事件)。- 恢复。
DevLeadServer在init/1中订阅Engine.Dev.Wake.subscribe(project_id, "dev-lead")——与Engine.Gates.QaLeadServer已经为 QA 子 agent 复用的同一个模块,尽管名字是"dev":{:action_settled, ...}的投递是按 agent 进行的,由 payload 中的agentId路由(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 响应对所有 agent 本来就会被 engine 的 controller 丢弃。
propose_execution_plan 没有自己的 execute-* 流水线
与 parallelize/raise_max_parallel(它们在批准时确实会启动 agent)不同,批准计划没有需要施加的效果——启动 agent 仍然是一个单独的动作,在用户点击"Ativar execução"时发生。因此手动批准永远不会离开 status: "approved"(action-state-machine.ts 的状态机把 approved -> executed | failed 建模为开放的,但没有任何东西调用这个转换,也不应该——没有东西要执行)。engine 把 "executed"、"auto_approved" 和 "approved" 同样视为成功。
后果
有利
- 行为开始与
docs/fluxo.yml早已声明的一致——发现 A2 的分歧在不编辑流程的情况下关闭。 - 用户看着一个真正的审批(pt-BR 句子、动词、payload——RN-096)来决定计划,而不是看着会话流中一行他可能不会去读的文字。
TurnoAssincrono的挂起机制变得足够通用,下一个需要挂起的对话式 agent 无需重新发明任何东西——只需返回:aguardando_aprovacao键。
不利
- 接受并声明的缺口:等待期间重启。 与 dev agent(它通过
init/1中的handle_continue恢复laco_pendente)不同,Dev Lead 不恢复aguardando_aprovacao——只在内存中。如果 engine 在它挂起时重启,决定仍然被记录并在审批中可见(在 api 中是持久的),但 Dev Lead 不会自动叙述结局:订阅Wake的进程已经死了,而下一次重启会拉起一个新的 Dev Lead,对那个操作没有订阅。关闭这一点需要与 ADR 0052 相同的持久化机制(dev_lead_states或等价物)——超出本次改动的范围,本次只修正声明的流程与代码之间的错位。 - 当计划不是自动批准时,"Ativar execução"变得可用之前多了一个轮次的等待——与任何
proposed_action已经施加的代价相同,现在这里也有了。
考虑过的替代方案
只修正 docs/fluxo.yml 以匹配代码(via: evento)。 因产品所有者的明确决定被拒绝:分歧不是文档错误,而是代码还没有产品决定(让用户真正决定计划)早已要求的流水线。
把该类型加入 decide.ts 的绝对上限块。 被拒绝——见"各个部件"一节第 1 项。计划不是对上限的突破,也不是产品提高自身限制;它是初始决定,用户可以正当地想为它配置"始终允许",而这不会变成三个绝对上限所要封住的那种漏洞。
不挂起——保留简单事件,并并行打开第二个 proposed_action(发出即不管)。 被拒绝: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 — 第二个先例:QA/Infra 的 gate 挂起,同样通过
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