跳转到内容

05. SessionManager:会话 Actor

SessionManager.ts 约 9,000 行,是全项目最重要的控制面。它不是简单 CRUD service,而是“每个 session 的 Actor 容器”:串联持久化、模型 runtime、队列、工具资源、浏览器、后台任务和事件。

代码没有使用正式 Actor 框架,但行为很像:

  • 每个 session 有私有可变状态 ManagedSession
  • 外部通过 manager 方法/RPC 发送命令;
  • turn 事件按 session 归约;
  • session 拥有 agent/MCP/browser/background resources;
  • processing generation 和 refresh lock 防止过期异步结果写回;
  • 最终以事件向客户端广播。

区别是 mailbox 并非完全串行:消息队列、配置 refresh、provider stream、后台任务可能并发,因此代码需要显式 generation、Promise lock 和去重表。

定义位于 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 路径各造一套半初始化对象。

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 session

Disposed 不等于删除 session;只是释放 agent/MCP/browser 等运行时,磁盘 transcript 仍在,可稍后冷启动。

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。

createSession() 位于约 2590–3094。普通流程:

解析 workspace
→ 合并 create options / project / workspace defaults
→ 校验 workingDirectory、connection、model
→ 创建磁盘 StoredSession(sdkCwd 固定)
→ createManagedSession
→ 初始化 permission mode
→ 放入 sessions map
→ 发 session_created

设计点:创建 session 并不立即建 agent。这样空草稿/列表项成本很低,connection 仍可在首条消息前修改。

若带 branchFromMessageId

  1. 找父 session 与 cutoff message;
  2. 确保父消息已加载;
  3. 要求同 provider/backend;
  4. 检查父 sdkSessionIdsdkCwd、provider-native turn anchor;
  5. 把截止点前消息复制到 child,并重映射 session 路径;
  6. Pi 复制 turn anchor sidecar;
  7. 预创建 backend 并 ensureBranchReady()
  8. preflight 失败就删除/回滚 child;
  9. 成功后才广播 session_created

“先 preflight、后 announce”避免 UI 出现一个实际上不能保证硬截止的伪分支。

位于约 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 决定 provider type、credential、backend、base URL 和 resume 语义。首个 agent 创建后若悄悄切 connection,磁盘 sdkSessionId 可能被另一个 provider 误用。因此锁定是领域不变量,而非 UI 限制。

后端初始化需要工具定义。SessionManager 先读取 source、刷新 credential、构建 server config、同步 pool,再把 proxy tools 交给 backend。Pi subprocess 不直接持有 credential/MCP client,它通过父进程 pool bridge 执行。

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

用户消息构造后立即 persist + flush,然后才发 accepted/queued 状态。若服务在 ack 后、写盘前崩溃,客户端会以为消息已接收但重启后消失;代码刻意关闭这个窗口。

测试证据:sendmessage-durability.test.ts

第一条消息会先生成立即可用的 fallback title,再异步调用小模型生成更好标题。标题失败不影响主 turn,结果通过 title_generated 事件更新。

发送前先解析显式 skill mentions,启用它们要求的 source;对 OAuth source 在冷建 agent 前刷新 token。否则 backend 注册完工具才发现 credential 过期,会让第一轮失败。

若 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 / fallback

Queue 只是延迟处理,不能污染当前 assistant message 的“被中断”标记。这一不变量在 midstream-queue.test.ts 中覆盖。

Abort、runtime restart、source retry 都可能让旧 async generator 在稍后才 yield/throw。若只靠 isProcessing,旧 turn 可能把新 turn 状态清掉。

典型策略:

const myGeneration = ++session.processingGeneration
for await (const event of agent.chat(...)) {
if (session.processingGeneration !== myGeneration) break
processEvent(event)
}

generation 是轻量 fencing token。它不取消旧计算本身,但阻止过期结果继续成为当前事实。

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。

Provider token 很碎。如果每个 delta 都:

改内存 → JSONL enqueue → WS serialize → renderer atom update → React render

