# ZACP + Grok WebUI「unknown session id」报错：根因与修复

- 日期：2026-09-24
- 机器：DSH 机 192.168.31.76（本机）
- 涉及：ZACP（ACP Web 网关，`http://192.168.31.76:8680`）+ grok CLI 1.0.41
- 状态：**已修复并实测通过**（补丁二进制已上线，配置已调整，用户原有 3 个会话未受影响）

---

## 0. 结论速览（TL;DR）

报错 `出错了: prompt: {"code":-32602,"message":"Invalid params","data":"unknown session id"}`
是**两个条件叠加**的结果，不是 grok 坏了，也不是网络问题：

1. **触发条件**：ZACP 的 `session.idle_timeout = "30m"`。你离开超过 30 分钟，ZACP 就把
   grok agent 进程**杀掉回收内存**（这是它的设计），而 grok 的会话（session id）只存在于
   **进程内存**里 → 磁盘上 ZACP 的会话记录还在，但 agent 已经不认这个 id 了。
2. **放大条件（真正的 bug）**：ZACP **本来就有**「检测到 agent 侧会话失效 → 自动
   `session/load` 恢复上下文 → 重试这条 prompt」的自愈逻辑；但它的失效判定要求错误文本里
   **同时出现 "session" 和一段 session id**，而 grok 的报错恰恰**不带 id**
   （只有 `unknown session id` 五个词）→ 判定失败 → 自愈逻辑从未被触发 → 直接把错误抛给前端。

修复 = 给 ZACP 打一个 **6 行**补丁，让判定认识 grok 的措辞。修好后实测：
**杀掉 agent 后继续发消息，回复照常返回，而且上一轮对话内容（暗号「紫电青霜」）完整保留**
——因为 grok 支持 ACP `session/load`，能从磁盘把整个会话回放进新进程。

---

## 1. 现象与环境

| 项 | 值 |
|---|---|
| 报错原文 | `出错了: prompt: {"code":-32602,"message":"Invalid params","data":"unknown session id"}` |
| 触发时机 | 离开较长时间（≥30 分钟）后回来发消息 |
| ZACP | v0.9.0，commit `9360fc6`，pm2 托管，监听 `:8680`（`~/.local/bin/zacp` → `~/.zacp/bin/zacp-0.9.0`） |
| 配置 | `~/.zacp/config.toml`，`[[agents]] id="grok"`，`args=["agent","--always-approve","stdio"]` |
| grok | `grok 1.0.41 (4220f3b224a6)`，`~/.grok/bin/grok` |
| 数据 | `~/.zacp/data/zacp.db`（sqlite），会话消息 + acp_session_id |
| 日志 | `~/.pm2/logs/zacp-out.log` / `zacp-error.log` |

历史报错（修复前 `zacp-error.log` 里的三条，全部形态一致）：

```
time=2026-09-24T17:56:27 level=ERROR msg="handle prompt error" error="prompt: {\"code\":-32602,\"message\":\"Invalid params\",\"data\":\"unknown session id\"}"
time=2026-09-24T20:10:40 level=ERROR msg="handle prompt error" error="prompt: {...\"data\":\"unknown session id\"}"
time=2026-09-24T20:10:51 level=ERROR msg="handle prompt error" error="prompt: {...\"data\":\"unknown session id\"}"
```

---

## 2. 根因分析

### 2.1 触发：`idle_timeout` 回收 agent，会话随进程消失

`config.toml` 里 `[session] idle_timeout = "30m"`。ZACP 源码里这个回收器的注释写得很直白
（`backend/internal/acp/manager/manager.go` → `recycleIdle`）：

> 回收 = 杀掉 agent 进程并移除连接；DB 中该 agent 的 session 会在下次使用时经
> unknown-session 自动恢复逻辑（RecoverSession）重建。

时间线与报错**逐秒对得上**（两次独立复现）：

| 会话 | DB 最后一次活动 | +30 分钟 = 预期回收 | 实际报错时刻 |
|---|---|---|---|
| #1 爽文小说《征召》 | 17:11:45 | ≈17:41 | **17:56:27** |
| #3 敦煌飞天出图 | 19:00:07 | ≈19:30 | **20:10:40 / 20:10:51** |

