← 返回博客2026-08-15

别把二阶 pin 留给人记

一个发布脚本正确刷新了公开源码 URL,却没有告诉 agent 同提交更新 CI 成本守卫里的 SHA-256 pin。真正的修复不是再写一条提醒,而是让产生变更的工具输出下游 pin,并把旧断言反转成拒绝危险回归的测试。

问题

agent 工作流最常见的失败方式很朴素:不是 agent 不听话,而是你给它的指令本来就少了一步。

这次是一个发布辅助脚本的问题。OSS sync 脚本会发布并验证公开源码 tag,然后改生产 compose 文件里的 POSTFLOP_SOLVER_SOURCE_URL,让它指向本次部署 solver 对应的不可变源码 commit。可这个 compose 文件本身又被 GitHub Actions 的成本守卫用 SHA-256 pin 住了。URL 一变,文件 hash 也必然变。

脚本结尾的 Next: 清单列了 sync 后要提交哪些文件,却没列成本守卫里的 pin。于是 agent 完全照着清单做,也能通过本地 gate,最后在 hosted CI 的 GitHub Actions cost policy 步骤失败。标准生产发布的 admission 一旦预留确实不会退还,但这次并没有走到那一步:run 31818321915 是 hotfix dispatch,而且在 Reserve production admission 之前就失败了,所以消耗的是 hosted runner 时间,不是 standard 每日名额。

可复用的教训很窄,但很关键:当一个工具会改动另一个守卫按内容 pin 住的文件时,这个工具必须自己输出下游 pin 的更新。不要把这个二阶改动留给记忆、reviewer 口口相传,或者另一份文档。

机制

内容 pin 好用,正是因为它故意很笨。regex 可以争辩;review 过的 digest 要么匹配,要么不匹配。我们的 hosted-cost policy 会 pin 住允许进入 hosted runner 的 workflow 和本地文件;只要出现没 review 过的 payload,就 fail closed。

HOSTED_LOCAL_FILE_SHA256=(
  'scripts/tests/db-observability-guard.sh|...'
  'deploy/docker-compose.prod.yml|...'
)

对于昂贵的 hosted runner 和生产发布路径,这种做法是合理的。它能防止一个小小的 YAML 写法变化、helper 改写,或者 build context 扩大,悄悄把新工作塞进 CI。

但内容 pin 会制造第二个必须同步移动的对象。这次 oss-sync.sh 明明知道自己何时改了 deploy/docker-compose.prod.yml,也能直接读到改完后的本地字节。让后面的 agent 再去记「还要 repin cost guard」,就是薄弱点。

改了什么

修复把指令挪回了生产者。源码 tag 验证完成、compose 文件重写之后,脚本现在会直接打印应该写进成本守卫的下一个 SHA-256:

2. REPIN in the same commit: set the deploy/docker-compose.prod.yml SHA-256 in
   scripts/tests/github-actions-cost-guard.sh to $(shasum -a 256 deploy/docker-compose.prod.yml | awk '{print $1}')

改动很小,但工作流的形状变了。agent 不再需要推断「source URL 变了」和「hosted-cost manifest 也要变」之间的隐藏依赖。制造第一处改动的命令,现在把第二处改动作为自己的回执一起吐出来。

随后成本守卫里的 compose pin 被更新到 review 过的文件字节。同一个补丁还更新了 scripts/tests/db-observability-guard.sh 的 digest,因为那个 guard 自己也变了。

不要只删掉过时断言

这个补丁的后半段,才是我会复制到任何 agent-heavy 代码库里的做法。数据库可观测性 guard 以前要求 0065 down migration 里必须有 DROP EXTENSION IF EXISTS pg_stat_statements。这个反向操作是错的:生产环境通过 shared_preload_libraries 预加载 pg_stat_statements,而 up migration 写的是 CREATE EXTENSION IF NOT EXISTS。回滚时如果 drop,它可能删掉一个本 migration 根本没创建的扩展。

偷懒的修法,是把旧断言删掉。更稳的修法,是把它反转:

if grep -Fq 'DROP EXTENSION IF EXISTS pg_stat_statements' "$down"; then
  echo "0065's down migration must NOT drop pg_stat_statements" >&2
  exit 1
fi

这样,昨天的错误要求变成了明天的回归测试。这对 agent 尤其重要:未来某次修复可能会按「up 有 CREATE EXTENSION,down 就该对称 drop」去 grep,然后把危险步骤「补回去」。反转后的检查会让这个看起来很顺手的改动立刻失败。

可以照抄的模式

凡是生成或半生成的发布产物,都把三件事放近一点:

问题应该放哪缺了会怎样
工具改了哪个文件?生产者自己的输出agent 只提交了半套文件
谁按内容 pin 或 review 这个文件?同一段输出里,点名下游文件本地证明绿,hosted 证明红
哪种旧形状绝不能回来?反转后的 guard,而不是删掉的 prose后续「清理」把 bug 带回来

重点不是让 agent 更聪明。重点是让工作流少依赖聪明。如果工具能算出下一个 hash,就把 hash 打出来。如果旧检查错了,而旧形状本身危险,就把它写成 forbidden shape。如果 hosted guard 会消耗时间或发布容量,就让它在平台允许的最早位置失败。

总结

  1. 内容 pin 是契约。只有每个会改变被 pin 内容的生产者都知道怎么移动 pin,它才真的可靠。
  2. 回执胜过提醒。字节变化的那一刻,就在脚本输出里给出 repin 命令。
  3. 反转坏断言。旧检查过时且危险时,不要直接删除;把那种旧形状明确列为禁止。
  4. 按字面 agent 设计。一个只有在「有人记得隐藏第二步」时才会成功的流程,还不能算自动化。