Agent 回滚不彻底:按域回滚,别只靠 git reset

Agent 报告「已回滚」,但 migration、`dist/` bundle、Stripe webhook、PostHog flag 还活着。这是 6 个域的 checklist 和把活儿真正干完的逆向命令。

你让 agent 把过去一小时的活儿撤了。它跑了 git reset --hard HEAD~3,报告「已回滚」。代码确实回来了,但是:数据库还多着 20 分钟前 migration 加的那个列;dist/ 里还是旧 bundle、Vercel 已经部署上线了;Stripe webhook URL 在 dashboard 里被改过、现在指向一个已经不存在的路径;PostHog 里 BILLING_V2_ENABLED 还开着。

一句话先给最快的正确做法: 别信一条 git 命令。先跑 git revert HEAD~3..HEAD --no-edit(注意:HEAD~3..HEAD 回退最近 3 个 commit,HEAD~2..HEAD 只回 2 个——见 Step 2),然后按这个顺序走下面 6 个域的 checklist:数据库 migration、构建产物 + 部署、第三方配置、环境变量 / flag、缓存。每个域都核一遍,别假设 revert 已经覆盖了它。

「回滚」只撤 git 里 tracked 的东西。其他一切(生成产物、DB 状态、第三方配置、环境变量、feature flag、缓存)各有自己的生命周期。完整回滚是一份按域 checklist,不是一条 git 命令。下面是 agent 通常漏掉的 6 个域、精确的逆向命令,以及能 catch 它们的 prompt。

你属于哪一类?

如果症状是「代码回了,但 app 跑起来还是新版的行为」,差异就出在下面某一层。先诊断,再动手。

回滚后的症状大概率没回的域最快的确认方式
App 读/写一个老代码不认识的列,query 报错数据库 migration 还应用着prisma migrate status 显示该 migration 已应用
浏览器看新行为,硬刷新或 curl 看老的构建产物或 CDN/边缘缓存过期ls -la dist/ 文件比回滚后的 commit 还新
外部集成(Stripe/OAuth/DNS)call 一个 404 路径第三方配置没回vendor dashboard 里的 webhook/redirect URL ≠ 回滚后代码
老代码启动崩,或忽略某个设置env 变量 / feature flag 还设着vercel env ls 有个变量在回滚后的 .env.example 里没有
同样的代码,部分用户看新数据、部分看老的CDN / Redis / 浏览器缓存curl(冷)和普通浏览器结果不一致

常见原因

按命中率从高到低:

1. Agent 只回了 git tracked 的代码

git reset --hard 回代码,其他都不动。Agent 自信地说”已回滚”——它眼里的”状态”是 repo,不是系统。

如何判断:回滚完磁盘上代码是目标 commit,但跑起来表现还是新代码——某处的输出/状态 ≠ 代码。

2. DB migration 已应用且非幂等

新 feature 加了列,agent 跑了 migration。现在代码回了但列还在——老代码读/写它不认识的 schema 可能直接挂。

如何判断pnpm db:statusprisma migrate status 显示 migration 已应用。或老代码的 model 定义和在线 DB schema 不一致。

3. dist/.next/.svelte-kit/ 产物没重建

代码回了,但 dev server 还在服务缓存的编译产物——浏览器看到的是新行为。更糟:你的部署管道把 dist/ 推到了 CDN,CDN 还在服务它。

如何判断:代码 X、浏览器 Y。ls -la dist/ 文件比最新代码 commit 还新。

4. 第三方配置改了没回

新 feature 要改 Stripe webhook URL、Google OAuth redirect URI、Cloudflare DNS——这些完全在 repo 外。Agent 根本不知道。

如何判断:回滚后外部集成挂——因为它们在 call 已经不存在于回滚代码里的 URL。

5. 环境变量 / feature flag 还开着

NEW_FEATURE_ENABLED=true 还在 .env.production 或 PostHog/LaunchDarkly。新代码没了,flag 要么没用、要么被老代码读到崩——因为老代码不认识这个 flag。

如何判断vercel env ls(或你平台对应命令)有的变量在回滚后的 .env.example 里找不到。

6. 缓存(CDN / Redis / 浏览器)还有新响应

Cloudflare 缓存了新 API 响应 1 小时;Redis 缓存了新计算值 24 小时。代码回了,用户在 cache 过期前还是看新数据。

如何判断:硬刷新 / 换浏览器 / curl 看到老的,但普通浏览器看新的——cache 层是差异。

最短修复路径

按收益从高到低。Step 1 是框架,2-6 是按域回滚。

Step 1:列出原改动触达的每个域

