「做完了」不该由干活的人说:给主干配一个 Maintainer

编码 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,正文写:
上一次唤醒之后你的回合结束了,但这一单没有被处置,仍占着你的位置、后面的单都在等它。现在对它做出处置。
两个防抖:
- 上一次唤醒还没进历史时不重发,等满
WAKE_STALE_MS(30 分钟)再说,避免唤醒还在路上就补发一条。 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:outcomeresolved,附上每一单的done_looks_like。读到的是「做成的样子」,不是「测试通过」。 - 有
failed:outcomefailed,附原因,以及哪些部分已经上线了。
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.ts | 17 | 串行与占位、空转重唤醒、整合四种、缺陷退回与回来、任务消失、交回与再交、需求只报一次、通道记录的读法 |
scenarios.test.ts | 2 | 上面两个真实 git 场景 |
organ.test.ts | 6 | 线程隐藏、工具只在本线程、提示词原则、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 都觉得自己做完了,而这个「完」从来没有对照过一个事先写好的标准。
这次改动拆成三部分:
- 意图前置:派活时强制写
why / done_looks_like。验收标准出现在代码之前,不是代码写完后补的说明。 - 验收独立:交付时由一个不写代码、不和人聊天、一次只看一单的角色,拿意图去对照 diff,再按问题归属三路分流。
- 写入不变:落地通道仍是 main 唯一的写入者,门一道没少。Maintainer 多了一份判断,没多一个写入口。
对派活的对话来说,task_merge_workspace 之后它就不用管这件事了,下一次听到这件事,就是那句「需求解决了」。