ADR 0143 — dev agent 只在容器之后
状态
已接受——2026-09-04
Context
在项目 exp001 的一次真实执行中(2026-09-04),流程走得很远:Gitflow bootstrap、product_brief、module_map,以及 18:00 Arquiteto 交付 project_image、module_routing 和 C4 图。两份 ADR 变成了 PR,三次合并通过。然后十个 dev task 一下子全部卡住,结果都一样:dev agent 的工作树没有地方诞生。
这里重要的发现不是那个特定文件夹的原因——而是没有任何东西让容器先于 dev agent。没有提出任何 container_start,十个 dev agent 照样开始了,然后卡住十次。
在流程的同一行上还有第二个更隐蔽的缺陷。
ADR 0134(RN-492)让 dev agent 的终端命令穿过 engine → api → broker,并在真实容器内部运行——当有容器时。没有时,
Engine.Actions.TerminalExecutor.decisao_de_execucao/1 落入
:caminho_de_sempre,也就是在 engine 进程内的 System.cmd——这个进程与数据库、api 以及安装中所有其他项目通信。ADR 0134 存在所要创造的隔离只在正常路径上有效,而它的缺失并不拒绝:它降级,而且是静默降级。
本 ADR 关闭这两半。
Decision
1. 守卫住在 try_claim 中,而不只是在 HTTP 边界
Engine.Dev.AgentIo.try_claim/2——唯一的 claim 入口——在调用 claim_task/1 之前查询 Engine.Containers.ProjectContainerLifecycle.running?/1。如果 project_containers 中没有已登记为 running 的行
(ADR 0081),agent 落入
:idle,持久化并发出 dev.blocked_by_container——不调用 api。
这个谓词早已存在:它与 TerminalExecutor 自 ADR 0134 起查询的是同一个。没有为回答这个问题构建任何新东西;改变的是谁来问、何时问。
api 中的等价 gate(ActivateExecutionUseCase 在没有
ciclo?.status === 'running' 时以 400 拒绝)是姊妹 PR 的决定,正是它在点击时给人提醒。它不能取代这里的守卫,因为 claim 有一条任何路由都覆盖不到的路径:重建(rehydration)。
Engine.Dev.DevRehydrator 不会 cast :work;engine 重启后执行 claim 的是 DevAgentServer.init/1 → finish_restart_recovery/1 →
try_claim/2,不经过任何端点。只放在 HTTP 边界的 gate 会让每个重建的 agent 在没有容器的情况下重新开始工作——而重启恰恰是没人在看的时刻。
守卫在 claim_task/1 之前,而不是之后:先声明再立即归还,会让 task 被标记却没有活着的主人,而这正是 AgentIo.block_task/4 存在所要永不产生的状态。
2. :idle,而不是新状态
try_claim/2 自身 {:error, reason} 子句的 docblock 早已记录了原因,写于第 12b 阶段修复 agent 永久卡死时:":idle é o único estado do qual um wake ainda
resgata"。handle_info/2 的所有守卫都与之匹配——
{:wake, :became_claimable} 要求 :idle,:rearm 要求 :idle_tripped,
{:gate_resolved, …} 要求 task_id 匹配。
在这里发明一个 :blocked_by_container 将是一个没有任何东西能救回的状态:agent 会永远停住,重现第 12b 阶段为消除它而存在的症状。区分“因为队列空了而停”与“因为没有容器而停”的是事件(dev.idle ×
dev.blocked_by_container),而不是状态——而事件是持久的,遵循 RN-059 的模式。
3. 唤醒经由 outbox,在专属聚合中
读取不会通知任何人。一个已经停在 :idle 的 agent 会一直停着,直到某个无关事件经过——实际上就是“直到有人手动重新启用执行”。
因此 RegistrarTransicaoDeContainerUseCase 进行发布:在写入到达 running 时,它在同一个事务中追加一行 outbox
container.running。Engine.Outbox.Drain 开始排空第三种 aggregate_type("container",与 "session" 和 "task" 并列),Engine.Workers.DevAgentWakeWorker 把消息投递给项目的 agent。
其中有三个选择:
aggregateType: 'container',而不是'task'。task聚合本已被排空,复用它在 drain 中零行代码成本——但这个事件与任何 task 无关。ADR 0099 的graph_projection已经确立aggregate_type是不同消费者互不竞争的方式;为了省一行查询而在这上面撒谎会老得很难看。aggregateId是项目**,而不是project_containers的那一行:消费者按项目唤醒 agent,而那一行可能已被重建。{:wake, :became_claimable},已经存在的消息。 它的语义已经是“现在可能有活干了”——EngineWeb.ExecutionCommandController.acordar/4的注释正是这样把它用于重新启用执行,那同样与特定 task 无关。新消息需要在两个服务器(真实的和 Noop)中新增handle_info/2子句,守卫与已有的相同,却没有任何不同的决定要做。
扇出按项目(DevAgentState.list_by_project/1),而不是按模块:容器属于项目,而 api 无从知道 engine 一侧存在哪些模块。
4. 终端不再静默降级
decisao_de_execucao/1 增加 :recusar_container_ausente,镜像 runner 模式自
ADR 0104 起就有的
:recusar_nao_verificado/:recusar_runner_desconectado。这是同一纪律——“缺少前置条件,就不在任何地方执行”——应用到尚未拥有它的模式上。
mounted 进入同一个分支:有 running 容器时,像 container 一样穿到 broker;没有时,拒绝。在 mounted 开始真正启动容器之后再区别对待它将是任意的。
兜底的 _ -> :caminho_de_sempre 收缩为它本应独自承担的情况:不存在的项目或格式错误的 project_id。没有任何执行模式会落入其中。
拒绝是普通的 failed_result,绝不是崩溃——与该模块其他两种拒绝的失败合约相同。
Consequences
没有运行中容器的项目停止工作,并说明原因。 这正是该决定存在的目的,不是副作用。这也是它只能在之后——在为 mounted 模式提供容器的改动之后——进入的原因:没有那些改动,mounted 项目将完全没有 dev agent,唯一的出路是 container 模式。
之前的每个 dev agent spec 都开始需要一个 running 容器。 engine 中有九个测试文件,每个的改动都是一行 setup(Engine.DataCase.container_running!/1)——而不是一个可通过配置替换的模块。这是选择:一个只为让测试套件通过而存在的生产接缝,会让守卫恰恰在被演练的地方失效。
一个问题,两个提问的地方。 AgentIo.try_claim/2 和
TerminalExecutor.decisao_de_execucao/1 都调用同一个
ProjectContainerLifecycle.running?/1。这不是两种推导:是一个函数,有两个调用者,回答不同的问题(“我可以开始吗?”和“这在哪里运行?”)。
唤醒的投递仍然是至多一次,与所有 Engine.Dev.Wake 一样(它的 moduledoc 自第 12b 阶段起就声明了这一点)。如果广播发生时 agent 暂时不在线,它会保持 :idle 直到下一个事件——包括手动重新启用执行,后者投递同一个 {:wake, :became_claimable}。
已登记的 running 仍然不是观测到的 running
(RN-486)。一个在外部死掉的容器仍会通过这个守卫;agent 执行 claim,失败出现在对 broker 的第一次调用中,作为普通的命令失败。合并两者需要每次 claim 一次网络调用,而 RN-486 明确不合并它们。