回滚前先做”blast radius 清单”:

为最近 3 个 commit,列出受影响的每个域:
1. Git 代码:哪些文件
2. 构建产物:哪些 dist/build 目录
3. DB:哪些 migration 应用了、什么 schema 变化
4. 环境变量:哪个 env 加/改了什么
5. Feature flag:哪个工具里新建/翻转了什么
6. 第三方配置:Stripe/OAuth/DNS 等的具体改动
7. 缓存:CDN/Redis/浏览器哪层可能还存着新状态

每个域给出精确回滚动作 + 对应命令。

你立刻能看出哪些域得在 git revert 之外另外处理。

Step 2:用 git revert,不要 git reset --hard

git reset --hard 是破坏性的:任何已经 pull 或开了分支的人会丢活儿,而且你还得 force-push。用 git revert,它记录新的「撤销」commit 并能干净 push:

# 回退最近 3 个 commit,从最新往前。
# 区间坑:A..B 不含 A。所以 HEAD~3..HEAD 回的是 HEAD、HEAD~1、HEAD~2
# (3 个 commit)。HEAD~2..HEAD 只会回 2 个。数清楚。
git revert HEAD~3..HEAD --no-edit
git push origin HEAD

如果其中某个 commit 是 merge commit,加 -m 1 选主线父节点。想把这些 revert 合成一个 commit 而不是 3 个,用 git revert --no-commit HEAD~3..HEAD 再单独 git commit。然后部署回滚后的代码让生产跟上(Step 4)。

Step 3:回滚 DB migration

如果 migration 是非破坏性的(只加列/表),老代码通常还能在更宽的 schema 上正常跑——别动它,停止往新列写即可。如果是破坏性的(改名/删列、改类型),就需要显式 down migration。

Prisma 没有 prisma migrate down 命令。截至 2026 年 6 月,反向一个 migration 的官方做法是:把当前 schema 和 migration 历史做 diff、跑生成的 SQL,然后记录这次回滚让历史保持一致:

# 1. 生成反向 SQL(diff 改后的 schema -> migration 历史)
npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-migrations prisma/migrations \
  --script > down.sql

# 2. 应用它
npx prisma db execute --file ./down.sql --schema prisma/schema.prisma

# 3. 标记这个 migration 已回滚,让 `migrate status` 重新干净
npx prisma migrate resolve --rolled-back <migration_name>

其他栈:Drizzle 也不自动生成 down(drizzle-kit 只生成正向 migration),手写 undo SQL 文件应用即可。自带可逆能力的框架(Rails rails db:rollback、Knex knex migrate:rollback、Laravel php artisan migrate:rollback)能回退一个批次。

注意:down migration 回的是 schema,不是 数据。新代码在线期间写入的行不会被撤掉。如果 up migration 做过回填或数据转换,从 migration 之前的备份恢复往往比手写 down 更稳。

Step 4:重建并重部产物

# 本地
rm -rf dist .next .svelte-kit node_modules/.cache
pnpm install
pnpm build

# 推 —— `--force`(别名 -f)跳过 Vercel 的 build cache,
# 这样 revert 就不会从过期的缓存构建里被服务出来。
vercel deploy --prod --force
# CI 里则设环境变量:VERCEL_FORCE_NO_BUILD_CACHE=1
# 或直接把上一个好的 deployment 重新提为生产:vercel rollback

这里隐蔽的坑是 build cache:普通 vercel deploy 可能复用缓存的编译产物,源码明明回滚了,却悄悄又把新行为部上线。--force(或 VERCEL_FORCE_NO_BUILD_CACHE=1)才保证干净重建。如果你只想让生产回到上一个好版本、根本不重新构建,vercel rollback 能瞬间把上一个 deployment 重新提为生产。

然后清 CDN,别让边缘缓存继续服务新响应(见 Step 6)。

Step 5:回第三方配置和 env 变量

每个外部系统走一遍,照着 checklist:

- Stripe webhook URL:https://app.example.com/api/webhooks/billing(原 /api/v2/webhook)
- Google OAuth redirect:https://app.example.com/auth/callback
- DNS:api.example.com CNAME
- PostHog flag BILLING_V2_ENABLED:关
- Vercel env vars 新增:BILLING_V2_API_KEY 删
- Cron 调度:hourly billing-sweep 停

这些手动回滚是 agent 漏得最多的部分。团队用 Terraform/Pulumi 管这块的话,git revert + terraform apply 就够;否则得在 dashboard 里点。

Step 6:必要时强制清缓存

