本教程教你用 claude -p 无头模式 + 会话续跑(--resume <session-id>)+ Git 工作树隔离,搭建一台断点可恢复、并发可控、成本可追踪的批量重构引擎。它能在你下班后跑遍几十个仓库,被 Ctrl-C 或断网打断后,重跑只接续未完成的任务,绝不重复劳动。适合运维工程师、平台/SRE、迁移负责人,以及任何要把 Claude Code 当"通宵工人"派活的开发者。
批量派 Claude 干活这件事,社区已经讲过很多:有人用 -p 流式协议配钩子造引擎,有人用子代理并行把一个任务拆开分头干。但真实通宵场景里,最大的敌人不是"怎么并发",而是这三件事:
state.json,jq 一抢就写花。本引擎针对这三点分别下药:用会话级 session_id 做断点、用每任务独立状态文件消除竞争、用结构化输出里的 total_cost_usd 自动累计成本。三者组合,是它与已有方案的根本区别。
--resume <session-id> 续跑无头会话、结构化输出等特性,建议直接装最新版)jq(解析 --output-format json)git ≥ 2.20(工作树支持)jobs -rp)ANTHROPIC_API_KEY,或已登录的 Claude 订阅账号claude -p、--output-format json、--allowedTools 有基本认知(零基础请先补"装通 Claude Code"入门篇)claude --version # 确认版本
jq --version
git --version
echo "$ANTHROPIC_API_KEY" | head -c 6 # 确认鉴权在位(只打印前6位)
| 机制 | 作用 | 在本引擎里的角色 |
|---|---|---|
claude -p "..." |
无头一次性执行 | 单个任务的执行器 |
--bare |
跳过自动加载 hooks/skills/plugins/MCP/CLAUDE.md | 可复现性:CI/任何机器结果一致,不被队友的 ~/.claude 污染 |
--output-format json |
返回含 session_id、total_cost_usd 的 JSON |
续跑句柄 + 成本采集 |
--json-schema |
强约束返回结构,落到 structured_output 字段 |
让 agent 自报"done/failed",机器可判 |
claude --resume <sid> -p |
按 session-id 续跑无头会话 | 断点续跑的核心 |
| Git worktree | 同一仓库的多个独立检出 | 每个任务一个隔离目录,并行不互相覆盖 |
CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS |
限制后台 agent 最长等待 | 防止卡死任务拖垮整批 |
关键事实:
claude -p创建的会话不会出现在交互式选择器里,但可以用claude --resume <session-id>续跑——前提是在那个会话最初启动的目录里运行(session-id 查找范围限定在当前项目目录及其 Git 工作树)。这正是本引擎"在工作树内启动、在工作树内续跑"的设计依据。
引擎读一个 JSONL 队列(每行一个任务),写每任务独立状态文件。
mkdir -p worktrees state logs
编辑 queue.jsonl(示例:把若干服务从 CommonJS 迁到 ESM):
{"id":"svc-a-esm","repo":"https://github.com/acme/svc-a.git","rule":"把所有 .js 从 CommonJS(require/module.exports)迁到 ESM(import/export),package.json 加 \"type\":\"module\",最后跑 npm test 验证全绿"}
{"id":"svc-b-pytest","repo":"https://github.com/acme/svc-b.git","rule":"把测试框架从 nose 迁到 pytest,修正全部兼容性问题并跑通"}
{"id":"svc-c-logfmt","repo":"https://github.com/acme/svc-c.git","rule":"把项目日志从 console.log 换成 pino 结构化日志,保留原有信息字段"}
为什么用 JSONL 而不是单个 JSON 数组? 因为队列会增长、会被流式追加(CI 里新仓库不断入队),逐行可 >> 追加,数组不行。
预期结果:ls 看到 worktrees state logs queue.jsonl 四个条目。
这是整台引擎的心脏。一个函数,两条路径:
SCHEMA='{"type":"object","properties":{"status":{"type":"string","enum":["done","failed"]},"summary":{"type":"string"}},"required":["status","summary"]}'
task_state() { cat "state/$1.json" 2>/dev/null || echo '{}'; }
ts_get() { task_state "$1" | jq -r --arg k "$2" '.[$k] // empty'; }
ts_set() { # id key value —— 原子写:写临时文件再 mv
jq --arg k "$2" --arg v "$3" '.[$k]=$v' "state/$1.json" > "state/$1.json.tmp" \
&& mv "state/$1.json.tmp" "state/$1.json"
}
run_task() {
local id="$1" repo="$2" rule="$3" wt="worktrees/$id" out sid status
# 2a. 首次执行才 clone;续跑复用已有工作树
[ -d "$wt/.git" ] || { git clone -q "$repo" "$wt" 2>>"logs/$id.err" \
|| { ts_set "$id" status clone-failed; return 1; }; }
local prev_sid; prev_sid=$(ts_get "$id" session_id)
if [ -n "$prev_sid" ] && [ "$prev_sid" != "none" ]; then
# 续跑分支:在原目录里 resume 那个未完成的会话
out=$(cd "$wt" && claude --resume "$prev_sid" -p \
"继续完成上一轮未跑完的重构任务,规则同前:$rule。完成后自报 done。" \
--bare --allowedTools "Read,Edit,Bash" \
--output-format json --json-schema "$SCHEMA" 2>&1)
else
# 启动分支:全新无头会话
out=$(cd "$wt" && claude -p "$rule" \
--bare --allowedTools "Read,Edit,Bash" \
--output-format json --json-schema "$SCHEMA" 2>&1)
fi
# 2b. 解析结构化结果,回写状态
sid=$(printf '%s' "$out" | jq -r '.session_id // empty' 2>/dev/null)
status=$(printf '%s' "$out" | jq -r '.structured_output.status // "running"' 2>/dev/null)
ts_set "$id" session_id "${sid:-none}"
ts_set "$id" status "$status"
ts_set "$id" cost "$(printf '%s' "$out" | jq -r '.total_cost_usd // 0' 2>/dev/null)"
ts_set "$id" attempts "$(( $(ts_get "$id" attempts) + 1 ))"
ts_set "$id" updated "$(date -u +%FT%TZ)"
printf '%s' "$out" > "logs/$id.jsonl" # 留底,方便排查
}
两条分支的判据就是 state/<id>.json 里有没有 session_id:
- 没有 → 全新启动,claude -p
- 有 → 续跑,claude --resume <sid> -p
为什么 cd "$wt" 必须在 claude 同一行? 因为 session-id 续跑要求"在原会话启动的目录里"。当初是在 worktrees/<id> 启动的,续跑就必须回到这里,否则报 No conversation found with session ID。
预期结果:手动调用 run_task svc-a-esm <repo> "<rule>" 后,cat state/svc-a-esm.json 能看到 session_id、status、cost、attempts 四个字段。
常见问题:
- status 一直是 running:说明这一轮没干完(agent 主动停下等输入,或被中断)。没关系——这正是续跑要接的活,下次重跑会自动 --resume。
- session_id 为空:多半是 claude 报错退出(鉴权/配额)。看 logs/<id>.jsonl 头部,out 里会带错误信息;这种任务下一轮会重新走"启动分支",不会卡死。
用 jobs -rp | wc -l 当信号量,控制同时在飞的任务数:
MAX_PARALLEL="${MAX_PARALLEL:-2}"
wait_for_slot() {
while [ "$(jobs -rp | wc -l)" -ge "$MAX_PARALLEL" ]; do sleep 0.5; done
}
while IFS= read -r line; do
id=$(printf '%s' "$line" | jq -r '.id')
repo=$(printf '%s' "$line" | jq -r '.repo')
rule=$(printf '%s' "$line" | jq -r '.rule')
# 幂等:已 done 的直接跳过,不重复消费额度
[ "$(ts_get "$id" status)" = "done" ] && { echo "skip (done): $id"; continue; }
wait_for_slot
echo "dispatch: $id"
run_task "$id" "$repo" "$rule" &
done < "$QUEUE"
wait # 等所有后台任务收尾
echo "本轮派发结束;未完成的请重跑脚本续接"
为什么并发写不冲突? 关键在 Step 2 的状态设计——每个任务只写自己的 state/<id>.json,互不触碰。N 个后台任务各写各的文件,零竞争。这是比"全局单状态文件 + 文件锁"干净得多的方案,也避免了 jq 并发写花。
MAX_PARALLEL 怎么定? 起步用 2。Claude Code 自身还有内部并发上限,外层并发太高会触发限流(429)。监控一轮后按吞吐和成本再调。
预期结果:跑完一轮,state/ 下每个任务一个 json;done 的下一轮跳过,其余的带 session_id 等续跑。
把上面三步合进一个 overnight.sh。它的"魔法"其实只有一条规则:状态文件是唯一事实来源,脚本是幂等的。
#!/usr/bin/env bash
set -uo pipefail
QUEUE="${QUEUE:-queue.jsonl}"
export MAX_PARALLEL="${MAX_PARALLEL:-2}"
mkdir -p worktrees state logs
# ...(粘贴 Step 2 的 SCHEMA + task_state/ts_get/ts_set/run_task)
# ...(粘贴 Step 3 的 wait_for_slot + 主循环)
# 收尾:汇总报告
echo "================ 批量重构汇总 ================"
for f in state/*.json; do
i=$(basename "$f" .json)
printf "%-18s %-10s \$%-8s attempts=%s\n" \
"$i" "$(jq -r '.status' "$f")" "$(jq -r '.cost//0' "$f")" "$(jq -r '.attempts//0' "$f")"
done
total=$(cat state/*.json | jq -s '[.[] | (.cost//0|tonumber)] | add // 0')
echo "----------------------------------------------"
echo "本轮总花费: \$$total (重跑只增不减已完成项)"
续跑演示——这就是本引擎存在的全部意义:
./overnight.sh # 跑到一半
# ... 6 小时后,机器重启 / 你按了 Ctrl-C / 网断了 ...
./overnight.sh # 立刻重跑
第二次运行时:
- status==done 的任务 → 跳过(不花一分钱、不重复改代码)
- status==running 且有 session_id 的 → --resume <sid> 接着干,上下文还在
- clone-failed 或无 session_id 的 → 重新启动
实测建议:先造一个注定跑不完的任务(比如规则里要求"改 1000 个文件"),跑 30 秒 Ctrl-C,再重跑,观察 attempts 自增、session_id 不变——这能直观验证续跑真的接上了同一根线程。
后台跑着,你总想瞄一眼进度。--output-format stream-json 能把 token 流和事件逐行吐出来:
claude -p "正在重构 svc-a..." \
--output-format stream-json --verbose --include-partial-messages \
| jq -rc 'select(.type=="stream_event" and .event.delta.type?=="text_delta") | .event.delta.text'
监控整批进度的"上帝视角"小脚本(另开一个终端):
watch -n 5 '
done=$(grep -l "\"status\": \"done\"" state/*.json 2>/dev/null | wc -l)
total=$(ls state/*.json 2>/dev/null | wc -l)
echo "进度: $done / $total 已完成"
grep -L "\"status\": \"done\"" state/*.json 2>/dev/null | xargs -r -n1 basename
'
三个常被忽略、但通宵必爆的坑:
1. 卡死的后台 agent 拖垮整批。 如果 agent 起了一个永不退出的后台进程(dev server、watch build),claude -p 会被它挂住。设一个上限:
export CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=600000 # 单个后台 agent 最多等 10 分钟
默认就是 10 分钟;设成 0 表示无限等(不推荐通宵用)。这是 v2.1.182 起的保护机制。
2. stdin 超 10MB 直接报错。 v2.1.128 起,管道喂给 claude -p 的 stdin 上限 10MB,超了会非零退出。批量场景别 cat 巨型日志 | claude,改成把路径写进 prompt 让它自己 Read。
3. 鉴权兜底。 --bare 会跳过 OAuth/keychain 读取,鉴权必须来自 ANTHROPIC_API_KEY 或 --settings 里的 apiKeyHelper。CI 里务必在脚本开头 export ANTHROPIC_API_KEY=...,否则 --bare 会静默失败、session_id 为空。
完整可跑版 overnight.sh:
#!/usr/bin/env bash
set -uo pipefail
QUEUE="${QUEUE:-queue.jsonl}"
MAX_PARALLEL="${MAX_PARALLEL:-2}"
export CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=600000
mkdir -p worktrees state logs
SCHEMA='{"type":"object","properties":{"status":{"type":"string","enum":["done","failed"]},"summary":{"type":"string"}},"required":["status","summary"]}'
ts_get() { { cat "state/$1.json" 2>/dev/null || echo '{}'; } | jq -r --arg k "$2" '.[$k] // empty'; }
ts_set() { jq --arg k "$2" --arg v "$3" '.[$k]=$v' "state/$1.json" > "state/$1.json.tmp" && mv "state/$1.json.tmp" "state/$1.json"; }
run_task() {
local id="$1" repo="$2" rule="$3" wt="worktrees/$1" out sid status
[ -d "$wt/.git" ] || git clone -q "$repo" "$wt" 2>>"logs/$1.err" || { ts_set "$1" status clone-failed; return 1; }
local prev; prev=$(ts_get "$1" session_id)
if [ -n "$prev" ] && [ "$prev" != none ]; then
out=$(cd "$wt" && claude --resume "$prev" -p "继续完成上一轮未跑完的重构:$rule。完成后自报 done。" \
--bare --allowedTools "Read,Edit,Bash" --output-format json --json-schema "$SCHEMA" 2>&1)
else
out=$(cd "$wt" && claude -p "$rule" --bare --allowedTools "Read,Edit,Bash" \
--output-format json --json-schema "$SCHEMA" 2>&1)
fi
sid=$(printf '%s' "$out" | jq -r '.session_id // empty' 2>/dev/null)
status=$(printf '%s' "$out" | jq -r '.structured_output.status // "running"' 2>/dev/null)
ts_set "$1" session_id "${sid:-none}"
ts_set "$1" status "$status"
ts_set "$1" cost "$(printf '%s' "$out" | jq -r '.total_cost_usd // 0' 2>/dev/null)"
ts_set "$1" attempts "$(( $(ts_get "$1" attempts) + 1 ))"
printf '%s' "$out" > "logs/$1.jsonl"
}
wait_for_slot() { while [ "$(jobs -rp | wc -l)" -ge "$MAX_PARALLEL" ]; do sleep 0.5; done; }
while IFS= read -r l; do
id=$(printf '%s' "$l" | jq -r '.id'); repo=$(printf '%s' "$l" | jq -r '.repo'); rule=$(printf '%s' "$l" | jq -r '.rule')
[ "$(ts_get "$id" status)" = done ] && { echo "skip(done): $id"; continue; }
wait_for_slot; echo "dispatch: $id"; run_task "$id" "$repo" "$rule" &
done < "$QUEUE"
wait
echo "================ 汇总 ================"
for f in state/*.json; do i=$(basename "$f" .json)
printf "%-18s %-10s \$%-8s attempts=%s\n" "$i" "$(jq -r '.status' "$f")" "$(jq -r '.cost//0' "$f")" "$(jq -r '.attempts//0' "$f")"
done
echo "总花费: \$$(cat state/*.json | jq -s '[.[]|(.cost//0|tonumber)]|add // 0')"
跑法:
chmod +x overnight.sh
QUEUE=queue.jsonl MAX_PARALLEL=3 ./overnight.sh
# 断了就再敲一遍同样的命令,自动续跑
真实节奏(参考):5 个中型仓库、MAX_PARALLEL=3、单仓库约改 30–80 个文件,首轮约 40–70 分钟跑完;若中途断在第 3 个,重跑只续第 3、跑完 4、5,总成本只增不减已完成项。
flowchart TD
A[queue.jsonl 任务队列] --> B[读取一行任务]
B --> C{state/id.json<br/>status==done?}
C -- 是 --> Z[跳过,不消费额度]
C -- 否 --> D{有有效 session_id?}
D -- 否 --> E[首次: git clone 工作树]
D -- 是 --> F[续跑: 复用已有工作树]
E --> G[启动分支<br/>claude -p --bare --json-schema]
F --> H[续跑分支<br/>claude --resume sid -p]
G --> I[解析 JSON:<br/>session_id / structured_output / cost]
H --> I
I --> J{structured_output.status}
J -- done --> K[标记完成]
J -- running/failed --> L[保留 session_id<br/>等下一轮续跑]
K --> M[等并发槽位]
L --> M
M --> B
Z --> N{队列还有任务?}
N -- 是 --> B
N -- 否 --> R[汇总报告 + 总成本]
断点续跑的时序(被中断后重跑):
sequenceDiagram
participant U as 你/CI
participant O as overnight.sh
participant W as worktrees/id
participant C as claude -p
U->>O: ./overnight.sh(首轮)
O->>W: git clone(首次)
O->>C: claude -p rule --bare --json-schema
C-->>O: {session_id:S1, status:running, cost:0.12}
Note over O: 写 state/id.json(S1 未完成)
U-xO: Ctrl-C / 断电(S1 留在 running)
U->>O: ./overnight.sh(重跑)
O->>O: 读 state,status≠done,有 S1
O->>C: claude --resume S1 -p 续跑
C-->>O: {session_id:S1, status:done, cost:0.31}
Note over O: 标记 done,成本累加
Q1:续跑报 No conversation found with session ID: S1?
90% 是没在原目录续跑。检查 run_task 里 cd "$wt" 是否在 claude --resume 之前;session-id 查找范围限定在当前项目目录及其工作树。
Q2:structured_output 字段取不到?
确认用了 --output-format json 且 --json-schema 是合法 JSON Schema(v2.1.205 起,非法 schema 会直接报错退出而非静默降级)。schema 里别依赖 format 关键字做校验,它只被当注释。
Q3:成本对不上、total_cost_usd 缺失?
旧版本字段名/位置可能不同,确认 CLI ≥ v2.1.128。jq 解析前先 cat logs/<id>.jsonl | jq . 看顶层有哪些键。
Q4:并发太高被限流(429)?
降 MAX_PARALLEL 到 1–2,或在 wait_for_slot 里把 sleep 拉长。Claude Code 内部还有并发上限,外层不必太激进。
Q5:想给单个任务设超时?
外层包 timeout:out=$( ... claude --resume ... ) 改成 out=$(timeout 1800 bash -c 'cd "$0" && claude ...' "$wt")。超时退出后 status 留空,下一轮自动续跑。
--fork-session + --continue 能在不污染原会话的前提下试另一种重构思路,失败可丢、成功可留,适合"两条路线并行探索"。claude --from-pr <number> 直接接回某个 PR 关联的会话,CI 里按 PR 粒度续跑很顺手。bash + jq 的拼装开始吃力,改用 Agent SDK(Python/TypeScript)能拿到原生消息对象、工具审批回调,结构化输出也更稳。本引擎的思路(session-id 断点 + 每任务状态 + 成本聚合)可直接平移过去。/export 留证:每个任务跑完用 /export 导出可读 transcript,归档进 logs/,方便事后审计 agent 改了什么。| 维度 | 评估 |
|---|---|
| 适合的训练营类型 | DevOps/SRE 进阶营、平台工程营、AI 工程化实战营、遗留系统现代化迁移营 |
| 学员基础 | 需熟悉 Bash、Git、jq,理解 Claude Code -p 无头模式;不适合纯零基础 |
| 教学时长 | 建议 1 个半天(3–4 小时):1h 讲概念 + 1.5h 搭引擎 + 1h 跑通断点续跑实操 |
| 亮点 | "断点续跑 + 成本可追踪"是真实生产痛点,学员当场能感受到价值,比单纯讲并发更有记忆点 |
| 风险点 | 需为每人准备可消耗的 API 额度(成本可控,但要有预算);实操环节要预备 5 个小型测试仓库 |
--bare、--output-format json、--json-schema、total_cost_usd、后台任务上限与 stdin 10MB 限制的权威出处--resume <session-id> 续跑无头会话、session-id 查找范围限定、--fork-session 的依据jq 解析、stdin 管道、CI 集成模式[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
