用云端 Claude Code Agent 搭一个每天自动找 bug、修 bug、开 PR 的 Loop

给自己的项目挂了个每天凌晨跑的云端 Agent:审计代码 → 挑一个最严重的 bug → 写复现测试 → 修 → 开 PR,我早上起来审一下就行。

跑了两轮,这东西说白了就是一个 Loop。

前言

手上有一套自己维护了很久的系统,Kotlin 的服务一堆,数据处理和统计分析的代码是 Python,都在一个仓库里。

这种项目最怕的不是崩。崩了至少有告警。

最怕的是静默错。数据少写了一截,单位换算错了一个倍数,统计门槛形同虚设——程序照常跑,日志一片祥和,结论全是错的。

前阵子在 k3s 里搭了一套云端 Agent:控制面把任务派给一个 runner Pod,runner 里跑 claude -p(Claude Code 的无头模式),模型走 DeepSeek。平时我在网页上发任务,它在隔离的工作区里干活,能实时看到它在想什么、调了什么工具。

既然能派任务,那就让它每天自己去仓库里翻 bug。

Loop 是怎么跑的

Claude Code 里有个 /loop,把一个任务循环着跑。我这个是一样的意思,只是周期长一点,一轮一天,中间还夹着一个我。

一圈下来是这样的:

1
2
3
4
5
6
7
  ┌──────────── 定时触发(每天凌晨 3 点)
▼
审计代码 → 选出一个最严重的 bug → 写复现测试(必须先红)
→ 最小修复(测试转绿) → 开 PR
→ 我审核 → 发布 → 合并到 main
│
└──► 下一轮基于新的 main 继续找

每一轮的产出都会改变下一轮看到的代码。上一轮修掉的地方,下一轮不会再当候选。一圈一圈下来,仓库里能被翻出来的 bug 越来越少。

定的规矩

最开始想过让它把找到的问题全列出来、一次全修掉。

想了想还是算了。

一次修十个,PR 一大坨,我根本没法认真审,最后要么全合要么全不合。而且大模型很爱报「可能存在的隐患」,这种东西一多,真问题就被淹了。

所以规矩定得很死:

  • 每天只交一个 PR,宁缺毋滥。
  • 必须说清楚「什么输入/时序 → 什么错误结果」,先写一个会失败的测试把它复现出来。失败原因得和 bug 对得上,不能是 ImportError 那种。
  • 风格问题、性能优化、重构都不算 bug。
  • 拿不准就别开 PR,把看过的范围列出来就结束。
  • PR 描述里必须附「其他候选」,按严重度排好。这个是给我排期用的,等于每天白送一份审计报告。

提示词

审计范围按我最在意的排了优先级:

1
2
3
4
5
6
7
8
9
10
11
12
**P0 核心链路**(错了后果最重的,最高优先)
- 看:数值精度与单位换算(不同量纲混用、取整方向)、重复提交/幂等键、
并发竞态、风控闸门/killswitch 被绕过或失效时默认放行、
测试环境与生产环境混淆、异常被吞后继续执行。

**P0 数据采集**
- 看:静默丢数(取数成功但零写入、异常被 catch 后返回成功)、时间戳与时区(UTC vs +08)、
去重键/幂等写入、缺口检测与回补游标前进条件……

**P0 数据分析 / 统计检验**
- 看:前视偏差(用到未来数据)、样本偏差(标的被剔除)、指标与标签错位、缺失值填充引入泄漏、
统计显著性/多重检验门槛计算错误、成本项漏算或符号反……

后面还补了两块:安全与权限(接口漏了权限校验、信任可伪造的身份头、第三方凭证被打进日志)、可靠性(告警被吞、定时任务重入、RPC 超时后服务端还在执行)。

光给范围不够,还得告诉它怎么排「最严重」:

  1. 会造成数据损坏或错误执行 / 风控失效
  2. 密钥泄漏、鉴权绕过
  3. 数据被静默写错或永久丢失
  4. 研究结论被系统性带偏
  5. 服务崩溃、任务卡死、告警失灵

同级里挑触发概率高、影响面大、修复又小又确定的。

PR 是怎么开的

Agent 自己没有 push 权限。它只有一个 MCP 工具 open_pull_request:把本地 commit 交给控制面,控制面替它推到一个机器账号的 fork 上,再往主仓库开 PR。

