编码 agent 交上来的一个分支,在进主干之前还差三件事没人管:验收、整合、往回报告。这次把这三件事交给一个隐藏的单线程角色,落地通道照旧是 main 唯一的写入者。

1. 「做完了」是谁说的⌗

MaxClaw 能改自己的源码。流程大致是:一个专题对话(side chat)开一个后台任务,任务在独立 worktree 里改代码,改完后对话调 task_merge_workspace,分支进落地通道。通道是 main 唯一的写入者:合并到最新 main、全量测试、快进、空闲时过预演门重启、试用期、发布。

这条通道守的是机械正确性:测试绿不绿、能不能快进、上线后会不会被回滚。它不回答另一个问题:这个分支做的是不是当初要做的事。

改动之前,回答这个问题的是写代码的那个 agent 自己,外加派活的对话。这样做会出三种毛病:

毛病表现
自己验自己编码 agent 交差时宣布「完成」,判断标准是它自己当时的理解。任务开出去的时候没把「做成以后什么样」写下来,事后也就没有东西可以对照
意外全涌进对话与移动中的 main 冲突、测试机超时、main 上本来就红的测试、预演抖动、回滚……通道每出一次结论就往对话里推一条。对话里的 agent 正在和人说话,于是它一边聊天一边修合并冲突
谁的锅分不清同样是「落地失败」,有的是改动自己的 bug,有的是环境的问题,有的说明需求本身就自相矛盾。全部扔回给同一个对象,结果要么推给任务重做,要么对话自己去补

说到底是职责错位:和人说话的 agent 被当成了主干的调度员。

2. 三种常见补法,各差在哪⌗

做法问题
让编码 agent 交付前再自查一遍还是自己验自己,只是多跑了一轮。而且它看不到交付以后的事:冲突、回滚都发生在它交差之后
合并前让对话里的 agent 把关给前台多加一道人工门。它要读 diff、读通道日志、判断归属,正好是上一节说的错位,只是换了个说法
通道里多写几条规则通道能判断「测试是不是这个改动弄红的」,判断不了「这个改动有没有兑现意图」。后者需要读意图、读 diff,靠规则做不到

这一单的思路是:merge 应该是一个可信的结论,不该是一道需要谁去把关的门。 要让它可信,得满足两个条件:派活的时候把意图写成验收标准;交付的时候由一个独立的角色拿意图去对照。处理意外的也应该是这个角色,不是正在和人说话的那个 agent。

3. 前提:意图在派活时就写下来⌗

task_start 多了一个参数:

intent: {
  why: string              // 为什么做:要解决的摩擦、背后的需求
  done_looks_like: string  // 做成以后的样子:落地前拿它验收
  requirement?: string     // 同一需求拆成多个任务时共用的短名字
}

改 MaxClaw 源码树的任务必须带这个参数,没带就直接报错。判断靠 git toplevel,不靠路径前缀,所以 workspace 指向源码树里的子目录也算(src/dispatch/task-intent.ts):

export async function workspaceIsSourceTree(spec: TaskWorkspaceSpec): Promise<boolean> {
  const top = await requireGitToplevel(resolve(spec.repo)).catch(() => null)
  return top !== null && resolve(top) === resolve(sourceRoot())
}

只写了一半的意图也不算数:why 和 done_looks_like 任一项 trim 后为空,normalizeIntent 就拒绝。意图存进 tasks.intent(迁移里只加了一个可空的 jsonb 列,旧版本不读也不写)。在这个参数出现之前开的任务,可以在 task_merge_workspace 时补写,补写只在任务原来没有 intent 时生效。

为什么强制、为什么放在派活这一刻?因为这是唯一一个「做什么」还没被「做成了什么」污染的时间点。等代码写完再补意图,写下来的往往就是代码已经做到的那些。

requirement 管的是汇报粒度:一个需求拆成三个任务时,派活的对话要的是一句「需求解决了」,不想听三遍。

4. Maintainer:住在隐藏线程里,一次只处理一单⌗