进程侧也吻合：报错发生时刻 grok agent 才刚被拉起（`ps` 显示 agent 启动于 20:10，
父进程是 zacp），说明发消息时 ZACP 起了一个**全新 agent 进程**，而它内存里当然没有旧 session。

### 2.2 真凶：失效判定漏判 grok 的报错措辞

ZACP 的判定函数（`manager.IsUnknownSessionErr`）核心逻辑：

```go
msg := strings.ToLower(err.Error())
if !strings.Contains(msg, "session") || !uuidLikeRe.MatchString(msg) {  // uuidLikeRe = [0-9a-f]{8,}
    return false
}
```

即：**必须同时含 "session" 和一段 ≥8 位的 uuid/hex 前缀**，才认为「会话失效」。
把 grok 的报错代进去：

```
prompt: {"code":-32602,"message":"invalid params","data":"unknown session id"}
                                    ↑ 唯一像 id 的片段是 "32602"，只有 5 位
```

实测（正则等价复算）：`hex runs: ['c','de','32602','e','a','e','a','d','a','a','da','a','e','d']`，
最长 5 位 → `[0-9a-f]{8,}` **不匹配** → `IsUnknownSessionErr` 返回 `false`
→ `recoverSession` / `RecoverSession` 这条自愈分支**永远进不去** → 错误原样抛给前端。

对比：ZACP 已适配的其他 agent 报错都带 id，所以从来没暴露过这个洞：

- omp/pi 系：`Unsupported ACP session: <uuid>`
- reasonix：`session/<uuid>: unknown session <uuid>`
- qoder：`Session not found: <uuid>`
- **grok：`unknown session id`（无 id）** ← 漏的就是它

判定里「要求 id 共现」本意是防误伤（例如 `unsupported session config option: foo` 这种
参数类错误不能当会话失效处理，否则会静默重建会话、丢上下文）。grok 的固定短语
`unknown session id` 语义唯一、无歧义，单独放行是安全的。

### 2.3 附带发现：`command = "grok"` 依赖 PATH（隐患）

ZACP 由 pm2 托管，进程环境是 pm2 缓存的，PATH 里没有 `~/.grok/bin`。
本次排查中用 `pm2 restart --update-env` 刷新环境后立刻暴露：

```
level=WARN msg="failed to preload agent" agent=grok err="start agent 'grok': start grok: exec: \"grok\": executable file not found in $PATH"
```

即：**只要 pm2 的环境变一次（重启机器、重装、换 shell 启动），整个 grok WebUI 就会起不来**。
已顺手改成绝对路径并显式钉住代理环境变量（见 §3.4）。

---

## 3. 修复内容

### 3.1 补丁（6 行逻辑 + 注释）

```go
 	msg := strings.ToLower(err.Error())
+	// grok（x.ai）：`{"code":-32602,"message":"Invalid params","data":"unknown session id"}`。
+	// 该措辞不含 session id 本身，uuid 共现条件永远无法满足，故单独放行；
+	// 短语唯一指向「会话 id 未知」，不存在误伤（见下方前置条件说明）。
+	if strings.Contains(msg, "unknown session id") {
+		return true
+	}
 	if !strings.Contains(msg, "session") || !uuidLikeRe.MatchString(msg) {
 		return false
 	}
```

同时在函数文档注释里补上 grok 这一条措辞。补丁文件：
**`grok-unknown-session-fix.patch`**（同目录，`git apply` 可直接打）。
另附回归测试 `session_err_test.go`（注意：上游 `.gitignore` 忽略 `*_test.go`，
要入库需 `git add -f`）。

### 3.2 构建（本机首次需要装工具链）

上游 `go.mod` 要求 **go 1.25.7**，系统 apt 只有 1.22，且前端构建强制用 **bun**
（`scripts/build.sh` 会直接报错退出）。本次已装好并留在本机：

