自己改自己、自己上线:预演门一个真 bug 都没拦下,救场的是回滚
一篇设计复盘:MaxClaw 的核心代码上线为什么走「真实预演 + 试用期自动回滚」,记录能证明什么、证明不了什么。
证据口径:所有数字取自运行时的落地 / 部署 / release 记录、git 历史和改进信号库。出处格式:文件:行(相对源码仓库根)、commit 短 SHA、信号 id(改进信号库主键前缀)、落地 / 部署记录 id。源码仓库不公开,这些出处用于自查和后续追踪。标「推断」的是我的解读;拿不到的写「无数据」。各组记录的时间窗口和计数单位不同(release 按版本、落地按分支、部署按批次),不能拼成一条成功率漏斗。
1. 要解决的问题⌗
MaxClaw 是一个单机部署、单一所有者的自托管 agent 运行时:主进程跑对话与工具调度,守护进程(guardian)负责拉起和重启它,若干工具执行进程跑 shell 和文件操作,状态在 PostgreSQL。特殊之处在于:改它源码的主要是它自己。agent 在隔离 worktree 里改代码、写测试,然后把改动送上线;所有者大多数时候不在机器前,远程看结果。
在这样的系统里,“上线失败"有具体形态:
- 起不来。新版本启动即退出。记录里唯一一次真实代码缺陷就是这样:一个分支新增的数据库迁移编号
0441低于生产已应用的最高编号460,生产启动时迁移器拒绝"低于已应用最高版本的待应用迁移”,进程exit 1(信号eac67ce1的分诊记录;修复 commitc5aea4df)。agent 把自己弄死了,而它死了就没人接下一条消息。 - 重启把在飞的工作弄丢。一个回合可能正跑着几分钟的工具调用,重启必须先 drain、挂起回合、重启后续跑。这条路径本身是代码,会坏。
- 代码 × 生产状态的组合出问题。同一份代码在测试配置和测试数据库下是好的,在生产的那一份状态下就不是。上面的迁移编号问题正是这种:分支自己的测试全绿。
- 上线机制自身出错。落地通道 196 条记录里有 5 条是通道自己的错误(worktree 被删后
posix_spawn 'git'ENOENT、源码树有未提交改动挡住快进),12 条是合并冲突(落地记录,2026-10-05 至 10-20)。
问题因此不是"要不要有门",而是:一个经常无人值守、由被检查者本人发起上线的系统,门该检查什么、谁来检查、检查失败之后谁兜底。
先划清范围:本文讨论的是 src/ 核心代码和需要重启的配置段。技能文档、系统提示词、技能工具代码、大部分配置是热加载的,保存即生效,不走这条路径(skills/maxclaw/SKILL.md:375-385)。它们同样能改变 agent 的行为,本文的任何安全结论都不覆盖它们(见 5.8)。
2. 设计本身⌗
三段:落地通道(main 只接收验证过的代码)、预演门(重启只加载预演过的那一份)、试用期(预演漏掉的启动级故障自动退回)。
2.1 落地通道:main 的唯一写入者⌗
scripts/land.ts:4-9:分支在临时 worktree 里合并到最新 main → 全量测试 + tsc → 快进 main → 推送。
- main 由 git
reference-transaction钩子守着,拒绝通道之外的更新(scripts/land.ts:24-26;缺失则每次通道运行时重装,scripts/land.ts:309-316)。 - 全量测试失败在 ≤3 个文件时只重跑这些文件一次,重跑通过记为
verify-flaky、照常落地,并把每个"flaky"测试登记为改进信号(src/landing/lane.ts:15-17,src/landing/flaky.ts:20)。196 条落地中有 11 次这样过。注意:重跑通过只证明"重跑通过了",不能证明问题出在测试而不是候选代码的间歇性缺陷(见 5.13)。 - 新增迁移编号不高于 main 最高编号的直接拒绝(
src/landing/migration-order.ts:19-26,调用点src/landing/lane.ts:489)——这是那次回滚之后补的(commita7215960)。
落地只代表"main 里有了"。部署是单独一步(src/landing/deploy.ts:1-31,commit 5952a0aa 拆出):把自上次运行版本以来的全部落地合成一次经预演门的重启;先等在飞对话结束,超过配置时长后挂起后台对话、只等所有者本人的对话(src/landing/deploy.ts:12-16)。
2.2 预演门:对"将要运行的那一份"做一次真实启动⌗
重启端点不直接重启,先对候选 release 跑预演(src/rehearsal/run.ts,首个版本 commit 8704b006,替换了此前的 Docker smoke 门)。候选是由已提交 tree 构建的不可变 release,在同一台机器上以独立进程组、随机端口启动:
| 维度 | 预演里用的是 | 出处 |
|---|---|---|
| 配置 | 生产配置文件副本,只改端口、网关地址、状态路径 | skills/rehearsal/SKILL.md:19 |
| 密钥 | 生产密钥目录副本,结束即删 | skills/rehearsal/SKILL.md:20 |
| 数据库 | 生产 PG 服务器上的一次性库:用候选自己的迁移器建到生产账本最高版本,拷入最近 3 个会话各 300 行;余下迁移由候选启动时执行;结束 DROP | src/rehearsal/run.ts:201-230,src/rehearsal/pg-prepare.ts:1-7 |
| LLM | 生产当前生效的配置修订,会话钉在轻量档模型 | src/rehearsal/run.ts:83 |
| 进程拓扑 | 候选自己的守护进程 → 主进程,加候选的工具网关与工具执行进程;另对正在运行的生产网关做只读握手 | src/rehearsal/run.ts:248-272 |
注意数据库那一行:预演库是重建出来的,不是生产库的复制;3 个会话 × 300 行是一个低成本样本,不是完整的生产状态。第 4 节的案例 A 就栽在这里。
依次检查:
- 类型检查(主体 + UI,并行,
src/rehearsal/run.ts:241-246)。 - 启动验收:与生产守护进程同一判据,等启动记录变成
ready(src/rehearsal/run.ts:275-284)。 - 一个真实对话:经真实 LLM,含一次真实工具调用(
src/rehearsal/run.ts:292-294)。 - 重启续跑演练:让模型调用一个慢工具,执行中打生产重启路由,要求走完 drain → 挂起 →
exit 75→ 守护进程拉起 → 续跑,并确认守护进程日志里出现(exit 75)(src/rehearsal/run.ts:296-326);再校验历史:慢工具有真实结果而非合成结果、续跑事件在其后、最终回复在续跑之后(src/rehearsal/run.ts:397-411)。 - 日志扫描:全部日志大小写不敏感匹配
error|fatal|unhandled|uncaught,白名单为空(src/rehearsal/log-scan.ts:5,9)。 - 候选带来生产未应用的迁移时,在迁移后的预演库上启动当前运行的 release 跑一个对话,判断旧代码能否在新 schema 上继续服务(
src/rehearsal/run.ts:333-354)。这一步是"观察型"的:结论写进报告、供重启门决定能否在线预应用迁移,但不会让预演失败。
通过后签发回执,绑定候选 tree、配置、全部密钥文件、生效的 LLM 配置修订、运行时版本、锁文件与依赖的指纹;15 分钟有效,重启和发布各用一次(src/rehearsal/receipts.ts:11-13)。预演前后指纹不一致则拒绝:“预演期间输入发生变化,结论不适用”(src/runtime/restart-gate.ts:132)。
耗时:预演本身只有 1 个留存样本,60.5 秒(回执目录只保留未过期回执;各步:建库 9.2s、类型检查 9.3s、启动 12.6s、对话 8.3s、续跑演练 28.3s),不能当典型值。重启请求从发出到新进程健康的往返时长中位数 79 秒、最大 267 秒(部署记录 86 条,2026-10-12 至 10-20)。这不是停机时间:预演期间旧进程照常服务,停机只发生在 drain 之后;停机时长目前只在重启事件里有零星记录(我看到的一个样本是 3 秒),没有系统统计。
2.3 试用期与自动回滚⌗
预演通过后,新 release 进入 5 分钟试用期(watchdog/release-supervisor.ts:25)。判定是纯函数 decideProbation(watchdog/release-supervisor.ts:39-70):
- 启动验收失败,或期内非 0/75 退出 →
current指针切回known-good,该 tree 标为坏版本(不再部署),通知所有者一次,立即从 known-good 拉起; - 存活满 5 分钟 → 晋升为 known-good;
- 期内再次计划内重启(
exit 75)→ 不判定。
例外:启动迁移仍在推进而撞上 30 分钟上限时,回滚但不标坏(watchdog/release-supervisor.ts:62-64)。部署步骤看到回滚或预演拒绝后,在 main 上逐条 revert 这一批落地;一批只有一条时它就是元凶,多条时各自单独重落以定位(src/landing/deploy.ts:20-26)。
换个角度看,试用期就是一种按时间窗口的 canary:新版本先在真实生产里跑 5 分钟,没出事才晋升。只是它的检测指标极窄——只看"起没起来、崩没崩"。
回滚的边界要说清楚:切回旧 release 只回滚程序版本。它不回滚数据库(已执行的迁移、新代码写入的数据)、不撤回已发出的消息和已执行的外部动作,也不管热加载的提示词、技能和配置。唯一一次成功回滚走的是"迁移器拒绝启动、什么都还没改"这条最友好的路径;它证明了这条路径能恢复,不证明回滚在一般意义上是安全的(见 5.7)。
设计者在重启工具的注释里写了取消人工确认的理由:确认门的唯一作用是防止 agent 自我迭代时杀掉自己;它能否移除只取决于预检是否忠实模拟重启后的状态(skills/system/tools.ts:135-140)。这句话有两个前提:预演"忠实",以及确认门只承担"防自杀"这一种职能。下文两个前提都会被打折。
2.4 预演能碰到哪些外部系统⌗
预演用生产密钥、跑真实 LLM、起真实进程。它在外部世界的边界,查源码后是这样的(src/runtime/rehearsal-mode.ts,src/start.ts):
| 方向 | 处理 | 出处 |
|---|---|---|
| 入站消息渠道(长轮询、反向 WebSocket) | 不连接 | src/runtime/rehearsal-mode.ts:5 |
| 出站消息与推送(IM、手机推送、守护进程通知) | 写入捕获文件,不发送 | src/runtime/rehearsal-mode.ts:6,29-36 |
| 工具执行 | 白名单:只允许取时间、解析时间戳和预演专用慢工具,其余在执行点拒绝 | src/runtime/rehearsal-mode.ts:39-43,60-63 |
| 定时任务、收件箱分拣、主动行为、决策推送 | 不启动 | src/start.ts:279,1351,1430,1440 |
| 生产服务管理(重启生产 launchd 作业、落地通道) | 捕获,不执行 | src/landing/kick.ts:15-16 |
| 额度重置接口、待办系统写入 | 捕获,不执行 | src/quota/reset.ts:177,src/stability/external-owner.ts:262-273 |
| 网络兜底 | fetch 到 4 个消息投递主机直接拒绝(黑名单) | src/runtime/rehearsal-mode.ts:66-98 |
| 会真实触达的 | LLM 提供方(真实调用、消耗额度,每次用量无数据);生产 PG 服务器(建删一次性库、只读生产表);生产工具网关(只读握手);启动时的外部 MCP server 工具清单同步(用真实凭据连接,未见预演分支,src/start.ts:385);trace 导出端点(如已配置,以单独服务名上报,src/start.ts:224) | 见左 |
| 未知 | fetch 黑名单之外、以及不经 fetch 的网络调用(原生 socket、库自带的 HTTP 客户端、子进程)是否都已被上面的开关覆盖,没有做过完整审计;捕获文件只被计数(src/rehearsal/run.ts:368),没有人检查里面是否出现了不该出现的发送 | — |
结论:已知的对外效果都收口了,收口方式是逐个开关加一个黑名单,所以"新加一个对外通道却忘了加开关"的情况不会被任何机制发现。记录里没有预演造成外部副作用的报告,但这条也只能说"无记录",因为没有检测手段(见 5.9)。
3. 被否决的替代方案,以及它们在什么条件下反而更好⌗
3.1 人工确认门⌗
是什么:2026-09-06 到 10 月初,这个系统就是这样做的:重启前静默跑前置检查(git 状态、tsc、与远端差异),弹一张卡,所有者点批准才重启,10 分钟未决自动取消(当时的设计记录;commit 8704b006 的说明 “maxclaw_restart no longer asks for confirmation”)。
要分开两个问题。确认门其实可能承担两种职能:
- 技术判断:这一份能不能起来、能不能续跑?
- 方向判断:这个改动值不值得做、后果能不能接受?
对第 1 种,卡片上只有一份前置检查报告,确认者看不到候选在生产配置下能不能启动;他能判断的,预演都能更好地自动判断。对第 2 种,预演完全替代不了人,而确认者即使看不到运行时状态,也照样能判断方向和不可逆后果。
在这个系统里,方向判断不在部署时刻发生:所有者要的功能,是在写代码之前通过决策队列 / 提问卡定下的;部署时的确认卡只附前置检查报告,并不提供"这个改动值不值得"的信息。所以准确的说法是:在所有者事先接受的改动范围内,用自动验证和自动恢复替代逐次的运行时确认;方向和不可逆后果在上游单独决策。
这个说法也有缺口:196 条落地里有 42 条来自自动修复(落地记录 source=autofix),由分诊流程自行决定修什么,只有被判为"方向性"的问题才进所有者的决策队列。这部分改动的方向判断是 agent 自己做的,而不是所有者事先接受的。
在什么条件下确认门反而更好:
- 不可逆的数据变更。回滚只切代码指针(2.3)。删列、改类型、重写大表的迁移一旦在启动时执行,就回不去了。预演已经统计每条待应用迁移的代价(
src/rehearsal/run.ts:9-11,232),但只报告、不拦截。 - “能跑但不对"的改动:改对外发送的措辞或频率、改行为边界。预演和试用期都只看能不能启动、会不会崩。
- 方向本身有争议、所有者又恰好在场。这时确认是一次低成本的对齐,而不是技术检查。
3.2 只跑 CI / 单元测试⌗
问题不在 CI 这种形式:CI 里同样可以跑带真实配置的集成验收、迁移验证和重启演练。真正的区别是:只验证源码和局部逻辑,不等于验证"从生产当前状态转换到候选状态"这一步。测试跑在测试配置、测试数据库和桩掉的 LLM 上;案例 A 的分支自己的测试全绿,缺陷出在"代码 × 生产账本"的组合上(信号 eac67ce1)。测试环境本身也会坏:一次全量测试跑到一半,机器上的开发工具链被切换,git 全局报许可未同意,依赖 git 的测试成批失败(信号 044cb583)。预演是补充的部署验收,不是 CI 原理上做不到的另一类东西。
CI 在通道里仍然是主力:同一时期,测试 / 类型检查让 30 次落地失败(落地记录),预演门让 2 次落地失败,而且那 2 次都不是代码缺陷(第 4 节)。但这两个数口径不对等:30 次里有多少是真实缺陷、多少是 flaky 或环境问题,没有分类(5.13)。
在什么条件下只跑 CI 就够:
- 改动不触及启动路径、配置、数据库、进程拓扑(纯逻辑、UI)。这时预演只是再确认一遍"它还能启动”。
- 预演的误报率高于它要抓的缺陷率。在本系统的记录里这一条是成立的:预演门 3 次拒绝全是误报或环境漂移(第 4 节)。在这个条件下,预演对上线可靠性的净贡献可能是负的。
- 成本上反而不成立:“只跑 CI 更省"不对:全量测试中位数约 17 分钟(142 次,落地记录),是整条链路里最贵的一环。
3.3 灰度 / canary⌗
按用户或流量分流的传统灰度不适合:只有一个实例、一个用户、一条对话流,没有可切分的流量;让"一部分对话走新版本”,等于同时跑两个版本的主进程、协调它们对同一份状态的写入。(系统里叫 canary 的那个东西,是每小时一次的外部依赖探针,scripts/canary/run.ts:1-8,与部署无关。)
但 canary 的思路并没有被排除:2.3 的试用期就是一个时间窗口型 canary,问题只是指标窄。
在什么条件下更强的 canary 更好:
- 多实例或多用户时,流量灰度能看到预演看不到的东西:真实负载下的功能回归、慢性泄漏、特定输入才触发的错误,正是试用期判定不了的那些。
- 单用户场景也可以做影子流量:把真实入站消息复制给候选实例,只比对、不外发。系统已经有"每天在一次性实例上跑混沌测试"的机制(commit
e9e8a323),基础设施不远。如果功能回归在以后的事故里占主导,加宽试用期指标或上影子流量,会比继续加强预演更划算。
3.4 直接热更新⌗
核心代码不用热更新:主进程持有在飞对话、连接池、渠道长连接、调度器状态;不重启、原地替换模块,等于在一个没测过的半新半旧状态上运行。重启 + 续跑是一条每次预演都会演练的路径(2.2 第 4 步)。这解释了为什么需要安全重启,但不能证明预演足够忠实,也不能为取消确认门提供额外证据。
热更新已经在用,而且用得更多:技能说明、提示词、技能工具代码、大部分配置都是热加载,保存后 ≤1 秒、下一回合生效,新代码加载失败保留旧版本(skills/maxclaw/SKILL.md:375-384)。它们比走预演门快得多,也不中断在飞对话;代价是它们完全绕开了本文的整套保证(5.8)。
4. 证据:它实际挡住或回滚过什么⌗
总账(窗口与单位各不相同,不能相互折算)
| 记录 | 窗口 | 数字 | 出处 |
|---|---|---|---|
| release 晋升 / 回滚 | 2026-10-02 至 10-20 | 154 / 1 | release 状态文件 history |
| 落地(按分支) | 2026-10-05 至 10-20 | 196 条:147 落地;49 失败(测试/tsc 30、合并冲突 12、通道错误 5、预演门拒绝 2) | 落地记录 |
| 部署(按批次) | 2026-10-12 至 10-20 | 87 条:85 运行,2 被 revert | 部署记录 |
| 预演失败信号 | 2026-10-01 起 | 3 条,全是预演机制自身问题 | 信号 0784f24a、71209c86、5a1d12c7 |
| 预演门拦下真实候选缺陷 | 全窗口 | 0 次有记录 | 逐案见下 |
| 试用期回滚真实代码缺陷 | 全窗口 | 1 次 | 信号 eac67ce1 / 75f9b62e |
154 次晋升不是"上线正确率":存活满 5 分钟就晋升,功能写错、但进程不崩的版本同样会晋升。它说明的是"154 个版本满足了晋升条件",不说明这些版本的功能对不对、数据安不安全。
案例 A:试用期抓到了预演漏掉的迁移缺陷(唯一的真实代码缺陷)
- 2026-10-19 00:08,分支
feat/ask-and-wait落地为e6f5b4f1(落地记录1afe6353),新增迁移编号 0441;生产此时已应用到 460。 - 00:34 部署开始;00:35:16 预演通过,新进程报告健康(部署记录
d49daedf)。 - 00:35:27,约 11 秒后,试用期记录"试用期内崩溃:exit 1",自动回滚到上一版本,坏版本标记(信号
75f9b62e)。生产日志:PgMigrationError: pending migration 0441_live_activity_question.sql is below the highest applied version 460(信号eac67ce1分诊记录)。 - 预演为什么没抓到:预演库由候选自己的迁移器"建到生产账本最高版本",即把候选里所有编号 ≤460 的迁移都应用一遍(
src/rehearsal/pg-prepare.ts:19)。0441 在预演库里成了"已应用",候选启动时看不到待应用迁移;生产库却从没应用过它。两边最高版本号一致,但已应用迁移的集合不一致。这不是"覆盖不够",而是状态建模错了:由候选构造一个看起来像生产的库,不等于把真实生产状态升级到候选。 - 00:55:06 部署步骤在 main 上 revert(commit
96e14039)。从回滚到 revert 约 20 分钟。推断原因:revert 要拿通道锁,而另一个落地(48eb6f3e)当时正在跑一次耗时 1245 秒的全量测试。这 20 分钟里生产跑的是旧版本,但 main 的最新提交里含着已知会启动失败的迁移。 - 修复:00:57 人工改号为 0470 并重落(commit
c5aea4df、ec1a3d9f);自动修复先与人工修复的重命名冲突而失败(信号eac67ce1动作 608),换模型重做后补了两道检查(commita7215960):预演建库前用生产实际账本校验候选的迁移计划(src/rehearsal/run.ts:209-210),落地时拒绝编号不高于 main 最高编号的新迁移(src/landing/migration-order.ts)。前者让"计划"对齐了生产账本,但被预演启动的那个库仍然是重建出来的。缺口缩小了,没有消除(5.2)。 - 代价:约 11 秒内的一次启动失败加一次回滚重启(回滚重启耗时无数据);两路修复工作重复。
案例 B:预演门拒绝,但代码没问题,输入漂移被当成了代码失败(2 例)
- 2026-10-06
feat/connections落地(a814f197,落地记录5844326b),门拒绝:“预演期间输入发生变化(secrets)",main 上 revert(69e74004),约 1.5 小时后原样重落(cd13360d)。 - 2026-10-18
feat/retire-private落地(ca7b651a,落地记录7f4f33df),等在飞对话约 12 分钟后,门拒绝:“预演期间输入发生变化(llmRevision)",revert(6b742aa4),一分钟内原样重落(c2775316、523ed787)。部署记录d5a29214。 - 门自己知道这不是失败:失败观察者跳过
changed,注释写着”‘changed’ 是输入漂移不是失败”(src/runtime/restart-gate.ts:72,91)。但部署步骤把重启端点任何带stage的 409 都映射为gate-failed(src/landing/production.ts:164),而gate-failed一律 revert(src/landing/deploy.ts:289-291)。
案例 C:预演门拒绝,但坏的是预演自己(1 例拒绝 + 3 条信号)
- 2026-10-09
docs/system-prompt-no-unjustified-limits被拒:“restart-drill … did not settle: timed out after 180s”,revert(64719246;落地记录31a84992;信号5a1d12c7)。续跑演练时某个上游返回 503、路由回退,回退事件持久化在最终回复之后,而判定要求"最后一行必须是 assistant",于是一直等不到"已结束"。实际上对话 4 秒就完成了(commitab3dc3ba说明)。 - 另外两条预演失败信号同样出在预演机制自身:release 构建在无
.git的导出目录里拿不到 commit 号(0784f24a,修复51fdc772);预演端口取自系统临时端口段导致 EADDRINUSE(71209c86,修复a2abc0fe)。 - 2026-10-01 还有一次预演留存日志里出现外键约束冲突。那天改进信号机制刚上线(commit
4c5b0607),没有对应信号,也没有分诊:它是候选缺陷还是预演数据拷贝不完整,无数据判定。
这些证据能说明什么
- 试用期:有 1 次实打实的无人值守挽回。没有它,那次启动失败会一直重试到守护进程的崩溃预算耗尽(1 小时 3 次后暂停,
watchdog/README.md:16),然后等所有者来处理。 - 预演门:在有记录的窗口里一次也没有拦下真实的候选代码缺陷;它的拒绝全是误报或环境漂移,还因此 revert 了 3 次本来没问题的落地。它有没有在 agent 落地前的手动重启里拦下过缺陷,无数据。
- 预演门目前看得见的价值在别处:每次重启都真的把 drain → exit 75 → 续跑走一遍,相当于持续演练;再加上"重启前必须拿出证据"这条纪律。至于它作为缺陷过滤器有没有用,记录给不出答案。
5. 写的时候说服不了自己的地方⌗
每条写成"问题 → 证据 → 可执行的改进"。
5.1 “不放行"和"判代码有罪"被混成了一件事。 证据:案例 B、C,3 次无辜 revert。 改进:按结论的性质分四类处理:
| 结果 | 含义 | 动作 |
|---|---|---|
| 输入漂移 | 验收依据过期 | 保持当前运行版本,用新输入重新验收;不 revert |
| 预演设施 / 判定器故障 | 得不出结论 | 停止部署、修预演;不 revert;产出预演基础设施信号 |
| 候选触发明确的验收失败 | 有候选不合格的证据 | 阻止上线,定位或撤销候选 |
| 生产启动失败 | 真实部署失败 | 自动回滚,再处理源码 |
落地手段:productionRestart 把 rehearsal.stage === 'changed' 映射为 not-restarted(src/landing/production.ts:164);预演失败时,用同一流程对当前 known-good release 再跑一次作对照,对照也失败 → 判为"设施故障”,对照通过 → 才判为候选不合格。代价是失败路径多一次预演。
5.2 预演库应该从生产的真实账本升级,而不是由候选重建。
证据:案例 A;src/rehearsal/pg-prepare.ts:19。commit a7215960 只让迁移计划对齐了生产账本,被启动的库仍是重建的。
改进:预演库以生产实际已应用迁移的集合为起点(复制 schema 与账本,或从生产库的一次性快照/克隆起步),再由候选执行它自己的待应用迁移,即真实演练"生产 → 候选"这次转换。做不到全量克隆之前,至少在建库后对比预演库与生产库的 schema 摘要和已应用集合,不一致即失败。同时在文档里明写:3 个会话 × 300 行是样本,依赖大表或旧数据形态的问题不在覆盖范围内。
5.3 没有证据表明预演门抓到过任何一个真实缺陷。
证据:第 4 节。“能否移除确认门只取决于预检是否忠实”(skills/system/tools.ts:135-136),而"忠实"从未被度量。
改进:建一组已知坏的候选(启动时抛错、案例 A 的原始 tree e6f5b4f1、续跑后丢工具结果、日志打 error),定期跑预演、记录检出率,任何漏检都算预演的缺陷。再把 CI 拦下的那批落地放进预演回放,看预演能拦下多少,回答"预演相对 CI 多拦下了多少"。
5.4 功能回归不会触发任何东西。
证据:试用期事件只有 spawn / startup-failed / crash / survived(watchdog/release-supervisor.ts:27-31)。功能回归有多频繁:无数据,没有机制把部署后出现的问题归因到具体版本。
改进:先度量,再决定加不加门。对每个 release,统计晋升后 24 小时内出现的改进信号(工具错误率突增、失败报告、事故),按 tree 归因写进部署记录。
5.5 5 分钟试用期没有依据。
证据:唯一一次回滚发生在启动后约 11 秒;PROBATION_MS = 5 * 60_000 没有对应的数据。
改进:记录每个 release 第一次非自愿退出距启动的时长(含试用期外的),按分布定窗口;在此之前文档里写明"5 分钟是未校准的初值"。
5.6 回滚之后,main 最新提交仍含坏代码约 20 分钟。
证据:案例 A,00:35:27 回滚,00:55:06 revert。坏标记按 tree 生效,这期间基于坏 main 的新落地会生成新 tree,部署时会再踩一次。
改进:回滚事件让通道在下一个落地开始前优先 revert(已有 beforeEachLanding 钩子,scripts/land.ts:135);正在跑的落地快进前检查基线是否已被标坏;坏标记扩展为"包含该落地 commit 的任何 tree 都不部署,直到 revert 进 main"。
5.7 回滚只回滚程序,迁移得保证旧版本能活。
证据:2.3;唯一一次回滚走的是"迁移还没执行"的路径。预演第 6 步已经会在迁移后的 schema 上启动当前 release 跑一个对话,但只观察、不拦截(src/rehearsal/run.ts:338-350)。
改进:把 expand/contract 作为迁移规则写下来(先加后用、先停用后删除,破坏性的一步单独发布);把第 6 步的兼容性结论升级为门:旧版本不兼容新 schema 的候选不得自动上线;回滚前也校验一次旧版本能否在当前 schema 上启动,不能就停在"需要人",而不是盲切。
5.8 热加载变更不受这套保证覆盖。 证据:1 节末、3.4。提示词、技能、技能工具代码、配置都能改变 agent 的行为,却不经过预演和试用期;工具代码"加载失败保留旧版本"只防语法级失败。 改进:单独写一页热加载变更的安全说明:哪些东西有什么校验(例如热文档写入后跑全量测试的同一规则再提交)、哪些什么都没有、出错靠什么发现和撤回。没有 git 历史可撤的热配置,至少要能回到上一版本。
5.9 预演的外部副作用边界靠黑名单维持,而且没人检查捕获文件。
证据:2.4。fetch 兜底是 4 个主机的黑名单;不经 fetch 的网络调用、启动时的外部 MCP 连接、trace 导出不在收口范围;捕获文件只计数。
改进:网络兜底改成白名单(LLM 提供方、本机回环、PG、网关),其余全拒并记录;预演结束时断言捕获文件里只有预期种类的条目;把"新增对外通道必须声明预演行为"写进代码规范,并加一个扫描测试。
5.10 否决确认门的理由,没有当年的数据作对照。 证据:确认门时期(2026-09-06 至 10-01)确认卡被拒绝过几次、拒绝时拦下了什么:本文无数据。3.1 是论证,不是测量。自动修复类落地的方向由 agent 分诊自行决定(3.1)。 改进:从当时的交互记录回溯批准、拒绝、超时次数和拒绝理由;查不到就保留"论证而非证据"的标注。含破坏性迁移的候选是否另设一道门、自动修复的方向边界划在哪里,这两件是方向问题,交给所有者拍板,本文不替他回答。
5.11 预演的覆盖面没有一张总表。
证据:预演只用轻量档模型、只允许白名单工具、不连渠道、不跑定时任务(skills/rehearsal/SKILL.md:32-33),远程工具执行进程只做只读握手。每个取舍都有理由,但哪些生产入口被演练过、哪些没有,没有一处集中记录。
改进:在 skills/rehearsal/SKILL.md 加一张"生产入口 × 预演是否覆盖"矩阵,每个"未覆盖"标明由哪一层兜底(试用期 / CI / 无)。
5.12 成本账不完整。
证据:预演耗时只有 1 个样本;等待空闲最长约 75 分钟(部署记录);曾有一次落地在 restarting 状态停留 54 分钟(信号 9fe0b70c);后台任务自己的回合会挡住自己的部署(信号 96f79655);停机时长没有系统统计。
改进:每次预演的总耗时与分步耗时写入部署记录(回执 15 分钟就过期);每次部署记录"落地到运行"的端到端时长,以及 drain 到新进程就绪的真实停机时长。
5.13 CI 失败没有分类,“flaky"标签可能放走真缺陷。
证据:30 次测试失败未区分真实缺陷、flaky 与环境(如信号 044cb583);另 11 次靠重跑通过、记为 flaky(如信号 e64a627e、86b9c98b)。用"CI 拦下 30 次"对比"预演 0 次”,口径不对等。
改进:落地失败加分类字段(代码缺陷 / flaky / 环境 / 合并),由修复它的那次提交回填;重跑通过的测试,在 flaky 信号分诊时必须给出"是测试的问题还是代码的问题"的结论,而不是默认前者。
6. 外部质询⌗
质询方:GPT-6 Astra(OpenAI 家族,非本文作者所在的模型家族),panel 模式。它只看到一份脱敏场景:系统形态、设计描述、A–E 五条主张和证据摘要。它没有看到源码、本对话上下文,也没有任何身份信息。以下按"批评 → 回应"逐条记录,正文已按回应修改。
154/155 次晋升不能写成正确率;各组记录窗口和单位不同,不能拼成漏斗。 → 接受。第 4 节总账加注了口径,并单独说明晋升 ≠ 功能正确。
主张 A 混淆了"技术上能否启动 / 续跑"和"改动值不值得做、后果能否接受";确认者看不到运行时状态,不等于不能判断方向和不可逆后果;文中的"例外"其实是自动上线的适用边界。 → 部分接受。前半句成立,3.1 已拆成两种职能,例外改写为适用边界。但我反驳"部署时的确认卡承担了方向判断"这一隐含前提:在这个系统里,方向在写代码之前就通过决策队列定了,部署时的确认卡只附前置检查报告,从未承载"值不值得做"的信息。主张 A 因此改写为"在所有者事先接受的改动范围内,用自动验证和恢复替代逐次运行时确认;方向和不可逆后果在上游单独决策"。同时如实补上了这个反驳自己的缺口:自动修复类落地的方向是 agent 分诊决定的(5.10)。
B 的问题不在 CI 这种形式,而在"只验证源码与局部逻辑 ≠ 验证生产状态到候选状态的转换"。 → 接受。3.2 已按此改写。
C 应收窄为"按用户 / 流量分流的传统灰度不适用";按时间窗口试运行也是 canary,5 分钟试用期就是一种,只是指标很窄。 → 接受。2.3、3.3 已改。
最重要的漏洞:预演验证的是"候选自己构造的状态",不是"生产将要发生的转换";迁移事故里最高版本一致而迁移身份 / 应用历史不一致,是状态建模错误而非覆盖不足;复制最近几个会话只是有限样本。 → 接受,这是整篇最有价值的一条。我核对了它当时看不到的补丁:commit
a7215960让迁移计划对齐了生产账本,但被启动的库仍是重建的。所以 5.2 改成"预演库以生产实际已应用集合为起点做升级",并写明样本有限。失败要分类:输入漂移 = 依据过期,应重新验收;预演设施故障 = 无结论,应修预演;候选验收失败才撤销候选;生产启动失败才回滚。“不放行"和"判有罪"是两件事。另:重跑通过只证明重跑通过,不证明是测试不稳定。 → 接受。四行表放进 5.1(合并了原先两条改进点);flaky 的问题并入 5.13。
回滚的边界:切回旧 release 不回滚数据库、外部动作和热加载状态,这次成功只证明了"迁移器拒绝启动"这一条路径;“核心代码唯一路径"不等于"agent 行为变更唯一路径”。 → 接受。2.3 写明边界,新增 5.7(expand/contract、兼容性结论升级为门、回滚前校验)和 5.8(热加载变更的安全说明)。补一条它不知道的事实:预演里已有"当前版本在迁移后 schema 上启动"的检查,只是只观察、不拦截。
预演用生产密钥、跑真实 LLM 和工具,其外部副作用边界在材料里是空白。 → 接受并追查。查源码的结果写成了 2.4:已知的对外通道都已收口;真实触达的是 LLM 提供方、PG 服务器、生产网关的只读握手、启动时的外部 MCP 工具清单同步和 trace 导出;黑名单之外与不经
fetch的调用标为"未知”。改进列为 5.9。79 秒不是停机时间;60 秒预演只有 1 个样本。 → 接受。2.2 已改措辞,停机时长无系统统计也列进了 5.12。
建议的结论力度:合理的工程选择,但"合理"与"已验证有效"要分开写。 → 接受,见第 7 节。
7. 结论⌗
对一个单机、单所有者、经常无人值守、自己修改自己的 agent,用"自动部署验收 + 启动失败自动回滚"替代逐次的人工运行时确认,是一个合理的工程选择。前提是方向与不可逆后果在上游由所有者决定,而部署时的确认本来也不承担这件事。
已经被记录证明的只有两件:这套流程能持续运转(18 天里 155 次 release 切换,其中 154 次晋升);自动回滚恢复过一次真实的启动失败。
还没被证明的:预演门拦下过真实的候选缺陷(记录里一次都没有,反而制造了 3 次无辜 revert);取消人工确认的净收益(没有对照数据)。
它的保证不覆盖:一般的功能回归、不可逆的数据与外部副作用、绕过这条路径的热加载变更。预演本身还有两个已知的结构问题:它验证的是重建出来的状态,而不是生产将要发生的转换;它分不清"候选坏了"和"自己坏了"。
写到这里,最该改的不是"两层组合"这个方向,而是第 5 节的前两条:先让预演别冤枉好代码,再让它验证对的东西。