Maintainer 的形态和记忆管家一样:一条自己的线程 web:maintainer,不进对话列表,没有人格,提示词是热文档 prompts/maintainer.md。它的记录按最高密级处理,hiddenThreadSql 把它和管家线程一起排除,任何 agent 都读不到;它回合的回复从不推送。侧栏底部只有一行状态:「Maintainer · 在验收 N 个 / 空闲」。

它的工具只在它自己的线程里可用,换个线程调用一律报「只有 Maintainer……」(organ.test.ts 把每个工具在普通对话里都调了一遍)。

task_merge_workspace 对源码树分支的行为变了:不再直接排进通道,而是交给 Maintainer,任务的 owner 同时改成 web:maintainer。从这一刻起,任务的结果、后续的 task_message 和 task_stop 都归 Maintainer,派活的对话碰不到它。工具返回值直接告诉对话:继续干别的或者结束回合,不用等,也不用查。

一单在表 maintainer_submissions 里的生命周期:

  task_merge_workspace
         │  任务改归 Maintainer
         ▼
      queued ───(位置空着)───► reviewing ◄──── 落地失败 / 被回滚 / 部署卡住
         ▲                      │ │ │ │        (对账时改写,带上通道的记录)
  任务这一轮结束                  │ │ │ └─ defer:过 N 分钟再看,位置仍占着
  (round+1)                     │ │ └─── escalate ──► escalated(任务交回对话)
      returned ◄─── return ─────┘ └───── close ─────► failed
                                │
                                │ land(kind, basis)
                                ▼
                             landing ──通道 landed──► shipping ──部署 running──► resolved
                                │                        │
                           通道 failed              reverted / stuck
                                └────────► reviewing ◄───┘

几个关键约束:

  • 串行。 「位置」指有一单处在 reviewing 或 landing(SLOT_STATES)。位置空着时,分钟 tick 才把最老的 queued 提到 reviewing 并唤醒它。shipping 不占位:已经进了 main、在等部署的单不挡后面的单。
  • 一次唤醒只带一单。 唤醒正文由 renderWake 拼成,内容包括意图两句、分支和工作区、基线、提交列表、diff 统计、通道记录及归因、这一单最近 10 条经过,最后一行是:「处置后这一单必须离开 reviewing:land / return / escalate / defer / close 之一。」
  • 不靠通知,靠对账。 reconcile() 挂在 cron 分钟 tick 上,直接读通道落在磁盘上的记录。部署失败时,通道会把有问题的改动单独重新落地(deploy.relandId),Maintainer 沿着这条链追到最新的记录。为此 deploy.ts 只改了一处:重新落地时把 submissionId 一起带过去。
  • 通道的汇报器对它保持沉默。 LandingResultReporter.report 加了一个条件:只要记录带 submissionId 就直接 return。这一单的任何中间结论都不会推给任务或对话,测试 a lane record carrying a submission is never reported... 断言投递列表为空。

最后一点值得多说一句。Maintainer 拿到的权限是排队,不是写入。maintainer_land 调的就是通道的 enqueue;预演门、试用期、全量测试一道都没少;它不合并到 main、不重启、不发布。这次改动没有给主干多开一个写入口,只是在入口前面多了一个验收的人。

5. 三路分流:同一个失败,处理的人不一样⌗

这是整个设计的核心。Maintainer 每次被唤醒,都先回答一个问题:这个问题归谁?

归属例子谁处理怎么处理对话听到什么
整合问题与移动中的 main 冲突;测试机没给结论;main 上本来就红;预演抖动;源码树里别人的未提交改动挡住快进Maintainer在这一单的工作区里 git merge main、解冲突、提交,再 land;等环境就 defer;障碍做成修复分支,走 land_repair什么都不说
成果缺陷没兑现意图;有 bug;失败的测试确实是这个改动造成的;单独上线后又被回滚原任务return:对照意图写清缺什么,唤醒任务;任务这一轮结束后,这一单自动回到 queued 重新验收什么都不说
意图本身有问题和别的需求冲突;和既定原则冲突;done_looks_like 自相矛盾派活的对话(和人)escalate:任务交回,对话收到一条说明一次说明

