<!-- title: 断点续跑：打造自愈式Agent实战 -->

# 断点续跑：用 session 续接打造崩溃自愈的 Claude Agent

## 概述

把 `claude -p` 丢进 CI 或定时任务后，最痛的不是它跑得慢，而是跑到一半**断在网络抖动、限流（rate_limit）、模型过载（overloaded）或账单异常上，全部上下文归零，从头再来**。本教程聚焦一个被多数自动化教程忽略的能力：**会话持久化与断点续跑**。你将学会用 `--output-format json` 捕获 `session_id`、用 `--resume` 在崩溃后接续同一对话、用 `StopFailure` 钩子做失败观测、并写一个能在 API 错误后自动续跑的"自愈式"包装脚本。适合已经会用 `-p` 跑一次性任务、想把 Claude Code 真正塞进长流程自动化的工程师。

## 前置条件

- Claude Code v2.1.x（本文命令基于 v2.1.89+ 的 `defer`、v2.1.128 的 stdin 上限、v2.1.163 的后台任务回收、v2.1.181 的 `-p` 下 `/config` 等行为，这些在当前 2.1.x 版本均已落地）。
- 一台装好 `claude` CLI 并完成认证的机器；`jq` 必备，`column` 可选。
- 一个有 `git` 仓库的真实项目目录——**续跑是按"项目目录 + git worktree"作用域的**，所以目录不能随便换。
- 理解 `-p`/`--print` 非交互模式的基础用法（管道、`--allowedTools`、`--output-format`）。

---

## 第一步：先搞清楚"状态"到底存在哪

很多人写自动化脚本失败，是因为默认 `claude -p` 是"无状态"的——其实不是。每一段对话都被持续落盘成 JSONL 转录文件：

```
~/.claude/projects/<把工作目录路径编码后的串>/<session-id>.jsonl
```

也就是说，**你的工作目录路径决定了会话存到哪个项目桶里**。这带来三条铁律，后面每一步都绕不开：

1. **续跑必须从同一个目录发起**，否则会得到 `No conversation found with session ID: ...`。会话查找的作用域是"当前项目目录及其 git worktree"，换个不相关的目录就找不到。
2. **`-p` 创建的会话不会出现在 `/resume` 选择器里**，只能用 `--resume <session-id>` 按 ID 续。这是脚本场景最常踩的坑——你不能指望人去选择器里点。
3. **转录文件默认 30 天后被清理**（`cleanupPeriodDays` 默认 30）。如果你的巡检要跨月，要么调大这个值，要么把状态目录挪到不会被清的地方。

记住这三点，后面的设计就顺了。整张状态流转如下：

```mermaid
flowchart LR
    subgraph 一次claude-p调用
      A["claude -p '任务'<br/>--output-format json"] -->|stdout| B["JSON: result / session_id /<br/>is_error / stop_reason"]
    end
    B --> C["解析出 session_id<br/>写入状态文件"]
    C --> D[("~/.claude/projects/<cwd>/<sid>.jsonl<br/>持续落盘的对话转录")]
    D -.崩溃后.-> E["claude -p '继续' --resume SID"]
    E --> B
```

---

## 第二步：第一次运行，并把 session_id 落盘

自愈的前提是"能找回现场"。`--output-format json` 会在响应里带上 `session_id`，把它捕获下来：

```bash
# 跑一次审查任务，拿到 session_id 并落盘
out=$(claude -p "审查 src/ 目录里的性能问题，先列出 top 5" \
  --allowedTools "Read,Grep,Glob" \
  --output-format json)

# 提取关键字段
session_id=$(jq -r '.session_id'    <<<"$out")
cost=$(jq -r '.total_cost_usd // 0' <<<"$out")
result=$(jq -r '.result'            <<<"$out")

# 落盘，供后续续跑或排障使用
echo "$session_id" > .last-session
printf '%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$session_id" "$cost" >> .agent-runs.tsv

echo "$result"
echo "session_id = $session_id （已保存到 .last-session）"
```

**预期结果**：`out` 是一个 JSON 对象，至少包含 `result`（文本结论）、`session_id`（UUID）、`total_cost_usd`（本次花费）。`.last-session` 里写入了这次对话的 ID。

**常见问题**：

