# cutebot「服务重启后消息回复丢失」修复报告

- 站点：<https://xybcloud.online/chat/>
- 后端：`/var/www/cutebot/cutebot.py`（3920 行）
- 前端：`/var/www/html/chat/index.php`
- 备份：`/var/www/html/data/back/chat-recovery-20261102132754/`（cutebot.py + index.php 原始版本）
- 文档备份：`~/.claude/backups/claude-md-20261102134421/`
- 报告时间：2026-11-02

---

## 一、问题根因

现象：后端服务重启后，助手立刻回复「network 失败」；重启完成、重新登录网页后，该条消息的完整回复始终没有被推送。

实测 `/proc/<pid>/cgroup` 后确认，**cutebot 的主进程、它拉起的 Claude CLI 子进程、以及当前所有对话会话，全部在同一个 systemd cgroup `/system.slice/cutebot.service` 里**。因此 `systemctl restart cutebot` 会一次性 SIGTERM 掉整个 cgroup：

1. 正在向浏览器推流的 SSE 连接被掐断 → 浏览器抛 `TypeError: network error` → 气泡显示「请求失败: network error」（即用户看到的「network 失败」）；
2. 正在干活的 Claude CLI 子进程一起被杀 → 这一轮的最终答复**根本没机会生成**；
3. Bash 工具自身也在这个 cgroup 里 → tool_result 显示 `Exit code 137`。

换句话说：**在对话里执行 `systemctl restart cutebot`，等于让当前这一轮自断流。** 这就是「服务重启 → 答复丢失 → 重新登录也拿不回来」的完整链路。

---

## 二、改造清单（已全部落地并通过语法检查）

### 1. 延迟重启 + 优雅停机收尾
- 新增 `/var/www/cutebot/restart-delayed.sh`：用 `systemd-run --on-active=N` 把重启排到 N 秒之后，让当前轮先把答复输出完再重启；脚本头部写明了「为什么不能直接 restart」。
- `cutebot.py` 注册 SIGTERM / SIGINT handler：停机时把在途助手消息标记为 `status=interrupted` 并落盘，已输出的半截进度存入 `partial_content`，附中断原因。

### 2. Claude CLI transcript 回读
- 新增 `_read_transcript_turns()` / `_match_transcript_turn()` / `_backfill_from_transcript()`。
- 从 `~/.claude/projects/<proj>/<sid>.jsonl` 回读最终答复：CLI 跑完但 cutebot 还没落库就被杀时，可零成本补回；CLI 本身被 kill 时则标记为「已中断」。附 `_transcript_exists()` 判断 transcript 是否还在磁盘上（老会话常已被清理）。

### 3. `/api/continue` 断点续跑 + 前端「继续」按钮
- 后端 `handle_continue()`（`cutebot.py:2919`，路由注册于 `:3863`）：用同一个 CLI 会话 `--resume` 原地补全**同一条消息**，不新增消息。
- 前端：消息气泡下方挂「▶ 继续完成这一轮」按钮；`client_msg_id` 幂等，避免同一件事跑两遍；续跑中按钮显示「继续中…」，结束后恢复可点。

### 4. 前端断流分类 / token 持久化 / events 重放
- 断流 ≠ 失败：按消息状态分类 —— `interrupted` → 挂「继续」按钮；`pending` → 服务端还在跑，继续等；其余 → 报真实错误。
- token 持久化：`web_tokens.json`（`cutebot.py:1879`），带加载/保存失败告警。
- events 断点续传：`events_since` 落 localStorage，重连后从同一位置继续拉。

---

## 三、本轮实测发现并修复的 Bug

| # | Bug | 症状 | 修复 |
|---|---|---|---|
| 1 | **在途登记表 key 没跟着迁移** | 新会话 `storage_key` 从 `pending:xxx` 变成真实 sid 时，`_inflight_messages` 的 key 没改，停机/对账时按旧 key 找不到消息 → **0 条被标记** | 新增 `_rekey_inflight()`（`:1948`），在 `:2794` / `:2827` 两处调用 |
| 2 | **对账扫描漏掉悬空消息** | 崩溃时来不及改状态，消息停留在 `status='pending'`，对账扫描认不出来 | 扫描改为认 `status='pending'`（`:1179`），并用 `_PROCESS_START`（`:1922`）防止误伤本进程正在跑的消息 |
| 3 | **session_id 假告警** | `cutebot.py:1623` / `:1669` 只要 `treat_as_new_session` 为 False 就无条件打 warning，**两个 id 完全一样也报「不匹配」**，线上 journal 已被刷屏 | 改为仅在 `cli_session_id != session_id` 时才告警 |

