跳到正文
原文
DEV Community · Claude Code· yureki_lab·· 1天前精选AI 评分82

我如何用 Git 检查点撤销 AI 编码智能体的任何改动

原文标题:How I Built Git Checkpoints So I Can Undo Anything My AI Coding Agent Does

AI 导读

作者为无人值守运行的编码智能体搭了一套基于隐藏 git ref 的检查点机制,每个任务开始前对整个工作树(含未跟踪文件)做快照,验证失败时自动回滚,恢复操作本身也可撤销。

推荐理由

作者用约 40 行 shell 把 git 检查点做成可回滚基础设施,给出了无人值守跑编码智能体时的具体做法和边界。

正文 · AI 翻译

太长不看

我的全自动实现系统能在我睡觉时跑编码任务,而很长一段时间里,我唯一的“撤销”手段就是git reset --hard加祈祷。后来我换成了把每个任务的检查点存到隐藏的 git ref 上:每个任务开始前对整个工作树(包括未跟踪文件)做一次快照,验证失败就自动回滚,恢复命令本身也能被撤销。下面是干重活的约40行 shell,以及让我重新思考智能体安全性的五条经验。🚀

问题

我跑着一个编排模块,夜里把任务分发给多个并行执行的实现智能体。大多数早晨都挺好,但有一个早晨不是。

队列里有七个任务。第3个任务是“清理配置加载器”。智能体的清理在测试上确实通过了,但它悄悄改了默认值的解析方式。第4到第7个任务又建立在这个改动之上。等我打开笔记本时,已经有四个任务看起来都挺合理,却全都压在一个错误的地基上。

我的选择都很糟:

  • ❌ git reset --hard 回到运行前:会丢掉第1–2个任务(没问题)和第4–7个任务(大部分没问题)
  • ❌ 手动拆解 diff:那次“清理”已经散落在后续任务改过的同一批文件里
  • ❌ 让智能体“撤销第3个任务”:它倒是很乐意产出一个新的改动来近似旧行为,但这跟撤销根本不是一回事

还有第二个更隐蔽的问题。另一天晚上,一个智能体执行任务到一半失败了,下一个任务就从那个改了一半的工作树开始。那些半成品改动没提交到任何地方,就这么漏到了后面,结果第二个任务背了不是它造成的锅。

这两个故障的根因一样:我没有一种便宜、精确、机械化的方式回退。我之前搭的一切都在防止坏改动,没有一样是在逆转它们。

Claude Code 的交互式回退在我坐在键盘前时很好用,但我的智能体是在编排器下无头运行的(claude -p,Claude Code 2.x)。我需要的是把撤销做成基础设施,而不是按个键。

我是怎么解决的

设计有三条规则:

  1. 每个任务前先做快照——整个工作树,不只是已提交的状态
  2. 任务验证失败就自动回滚,不让任何东西漏到下一个任务
  3. 一个任务对应一个提交,通过验证后就能单独 revert 已落地的成果
flowchart TD
    A[Orchestrator picks task] --> B[Checkpoint: pre]
    B --> C[Agent implements]
    C --> D{Verification passes?}
    D -- yes --> E[Squash into one commit with task ID]
    D -- no, retries left --> C
    D -- no, out of retries --> F[Restore pre checkpoint]
    F --> G[Mark task failed, attach diff of the attempt]
    E --> H[Next task starts from a clean tree]
    G --> H

检查点放在隐藏 ref 上,而不是分支上

我一开始用的是git stash。别这么干。Stash 是一个共享栈,多个智能体各自在自己的 worktree 里干活时,哪条记录是谁的就只能靠猜。

第二次我试着在工作分支上做真正的“WIP”提交。结果污染了历史,还让智能体犯迷糊——它们读到git log后,会把检查点提交当成有意义的上下文。

最后管用的做法是:不碰分支、也不碰真实索引,直接构建快照提交,然后挂到自定义的 ref 命名空间下。普通的git log和git branch根本看不到它,但它是一个真实的 commit 对象,只要 ref 还在,git 就不会回收它。