CPU 和 GC 会被协议开销淹没。SessionManager 维护 per-session delta buffer,按短时间窗合批 push;权威 text_complete 仍携带完整文本,所以即使某个 delta 丢失,最终消息可纠正 UI。

5.12 onProcessingStopped():真正的收尾点

Section titled “5.12 onProcessingStopped():真正的收尾点”

6572–6700。它统一处理 complete/error/abort 后的尾部:

  1. 清 processing/generation 相关状态;
  2. 若策略不允许 keepalive,标记/处理 orphan background tasks;
  3. 根据 active viewing 更新 unread;
  4. 完成 mini session/异步 metadata;
  5. 若队列非空,重放下一条;否则广播 complete;
  6. 释放 browser owner/pane;
  7. 触发 TaskRunner 的 onSessionComplete seam;
  8. persist 最终状态。

如果 complete/error/abort 各写一套 cleanup,很容易漏释放资源或重复发事件;集中收尾是必要的。

两者语义不同:

操作 目的 是否错误 后续
abort/forceAbort 用户取消、runtime 失效、强制停止 可能产生 interrupted 当前 turn 结束
interruptForHandoff plan/auth 交给人处理 否,是预期暂停 用户响应后恢复

Plan submit 路径会先创建 plan message,再 handoff interrupt、清 processing、释放 browser、持久化并发 complete-ish UI 状态。这样用户看到的是“等待决策”,不是“Agent 崩了”。

配置 watcher 可能在会话运行时触发。refreshConnectionRuntime() 使用 per-session 串行 lock,比较两类签名:

  • restart-required signature:provider/backend/critical endpoint/credential shape 等改变,需要 dispose + 重建;
  • in-place signature:model、thinking、部分工具/source 等 backend 支持热更新的字段。

这样避免并发 watcher 同时重启两次,也避免任何微小配置改动都丢掉热上下文。

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。

  1. 不变量集中:所有入口(UI、CLI、gateway、automation、task)最终走同一 send/create 流程。
  2. 耐久性清晰:消息 ack 与 persistence 顺序统一。
  3. provider 差异被包住:上层事件和持久消息稳定。
  4. 跨会话编排有 seam:completion listener、spawn/send callback 无需窥探 provider。
  5. 资源所有权明确:session 销毁时知道要清什么。

9,000 行、数十字段、多个 async seam,使“某字段在哪个 exit path 清理”难以证明。适合继续拆为:

  • TurnCoordinator;
  • SessionRuntimeFactory;
  • SessionPersistenceFacade;
  • HandoffCoordinator;
  • SourceRuntimeController;
  • BrowserLeaseController。

Actor 感很强,但并非真正单线程 mailbox。send、watcher refresh、browser callback、background completion 仍可能交错。generation/lock 是局部防线,新增异步入口必须明确自己的 fencing。

文件写和 WS push 无法处于同一事务。代码通过“durable first”和客户端可重拉 snapshot 缩小问题,但仍要把 event 设计成可重放/可纠正,而不是假设 exactly-once。

当出现“UI 卡住/重复消息/会话恢复错乱”,按顺序检查:

  1. session processingGeneration 是否已换代;
  2. provider stream 是否仍在 yield 旧事件;
  3. user message 是否已写入 JSONL;
  4. queue/steer 路径是否错误标 interrupted;
  5. text_complete.messageId/turnId 是否能关联 delta;
  6. onProcessingStopped() 是否运行且只运行一次;
  7. WS event 是否断线重放,而 UI 是否重复归约;
  8. watcher 是否触发 runtime restart;
  9. source activation auto-retry key 是否去重;
  10. browser/background keepalive 是否让 turn 看似不结束。

SessionManager 是 Craft Agents 的真正应用内核:磁盘 session 只是静态记录,backend 只是执行器,renderer 只是投影;把三者在一个耐久生命周期中连起来的就是这个会话 Actor 容器。

下一章抽掉具体 Claude/Pi 细节,先看 AgentBackend 怎样定义稳定上层契约,以及 factory 如何从 connection/model 解析出真实 runtime。