05. SessionManager:会话 Actor
SessionManager.ts 约 9,000 行,是全项目最重要的控制面。它不是简单 CRUD service,而是“每个 session 的 Actor 容器”:串联持久化、模型 runtime、队列、工具资源、浏览器、后台任务和事件。
5.1 为什么叫 Actor
Section titled “5.1 为什么叫 Actor”代码没有使用正式 Actor 框架,但行为很像:
- 每个 session 有私有可变状态
ManagedSession; - 外部通过 manager 方法/RPC 发送命令;
- turn 事件按 session 归约;
- session 拥有 agent/MCP/browser/background resources;
- processing generation 和 refresh lock 防止过期异步结果写回;
- 最终以事件向客户端广播。
区别是 mailbox 并非完全串行:消息队列、配置 refresh、provider stream、后台任务可能并发,因此代码需要显式 generation、Promise lock 和去重表。
5.2 ManagedSession 的状态簇
Section titled “5.2 ManagedSession 的状态簇”定义位于 SessionManager.ts。可分为九组:
| 状态簇 | 代表字段 | 风险 |
|---|---|---|
| durable projection | metadata、messages、tokenUsage | 与磁盘漂移 |
| backend | agent, backend type/context, sdk ids |
stale runtime |
| processing | isProcessing, generation, abort reason |
旧 stream 回写 |
| midstream | FIFO queue、steer 状态、auto-retry key | 重复/乱序 |
| sources | loaded sources、server configs、MCP pool | credential/tool 热更新 |
| prompts | recovery/branch/transfer one-shot flags | 重复注入 |
| handoff | permission、plan、auth pending | UI 与 runtime 不一致 |
| background | task/shell registry、keepalive | turn 完成后遗留进程 |
| infrastructure | persistence、refresh lock、browser host | 泄漏/并发写 |
createManagedSession() 给所有字段建立一致默认值,避免 load 与 create 路径各造一套半初始化对象。
5.3 生命周期状态机
Section titled “5.3 生命周期状态机”stateDiagram-v2 [*] --> HeaderLoaded: initialize/list HeaderLoaded --> Hydrated: open/send/lazy load messages Hydrated --> RuntimeReady: getOrCreateAgent RuntimeReady --> Processing: sendMessage Processing --> Processing: text/tool/status events Processing --> Handoff: plan/auth/permission pause Handoff --> RuntimeReady: user resolves Processing --> RuntimeReady: complete/error/abort cleanup RuntimeReady --> Disposed: session delete/server stop/config restart Disposed --> Hydrated: future recreate runtime Hydrated --> [*]: delete sessionDisposed 不等于删除 session;只是释放 agent/MCP/browser 等运行时,磁盘 transcript 仍在,可稍后冷启动。
5.4 初始化:先轻后重
Section titled “5.4 初始化:先轻后重”initialize() 遍历 workspaces,并通过 loadSessionsFromDisk() 加载 session header。列表阶段不应构造所有 provider runtime,也不应读所有消息;否则几百个历史会话会在启动时生成大量 I/O 和子进程。
消息在打开、读取详情或发送时懒加载。这个策略与 JSONL 首行 header 配合:
app startup O(number of sessions × header)open one session O(messages in that session)first execution + backend/source initialization初始化完成后才应对外宣告完整 session 集;加载失败会被局部记录,不让一个坏 transcript 阻止整个 workspace。
5.5 创建会话
Section titled “5.5 创建会话”createSession() 位于约 2590–3094。普通流程:
解析 workspace→ 合并 create options / project / workspace defaults→ 校验 workingDirectory、connection、model→ 创建磁盘 StoredSession(sdkCwd 固定)→ createManagedSession→ 初始化 permission mode→ 放入 sessions map→ 发 session_created设计点:创建 session 并不立即建 agent。这样空草稿/列表项成本很低,connection 仍可在首条消息前修改。
分支创建是例外
Section titled “分支创建是例外”若带 branchFromMessageId:
- 找父 session 与 cutoff message;
- 确保父消息已加载;
- 要求同 provider/backend;
- 检查父
sdkSessionId、sdkCwd、provider-native turn anchor; - 把截止点前消息复制到 child,并重映射 session 路径;
- Pi 复制 turn anchor sidecar;
- 预创建 backend 并
ensureBranchReady(); - preflight 失败就删除/回滚 child;
- 成功后才广播
session_created。
“先 preflight、后 announce”避免 UI 出现一个实际上不能保证硬截止的伪分支。
5.6 getOrCreateAgent():冷启动主链
Section titled “5.6 getOrCreateAgent():冷启动主链”位于约 3329–3660。它不是一行 factory:
flowchart TD S["ManagedSession"] --> R["resolve connection/model/provider"] R --> L["锁定 connection"] L --> SRC["加载 enabled sources + credentials"] SRC --> BUILD["SourceServerBuilder"] BUILD --> POOL["McpClientPool.sync"] POOL --> CB["组装 session callbacks"] CB --> F["createBackendFromResolvedContext"] F --> INIT["postInit / ensure branch / auth diagnostics"] INIT --> READY["缓存 agent"]为什么 connection 要锁
Section titled “为什么 connection 要锁”connection 决定 provider type、credential、backend、base URL 和 resume 语义。首个 agent 创建后若悄悄切 connection,磁盘 sdkSessionId 可能被另一个 provider 误用。因此锁定是领域不变量,而非 UI 限制。
Source 先于 backend
Section titled “Source 先于 backend”后端初始化需要工具定义。SessionManager 先读取 source、刷新 credential、构建 server config、同步 pool,再把 proxy tools 交给 backend。Pi subprocess 不直接持有 credential/MCP client,它通过父进程 pool bridge 执行。
Callback 反转依赖
Section titled “Callback 反转依赖”Backend 需要更新 sdk id、读取 recovery messages、提交 plan、请求 auth、spawn session、调用浏览器等。它不依赖 SessionManager 类,而由 config 接收 callback。这样 shared agent 包不反向依赖 server-core。
5.7 sendMessage():耐久性优先的主链
Section titled “5.7 sendMessage():耐久性优先的主链”入口位于约 5746–6365。先看简化时序:
sequenceDiagram participant C as Client participant SM as SessionManager participant D as Disk participant B as AgentBackend participant E as Event Sink
C->>SM: sendMessage(text, attachments, options) SM->>SM: hydrate + clear stale handoff SM->>SM: create authoritative user Message SM->>D: persist + flush SM-->>E: user_message(accepted) SM->>SM: generation++, isProcessing=true SM->>SM: refresh skill/source/auth SM->>B: chat(...) loop AgentEvent stream B-->>SM: text/tool/status/complete SM->>SM: processEvent SM->>D: enqueue persistence SM-->>E: session_event end SM->>SM: onProcessingStopped SM->>D: flush final state先落盘再 accepted
Section titled “先落盘再 accepted”用户消息构造后立即 persist + flush,然后才发 accepted/queued 状态。若服务在 ack 后、写盘前崩溃,客户端会以为消息已接收但重启后消失;代码刻意关闭这个窗口。
测试证据:sendmessage-durability.test.ts。
第一条消息会先生成立即可用的 fallback title,再异步调用小模型生成更好标题。标题失败不影响主 turn,结果通过 title_generated 事件更新。
Skill 与 Source
Section titled “Skill 与 Source”发送前先解析显式 skill mentions,启用它们要求的 source;对 OAuth source 在冷建 agent 前刷新 token。否则 backend 注册完工具才发现 credential 过期,会让第一轮失败。
5.8 Mid-stream:queue 与 steer
Section titled “5.8 Mid-stream:queue 与 steer”若 session 正在处理,新消息不只有“拒绝”一种选择。connection 的 midStreamBehavior 决定:
- Claude 默认
queue; - Pi 默认
steer; - SessionManager 是唯一做决策的地方。
创建 user message→ 落盘/flush→ user_message(status=queued)→ FIFO 放入 pending queue→ 当前 turn 不标 interrupted当前 turn 完成后 processNextQueuedMessage() 取队首,把 timestamp 重写到上个 assistant message 之后,再进入 normal send。重写保证重载后的时间排序与实时处理顺序一致。
创建并落盘 user message→ backend.redirect/steer(message)→ 若 provider 接受,当前生成被转向→ 只有真实 abort/redirect 才标 interrupted→ 若无法投递,发 steer_undelivered / fallbackQueue 只是延迟处理,不能污染当前 assistant message 的“被中断”标记。这一不变量在 midstream-queue.test.ts 中覆盖。
5.9 Processing generation
Section titled “5.9 Processing generation”Abort、runtime restart、source retry 都可能让旧 async generator 在稍后才 yield/throw。若只靠 isProcessing,旧 turn 可能把新 turn 状态清掉。
典型策略:
const myGeneration = ++session.processingGenerationfor await (const event of agent.chat(...)) { if (session.processingGeneration !== myGeneration) break processEvent(event)}generation 是轻量 fencing token。它不取消旧计算本身,但阻止过期结果继续成为当前事实。
5.10 AgentEvent 归约
Section titled “5.10 AgentEvent 归约”processEvent() 位于约 7579–8384。主要分支:
text_delta:合批后推给 UI,避免每 token 一次高频更新;text_complete:创建权威 assistant message,分配单调 timestamp;- 保存 Claude sdk message id 或 Pi turn anchor,供 branch cutoff 使用。
tool_start:按 toolUseId 去重(SDK 可能从多个事件面重复报告),创建 tool message/card;tool_result:找到 start 记录并更新;- 超过约 200k 字符的持久结果截断/外置,避免 session JSONL 爆炸;
- 某些父工具结束时安全补全未闭合 child tool 状态;
- browser tool 同步 overlay/pane 状态。
status/info更新活动提示;typed_error/error转持久错误消息和 UI event;complete累加 usage 并进入 cleanup;source_activated触发一次去重的原消息自动重试;- background task/shell 事件更新 registry 和 UI。
5.11 Delta 合批
Section titled “5.11 Delta 合批”Provider token 很碎。如果每个 delta 都:
改内存 → JSONL enqueue → WS serialize → renderer atom update → React renderCPU 和 GC 会被协议开销淹没。SessionManager 维护 per-session delta buffer,按短时间窗合批 push;权威 text_complete 仍携带完整文本,所以即使某个 delta 丢失,最终消息可纠正 UI。
5.12 onProcessingStopped():真正的收尾点
Section titled “5.12 onProcessingStopped():真正的收尾点”约 6572–6700。它统一处理 complete/error/abort 后的尾部:
- 清 processing/generation 相关状态;
- 若策略不允许 keepalive,标记/处理 orphan background tasks;
- 根据 active viewing 更新 unread;
- 完成 mini session/异步 metadata;
- 若队列非空,重放下一条;否则广播 complete;
- 释放 browser owner/pane;
- 触发 TaskRunner 的
onSessionCompleteseam; - persist 最终状态。
如果 complete/error/abort 各写一套 cleanup,很容易漏释放资源或重复发事件;集中收尾是必要的。
5.13 Hard abort 与 UI handoff interrupt
Section titled “5.13 Hard abort 与 UI handoff interrupt”两者语义不同:
| 操作 | 目的 | 是否错误 | 后续 |
|---|---|---|---|
abort/forceAbort |
用户取消、runtime 失效、强制停止 | 可能产生 interrupted | 当前 turn 结束 |
interruptForHandoff |
plan/auth 交给人处理 | 否,是预期暂停 | 用户响应后恢复 |
Plan submit 路径会先创建 plan message,再 handoff interrupt、清 processing、释放 browser、持久化并发 complete-ish UI 状态。这样用户看到的是“等待决策”,不是“Agent 崩了”。
5.14 Runtime refresh
Section titled “5.14 Runtime refresh”配置 watcher 可能在会话运行时触发。refreshConnectionRuntime() 使用 per-session 串行 lock,比较两类签名:
- restart-required signature:provider/backend/critical endpoint/credential shape 等改变,需要 dispose + 重建;
- in-place signature:model、thinking、部分工具/source 等 backend 支持热更新的字段。
这样避免并发 watcher 同时重启两次,也避免任何微小配置改动都丢掉热上下文。
5.15 资源释放
Section titled “5.15 资源释放”disposeSessionRuntime() 大致释放:
- agent/subprocess;
- MCP pool clients;
- session-scoped tool callbacks;
- source activation/drain;
- browser session owner;
- pending callback/permission;
- config watchers/refresh handles。
删除 session 还需先 cancel processing、flush/close persistence,再删除磁盘目录。单纯从 sessions Map 删除会留下子进程与 socket。
5.16 SessionManager 的优点
Section titled “5.16 SessionManager 的优点”- 不变量集中:所有入口(UI、CLI、gateway、automation、task)最终走同一 send/create 流程。
- 耐久性清晰:消息 ack 与 persistence 顺序统一。
- provider 差异被包住:上层事件和持久消息稳定。
- 跨会话编排有 seam:completion listener、spawn/send callback 无需窥探 provider。
- 资源所有权明确:session 销毁时知道要清什么。
5.17 SessionManager 的风险
Section titled “5.17 SessionManager 的风险”状态空间过大
Section titled “状态空间过大”9,000 行、数十字段、多个 async seam,使“某字段在哪个 exit path 清理”难以证明。适合继续拆为:
- TurnCoordinator;
- SessionRuntimeFactory;
- SessionPersistenceFacade;
- HandoffCoordinator;
- SourceRuntimeController;
- BrowserLeaseController。
Actor 感很强,但并非真正单线程 mailbox。send、watcher refresh、browser callback、background completion 仍可能交错。generation/lock 是局部防线,新增异步入口必须明确自己的 fencing。
事件与持久化原子性
Section titled “事件与持久化原子性”文件写和 WS push 无法处于同一事务。代码通过“durable first”和客户端可重拉 snapshot 缩小问题,但仍要把 event 设计成可重放/可纠正,而不是假设 exactly-once。
5.18 调试清单
Section titled “5.18 调试清单”当出现“UI 卡住/重复消息/会话恢复错乱”,按顺序检查:
- session
processingGeneration是否已换代; - provider stream 是否仍在 yield 旧事件;
- user message 是否已写入 JSONL;
- queue/steer 路径是否错误标 interrupted;
text_complete.messageId/turnId是否能关联 delta;onProcessingStopped()是否运行且只运行一次;- WS event 是否断线重放,而 UI 是否重复归约;
- watcher 是否触发 runtime restart;
- source activation auto-retry key 是否去重;
- browser/background keepalive 是否让 turn 看似不结束。
5.19 本章小结
Section titled “5.19 本章小结”SessionManager 是 Craft Agents 的真正应用内核:磁盘 session 只是静态记录,backend 只是执行器,renderer 只是投影;把三者在一个耐久生命周期中连起来的就是这个会话 Actor 容器。
下一章抽掉具体 Claude/Pi 细节,先看 AgentBackend 怎样定义稳定上层契约,以及 factory 如何从 connection/model 解析出真实 runtime。