checkpoint() {
  local task_id="$1" label="$2"
  local tmp_index; tmp_index="$(mktemp -u)"

  # Work on a copy of the index so the agent's staging area is untouched
  cp "$(git rev-parse --git-dir)/index" "$tmp_index"
  GIT_INDEX_FILE="$tmp_index" git add -A

  local tree commit
  tree="$(GIT_INDEX_FILE="$tmp_index" git write-tree)"
  commit="$(git commit-tree "$tree" -p HEAD -m "checkpoint: $task_id ($label)")"

  git update-ref "refs/checkpoints/$task_id/$label" "$commit"
  rm -f "$tmp_index"
}

诀窍在GIT_INDEX_FILE。用临时索引执行git add -A,能把修改过的和未跟踪的文件都收进一棵树,再用commit-tree把这棵树包成一个提交,父提交就是当前的HEAD。分支指针始终不动,真实索引也一直不变。从智能体的角度看,什么都没发生。

在中等规模的仓库里,这个操作通常用不到一秒,因为 Git 只会为真正发生变更的文件写入 blob。

恢复是精确的,而且可以撤销

restore() {
  local task_id="$1" ref="refs/checkpoints/$1/pre"
  git rev-parse --verify --quiet "$ref" >/dev/null \
    || { echo "no checkpoint for $task_id" >&2; return 1; }

  # Undo must be undoable: snapshot the current mess first
  checkpoint "$task_id" "before-restore-$(date +%s)"

  git add -A                                            # make new files visible to git
  git restore --source="$ref" --staged --worktree -- .  # tree now matches the checkpoint
  git reset -q                                          # unstage; leave the files alone
}

真正干活的只有三行:

  • git add -A 会暂存所有内容,这样代理在失败尝试期间创建的文件也能被 Git 记录到
  • git restore --source=<ref> --staged --worktree 让工作树与检查点完全一致:改动被还原、删除的文件恢复、新建的文件消失
  • git reset -q 把索引恢复到 HEAD,这样任务开始前未跟踪的文件又回到未跟踪状态

在做这些之前,restore 会先给自己创建一个检查点。第一次我回滚了错误的任务 ID 时,就是这一行救了我一个下午。

接入编排器

编排器的循环刻意设计得很无聊:

run_task() {
  local task_id="$1"
  checkpoint "$task_id" pre

  for attempt in 1 2 3; do
    run_agent "$task_id" "$attempt"
    if verify "$task_id"; then
      git add -A
      git commit -q -m "$(task_title "$task_id")" -m "Task-Id: $task_id"
      return 0
    fi
  done

  # Keep the evidence, then go back to a clean slate
  checkpoint "$task_id" failed
  git diff "refs/checkpoints/$task_id/pre" "refs/checkpoints/$task_id/failed" \
    > "reports/$task_id.failed.diff"
  restore "$task_id"
  mark_failed "$task_id"
}

这里有两个细节值得注意。

失败的尝试会被保留,而不是被销毁。failed 检查点和保存下来的 diff 意味着我可以准确查看代理到底尝试了什么,甚至手动从那里继续。回滚工作树并不等于丢弃信息。

每个成功落地的任务都带一个 Task-Id trailer,并且是单个提交。这正是解决我最初七次任务灾难的关键。现在找到并回滚某个任务已经变成机械操作:

git revert "$(git log --format=%H --grep="Task-Id: task-003" -1)"

如果回滚与后续任务冲突,至少冲突是真实且局部的,而不是让我在凌晨 7 点去逆向解析一团模糊的 diff。

日常维护

检查点引用会让对象永远存活,所以我用每晚的定时任务清理超过 14 天的记录:

cutoff=$(( $(date +%s) - 14 * 86400 ))
git for-each-ref --format='%(refname) %(committerdate:unix)' refs/checkpoints |
while read -r ref ts; do
  [ "$ts" -lt "$cutoff" ] && git update-ref -d "$ref"
