会话系统

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

作者 David更新于 第 10 篇(共 33 篇)

回合系统负责“跑”,会话系统负责“存”。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 表结构、迁移与数据目录布局留给存储一篇,这里讲数据模型与数据怎么流。

怎么用

用法作用出处
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 --continueheadless 模式下接着某个会话跑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),关键字段与取值:

字段取值说明
runtimepi-agent、opencode后者是从旧版导入、尚未转换的会话(sessions/repo/contract.ts:12)
sessionTyperoot、branch用户新建的会话一律是 branch(packages/local-runtime-v2/src/service/session-system/sessions/lifecycle/record-service.ts:969);root 是每个 Agent 的主会话,标题固定为 Main(record-service.ts:653)
sessionKindconversation、task、peek、channel、cron、unknown子 Agent 任务、旁支对话、IM 渠道、定时任务各有其类(sessions/repo/contract.ts:17)
statusidle、started、error、aborted、interruptedsessions/repo/contract.ts:14
visibilityvisible、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):

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,这是回合系统做幂等的基础之一。

流式帧

实时输出不走数据库。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):

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 遗产。
  • 删除:会话删除要清理画布、产物、差异、通信、渠道绑定、问卷、权限、Goal、查询折叠、置顶,并给旧会话写墓碑防止被重新发现(session-system/initialize.ts:184)。删除前回合系统先封住这个会话、排空队列派发、中止活动回合(packages/local-runtime-v2/src/service/turn-system/lifecycle/session-turn-deletion.ts:30)。

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

图表加载中…

--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)。

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

--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)。

下一篇:回合系统:受理、排队与执行——一条消息从提交到跑完,中间经过哪些关卡。

本页目录