- **`jq` 报错、`session_id` 为空**：八成是这次调用压根没进到"会话持久化"。检查是否误加了 `--no-session-persistence`（它显式关闭转录写入，那就没法续了）。另一个原因是 stdin 输入超过 **10MB 上限**（v2.1.128 引入），超了会非零退出、不给 JSON——把大输入写进文件、在 prompt 里引用路径，而不是用管道喂。
- **想确认转录真的写了**：去 `~/.claude/projects/` 下找到对应当前目录编码名的文件夹，能看到一个 `<session_id>.jsonl`，每行是一条消息/工具调用/元数据。

---

## 第三步：续跑——`--continue` vs `--resume <id>`

拿到 `session_id` 后，续跑有两条路，别用混：

| 命令 | 行为 | 适用场景 |
| :--- | :--- | :--- |
| `claude -p "..." --continue` | 续接**当前目录最近一次**会话 | 单线推进、你确定没别的并发会话 |
| `claude -p "..." --resume <id>` | 续接**指定**会话 | 脚本场景、多仓库并发——**唯一可靠的选择** |
| `claude --from-pr <url>` | 续接与某个 PR 绑定的会话 | CI 里按 PR 续跑 |

脚本里**永远用 `--resume <id>`**，因为 `--continue` 的"最近一次"在并发或重试时会被别的调用顶掉，不可靠：

```bash
session_id=$(cat .last-session)
claude -p "刚才你列了 top 5，现在重点看数据库查询那两条，给出优化方案" \
  --resume "$session_id" \
  --allowedTools "Read,Grep,Glob" \
  --output-format json | jq -r '.result'
```

续接后，**上一轮的上下文（读过的文件、得出的中间结论）都在**，模型不会重复劳动——这正是省 token、省时间的关键。

**给会话起名（强烈推荐）**：用 `-n <name>` 或 `/rename` 命名，`--resume <name>` 就能按名续，且名字会在同仓库的 worktree 间解析：

```bash
claude -n dep-upgrade-api -p "升级 services/api 的依赖" --output-format json | jq -r .session_id
# 之后任意位置、同仓库内：
claude -p "继续" --resume dep-upgrade-api
```

**常见问题**：

- **`No conversation found with session ID`**：99% 是**目录不对**。续跑必须在创建该会话的同一目录（或其 worktree）下发起。脚本里务必先 `cd` 到目标项目目录。
- **`--continue` 续到了"错误的"会话**：因为最近一次被别的任务占了。换成 `--resume <id>` 即可。
- **想"试另一条路但不丢原对话"**：用分支。命令行 `claude --resume <id> --fork-session` 会复制一份对话进入新分支，原会话不动；交互里则是 `/branch`。注意"本会话允许"的权限**不会**带到分支。

---

## 第四步：用 `StopFailure` 钩子观测每一次崩溃

续跑能力有了，但"什么时候该续"得先能**发现**。`StopFailure` 钩子专门在"一轮因 API 错误而结束"时触发，并且 `matcher` 可以精确到错误类型：

| matcher 值 | 含义 |
| :--- | :--- |
| `rate_limit` | 触发限流，通常等一会能好 |
| `overloaded` | 模型过载，服务端 529 类 |
| `server_error` | 服务端错误 |
| `billing_error` | 账单/额度问题——续跑也没用，要人工 |
| `authentication_failed` | 鉴权失败 |
| `max_output_tokens` | 单轮输出超长 |

在 `.claude/settings.json`（项目级，可提交）里挂一个失败记录钩子：

```json
{
  "hooks": {
    "StopFailure": [
      {
        "matcher": "rate_limit|overloaded|server_error",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/on-failure.sh",
            "args": []
          }
        ]
      }
    ]
    ,
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/on-stop.sh", "args": [] }
        ]
      }
    ]
  }
}
```

`StopFailure` 的关键特性：**它的输出和退出码会被忽略**——纯做副作用（日志、告警、打标记）。钩子脚本从 stdin 收到 JSON（含 `session_id`、`transcript_path` 等公共字段），把"需要续跑"的标记写出来：

```bash
#!/usr/bin/env bash
# .claude/hooks/on-failure.sh  —— chmod +x
input=$(cat)
sid=$(jq -r '.session_id'         <<<"$input")
evt=$(jq -r '.hook_event_name'    <<<"$input")
ts=$(date -u +%FT%TZ)

# 写失败日志
printf '%s\t%s\t%s\n' "$ts" "$sid" "$evt" >> "${CLAUDE_PROJECT_DIR}/.agent-failures.log"

# 打一个"待续跑"标记，供包装脚本或定时器读取
echo "$sid" > "${CLAUDE_PROJECT_DIR}/.resume-needed"
exit 0
```

`on-stop.sh`（正常结束时清掉待续跑标记、记录成本）：