done

自定义引用默认也不会被推送,这正是我想要的。它们只是本地的脚手架,不是历史。

检查点覆盖不到的地方 ⚠️

我故意把一个临时仓库搞乱来测试,问题立刻暴露出来:被 gitignore 的文件不会被恢复。git add -A 从设计上就会跳过它们。这意味着 .env 文件、本地 SQLite 数据库和构建缓存都不在保护范围内。

所以我把状态分成三类:

状态 撤销机制
已跟踪和未跟踪的源文件 Git 检查点(如上)
被忽略但重要的本地状态(开发数据库、环境变量文件) 任务开始前把文件直接拷贝到该任务的快照目录
任何离开本机的东西(部署、邮件、第三方 API 写入、已推送的提交) 不存在撤销——需要事先人工批准

第三行才是关键。检查点系统会让人觉得自己无所不能,而恰恰在这种时候,代理会对着共享数据库跑迁移。

经验总结

1. 廉价的撤销胜过完美的预防。
我花了好几个月给代理加护栏,防止它做出糟糕的改动。护栏值得有,但它是概率性的;撤销是确定性的。当回滚成本降到一条命令,我就能放宽几条拖慢代理的规则,因为最坏情况从“浪费一上午”变成了“浪费一个任务”。

2. 快照工作树,而不只是提交。
“它在 Git 里”只对已提交的状态成立。代理活跃在未提交的区域:写了一半的文件、生成的 fixture、临时脚本。如果你的检查点忽略未跟踪文件,恢复后就会留下残渣,下一个任务又会继承它。

3. 永远不要让代理撤销自己的工作。
让 LLM 去“撤销”一个改动,等于让它写一个它自认为相反的新改动。有时候确实是。撤销应该是一个机械操作,整个过程中不调用任何模型。

4. 让可撤销性成为工作落地方式的一项属性。
一项任务、一次提交、一个机器可读的 ID。这在提交时没有任何成本,却决定了是 git revert 还是考古。如果我无法用一条命令撤销某项任务,那要么任务太大,要么落地流程有问题。

5. 在每项操作运行之前,按可撤销性给它分类。
真正有用的问题不是“这个操作危险吗?”,而是“我能把它撤回来吗?”文件编辑:可以,完全放开;本地数据库变更:也可以,但要配文件快照;外部副作用:不行,所以只能等人工介入。就靠这一条,我的权限规则比任何白名单都更简洁。

下一步

  • 任务中途的检查点。 目前我按任务打快照。对于较长的任务,我希望在每个验证步骤通过后都设一个检查点,这样一旦失败,就能从上一个正常状态继续,而不是从头再来。
  • 更智能的重试。 目前第 2 次尝试会直接从第 1 次尝试的 working tree 继续。我正在试验先恢复到 pre,再把失败的 diff 作为上下文传过去(“这里是没走通的部分”),对于那些第一步就走不通的任务来说,这看起来很有前景。
  • 从远程控制面板恢复。 用手机回滚一项任务应该只需一键,而“还原前”的检查点让这一键更安全。

总结

如果你让编码代理无人值守地运行,那么在搭第十道护栏之前,先把撤销按钮做出来。整套东西就是一个临时索引 commit-tree 和一个 ref 命名空间:基于原生 git 的 shell 脚本大约 40 行(我用的是 git 2.47,但这里不需要比 git restore 更新的版本,那个版本在 2.23 就有了)。

💡 本周试试看:把 checkpoint 函数加进启动你代理的脚本里,每次任务开始前运行它。你会忘了它的存在,直到某天早上真的需要它。

如果这篇文章对你有帮助,欢迎在 Dev.to 上关注我。我经常写当让 AI 代理自行上线代码时到底会出什么问题,以及我是怎么解决的。也欢迎在评论区聊聊:针对代理生成的代码变更,你的撤销策略是什么?👇

来源:DEV Community · Claude Code · dev.to