<!-- title: 造断点续跑的批量重构引擎 -->
# 用 Claude Code 造一个断点续跑的批量重构引擎

## 概述

本教程教你用 `claude -p` 无头模式 + 会话续跑（`--resume <session-id>`）+ Git 工作树隔离，搭建一台**断点可恢复、并发可控、成本可追踪**的批量重构引擎。它能在你下班后跑遍几十个仓库，被 Ctrl-C 或断网打断后，重跑只接续未完成的任务，绝不重复劳动。适合运维工程师、平台/SRE、迁移负责人，以及任何要把 Claude Code 当"通宵工人"派活的开发者。

---

## 为什么是"断点续跑"，而不是又一个批量脚本

批量派 Claude 干活这件事，社区已经讲过很多：有人用 `-p` 流式协议配钩子造引擎，有人用子代理并行把一个任务拆开分头干。但真实通宵场景里，最大的敌人不是"怎么并发"，而是这三件事：

1. **中断后从零重来**。跑了 6 小时、改到第 18 个仓库时机器重启了——没有断点，第二天得从头再跑 6 小时。
2. **并发写同一个状态文件**。多个后台 agent 同时回写 `state.json`，`jq` 一抢就写花。
3. **成本黑盒**。一晚上几十次调用，到底花了多少美元？逐条去用量面板对账太累。

本引擎针对这三点分别下药：用**会话级 `session_id` 做断点**、用**每任务独立状态文件**消除竞争、用**结构化输出里的 `total_cost_usd`** 自动累计成本。三者组合，是它与已有方案的根本区别。

---

## 前置条件

- Claude Code CLI ≥ v2.1.196（用到 `--resume <session-id>` 续跑无头会话、结构化输出等特性，建议直接装最新版）
- `jq`（解析 `--output-format json`）
- `git` ≥ 2.20（工作树支持）
- Bash 4+（并发用 `jobs -rp`）
- 一个可用鉴权：环境变量 `ANTHROPIC_API_KEY`，或已登录的 Claude 订阅账号
- 对 `claude -p`、`--output-format json`、`--allowedTools` 有基本认知（零基础请先补"装通 Claude Code"入门篇）

```bash
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 工作树）。这正是本引擎"在工作树内启动、在工作树内续跑"的设计依据。

---

## Step 1：准备任务队列与目录骨架

引擎读一个 **JSONL 队列**（每行一个任务），写**每任务独立状态文件**。

```bash
mkdir -p worktrees state logs
```

编辑 `queue.jsonl`（示例：把若干服务从 CommonJS 迁到 ESM）：

```json
{"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` 四个条目。

---

## Step 2：单任务执行函数（启动 vs 续跑的双分支）

这是整台引擎的心脏。一个函数，两条路径：

```bash
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` 里会带错误信息；这种任务下一轮会重新走"启动分支"，不会卡死。

---

## Step 3：并发派发主循环

用 `jobs -rp | wc -l` 当信号量，控制同时在飞的任务数：

```bash
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` 等续跑。

---

## Step 4：断点续跑的真相——状态即程序

把上面三步合进一个 `overnight.sh`。它的"魔法"其实只有一条规则：**状态文件是唯一事实来源，脚本是幂等的**。

```bash
#!/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   (重跑只增不减已完成项)"
```

**续跑演示**——这就是本引擎存在的全部意义：

```bash
./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` 不变——这能直观验证续跑真的接上了同一根线程。

---

## Step 5：实时监控（可选，用 stream-json）

后台跑着，你总想瞄一眼进度。`--output-format stream-json` 能把 token 流和事件逐行吐出来：

```bash
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'
```

监控整批进度的"上帝视角"小脚本（另开一个终端）：

```bash
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
'
```

---

## Step 6：健壮性——给通宵任务装保险

三个常被忽略、但通宵必爆的坑：

**1. 卡死的后台 agent 拖垮整批。** 如果 agent 起了一个永不退出的后台进程（dev server、watch build），`claude -p` 会被它挂住。设一个上限：

```bash
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` 为空。

---

## 实战案例：通宵把 5 个仓库从 CommonJS 迁到 ESM

完整可跑版 `overnight.sh`：

```bash
#!/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')"
```

跑法：

```bash
chmod +x overnight.sh
QUEUE=queue.jsonl MAX_PARALLEL=3 ./overnight.sh
# 断了就再敲一遍同样的命令，自动续跑
```

**真实节奏（参考）**：5 个中型仓库、`MAX_PARALLEL=3`、单仓库约改 30–80 个文件，首轮约 40–70 分钟跑完；若中途断在第 3 个，重跑只续第 3、跑完 4、5，总成本只增不减已完成项。

---

## 工作流程图

```mermaid
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[汇总报告 + 总成本]
```

断点续跑的时序（被中断后重跑）：

```mermaid
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` 能在不污染原会话的前提下试另一种重构思路，失败可丢、成功可留，适合"两条路线并行探索"。
- **从 PR 续跑**：`claude --from-pr <number>` 直接接回某个 PR 关联的会话，CI 里按 PR 粒度续跑很顺手。
- **Agent SDK 替代 CLI**：当 `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 个小型测试仓库 |

---

## 参考来源

1. [Run Claude Code programmatically — Claude Code Docs](https://code.claude.com/docs/en/headless) — 官方无头模式文档，`--bare`、`--output-format json`、`--json-schema`、`total_cost_usd`、后台任务上限与 stdin 10MB 限制的权威出处
2. [Manage sessions — Claude Code Docs](https://code.claude.com/docs/en/sessions) — 官方会话管理文档，`--resume <session-id>` 续跑无头会话、session-id 查找范围限定、`--fork-session` 的依据
3. [Claude Code Headless Mode: The Definitive Guide to claude -p](https://www.buildthisnow.com/blog/guide/development/claude-code-headless-mode) — 实战向无头模式指南，覆盖 `jq` 解析、stdin 管道、CI 集成模式
4. [Claude Code in CI/CD and Headless Automation](https://hidekazu-konishi.com/entry/claude_code_cicd_and_headless_automation.html) — CI/CD 中无人值守运行 Claude Code 的实现级指南，含 print/headless 与权限处理
5. [Isolate sessions with worktrees — Claude Code Docs](https://code.claude.com/docs/en/agents-and-parallel-work) — 用 Git 工作树隔离并行会话的官方实践，工作树内 session-id 复用的设计依据

> [!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 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