| 组件 | 位置 | 来源（本网可达的镜像） |
|---|---|---|
| Go 1.25.7 | `~/.local/go1.25.7` | `https://mirrors.aliyun.com/golang/go1.25.7.linux-amd64.tar.gz`（22 MB/s） |
| bun 1.4.2 | `~/.bun/bin/bun` | `https://registry.npmmirror.com/-/binary/bun/bun-v1.4.2/bun-linux-x64.zip` |
| 前端依赖 | `frontend/node_modules` | `bun install --registry https://registry.npmmirror.com`（709 包 / 5.4s） |
| Go 模块 | — | `GOPROXY=https://goproxy.cn,direct` |

⚠️ 坑：`go.dev` 直连只有 ~30 KB/s（78MB 要下很久），**GitHub Release 资产直连拉不动**
（`github.com/.../releases/latest/download/...` 返回 000），但 `git clone` 是通的。

一键重建脚本见同目录 **`rebuild-zacp-grokfix.sh`**。

### 3.3 部署（已执行，保留回滚）

```
~/.zacp/bin/zacp-0.9.0                         官方原版 12MB（未动，回滚目标）
~/.zacp/bin/zacp-0.9.0.orig-20260924-202814    官方原版备份
~/.zacp/bin/zacp-0.9.0-grokfix                 本次补丁版 43MB（buildTime 2026-09-24T12:31:11Z）
~/.local/bin/zacp -> ~/.zacp/bin/zacp-0.9.0-grokfix
```

- 部署方式：**先写新文件再 `mv -f` 原子替换**（运行中的进程持旧 inode，不会被 ETXTBSY 卡住），
  然后 `pm2 restart zacp`。
- 版本自检：`GET /api/v1/version` 返回 `buildTime: 2026-09-24T12:31:11Z`（官方版是 08-23），
  确认跑的是新二进制。
- 体积差异说明：官方 Release 二进制 12MB 是 **UPX 压缩过**的（`file` 显示 `no section header`），
  本机这份是未压缩的 43MB，功能一致，不影响使用。

### 3.4 配置调整（`~/.zacp/config.toml`）

```toml
[session]
idle_timeout = "8h"        # 原 "30m"

[[agents]]
id = "grok"
name = "Grok"
enabled = true
command = "/home/zyw/.grok/bin/grok"        # 原裸命令 "grok"（PATH 依赖，见 §2.3）
args = ["agent", "--always-approve", "stdio"]
env = [
  "HTTPS_PROXY=http://127.0.0.1:10809",
  "HTTP_PROXY=http://127.0.0.1:10809",
  "NO_PROXY=127.0.0.1,localhost,192.168.31.0/24",   # grok 调 GPU 机 192.168.31.31 不走代理
]
```

- `8h` 的用意：同一天内回来**不用重启 agent**（恢复更快、也不吃重新回放的 token）；
  跨夜回收也没关系——补丁已经让回收变成无感自愈。想彻底不回收就写 `"0"`。
- `env` 显式钉住代理：x.ai 必须走本机 v2ray（`127.0.0.1:10809`，已验证
  `curl -x ... https://api.x.ai/v1/models` 返回 401=可达）。这样不再依赖 pm2 缓存环境。
- 配置备份：`~/.zacp/config.toml.bak-idlefix-20260924-202814`。

### 3.5 顺手补的可靠性

`pm2 save` 之前**没有**包含 zacp（`pm2-zyw.service` 是 enabled+active，但 dump 里只有
deepseek-harness）→ 重启机器后 ZACP 不会自动回来。已执行 `pm2 save`，
现在 dump 内进程为 `['deepseek-harness', 'zacp']`。

---

## 4. 验证记录（全部实测通过）

