# 会话系统

> 会话记录、两层消息、流式帧、队列与用量由谁持有，分叉、回退与旧会话导入如何分工，以及 --continue、--session、/sessions 的调用路径。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.org/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/minimax-code/session-system
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

回合系统负责“跑”，会话系统负责“存”。`packages/local-runtime-v2/src/service/session-system` 是会话数据唯一的 owner：会话记录、项目归属、展示消息与规范历史、流式帧、队列、用量、查询折叠状态，都由它建库、建服务、对外发能力。入口 `initializeSessionSystem` 的注释只有一句（`packages/local-runtime-v2/src/service/session-system/owner.ts:157`）：

> One production graph over the shared AppDb; repositories never own the connection.

它返回的对象按领域分组：`repositories`、`sessions`、`messages`、`fork`、`projects`、`files`、`queue`、`usage`、`stream`、`canonicalHistory`、`queryCollapse` 等（`owner.ts:272`）。应用层再在其上叠一层用例，`initializeSessionApplicationSystem` 特意“accepts no AppDb and creates no repository”（`packages/local-runtime-v2/src/service/session-system/initialize.ts:139`），保证全进程只有一套会话仓储。SQLite 表结构、迁移与数据目录布局留给[存储](https://daiw.org/manual/minimax-code/storage)一篇，这里讲数据模型与数据怎么流。

## 怎么用

| 用法 | 作用 | 出处 |
| --- | --- | --- |
| `mcode -c`，即 `mcode --continue` | 打开当前工作区最近的会话 | `packages/tui/src/cli/contract.ts:38` |
| `mcode --session <id>` | 按 ID 打开会话 | `cli/contract.ts:36` |
| `mcode --session`（不带 ID） | 启动后直接打开会话管理器 | `cli/contract.ts:36` |
| `mcode --resume <id>` | 隐藏的兼容参数，等同 `--session <id>` | `cli/contract.ts:44` |
| `mcode exec --session <id>`、`mcode exec --continue` | headless 模式下接着某个会话跑 | `cli/contract.ts:106` |
| `/sessions [query]`，别名 `/resume` | 搜索、恢复、改名、归档会话 | `packages/tui/src/tui/commands/catalog.ts:146` |
| `/new`，别名 `/clear` | 在当前工作区开新会话 | `catalog.ts:129` |
| `/fork`、`/rewind`、`/edit`、`/history` | 分叉、回退、改写最后一条提示词、浏览历史提示词 | `catalog.ts:211`–`244` |
| `/btw`，别名 `/side` | 开一个不打断主任务的旁支对话 | `catalog.ts:197` |
| `/usage` | 查看会话用量 | `packages/tui/src/application/command-descriptors.ts:39` |

`--session`、`--continue`、`--resume` 三者互斥，同时给会报“cannot be combined”（`cli/contract.ts:74`）。正常退出时 TUI 会打印一行 `mcode --session <id>` 提示怎样回到刚才的会话（`packages/tui/src/tui/launcher.ts:880`）。`/resume` 后面跟的参数看起来像会话 ID（以 `mvs_`、`ses_` 等开头）时会直接打开，否则当作搜索词打开管理器（`packages/tui/src/tui/controller/product/command-flow.ts:963`、`1766`）。

## 会话记录

一个会话在代码里是 `SessionRecord`（`packages/local-runtime-v2/src/service/session-system/sessions/repo/contract.ts:48`），关键字段与取值：

| 字段 | 取值 | 说明 |
| --- | --- | --- |
| `runtime` | `pi-agent`、`opencode` | 后者是从旧版导入、尚未转换的会话（`sessions/repo/contract.ts:12`） |
| `sessionType` | `root`、`branch` | 用户新建的会话一律是 `branch`（`packages/local-runtime-v2/src/service/session-system/sessions/lifecycle/record-service.ts:969`）；`root` 是每个 Agent 的主会话，标题固定为 `Main`（`record-service.ts:653`） |
| `sessionKind` | `conversation`、`task`、`peek`、`channel`、`cron`、`unknown` | 子 Agent 任务、旁支对话、IM 渠道、定时任务各有其类（`sessions/repo/contract.ts:17`） |
| `status` | `idle`、`started`、`error`、`aborted`、`interrupted` | `sessions/repo/contract.ts:14` |
| `visibility` | `visible`、`hidden` | 分叉的中间态与旁支对话是 `hidden` |
| `workspaceDir`、`runLocation` | 路径、worktree 信息 | 工作目录，或新建 worktree 时的实际目录 |
| `effectiveModel` 等 | 模型键、变体、思考档位、上下文窗口 | 会话级的模型选择 |
| `historyRelativeDir` | 相对 `<dataDir>/v2/sessions` 的目录 | 规范历史的存放位置，只分配一次 |

新会话由 `createNativeSessionRecordService` 落库（`packages/local-runtime-v2/src/service/session-system/sessions/support/native-record-factory.ts:54`）：先解析项目，再以 `runtime: 'pi-agent'`、`status: 'idle'`、`sessionOrigin: 'local-runtime'` 写入。会话 ID 形如 `mvs_` 加去掉连字符的 UUID（`record-service.ts:261`）。回合结束时状态按结局回写：完成回到 `idle`，中止记 `aborted`，失败记 `error` 并带上错误信息（`packages/local-runtime-v2/src/service/session-system/sessions/recovery/capabilities.ts:107`）。对外输出时，状态被压成协议里的四值枚举，`interrupted` 与 `aborted` 都映射为 `Abort`（`packages/local-runtime-v2/src/application/session/wire.ts:239`）。

## 项目归属

项目是按工作目录对会话的分组，存在 `local_runtime_projects` 表（`packages/local-runtime-v2/src/infra/db/schema/projects.ts:6`）。归属在建会话时一次定下（`native-record-factory.ts:64`）：

```ts
const project = input.isDefaultWorkspace
  ? await projectRepository.ensureDefault(nowMs())
  : await projectRepository.ensureByWorkspaceDir(input.workspaceDir, nowMs());
```

TUI 建会话总带着当前工作目录（`packages/tui/src/tui/controller/chat-controller.ts:745`），`CliService` 因此走 `createExplicitWorkspaceSession`，把 `isDefaultWorkspace` 显式置为 `false`（`packages/local-runtime-v2/src/application/session/lifecycle-application.ts:198`），所以命令行会话都落在“工作区项目”里。反查时 `resolveSessionProjectIdentity` 只认会话上的外键，结果要么是 `default`，要么是 `workspace:` 加规范化路径（`packages/local-runtime-v2/src/service/session-system/projects/service.ts:147`）。从代码看，项目列表与侧边栏查询主要服务桌面端；TUI 不调用项目接口，按工作目录过滤会话是在客户端做的（`packages/tui/src/runtime/adapters/session-access.ts:141`）。

## 消息的两层

同一段对话在磁盘上存两份，各管一件事：

- **展示消息**：给界面看的逐条记录，存在 SQLite 的 `local_runtime_message_rows`，每行有 `msg_id`、`role`、`turn_id`、`source` 与整条消息的 JSON（`packages/local-runtime-v2/src/infra/db/schema/messages.ts:10`）。Agent 事件投影把运行时事件写成这里的行（`packages/local-runtime-v2/src/service/session-system/agent-projection.ts:75`）；`getMessages` 默认每页 80 条（`packages/local-runtime-v2/src/service/session-system/messages/query/query-service.ts:6`）。
- **规范历史**：真正回放给模型的 pi 消息序列，是按会话建目录的 JSONL 文件。目录按创建时间（默认 UTC）分年月日，末级由时刻与编码后的会话 ID 拼成，形如 `v2/sessions/2026/09/19/08-30-00-123-…/`（`packages/local-runtime-v2/src/service/session-system/messages/history/session-history-paths.ts:97`）；目录里有 `manifest.json`、`messages.jsonl`、`snapshots/`、`reports/`（`session-history-paths.ts:73`）。

两层并存由来已久：旧表 `local_runtime_messages` 就是一个会话一行、`display_messages_json` 与 `pi_history_json` 两列并排（`schema/messages.ts:4`）。规范历史的写入最多尝试 3 次，两次重试前分别等 25 与 50 毫秒，提交结果不确定的追加则不重试（`packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-provider.ts:40`、`409`）；压缩时旧的一代整体归档到 `snapshots/`，文件名带代号与压缩 ID，新一代替换 `messages.jsonl`（`canonical-history-provider.ts:282`）。规范历史提交失败时，会话被标成 `error`，`errorSource` 记为 `canonical-history`（`agent-projection.ts:129`）。

用户消息的 ID 是确定性的：`msg-user-v1-` 加上会话 ID 与一个 `messageKey` 的 SHA-256（`packages/local-runtime-v2/src/service/session-system/shared/user-message-id.ts:8`）。同一次提交无论重试几次都得到同一个 ID，这是[回合系统](https://daiw.org/manual/minimax-code/turn-system)做幂等的基础之一。

## 流式帧

实时输出不走数据库。`SessionStreamService` 给每个会话在内存里维护一个有界环形缓冲，上限 2000 帧、8 MiB（`packages/local-runtime-v2/src/service/session-system/stream/session-frame-retention.ts:4`）。帧的种类有 `runtime-event`、`turn-terminal`、`message-committed`、`messages-rewound`、`resync-required` 等（`packages/local-runtime-v2/src/service/session-system/stream/session-frame.ts:1`）。

发消息之前，对话应用先 `reserve` 一个订阅（`packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts:70`），回合被受理后再用 `bindTurn` 把它过滤到这个回合的帧（`packages/local-runtime-v2/src/service/session-system/stream/session-stream-service.ts:107`），受理之后立刻产生的帧也不会漏掉。

重新打开一个仍在运行的会话时，TUI 用 `watchSessionTurn` 调 `resumeSession` 续接（`packages/tui/src/runtime/adapter.ts:666`）。带 `afterMsgId` 时，服务端先从展示消息表补出锚点之后已落库的消息，再与缓冲区里的帧拼接；带 `afterCursor` 时只重放缓冲区（`session-stream-service.ts:246`）。游标已被挤出缓冲、无效或锚点消息缺失时，只返回一帧 `resync-required` 就结束（`session-stream-service.ts:325`），headless 的监督器收到它会改为读取已落库的历史（`packages/tui/src/headless/supervisor.ts:177`）。续接的流在读到第一个 `turn-terminal` 帧后自动收尾（`session-stream-service.ts:345`）。

## 队列、用量与查询折叠

- **队列**：`local_runtime_queue_items` 每行一条待发消息，另有 `local_runtime_queue_pauses` 记录会话级暂停（`packages/local-runtime-v2/src/infra/db/schema/queue.ts:9`、`56`）。条目只有 `queued` 与 `claimed` 两种状态（`packages/local-runtime-v2/src/service/session-system/queue/repo/contract.ts:20`），暂停原因只有 `user-stop` 与 `turn-final-failure`（`queue/repo/contract.ts:150`）。认领带 30 秒租约（`packages/local-runtime-v2/src/service/session-system/queue/repo/drizzle.ts:51`），会话暂停或已有条目被认领时不再认领新的，显式指定条目或继续暂停的队列时例外（`queue/repo/drizzle.ts:304`）。谁来认领、何时派发，是回合系统的事。
- **用量**：每条带用量的已提交 assistant 消息写一行 `local_runtime_token_usage`，记输入、输出、推理、缓存读写 token 与费用（`packages/local-runtime-v2/src/infra/db/schema/usage.ts:4`；写入逻辑见 `packages/local-runtime-v2/src/service/session-system/usage/pi-usage.ts:64`）。写成功后发一个进程内信号（`owner.ts:705`），TUI 经 `watchSessionUsageCommits`（`packages/local-runtime-v2/src/local/cli-service.ts:478`）订阅它，刷新当前会话的用量显示（`packages/tui/src/tui/controller/runtime/runtime-event-flow.ts:326`）。全局汇总可按 `agent`、`session`、`model`、`day` 分组（`packages/local-runtime-v2/src/service/session-system/usage/service.ts:106`）。
- **查询折叠**：一个用户问题可能跨越多个回合（续跑、排队、引导），界面要把中间过程折成一组。这份状态存在 `local_runtime_query_view_states`（`packages/local-runtime-v2/src/infra/db/schema/query-collapse.ts:5`），代码称之为带回合栅栏的展示侧车（`packages/local-runtime-v2/src/service/session-system/query-collapse-state.ts:55`）：它不管执行，只用 `currentTurnId` 挡住过期的写入。

## 分叉、回退与差异

**分叉**（`/fork`）由应用层的 `ForkService` 编排，是一个可恢复、失败可补偿的多阶段流程（`packages/local-runtime-v2/src/application/session/conversation-fork-service.ts:504`）。先校验源会话与分叉边界，需要隔离 worktree 时先准备好（`conversation-fork-service.ts:542`），然后走共用的几步（`conversation-fork-service.ts:113`）：建一个 `hidden` 的 `branch` 子会话，把规范历史的前缀发布过去（经回合系统的 `fork`），复制展示消息前缀与附件，最后把子会话翻成 `visible`（`conversation-fork-service.ts:409`）。每一阶段都写进操作清单，进程中途退出后可以从清单续做。会话系统在这里只提供数据能力：建子会话、复制展示前缀、复制附件（`packages/local-runtime-v2/src/service/session-system/fork/data-capability.ts:12`）。

**旁支对话**（`/btw`）复用同一套分叉流程（`conversation-fork-service.ts:484`）：子会话 `sessionKind` 为 `peek`、保持 `hidden`（`conversation-fork-service.ts:260`），`purpose` 固定为 `peek_btw_session`（`lifecycle-application.ts:421`），并在继承来的历史后插入一段隐藏的边界提示（`conversation-fork-service.ts:31`），正文开头是（`conversation-fork-service.ts:34`）：

```text
Side conversation boundary.

Everything before this boundary is inherited history from the parent session. It is reference context only. It is not your current task.
```

**回退**（`/rewind`）以一条用户消息为界，连它在内把之后的展示消息与规范历史一起删掉，目标必须是已提交的 `msg-user-v1-` 消息（`packages/local-runtime-v2/src/service/session-system/messages/rewind/service.ts:25`），提交后会话状态清回 `idle`。可选地连文件改动一起回退（`rewindTurnDiff`）。

**差异**的存储不在会话系统：每个回合改了哪些文件，记在第一代运行时的 `SqliteLocalTurnDiffStore` 里（`packages/local-runtime-v2/src/compat/v1/session.ts:128`）。会话系统的 `SessionDiffService` 只判断资格，注释写得明白（`packages/local-runtime-v2/src/service/session-system/diffs/service.ts:21`）：

> Owns eligibility only. Diff capture/query/revert/reapply storage remains external.

对 `runtime` 为 `opencode` 的旧会话，它直接拒绝。回合差异保留 7 天，每批清理 250 条（`packages/local-runtime-v2/src/service/session-system/storage-retention.ts:3`），清理任务作为后台维护项注册（`packages/local-runtime-v2/src/runtime.ts:883`）。会话附件则由 `SessionFilesService` 从展示消息里懒建索引（`packages/local-runtime-v2/src/service/session-system/files/service.ts:67`）。

## 启动恢复、旧会话与删除

- **崩溃恢复**：新进程启动时，把上一个进程留下的 `started` 状态会话批量改成 `interrupted`，每批 1000 个，并写上一句说明（`packages/local-runtime-v2/src/service/session-system/sessions/recovery/recovery-service.ts:7`、`10`、`30`）。回合系统传入仍在运行或正在删除的会话作为保护名单，它们不受影响（`packages/local-runtime-v2/src/service/turn-system/initialize.ts:370`）。
- **旧会话**：`LegacyOpencodeImporter` 是把旧版 OpenCode 会话变成可执行 `pi-agent` 会话的唯一通道，而且是惰性、单向的（`packages/local-runtime-v2/src/service/session-system/legacy-migration/importer.ts:101`）。列会话时触发元数据发现，打开会话时补齐元数据，读消息时导入展示消息，真正执行时才导入历史（`importer.ts:116`–`180`）；数据源是数据目录下的 `sqlite.db`（`packages/local-runtime-v2/src/infra/legacy-db/readonly-source.ts:57`）。细节见[第一代运行时与 OpenCode 遗产](https://daiw.org/manual/minimax-code/runtime-v1)。
- **删除**：会话删除要清理画布、产物、差异、通信、渠道绑定、问卷、权限、Goal、查询折叠、置顶，并给旧会话写墓碑防止被重新发现（`session-system/initialize.ts:184`）。删除前回合系统先封住这个会话、排空队列派发、中止活动回合（`packages/local-runtime-v2/src/service/turn-system/lifecycle/session-turn-deletion.ts:30`）。

## 从 TUI 到会话系统的三条路径

```mermaid
flowchart TD
  A["mcode --continue"] --> D["activateLatestSession"]
  B["mcode --session id"] --> E["activateSessionById"]
  C["mcode --session 或 /sessions"] --> F["showSessionManager"]
  D --> E
  F --> G["listSessionPage → getSessionTree"]
  F -->|"选中一项"| E
  E --> H["loadSessionProjection → getSession + getMessages"]
  G --> I["SessionQueryService.tree"]
  H --> J["SessionQueryService.get"]
  H --> K["MessageQueryService.list"]
```

**`--continue`**：启动器在界面就绪后调用 `continueLatestSession`（`launcher.ts:902`），最终落到 `activateLatestSession`（`packages/tui/src/tui/controller/session-flow.ts:520`）：在控制器已加载的会话列表里找第一个工作目录相同、未归档、未隐藏的会话，找不到就提示“No saved Session exists in the current workspace.”。这份列表是控制器初始化时用 `listSessions` 取默认 Agent `mavis` 名下会话的结果，按 `updatedAt` 倒序（`chat-controller.ts:150`；`continueLatestSession` 要等这一步完成，见 `packages/tui/src/tui/app.ts:687`），默认不含已归档、隐藏与 `cron` 会话（`packages/local-runtime-v2/src/service/session-system/sessions/query/query-service.ts:182`、`231`）。请求没带 `limit`，仓储层默认每页 50 条、上限 200（`packages/local-runtime-v2/src/service/session-system/sessions/repo/pagination.ts:49`）。

<Callout type="warn">
  从代码推断，`--continue` 只在全局最近更新的 50 个会话里找当前目录的会话。如果别的目录里有 50 个更新的会话，当前目录那个较旧的会话不会被找到，这时可以用 `mcode --session` 打开管理器手动选择。
</Callout>

**`--session <id>`**：`activateSessionById`（`session-flow.ts:148`）先把 ID 解析成用户可导航的会话：子 Agent 的内部会话会沿父链上溯到公开的父会话，最多 32 层（`session-flow.ts:602`、`639`），并提示“Opened the parent Session instead.”。随后 `loadSessionProjection` 并发调用 `getSession` 与 `getMessages`（`chat-controller.ts:195`）；打开的会话若已归档，会顺手取消归档（`chat-controller.ts:215`）。运行时这边，`getSession` 经 `SessionQueryService.get` 先让旧会话导入器补齐元数据（`sessions/query/query-service.ts:168`），`getMessages` 经 `MessageQueryService` 先确保展示消息就绪（`messages/query/query-service.ts:58`）。会话仍在运行时，TUI 接着用前面说的 `resumeSession` 续接实时帧。

**`/sessions`**：会话管理器每页取 50 条（`packages/tui/src/tui/controller/product/feature-flow.ts:84`），先看当前工作区，可切到全部（`feature-flow.ts:478`）。它请求 `allAgents: true`，TUI 适配层因此调 `getSessionTree`，把根会话与其子会话拍平（`session-access.ts:320`）；工作区过滤在客户端逐页进行，直到凑满一页（`session-access.ts:143`）。运行时的 `SessionQueryService.tree` 先做旧会话发现，再分页取根会话、批量取子会话（`sessions/query/query-service.ts:97`）。请求带着 `includeArchived` 与 `includeHidden`，运行时照单全返；管理器在本地剔除内部子 Agent 会话与多数隐藏会话，已归档的放进单独的视图（`packages/tui/src/tui/features/session/manager.ts:625`、`618`）。

下一篇：[回合系统：受理、排队与执行](https://daiw.org/manual/minimax-code/turn-system)——一条消息从提交到跑完，中间经过哪些关卡。
