8 月下旬,我在自己写的一个评审 Skill 里发现两条规则在互相打架。一条说 Cursor 是“只读顾问”,只提意见;另一条却要求 Codex“必须改到 Cursor 评审通过”为止。按第二条执行,一个本该只提建议的顾问,实际上掌握了主 Agent 能不能停下来的决定权。
这个矛盾很小,改起来也不难,但它让我意识到:让第二个 Agent 参与工作,难的不是多调用一个模型,而是说清楚一条建议、一次批准和一个真实动作,分别由谁负责。后来在 DevFlow 的审批、桌面执行和团队协作里,我一再碰到同一类问题。
这篇文章沿着一项任务从提出、评审、决策到交付的顺序,把三段实践放在一起:我的 Cursor 评审 Skill、DevFlow 的治理设计与 Local First,以及 Gateway 对外部消息发送的处理。它们是分别存在的实践,并没有集成为一条多 Agent 产品链路;放在一起,是因为它们回答的是同一个问题。
第二个 Agent 应该带来什么
我写 cursor-collab-review 的初衷很简单:让 Codex 在工作时能直接拿到 Cursor 对方案的独立评审。6 月底的第一版用 Cursor CLI 的 ask 模式打通调用,此后按实际使用的反馈一点点补上输出格式、续谈和进度处理。
独立意见的价值,是提供另一个检查问题的角度。但它有没有用,要看指出的问题有没有依据、主 Agent 能不能核实、人能不能看懂其中的取舍。多加一个模型,并不会自动带来这些。
人读理由,机器读契约
第一版返回的是一段紧凑的 JSON。用了当天我就发现不够:我想看的是完整的论证、表格和修改建议,而不只是一个结论。于是同一天改成了完整的 Markdown 报告,末尾再附一个供程序解析的 Machine Verdict 块,并统一了 Schema、加上嵌套校验和解析失败时的重试。前者让人和主 Agent 能读到理由,后者让程序稳定地识别结论、问题和建议。
7 月中旬又加上了同题续谈:初审之后可以带着新材料回到同一个 Cursor 会话,不必每次从头讲一遍背景。但续谈时,Skill 要求每一轮都重新读取被评审的文件。保留过去的讨论和核对当前的事实,是两件都要做的事。
站内保留了一份评审 Schema 的公开快照,关键字段是这样描述的:
"verdict": {
"enum": ["pass", "pass_with_risks", "fail"],
"description": "Cursor's advisory verdict; it does not authorize, block, or control Codex."
},
"blocking_issues": {
"description": "Issues Cursor claims materially block progress; Codex independently accepts, rejects, or defers each one."
}
字段让报告可以被程序处理,完整的理由让结论可以被质疑。主 Agent 拿到报告后,要逐条决定采纳、拒绝还是暂缓,并说明原因。把顾问的报告直接覆盖到原方案上,恰恰跳过了最关键的那一步判断。
顾问不能替主 Agent 做决定
回到开头的矛盾。旧版 Schema 里有一个 should_continue 字段,再加上“改到通过为止”的规则,顾问的意见就变成了主 Agent 的执行命令。8 月下旬我把它换成了 cursor_recommends_follow_up,并补上两条约束:结论和阻塞问题必须互相一致;不能只是为了拿到一个 pass 而发起新一轮评审。默认最多跟进两轮。
你会注意到字段名仍然叫 blocking_issues。名字没有改,但描述写明了它只是顾问“声称”存在的阻塞问题,由 Codex 独立判断。机器字段的名称和它背后的执行规则,必须放在一起读。
这不是让主 Agent 无视批评,而是让它承担决定。问题成立就修改,证据不足就继续调查,理由不成立就说明为什么不采纳。一个 pass 不能替代自己的验证,一个 fail 也不该引发没有上限的来回修改。
当建议真的要变成代码修改或交付时,光靠文字约定就不够了,需要应用程序来判断一个动作能不能执行。这正是 DevFlow 要解决的问题。
程序决定动作能否执行
在 DevFlow 里,Agent 可以写方案、给审查意见、提出执行方案,但一个 Gate 能不能被批准,由工作流的转换逻辑(在新标签页打开来源)决定:
if (!approval.roleAllowed) {
blockers.push(blocker('authorization_denied', 'The actor is not authorized to approve this node'))
}
if (approval.policy.blocksApproval) {
blockers.push(blocker('policy_blocked', 'Gate enforcement policy blocks this approval'))
}
// review 为 required 时,最新一条匹配的 Gate Review 缺失或阻断,都会加入阻塞项
// budget 为 required 时,缺少预算决定或预算阻断,同样会阻止这次批准
注意 Agent Review 在这里只能产生 review_blocked,也就是只能挡住批准,不能促成批准。有界 Runtime 的 ADR(在新标签页打开来源) 写得更直白:Agent Runtime 不能批准 Gate;它的循环最终会以成功、失败、取消、超时、步数上限、预算耗尽或策略拒绝之一结束,而即使是“成功”,也只是一份证据,不会推进节点。
写代码的执行器也是如此。ADR 0015(在新标签页打开来源) 让 DevFlow 自己的 Native 执行器和外部的 OpenCode 实现同一份执行器契约:相同的事件、相同的终态结果(停止原因、改动路径、diff 引用、测试证据、成本和清理状态),共用 Gate、策略、预算和权限规则。两者都不能批准 Gate、发布或合并。Native 执行器在测试失败后可以提出一轮有界的修复,但这份修复用的 Change Set 需要重新批准;批准时 ID、摘要或有效期对不上,也会被拒绝。Native 执行器(在新标签页打开来源)
人确定目标和关键批准,Agent 提供判断和执行能力,程序约束哪些动作可以发生。模型说一句“我已经检查过了”,替代不了应用层的这些条件。
本地执行,接住团队的决策
Local First 在 DevFlow 里是一个具体的分工。完整的工作流、代码和本地执行记录由 Desktop 持有;团队端负责需求和协作意图,看到的是一份经过筛选和脱敏的状态投影。ADR 0012(在新标签页打开来源) 对这份投影的定位是:有损、脱敏的只读模型,从来不是执行的权威。
一条需求从团队端发起后,由 Desktop 领取;只有领取成功,本地才会创建对应的 Run。团队端提交的 Gate Command 带着它“以为”的状态:预期的 Run 版本、策略版本和阻塞项集合(在新标签页打开来源),以及用于去重的幂等键。Desktop 在本地重新校验,全部一致才应用。
这里有一个我很看重的区分:命令的投递状态和处理结果是两套东西。前者是 acknowledged、ack_pending、lease_expired 这类,描述消息有没有送达、有没有被确认;后者是 applied、stale_run、blockers_changed 这类,描述批准到底有没有生效。处理器实现(在新标签页打开来源) 团队端收到“已确认”,不等于批准已经生效。
执行进展通过 Outbox 同步回团队端。每次同步有 30 秒的超时,可重试的失败按指数退避重新排期,最多尝试 5 次,之后以 max_attempts 结束,而不是无限重试。Outbox 处理器(在新标签页打开来源)
这些机制处理的都是协作中很平常的时间差:团队成员看到的是刚才的状态,批准送达时代码可能已经变了;网络断开之后,同一条消息可能被送达两次。协作界面负责传递意图,真正持有执行事实的一端,负责判断现在能不能执行。
批准的是具体的提交
任务走到交付时,“同意这个方案”不足以授权之后任意的代码。DevFlow 的交付意图把批准绑定到一组具体的东西上:Run 版本、预期的提交 SHA、diff 来源的摘要、测试证据的 ID 和摘要、PR 包的摘要,以及仓库绑定的版本。交付意图(在新标签页打开来源) 预期的提交或证据摘要一旦变化,原来的批准就失效。GitHub 交付权限 ADR(在新标签页打开来源)
真正动手的两步也分给了不同的一方。Desktop 拿到一个只对单个仓库有写权限、最长一小时、只保存在内存里的令牌,把批准的那个提交推送到新分支:推送实现(在新标签页打开来源)
if (remoteHead && remoteHead !== input.expectedCommitSha) {
throw publisherError('remote_branch_diverged')
}
if (!remoteHead) {
await networkCommand(['push', '--porcelain', '--no-verify', input.canonicalHttpsUrl,
`${input.expectedCommitSha}:refs/heads/${input.headBranch}`], 'push_result_unknown')
}
只有远端还没有这个分支时才推送,refspec 里没有强制推送的 +,也不推送标签。这里的 --no-verify 跳过的是本地 Git 钩子,出站对象改由 Electron 主进程统一扫描。之后由 API 独立读取远端的分支头,用只读内容加 PR 写权限创建 Draft PR。ADR 0013 同时写明,DevFlow 不会合并或自动合并、不会强制推送、不会删除远端分支、不会发布标签,也不会扩大自己的权限。
这让我把协作中的“同意”理解得更具体:谁同意了哪个版本,允许执行哪些动作,完成之后用什么证据确认。把这些写进契约,人和 Agent 对同一句“批准”才不会有两种理解。
长任务要能继续,也要能停下来
回到我的评审 Skill。8 月下旬,一些高推理强度的 Cursor 调用很慢。当时的包装脚本是等进程结束后一次性读取全部输出,运行期间完全没有动静,一个还在认真调查的评审和一个已经卡住的进程,看上去一模一样。我先把固定时限提高到 300 秒,仍然有正常的评审被中途终止。
几天后我改成读取 Cursor 的流式事件:输出筛选过的进度,区分“进程还活着”和“真的有新事件”,并把时限拆成三层:运行 300 秒给出软提醒;连续 360 秒没有 Cursor 事件就停止,包装脚本自己的心跳不算;1800 秒是绝对上限。会话 ID 在一开始就保存下来,即使因为超时停止,也能回到同一道题继续。这些改动让“现在进行到哪里”变得可以解释,但并不能证明评审的准确率或恢复成功率达到了某个水平;ask 模式也不是操作系统级的沙箱。
另一种停下来的理由,来自 Gateway 的 WeLink 消息发送。发送前,操作记录会先落盘为 dispatching;之后如果回执不明确,状态就停在 unknown。发送实现(在新标签页打开来源)
if (operation.state === "dispatching" || operation.state === "unknown") {
throw new WelinkSendError("unknown", "Previous delivery is unconfirmed; do not resend.", id);
}
同一个操作一旦处于 dispatching 或 unknown,任何再次发送的请求都会被拒绝;已经确认过的操作再次调用,返回的是原来的回执,而不是再发一次。每一轮提示词也会告诉 Agent:遇到 unknown 不要重发,先查询这条操作的状态。操作状态记录(在新标签页打开来源)
“未知”必须能被表达出来。没有收到确认,不等于消息没有发出去;为了让界面显示“成功”而重发一次,可能把副作用放大一倍。持久化的记录和明确的重试边界提供了检查依据,但我不会把它说成分布式系统意义上“恰好一次”的保证。
让每一次交接都说得清楚
从个人的评审 Skill 到 DevFlow,再到 Gateway 的外部操作,我逐渐形成了一个判断:一项任务可以由多个参与者接力完成,但每一次交接都要说清楚输入是什么、当前的结论是什么、谁有权改变它,以及下一步怎样才算完成。
Collaboration 回答意见怎样进入工作,Governance 回答意见怎样变成被允许的行动,Local First 回答当意图和执行事实分处两端时,怎样接住同一项任务。它们在同一项任务里相遇,也共同决定了人能不能放心地把工作交给 Agent。
我希望这样的协作允许参与者提出不同的意见,允许人修改目标,也允许系统诚实地停在“条件不满足”或“结果未知”。有了这些清楚的状态,继续工作才有依据。
材料说明:评审 Skill 的演进依据我保存的 6 月至 8 月维护记录,以及 9 月核对时的实现;Skill 本身不公开,站内快照只包含评审协议,不含会话和运行数据,快照与当前使用的 Schema 一致。DevFlow 引用固定提交 b7903c3,Gateway 引用比赛阶段的固定提交 4296525。本文描述的是分别存在的实践及它们的共同判断,没有声称它们已经集成为同一个系统,也没有把受控回归的结果当成真实协作的收益。