分流不是从零判断。通道早就做过 main 基线比对和失败归因,readLanding 把这些记录确定性地读成「整合 / 缺陷」,写进唤醒正文:

if (CHECKOUT_DIRTY.test(summary))              return integration  // 检出被别人的改动挡住
if (rec.failureCause === 'conflict')           return integration
if (rec.failureCause === 'infra')              return integration  // 测试机/环境没结论
if (attribution === 'main-red')                return integration  // main 上本来就红
if (attribution === 'environment')             return integration  // 只在改动够不到的测试里失败
if (deploy === 'reverted')                     return defect       // 单独上线也被回滚:就是它
if (deploy === 'stuck')                        return integration
return defect                                                      // 其余:候选自己的红

这只是给它的一个初读。提示词明确要求它再对照 diff 做最终判断,并且点名了两种偷懒:「不要因为『退回去最省事』就退回去,也不要因为『自己修最快』就替任务补它没兑现的意图。」

前两类问题之外还有一层判断:fix 还是 feature。标准只有三条:有没有改动人设过的约束,有没有放宽权限或绕过某道门,有没有改变人能看到的行为。三条都没有就是 fix,验收通过就放行。只要占一条就是 feature,它必须对得上 docs/judgment.md,对不上就 escalate。kind 和 basis(判断依据)记在这一单上,事后可以查。

docs/judgment.md 是这次一起加进来的。它不是规则清单,是一份「被否决的方案」案例集:每条写摩擦是什么、agent 想怎么补、最后怎么判的、为什么。主线只有一句:遇到摩擦先问这个机制该不该以现在的形态存在;如果你的方案是在现有机制上再加一层(白名单、审核、例外、折叠),先停下。Maintainer 碰到 feature 时就拿这份文档做参照。用这个原则去审 Maintainer 本身也说得通:它没有在通道外面再套一道审核,而是把「验收」从前台对话里拿出来,交给一个真正对此负责的角色。

6. 没处置就再叫醒,不设上限⌗

LLM 角色有个常见的失败模式:回合结束了,嘴上说「我在看」,实际什么也没做。

wakeNext 对这种情况的处理很直接。如果一单停在 reviewing,attention_at 已到,上一次唤醒已经写进 Maintainer 的历史,它的线程又没有回合在跑,就说明上一个回合结束时没给出处置。下一次分钟 tick 会再唤醒它,reason 是 unhandled,正文写:

上一次唤醒之后你的回合结束了,但这一单没有被处置,仍占着你的位置、后面的单都在等它。现在对它做出处置。

两个防抖:

  1. 上一次唤醒还没进历史时不重发,等满 WAKE_STALE_MS(30 分钟)再说,避免唤醒还在路上就补发一条。
  2. defer 过的单不算空转。它被唤醒的时间是自己约定的。

不设次数上限是第二个 commit 专门改的。理由写在契约里:它要是反复空转,那是另一个需要单独处理的真实问题;加个上限只会让一单安静地卡在那里,后面的单全部排队等着。测试 as often as it takes 连续断言了第二次、第三次 unhandled 唤醒,而且排在后面的那一单始终保持 queued。

7. 一个需求只报一次结果⌗

结果事件 maintainer-result 只发给派活的那个对话(origin_thread_id),以 (origin_thread_id, requirement) 为单位。只有当这个需求下所有未报告的单都离开了 queued / reviewing / landing / shipping / returned,才会发一次:

  • 全部 resolved:outcome resolved,附上每一单的 done_looks_like。读到的是「做成的样子」,不是「测试通过」。
  • 有 failed:outcome failed,附原因,以及哪些部分已经上线了。

escalated 的单在交回时已经说过一次,不再重复报告。中间的失败、重试、回滚后的重新落地都不进对话。resolved 的定义是「上线并且度过试用期」,不是「合进了 main」。

测试 two tasks of one requirement:同一需求的两个任务,第一个已经 resolved,对话仍然收到 0 条;第二个 close 掉之后对话收到 1 条 failed,正文里既有失败原因也有「已上线」的那一半;再 tick 两次,还是 1 条。