```bash
#!/usr/bin/env bash
# .claude/hooks/on-stop.sh  —— chmod +x
input=$(cat)
sid=$(jq -r '.session_id' <<<"$input")
rm -f "${CLAUDE_PROJECT_DIR}/.resume-needed"
printf '%s\t%s\tCLEAN_STOP\n' "$(date -u +%FT%TZ)" "$sid" >> "${CLAUDE_PROJECT_DIR}/.agent-runs.tsv"
exit 0
```

**预期结果**：一旦某轮撞上 `rate_limit`/`overloaded`/`server_error`，`.agent-failures.log` 多一行、`.resume-needed` 里写下 session_id；正常结束则标记被清。

**常见问题**：

- **钩子死活不触发**：最大可能是你加了 `--bare`。`--bare` **会跳过 hooks/skills/plugins/MCP/CLAUDE.md 的自动发现**，钩子根本不加载。所以"钩子观测"和"bare 模式"二选一；要省启动开销用 bare，要钩子就用普通模式。
- **`exit 1` 不生效**：钩子里退出码 1 是**非阻塞错误**，会被忽略；要真"阻断"得用 `exit 2`。但 `StopFailure` 本来就忽略退出码，所以这里 `exit 0` 即可。
- **想区分目录/分支**：钩子输入里有 `cwd`，脚本里可以按目录分流日志。

> ⚠️ 注意 `Stop`/`UserPromptSubmit`/`PostToolBatch` 等**不支持 matcher**，写了会被静默忽略；`StopFailure` 才支持错误类型 matcher。

---

## 第五步：核心——一个会自动续跑的"自愈式"包装脚本

把前面几步合起来：包装脚本捕获 `session_id`，一旦检测到 `is_error` 或进程异常退出，就用 `--resume` 把**同一个会话**接续下去，并告诉它"上次中断了，从断点继续，别重复已完成的步骤"。

```bash
#!/usr/bin/env bash
# selfheal.sh —— 在 API 错误后自动用 --resume 续跑，直到完成或超上限
set -uo pipefail

PROMPT="$1"
MAX_ATTEMPTS="${2:-5}"
SESSION_ID=""
PERMISSIONS="Read,Edit,Bash(git *),Bash(npm test)"

for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
  if [[ -z "$SESSION_ID" ]]; then
    # 首次运行
    out=$(claude -p "$PROMPT" \
            --permission-mode acceptEdits \
            --allowedTools "$PERMISSIONS" \
            --output-format json 2>.selfheal.err)
  else
    # 续跑：明确告知中断、要求接着干
    out=$(claude -p "上一次运行因错误中断（第 ${attempt} 次重试）。请从断点继续，不要重复已完成的工作。" \
            --resume "$SESSION_ID" \
            --permission-mode acceptEdits \
            --allowedTools "$PERMISSIONS" \
            --output-format json 2>.selfheal.err)
  fi

  # 进程本身崩了（OOM/网络断/SIGKILL），没有 JSON
  if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
    echo "⚠️ 第 $attempt 次：进程异常退出（见 .selfheal.err）" >&2
    sleep $((attempt * 5))
    continue
  fi

  SESSION_ID=$(jq -r '.session_id // ""' <<<"$out")
  is_error=$(jq -r '.is_error // false'   <<<"$out")
  reason=$(jq -r '.stop_reason // ""'      <<<"$out")

  if [[ "$is_error" == "false" ]]; then
    jq -r '.result' <<<"$out"
    exit 0
  fi

  echo "⚠️ 第 $attempt 次失败 (stop_reason=$reason)，session=$SESSION_ID，退避后续跑..." >&2
  sleep $((attempt * 5))   # 简单指数退避
done

echo "❌ 超过最大重试次数 $MAX_ATTEMPTS。最终会话：$SESSION_ID" >&2
echo "手动续跑：claude --resume $SESSION_ID" >&2
exit 1
```

调用：

```bash
./selfheal.sh "把 auth 模块的老式回调改写成 async/await，并保证 npm test 全绿"
```

**预期结果**：哪怕中途撞上限流，脚本也会在退避后用同一个 `session_id` 续上，模型接着干而不是从头改。`.selfheal.err` 留有每次的 stderr 便于排障。

**设计要点**：

