青雲的博客
深入浅出 Pi 第五部:Coding Agent 的控制面 第 28 章

恢复、分叉与切换会话不是同一个动作

区分 SessionManager 同文件 branch、AgentSessionRuntime 的 resume/new/fork,以及旧 runtime 的 teardown 与重绑顺序,说明会话操作真正替换了什么。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

SessionManager.open(path) 能从 JSONL 还原消息、model 和 thinking level。产品里的 /resume 仍没有在旧 AgentSession 上调用 setSessionFile()。原因藏在 cwd-bound 依赖里:settings、资源发现、扩展、工具和 system prompt 都可能随 session 的 cwd 改变。只换一棵消息树,会留下半套旧运行时。

Pi 为此增加了 AgentSessionRuntime。它同时持有当前 AgentSession 和这份 session 对应的 services;所有 replacement 方法都遵守同一条规则:先 teardown 旧 runtime,再为目标 cwd 与 SessionManager 创建新 runtime,最后交给宿主重新绑定。

sequenceDiagram
  accTitle: 会话替换的共同顺序
  accDescr: 宿主先允许扩展取消操作;接受后旧 session 完成 abort 与 shutdown,再创建并应用新 runtime,最后重绑 UI 与新 extension context
  participant H as Host
  participant O as Old AgentSession
  participant F as Runtime factory
  participant N as New AgentSession
  H->>O: session_before_switch / fork
  O-->>H: cancel 或继续
  H->>O: abort() 并等待 settled
  H->>O: session_shutdown
  H->>O: dispose()
  H->>F: createRuntime(target cwd, SessionManager)
  F-->>H: New runtime result
  H->>N: apply + rebind + withSession

Resume 打开旧文件,但创建的是新运行时

switchSession() 先发出可取消的 session_before_switch(reason: "resume")。通过后,它保存 outgoing session path,用 SessionManager.open() 打开目标文件并确认 cwd 存在。随后旧 session 进入 teardown;factory 以目标 cwd、原 agentDir 和新 manager 重建 services 与 AgentSession,session_start 携带 reason 和 previous session file。

teardown 的顺序也不是随手写的。它先 await session.abort(),确保被中止的 assistant 消息和已产生的 tool result 经过 AgentSession 事件链持久化;再等待 session_shutdown handler,执行同步的 UI invalidation callback,最后 dispose。新 runtime 应用以后,宿主的 rebindSession 先执行,可选的 withSession 再拿到新 context。

这仍然不是 durable run resume。旧进程若还活着,Pi 会主动中止当前 turn,写下已经形成的终态,再换 session;进程已经崩溃时,JSONL 只能恢复已追加 entry,没有工具执行 receipt、流 offset 或未结算 Promise 可继续。

Fork 复制一条路径,branch 只移动 leaf

SessionManager.branch(entryId) 是同一 manager 内的逻辑导航:它把 leaf 指到旧 entry,下一次 append 会成为这个节点的新孩子,旧行不删不改。第 23 章讨论的树上导航属于这一类。

AgentSessionRuntime.fork() 是产品级 replacement。默认 position: "before" 只接受 user message entry,把目标 leaf 设为该 user message 的 parent,并把被选中的用户文本返回给编辑器;position: "at" 则保留选定 entry 本身。持久 session 会用 createBranchedSession() 生成新 session id 与文件,复制根到目标 leaf 的路径,重新串起移除 label entry 后的 parentId,再补回路径上的有效 label。新 header 的 parentSession 指向来源文件。

一个尚未写出首条 assistant 的持久 session 文件可能不存在,runtime fork 会拒绝从它复制路径;从 root 之前 fork 则可以新建空 session,并记录 parent session 关系。内存模式没有文件可复制,会在当前 manager 中替换 header 与选定路径,但 runtime 仍照样 teardown 和重建。

New 清空会话,仍保留相同 replacement 纪律

newSession() 依据当前 manager 是否持久化来创建新 manager;cwd 不变,session id、header 和 entry 树换新。可选 setup 可以先向新 manager 写入内容,再用 buildSessionContext() 同步 Agent 消息。它与 resume 都走 session_before_switch,区别在 reason、目标 manager 和 cwd 来源。

replacement 还有一个不那么舒服的失败边界。类注释写明旧 runtime 先 teardown,新 runtime 后创建;若 factory 抛错,错误直接交给调用方。这里没有保留可自动复活的旧 AgentSession,也没有事务式 rollback。宿主必须把失败呈现为致命 runtime 错误或重新建立一个 runtime,不能继续使用已经 dispose 的 extension context。

可以把源码顺序与 faux provider 生命周期测试并排核对:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/coding-agent/src/core/agent-session-runtime.ts |
  nl -ba | sed -n '67,95p;167,224p;262,352p'

# 依赖已安装时执行,不调用真实模型
cd "$repo"
npx vitest --run packages/coding-agent/test/agent-session-runtime-events.test.ts

生命周期测试依赖安装后的 Vitest 环境,本轮只核对了前一条静态路径。runtime replacement 解决了“哪套服务属于当前 session”;新 session 建好以后,model 和 thinking level 还要在当前状态、会话树和全局默认之间保持可解释的一致。第 29 章从 setModel() 的三次写入开始。