# Sessions · 会话怎么存、怎么回退、怎么 fork

> 进 Part 6。一盘对话怎么落盘、怎么恢复、怎么回退到某一步、怎么 fork 出一个分支。这一章拆 grok 的会话持久化：JSONL 双文件、UUIDv7、两阶段 rewind、跨压缩回放这个硬骨头，以及会话搜索与跨工具接续。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/sessions
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# Sessions · 会话怎么存、怎么回退、怎么 fork

Part 6 讲运行形态与工程。先看会话持久化——[Part 1 的 chat-state](https://daiw.org/manual/grok-build/chat-state) 在内存里管对话，这一章看它怎么落盘、恢复、回退、fork。

## 落盘布局

一个会话存在 `~/.grok/sessions/<编码后的 cwd>/<会话 id>/`，主要文件名集中定义在 `session/storage/mod.rs` 顶部的常量里(`UPDATES_FILE`、`CHAT_HISTORY_FILE`、`SUMMARY_FILE`……)。几个关键文件：

| 文件 | 装什么 |
| --- | --- |
| `chat_history.jsonl` | 模型面的对话历史(压缩后的视图) |
| `updates.jsonl` | 只追加的 `SessionUpdate` 流——**回放的真相源** |
| `summary.json` | 会话元信息(标题、模型、时间、父会话) |
| `rewind_points.jsonl` | 文件快照的回退点 |
| `compaction/` | 每段压缩的 markdown 与 `INDEX.md` |
| `compaction_checkpoints/` | 每次压缩的检查点 JSON |

另有 `plan.json`、`plan_mode.json`、`signals.json`、`usage.json`、`tool_state.json` 等小的 JSON 边车。

<Callout type="warn">
  一个**文档与实现不符**的点值得记：user-guide 的 headless 篇(`14-headless-mode.md`)在目录表里把 `sessions/` 写成「Session transcripts (SQLite)」，**实际是 JSONL + JSON 边车文件**(sessions 篇 `17-sessions.md` 倒是写对了)。SQLite 只是派生的 **FTS5 全文搜索缓存**：如今拆成了独立 crate `xai-grok-session-search`，库在 `<grok home>/sessions/session_search.sqlite`，坏了就隔离重建，而且它**从不直接读会话目录**(由调用方提供 `SessionSource`，`updates.jsonl` 的格式只归存储层管)。读源码时别被文档带偏——**JSONL 才是权威存储**。这也再次说明读真实源码(而非文档)的价值。
</Callout>

会话 id 是 **UUIDv7**(时间有序、定长 36 字符；客户端也可以用 `-s` 自带一个 UUID 新建)。写盘走一个持久化 actor(`PersistenceMsg`)，按 OS 做 `F_FULLFSYNC`/`fsync`；`summary.json` 这类小文件用「临时文件 + rename + 同步父目录」原子替换。`chat_history.jsonl` 的追加**不保证崩溃原子**，所以读时容忍坏行：跳过它们照常加载，原文件只隔离一次成 `chat_history.jsonl.corrupt`。

## 恢复

`--resume [id]`(空 = 最近一次；现在也能按当前目录下的会话标题匹配，UUID 形状的值总走 id 路径)/ `-c` / `--fork-session`。解析分「无 I/O 定意图 → 有 I/O 落地」两步：`session_startup_intent_from_flags`(纯函数，TUI 与 headless 共用，产出 `NewAuto`/`NewWithId`/`Resume`/`ForkFrom`；`--fork-session` 配 `--worktree` 这类非法组合在这一步就被拒)→ `materialize_startup_for_cwd`(查盘：最近会话、同仓不同 cwd……)。恢复时**沙箱 profile 必须匹配**：`async_main` 发现 `--sandbox` 与会话创建时的 profile 不同，会直接报错退出，不会悄悄改。

## rewind：两阶段回退

回退到「第 N 步之前」——保留 0..N-1、撤销 N 起的改动。每个 prompt 都是一个检查点，`RewindMode` 可选 `All`(对话 + 文件)、`ConversationOnly`(只回对话)、`FilesOnly`(只回文件)。`handle_rewind` 是**两阶段**的：

- `force=false`：**干跑预览**——算出哪些文件能干净回退、哪些冲突(被外部改过/删过)，**不动任何东西**；
- `force=true`：提交——按 `file_snapshots` 回放文件的旧内容，截断对话。

最硬的骨头是**跨压缩回放**：压缩会把 N+1 条用户消息折成三条左右，内存里的对话已经对不上 prompt 序号。所以现在**只要会话压缩过**(`needs_compaction_replay`)，不管回退点在压缩前还是后，都不再简单截断，而是由 `replay_to_prompt` 从 `updates.jsonl` + 压缩检查点**重建**那个时点的对话。回退还会往 `updates.jsonl` 追加一个 `RewindMarker`，回放时据此**剪掉死掉的时间线分支**。

## fork：复制一个分支

`fork_session`(`session/fork.rs`)把一个会话复制成新分支：`copy_session_data_sync`(`storage/jsonl/copy.rs`，放在阻塞线程池里跑，多个 fork 能真正并行)读 chat_history + updates，截断到目标 prompt，**重写每条 update 里的 session_id**，复制 `tool_state.json`、压缩段目录，以及保留下来的记录所引用的 `compaction_checkpoints/*.json`。新 id 也是 UUIDv7(不嵌源 id，免得 fork-of-fork 越来越长)；向后端登记则丢进后台任务，不挡关键路径。这就是 [subagent](https://daiw.org/manual/grok-build/subagents) 和 [worktree](https://daiw.org/manual/grok-build/fast-worktree) 隔离执行的会话侧基础。

## 搜索与跨工具接续

写作之后新增的两块：`grok sessions search` 把本地 FTS5 结果与远端结果合并；`/resume` 选择器还能列出 **Claude Code、Codex、Cursor** 的近期会话——`xai-grok-foreign-sessions` 只读元数据(每个工具最多 50 条、30 天内)，选中后交给内置的 `resume-claude`/`resume-codex`/`resume-cursor` 技能接手。

## 和《从 LLM 到 Coding Agent》对照

对照 [session-history](https://daiw.org/manual/llm-to-agent/session-history)：最小实现里存对话就是把数组写个 JSON。grok 的增量是把它做成**可恢复、可回退、可 fork、还能跨压缩重建**的持久化系统：双文件真相源、UUIDv7、两阶段 rewind、崩溃容错。下一章看一个特别的运行模式：plan mode。

---

上一篇：[PTY 包装与内联媒体](https://daiw.org/manual/grok-build/pty-wrap-media) · 下一篇：[Plan mode](https://daiw.org/manual/grok-build/plan-mode)