1. **续跑提示要"反重复"**：明确写"从断点继续，不要重复已完成的工作"。否则续跑时模型可能把已改的文件又读一遍、又改一遍，浪费 token。
2. **退避**：`rate_limit`/`overloaded` 多半等一会就好，线性/指数退避比硬刚强。
3. **`--resume` 与权限模式独立**：`--resume` 只恢复对话上下文；`--allowedTools`/`--permission-mode` 仍要每次显式传。脚本里我固定传 `acceptEdits`+白名单，保证续跑后权限行为一致。

**常见问题**：

- **续跑后模型"忘了"任务目标**：长任务被 `--compact` 压缩过上下文时偶发。可在续跑提示里补一句目标摘要；或对超长任务**先 fork 再续**（`--fork-session`），保留干净主线。
- **续跑报"找不到会话"**：仍是目录问题——`selfheal.sh` 必须在被操作项目的根目录运行。
- **`billing_error`/`authentication_failed` 也被无脑重试**：这两类续跑无用。把它们的判断加进循环（读到这两类就立即 `exit` 并告警），别浪费重试次数。

---

## 第六步：用 `stream-json` 的 `api_retry` 事件做退避可视化

`--output-format stream-json` 会在 API 重试时发出 `system/api_retry` 事件，字段含 `attempt`、`max_retries`、`retry_delay_ms`、`error` 类别。你可以一边跑一边把重试进度打到看板：

```bash
claude -p "生成迁移脚本" \
  --output-format stream-json --verbose \
  | jq -rc 'select(.type=="system" and .subtype=="api_retry")
            | "[retry] #\(.attempt)/\(.max_retries) 等 \(.retry_delay_ms)ms — \(.error)"' \
  >> .retry-trace.log &
```

这样你能在 `.retry-trace.log` 里看到"第 2/10 次重试，等了 1200ms，原因是 rate_limit"，据此判断是该继续等、还是该熔断告警。结合第五步的包装脚本，**重试由包装脚本掌控（跨进程续跑），观测由 stream-json 提供（单进程内）**，两者互补。

---

## 第七步：硬化——命名、独立状态目录、保留期、可控输入

把自愈 Agent 放到生产前，再做四件事：

**1. 给会话起稳定名字，别只靠 UUID**。脚本里用业务前缀命名，便于人肉排障：

```bash
claude -n "patrol-$(basename "$PWD")-$(date +%Y%m%d)" -p "..." --output-format json
```

**2. 长 batch 用独立的配置目录，避开 30 天清理**。`CLAUDE_CONFIG_DIR` 把所有会话状态挪到一个不会被清、且易备份的位置；`cleanupPeriodDays` 调大保留期：

```bash
export CLAUDE_CONFIG_DIR=/data/agent-state
```

```json
// .claude/settings.json
{ "cleanupPeriodDays": 90 }
```

**3. 大输入走文件，别走管道**。stdin 上限 10MB（v2.1.128）。把大日志/大 diff 写文件，prompt 里给路径：

```bash
git diff main > /tmp/d.diff
claude -p "审查 /tmp/d.diff 这个 diff 里的拼写和明显 bug" --allowedTools Read
```

**4. 后台任务别撑住进程**。v2.1.163 起，`claude -p` 结束后约 5 秒会回收它启动的后台 Bash 任务（如 dev server），给了个收尾宽限期。但若你故意要保留一个长驻服务，别指望它活过 `-p` 调用——那是设计如此。

---

## 实战案例：多仓库依赖升级巡检 Agent

把全部能力串起来：对一组仓库逐个跑"升级 patch 依赖 → 测试 → 提交"，每个仓库独立捕获 `session_id`，崩溃自动续跑，最后出一张状态表。

```bash
#!/usr/bin/env bash
# repo-upgrade-patrol.sh
set -uo pipefail
ROOT=$(pwd)
REPOS=(services/api services/auth services/billing web/frontend)
STATE="$ROOT/.patrol-state.tsv"
PERMISSIONS="Read,Edit,Bash(npm install),Bash(npm test),Bash(git *)"
printf 'repo\tsession_id\tstatus\tattempts\n' > "$STATE"

upgrade_one() {
  local repo="$1" sid="" n=0
  cd "$ROOT/$repo" || { printf '%s\t-\tSKIP\t-\n' "$repo" >> "$STATE"; return; }

  while (( n < 3 )); do
    n=$((n+1))
    local out
    if [[ -z "$sid" ]]; then
      out=$(claude -n "patrol-$repo" -p \
            "升级 package.json 中所有 patch 版本依赖到最新，npm install 后跑 npm test，全绿就 git commit。" \
            --permission-mode acceptEdits --allowedTools "$PERMISSIONS" \
            --output-format json)
    else
      out=$(claude -p "上次中断，从断点继续，不要重复已完成步骤。" \
            --resume "$sid" --permission-mode acceptEdits \
            --allowedTools "$PERMISSIONS" --output-format json)
    fi

    jq -e . >/dev/null 2>&1 <<<"$out" || { sleep 5; continue; }
    sid=$(jq -r '.session_id // ""' <<<"$out")
    if [[ "$(jq -r '.is_error // false' <<<"$out")" == "false" ]]; then
      printf '%s\t%s\tOK\t%s\n' "$repo" "$sid" "$n" >> "$STATE"
      cd "$ROOT"; return 0
    fi
    sleep $((n*5))
  done
  printf '%s\t%s\tFAILED\t%s\n' "$repo" "$sid" "$n" >> "$STATE"
  cd "$ROOT"
}

for r in "${REPOS[@]}"; do upgrade_one "$r"; done
echo "===== 巡检结果 ====="; column -t -s$'\t' "$STATE"
```