已知边界,契约里直接写明了:同一需求的任务如果不在同一时段交来(前一批已经报完,后一批才交),会各报一次。Maintainer 不知道还有没交来的成员。

8. 证据:在真实 git 上演一遍⌗

scenarios.test.ts 没用假的通道,而是建真实的 git 仓库、跑真实的落地通道(只把验证和发布替换成假的),走了两个场景。

场景二:两个分支改了同一个文件。

t1: same.txt = "one"     t2: same.txt = "two"     (都从同一个 main 分出)
  land(t1) → 通道 landed → shipping                ← 不占位
  land(t2) → 通道 failed, failureCause = conflict
  唤醒正文含「整合问题」「冲突」;taskMessages = [],对话结果 = []
  Maintainer 在 t2 的工作区:git merge main(失败)→ 写 "one\ntwo" → commit
  land(t2) → landed;源码树 same.txt == "one\ntwo"
  两单都部署 running → 都 resolved → 对话结果 = 2(两个需求各一次)
  taskMessages 始终 = []

最后一行最要紧:整个过程中原任务一次都没被打扰,冲突不是它的问题。

场景一:源码树里一份别人未提交的文件挡住了快进。 通道拒绝移动 main 并给出原因,readLanding 把它读成整合问题。Maintainer 把那份未提交的改动做成修复分支,经 maintainer_land_repair 交给通道落地(提示词要求:那是别人的活,不删、不丢),等它进了 main 再重新落地原分支。测试最后断言那个文件里两边的改动都在:任务写的那一节,和源码树里原本没提交的那一行。对话同样只收到一次结果,原任务同样一次都没被打扰。

测试分布:

文件用例覆盖
maintainer.test.ts17串行与占位、空转重唤醒、整合四种、缺陷退回与回来、任务消失、交回与再交、需求只报一次、通道记录的读法
scenarios.test.ts2上面两个真实 git 场景
organ.test.ts6线程隐藏、工具只在本线程、提示词原则、intent 必填(含子目录)、半截意图被拒

另外还有 tool-availability、skill-residency、事件种类与 PG CHECK 镜像(m9-contracts)、侧栏状态(organ-status)几处配套测试。整个改动 65 个文件,+2563 / −38。

9. 代价和例外⌗

取舍说明
串行吞吐一次只验收一单。shipping 不占位,缓解了「等部署」这段最长的时间;但验收和整合本身是串行的。这是有意为之:主干的完整性需要有一个时刻只看一件事的人
又一条特殊线程和管家线程一样,要在侧栏、Main 的 carry、进度板、收件箱分拣、影子记忆、记忆积压、引用等十来处被排除。diff 里那一串一两行的小改动大多是这个
隔离 / 类别对话绕过它Maintainer 的记录是任何线程都读不到的最高密级,但它的唤醒本身被标为无类别(正文只有意图、diff 统计和通道记录)。带类别的对话的意图和 diff 不能进这份记录,所以这类对话走老路:通道直接排队,失败时唤醒 owner
判断仍靠模型readLanding 是确定性的,fix/feature 和「是否兑现意图」不是。约束它的办法是三条硬标准、写下来的 basis,以及 feature 必须对得上 judgment,对不上只能 escalate、不能放行

10. 结论⌗

一个能改自己的系统,最容易出问题的不是测试红了没人发现(通道会发现),而是每个编码 agent 都觉得自己做完了,而这个「完」从来没有对照过一个事先写好的标准。

这次改动拆成三部分:

  1. 意图前置:派活时强制写 why / done_looks_like。验收标准出现在代码之前,不是代码写完后补的说明。
  2. 验收独立:交付时由一个不写代码、不和人聊天、一次只看一单的角色,拿意图去对照 diff,再按问题归属三路分流。
  3. 写入不变:落地通道仍是 main 唯一的写入者,门一道没少。Maintainer 多了一份判断,没多一个写入口。

对派活的对话来说,task_merge_workspace 之后它就不用管这件事了,下一次听到这件事,就是那句「需求解决了」。