并没有 wrangler cache purge 这条命令(截至 2026 年 6 月,Wrangler 没有 CDN 清缓存的子命令)。请改用 REST API 清 Cloudflare 边缘缓存。优先用定向清除而不是 purge_everything,免得整个 zone 冷启动:

# 定向:只清受影响的 URL(最佳——最小化冷缓存)。
# 按 prefix 清单次上限 30 个 prefix。
curl -X POST "https://api.cloudflare.com/client/v4/zones/<zone_id>/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"prefixes":["app.example.com/api/v2/"]}'

# 兜底的粗暴做法:清整个 zone(每个资产都变冷)。
curl -X POST "https://api.cloudflare.com/client/v4/zones/<zone_id>/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

# Redis:删新代码写入的特定 key(生产别用 FLUSHALL)
redis-cli --scan --pattern "billing:v2:*" | xargs -r redis-cli del

# 浏览器:让用户硬刷新,或在 HTML 里 bump 资源 hash/版本号

面向用户、TTL 短(秒到分钟级)的 cache,等它自然过期通常就行。只有 TTL 长、或过期数据会造成伤害(价格错、集成挂)时才强制 invalidate。

怎么确认真的修好了

别凭「代码看着对」就宣布完成。把每个可能还脏的域逐个再核一遍:

  • 代码: git log --oneline -5 顶部是那些 revert commit;git status 干净。
  • 数据库: prisma migrate status(或你工具的 status)显示没有 pending 或 out-of-sync 的 migration,对受影响的表随手查一下返回的是老 schema。
  • 构建/部署: 绕过缓存的冷请求返回老行为:curl -sI https://app.example.com/<changed-path>(或加 -H "Cache-Control: no-cache");在 vercel ls 或 dashboard 里确认线上 deployment ID 是新的那个。
  • 第三方: 打开每个 vendor dashboard,确认 webhook URL、OAuth redirect、DNS 记录匹配回滚后的代码,而不是你移除的那个 feature。
  • Flag/env: vercel env ls 和你的 flag 工具(PostHog、LaunchDarkly)显示新 flag 已关、新 env 变量已删。
  • 缓存: 同一个请求在干净的浏览器 profile(或无痕)里现在返回老行为,和冷 curl 一致。

6 个域都对得上,回滚才算真正彻底。还有一个仍显示新行为,那就是你剩下的那个脏域。

常见问题

代码明明没回干净,agent 为什么说「已回滚」? 因为在 agent 眼里,「状态」就是 git 工作区。git reset --hard 让工作区匹配了目标 commit,从它的视角看活儿就干完了。它对你的数据库、CDN、vendor dashboard、env store 没有概念,连那些域都看不见,更别说回滚了。

git reset --hardgit revert 该用哪个? 已经 push 或共享过的,用 git revert:它加新 commit、能干净 push,谁的活儿都不丢。git reset --hard 只留给本地、还没 push 的 commit。如果 agent 已经 git reset --hard 并 force-push 了,先用 git reflog 把丢掉的 commit 找回来,再做别的。

我的 migration 只加了个列,需要回滚吗? 通常不用。加列这种(非破坏性)migration 让老代码在更宽的 schema 上照常跑。把列留着、停止往里写,之后用一个干净的 migration 再反向掉。只有当新列带了没有 default 的 NOT NULL 约束、或唯一索引,导致老代码的 insert 现在违约时,才需要立刻回滚。

回滚 + 重部之后,生产还在服务新行为,为啥? 几乎都是 build cache 或 CDN cache。用 vercel deploy --prod --force(或 VERCEL_FORCE_NO_BUILD_CACHE=1)重部以击穿 build cache,再用 Cloudflare 的 purge_cache API 清边缘缓存。用冷 curl 验证,别用有自己缓存的普通浏览器。

怎么避免再发生? 风险改动前,让 agent 先产出一份「blast radius」清单(Step 1),列出它会动的每个域和对应的逆向命令。第三方配置走 IaC(Terraform/Pulumi),这样回滚它就是一次 git 操作;风险发布用 feature flag 兜,秒级就能关掉。

预防建议

  • 任何跨域风险改动前先做”rollback inventory”——每个可能被动的域都快照
  • migration 做幂等 + 可逆——每个 up 都有测过的 down
  • 风险发布用 feature flag——秒级关 flag,不需要回代码
  • 第三方配置走 IaC(Terraform / Pulumi)——回滚 = git 操作
  • 每个 feature 写「回滚 runbook」——agent 和人都按它走
  • 不要把 git reset --hard "rolled back" 当完整答案——每个域再核一次

相关阅读

标签: #排查 #Claude Code #排查 #回滚