这个机器账号在 Gitea 上是受限用户:

  • 合并不了 PR
  • 评论 /publish 触发的 Actions 会卡在「待审批」,发布不了
  • diff 里改 .gitea/ 下的 workflow 直接拒绝
  • diff 里出现模板环境变量的值(6 位以上)也直接拒绝,防止它把密钥提交进去

所以 Loop 中间那个「人」是故意留的闸门。Agent 能做的最坏的事,就是开一个烂 PR。

踩坑

本机无 java

第一轮跑完,PR 描述里的「其他候选」第一条是一个 Kotlin 服务里的回补游标问题,排名比它最后修的那个还高。但它写了一句:

本机无 java、无 gradle 缓存,无法跑 Kotlin 测试来「先复现再修」,故放弃。

我明明在 setup 脚本里装了 JDK……

翻了一遍 runner 的代码,问题有三层:

  1. runner 镜像只有 JRE(runner 自己是个 Java 服务)。给 agent 的 PATH 是写死的 $HOME/.local/bin:/usr/local/bin:/usr/bin:/bin,JRE 那个目录不在里面。setup 里我是临时 JAVA_HOME=... ./gradlew 跑的,所以 setup 看起来一切正常。
  2. 我往 $HOME/.gradle/gradle.properties 写了 org.gradle.java.home,以为能让 Gradle 找到 JDK,结果目录大小一直是 4K。后来才想明白:Gradle 的用户目录取的是 JVM 的 user.home,也就是 passwd 里那个用户的家目录 /home/node,不是环境变量 $HOME。每个 Run 的 $HOME 是 runner 单独指过去的,Gradle 压根不看。
  3. setup 里 export 的环境变量,传不到后面 agent 的进程里。

最后的解法:JDK 软链进 $HOME/.local/bin(这个在 PATH 上),JAVA_HOME 走 Claude Code 的用户设置注入。runner 给每个 Run 设了 CLAUDE_CONFIG_DIR=$HOME/.claude,那 $HOME/.claude/settings.json 里的 env 就会作用到 agent 跑的每条命令上。