| # | 验证项 | 方法 | 结果 |
|---|---|---|---|
| 1 | 判定函数 | `go test ./internal/acp/manager/ -run TestIsUnknownSessionErr` | 8 个子用例全 PASS（含 grok 正例 + 3 个反例不误判） |
| 2 | grok 是否支持 `session/load` | 直接对 grok 跑 ACP 握手探测 | 支持：`session/load` 返回正常，并把历史以 `isReplay:true` 全量回放 |
| 3 | REST 路径自愈 | 建测试会话 → 提问记暗号 → `kill` 掉 grok agent → 再问暗号 | 第 1 轮 `已记住`(14.2s)；杀 agent(pid 2829294) 后第 2 轮回 **`紫电青霜`**(8.5s)，新 agent pid 2830108 → 上下文经 `session/load` 完整保留 |
| 4 | **WS 路径（你实际用的那条）** | 再杀掉 agent → 直连 `ws://127.0.0.1:8680/api/v1/ws` 发 prompt | `turn.done reply="紫电青霜"`，49 个流事件，**全程无 error 消息** |

> 小观察：自愈时前端会先后收到两次 `turn.started`（第一次尝试被 agent 拒绝 → 恢复后重试），
> 属于观感噪音，不影响结果。

**直接证据（修复后 `zacp-error.log` 里第一次出现这条 WARN，修复前三个月的日志里从未出现）**：

```
time=2026-09-24T20:30:19 level=WARN msg="acp session invalid, recovering" sessionID=01a0d364-37e7-7613-adce-ba6b6a9f5a1f err="prompt: {\"code\":-32602,\"message\":\"Invalid params\",\"data\":\"unknown session id\"}"
```

同一个错误文本，改前是 `level=ERROR msg="handle prompt error"`（抛给前端），
改后是 `level=WARN msg="acp session invalid, recovering"`（识别成功、走向自愈）。

测试残留已清理：测试会话已删（`DELETE /api/v1/sessions/4`、`/5`），
测试时新建的工作区 2 已软删除；**你的 3 个原会话（#1 #2 #3）与工作区
`/home/zyw/Pictures/Wallpapers` 原样保留**。

---

## 5. 回滚与后续升级

### 回滚（30 秒）

```bash
ln -sfn /home/zyw/.zacp/bin/zacp-0.9.0 /home/zyw/.local/bin/zacp
cp ~/.zacp/config.toml.bak-idlefix-20260924-202814 ~/.zacp/config.toml
pm2 restart zacp
```

### ⚠️ 以后如果升级 ZACP（`install.sh`）

官方 `install.sh` 会把 `~/.local/bin/zacp` 重新指向**官方新版**——**补丁会丢**，
「离开久了再发消息」的报错会回来（本补丁尚未提交上游）。届时二选一：

1. 重打补丁：`bash rebuild-zacp-grokfix.sh`（会把 `VER` 改成新版号），或
2. 把 `upstream-fix/` 里的补丁提给 `helloxz/zacp`（这个洞对任何「不适配 grok 措辞」的
   判定都成立，属于通用修复）。

---

## 6. 附件清单

| 文件 | 说明 |
|---|---|
| `grok-unknown-session-fix.patch` | 对 `manager.go` 的最小补丁（`git apply` 可用） |
| `session_err_test.go` | 回归测试（放进 `backend/internal/acp/manager/`，`go test` 可跑） |
| `rebuild-zacp-grokfix.sh` | 一键：拉源码 → 打补丁 → 装工具链 → 构建 → 原子替换 → 重启 pm2 |
| `2026-09-24-ZACP-聊天图片不显示-根因与修复.md` | **后续问题**：修好会话恢复后发现的「聊天里的图片不显示（断裂图）」，含第二个前端补丁 `chat-image-workspace-url.patch` |

相关源码位置（本机临时目录，重启会清）：`/tmp/zacp-src`（v0.9.0 + 已打补丁）。

---

## 7. 给后续排查的一句话备忘

> ZACP 里 `unknown session id` 这类「agent 侧会话失效」错误，**本应**被
> `IsUnknownSessionErr` 捕获并自动 `session/load` 恢复；它对错误文本的要求是
> 「含 session」+「含 ≥8 位 id」。**任何不带 session id 的措辞都会漏判**，
> 表现就是离开一段时间后发消息直接报错。新接 agent 若报错里没有 id，
> 就该在 `manager.go` 的白名单/放行分支里补一条。