---

## 四、测试结果（`/tmp/probe2/`，独立测试实例，端口 8767）

| 用例 | 内容 | 结果 |
|---|---|---|
| TEST 1 | 发送消息 | 通过 |
| TEST 2 | 历史里该消息的 status / client_msg_id | 通过 |
| TEST 3 | 幂等（同一个 client_msg_id 再发一次） | 通过 |
| TEST 4 | 发长任务 → 运行中 SIGTERM | 通过：消息被标 `interrupted` + 原因 |
| TEST 5 | `/api/continue` 断点续跑 | **功能通过**（详见下方说明） |

### TEST 5 的「失败」是测试脚本超时，不是产品缺陷

测试脚本客户端有 300s 读超时，而被续跑的那一轮是真实长任务，跑不完就先超时了，所以末尾断言 `status == 'done'` 时还没跑完。

**服务端侧是通的**，证据是实测进程表：

```
pid 1001711  claude --resume 7d70a268-...   ← 原会话 agent（正常）
pid 1005499  claude --resume 0910b742-...   ← /api/continue 拉起的续跑 agent
```

`/api/continue` 确实用同一个 CLI 会话 `--resume` 拉起了新 agent 并持续推送 `progress` 事件（SSE 事件列表全是 progress，连接活着、agent 在干活），失败仅发生在客户端超时那一刻。

---

## 五、部署与校验

### 部署方式：由独立 cgroup 的 transient 单元执行

实测 `systemd-run` 创建的 transient 单元跑在 `/system.slice/<unit>.service`，**与 `/system.slice/cutebot.service` 是两个 cgroup**，所以 `restart cutebot` 不会连带杀掉执行者 —— 这正是「重启 + 重启后校验」必须这么做的原因。

```bash
sudo systemd-run --unit=cutebot-deploy-<date> \
  --property=Environment=PEER_PID=<在跑会话的 pid> \
  --property=Environment=MAX_WAIT=900 \
  /bin/bash /var/www/cutebot/deploy-cutebot.sh
```

`deploy-cutebot.sh` 的流程：
1. 等在跑的会话收尾（上限 `MAX_WAIT`，本次 900s），避免打断别人的答复；
2. **重复重启保护**：若发现 MainPID 已变（期间已被别人重启过），跳过重启只做校验；
3. 用 `restart-delayed.sh 2` 触发重启（遵循约定脚本，不直接 `systemctl restart`）；
4. 等服务 `active` 后跑 `verify-deploy.sh`；
5. 结果写日志：`/var/www/html/data/cutebot-deploy-verify-20261102.log`
   → <https://xybcloud.online/data/cutebot-deploy-verify-20261102.log>

### 部署后校验（`/var/www/cutebot/verify-deploy.sh`）
由 transient systemd 单元执行，跑在 `/system.slice` 而非 cutebot 的 cgroup，因此 cutebot 重启时它能活下来继续校验：

1. 服务状态 + MainPID 是否变化（证明重启真的发生）；
2. 进程启动时间 > `cutebot.py` mtime（证明加载的是新代码而不是旧代码）；
3. `GET /chat/` → 200；`GET /api/continue` → 405/501（证明路由已注册，404 就是没生效）；
4. 关键符号存在性 + `web_tokens.json`；
5. 优雅停机日志（是否真的把在途消息标成了 interrupted）；
6. 历史库中 `status='interrupted'` 消息抽样（含 `partial_content`）；
7. 前端「继续完成这一轮」按钮与断流分类代码。

---

## 六、遗留风险 / 已知限制

1. **所有会话与主进程同 cgroup**：`systemctl restart cutebot` 仍会杀掉当时所有在跑的会话。已用「延迟重启 + 优雅收尾 + 续跑」把损失降到最小，但**没有**消除。建议后续把 Claude CLI 子进程挪到独立 cgroup（例如 `systemd-run --scope`），从根上断开这个耦合。
2. **`/api/continue` 的并发保护**：同一消息已在跑时再次触发续跑，是否应拒绝重复拉起，建议后续加锁；本轮未覆盖该场景。
3. **transcript 回读**依赖 `~/.claude/projects/<proj>/<sid>.jsonl` 仍在磁盘上；老会话的 transcript 可能已被清理，此时只能标记为「已中断」。
4. 测试实例与线上实例共用 `chat_history.json`，测试会污染线上历史记录，建议后续给测试实例单独一份。