setup 脚本关键部分(这个 JDK 后来挪进了共享目录,第二轮起不用重下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
set -euo pipefail
JDK="$AGENT_SHARED_DIR/cache/jdk21"
if [ ! -x "$JDK/bin/javac" ]; then
mkdir -p "$JDK"
curl -fsSL --retry 3 --max-time 300 \
https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse \
| tar -xz -C "$JDK" --strip-components=1
fi
# 链进 PATH 上的目录
mkdir -p "$HOME/.local/bin"
for b in "$JDK"/bin/*; do ln -sf "$b" "$HOME/.local/bin/$(basename "$b")"; done

# setup 的环境变量传不到 agent,走 Claude Code 用户设置的 env
python3 - "$HOME/.claude/settings.json" "$JDK" <<'PY'
import json, os, sys
path, jdk = sys.argv[1], sys.argv[2]
os.makedirs(os.path.dirname(path), exist_ok=True)
cfg = json.load(open(path)) if os.path.exists(path) else {}
cfg.setdefault('env', {}).update({'JAVA_HOME': jdk, 'SKIP_INTEGRATION_TESTS': 'true'})
json.dump(cfg, open(path, 'w'), indent=2)
PY

私有 Maven 拉不到

项目的内部依赖平时从私有 Maven 仓库拉,runner 的网络策略访问不到。好在项目本来就支持「源码模式」,setup 里把所有模块切成源码依赖就行。

中文测试类名会炸

手动 kubectl exec 进去跑测试,没带 LANG=C.UTF-8,Kotlin 编译器写 class 文件直接报 Malformed input or input contains unmappable characters。agent 真实环境里是带了的,虚惊一场。

pkill 把自己杀了

杀 Kotlin 编译守护进程的时候顺手写了个 pkill -f KotlinCompileDaemon,把自己的 shell 也杀了(命令行里也有这个字符串)。这个坑我已经踩过不止一次了。

模型和时段

一开始我给它配的是 DeepSeek 的 pro,想着找 bug 是重活。结果被纠正:现在 deepseek-v4-flash 这个名字背后已经是更新的 V4.1-Flash,又强又便宜,单价只有 pro 的三分之一到四分之一。

DeepSeek 现在分峰谷计价,工作日北京时间 09:00–12:00、14:00–18:00 是高峰,价格翻倍。cron 定在北京时间凌晨 3 点(cron 按 UTC 写是 0 19 * * *),单轮最多跑 3 小时,怎么都碰不到高峰。

面板上的花费是假的

第一轮跑完,面板上显示花了 73 美元,吓一跳。

后来发现这个数是 Claude Code CLI 自己按 Claude 的价格估出来的,它不认识 DeepSeek。按 DeepSeek 的实际价格算,输入 46 万、缓存命中 1600 万、输出 9.7 万 token,大概 0.2 美元。

盯着它干活,发现了我自己的 bug

跑的过程中我一直开着网页看它实时输出,顺手发现了几个前端问题:对话区外面多一条滚动条、Tokens 一直是 0、长提示词没渲染 Markdown……

最离谱的一个是头部的运行 ID 显示成了 …356200,真实的是 …356225。前一天刚给对话区加了 SSE 实时推送,帧数据我直接用了 JSON.parse。后端主键是 19 位的雪花 ID,超出了 JS 的安全整数范围,解析出来就丢了精度,还把页面缓存里正确的那份覆盖掉了。项目里其实早就有一个专门处理大整数的解析函数,我新写的代码没用它。

后果不只是显示错:推送期间点「停止」,带的是错的 ID,停不掉。

Agent 还没给我找 bug,我先给自己找了一个。

第一轮找到了什么

第一轮跑了 28 分钟,交上来的是一个统计口径的问题。

评估流水线里有一道多重检验校正的显著性门槛,候选要从「初步评估」升级到「正式采用」,就靠这一道统计闸门。

门槛的公式要求输入逐观测(每个采样点)的统计量,代码里喂进去的是年化后的值。小时级序列的年化倍数是 √(365×24) ≈ 93.6,日级是 √365 ≈ 19.1。z 值被放大几十倍,p 值直接下溢到 0。

它在 PR 里给了一张表:

口径 p 值 门槛判定
正确(逐观测,1h) 0.1420 应该拒绝
现状(年化,1h) 7.70e-17 放行

说人话:这道门槛从来没拦下过任何东西,而且候选塞得越多越容易过。没有报错,没有异常日志,库里那一列照样是一个看着很正常的小数。

它还点出了一个到位的问题:原来唯一测到这段接线的测试,期望值也是用同一个年化后的值算出来的,镜子照镜子,自洽但不正确。修复后的测试里,年化因子在测试里独立算一遍,不从被测模块 import。

这个结论不难核对:这个统计量的方差项本来就是按观测数推的,喂年化值进去量纲就不对。机器账号的 PR 发布不了,我把它的修改重开成自己的 PR,发布、合并,前后十来分钟。

第二轮,翻车了

当天下午又跑了一轮,交上来的还是同一个问题。

时间线一对就明白了:它 clone 代码的时候,我重开的那个 PR 还差一分钟才合进 main,它看到的代码里这个 bug 还在。

本来指望稳定的分支名能兜住,结果两轮起的名字不一样,一个偏 units,一个偏 annualized。让模型给同一个问题起同一个名字,本来就不靠谱。

正式跑之后间隔是一天,这种撞车会少很多。但只要我有一天没审 PR,第二天它大概率还会再交一份。

给 Loop 加上跨轮记忆

分支名去重靠不住,根子还是每轮都是新会话,没有记忆。

当天就给模板加了一个跨轮共享目录。跟 Run 的工作区不一样,它按模板持久,跑完不清。

现在的流程:

  • 开局先读 findings.md 台账和 coverage.md 覆盖表(没有就按固定格式建)
  • 台账里状态是 已开PR / 已修复 / 不修 / 误报 的条目直接跳过,候选 的优先复核
  • 结束用 flock "$AGENT_SHARED_DIR/.lock" 把这一轮的结论追加回去

台账格式写死在提示词里:

1
- [编号] 标题 | 严重度 | 模块 | 状态 | PR | 首次/最近发现 | 备注

顺带还解决了一件事:以前我关掉某个 PR,是它修错了还是不值得修,这个判断只在我脑子里。现在面板上把那条改成 不修 或者 误报,下一轮它就真的不报了。

改完手动跑了一轮验证,开局它就把两个文件建了出来,JDK 也直接从共享目录里吃,那 300 多 MB 不用重下了。

后面就是每天跑,接力往前推。