FAILED 的仓库会留下 `session_id`，事后直接 `cd services/billing && claude --resume <id>` 人工介入即可——**现场永远不丢**。整体编排：

```mermaid
flowchart TD
    S([巡检脚本启动]) --> L{"遍历仓库列表"}
    L -->|每个 repo| R["cd repo; claude -p ... --output-format json"]
    R --> J{解析 JSON}
    J -->|is_error=false / 非 JSON 重试| OK["记 OK<br/>下一个仓库"]
    J -->|is_error=true| CAP["记 session_id"]
    CAP --> RT{重试<3?}
    RT -->|是| RES["claude -p '继续' --resume SID"]
    RES --> J
    RT -->|否| FAIL["记 FAILED + SID<br/>留待人工 --resume"]
    OK --> L
    FAIL --> L
    L -->|全部完成| T[[输出状态表]]
```

---

## 延伸阅读

- **Checkpointing 与 `/rewind`**：在单个会话内做"代码 + 对话"检查点回滚，适合"试错了想倒回去"的场景，比 fork 更轻。
- **Worktrees 并行**：多个自愈 Agent 同时跑不同分支时，用 git worktree 天然隔离，且会话按 worktree 作用域互不干扰。
- **`defer` 往返**：`AskUserQuestion`/`ExitPlanMode` 在 `-p` 下会卡住；用 PreToolUse 钩子返回 `permissionDecision: "defer"`（v2.1.89+），进程以 `stop_reason: "tool_deferred"` 退出、保留挂起工具，外部 UI 收集答案后再 `--resume` 回灌——这是把 `-p` Agent 接进"需要人工确认"流程的标准范式。
- **Agent SDK**：当 bash 包装脚本不够用时，用官方 Python/TypeScript Agent SDK 拿到原生消息对象、结构化输出与工具审批回调，做更复杂的编排。

## 训练营适用性评估

- **适合**：DevOps/SRE 自动化营、CI/CD 进阶营、平台工程营。学员需已过 `-p` 基础关。
- **教学时长**：讲授 + 动手约 **3.5–4 小时**（1h 原理与状态模型、1.5h 钩子与自愈脚本、1h 多仓库实战与排障）。
- **前置作业**：要求学员自带一个含测试套件的小仓库，课堂直接在其上跑巡检脚本，产出可见的状态表与续跑日志，反馈强、可验收。

## 参考来源

1. [Run Claude Code programmatically (Headless)](https://code.claude.com/docs/en/headless) — 官方 `-p`/`--bare`/`--output-format`/`--resume`/`--continue` 与 stdin 上限、后台任务回收等行为权威说明。
2. [Manage sessions](https://code.claude.com/docs/en/sessions) — 会话命名、`--resume`/`--from-pr`、作用域规则、JSONL 转录存储路径与 `cleanupPeriodDays` 保留期。
3. [Hooks reference](https://code.claude.com/docs/en/hooks) — `StopFailure`/`Stop` 事件、matcher 规则、退出码（2 阻断/1 非阻塞）、`--bare` 跳过 hooks 的行为。
4. [Headless Mode and CI/CD — Tips (SFEIR Institute)](https://institute.sfeir.com/en/claude-code/claude-code-headless-mode-and-ci-cd/tips/) — `-p` 标志、输出格式与多轮会话的 18 条实践技巧。
5. [How to resume, search, and manage Claude Code conversations](https://kentgigger.com/posts/claude-code-conversation-history) — `--continue`/`--resume` 实操与会话历史检索方法。

> [!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)
