跳转到内容

13. 持久化、分支、迁移与分享

这一章把四个看似独立的功能放在一起:保存、分支、跨服务器迁移、公开分享。它们其实都依赖同一问题——如何从一个活跃 session 中提取稳定、可搬运、不会泄漏运行时资源的表示。

Session 文件:

line 1 SessionHeader(列表所需 metadata + 预计算摘要)
line 2+ StoredMessage(每行一条)

createSessionHeader 预计算:

  • messageCount
  • lastMessageRole
  • preview
  • tokenUsage
  • lastFinalMessageId

所以 session list 读取固定 8KB 首行即可,见 readSessionHeader

readSessionJsonl()

  1. 首行必须能解析,否则 session 无法确定身份;
  2. 消息行逐条 resilient parse;
  3. crash 留下的截断/坏消息行被跳过,而不是丢掉整个 transcript;
  4. legacy 无 sdkCwd 时回退到旧 workingDirectory
  5. portable path 展开;
  6. legacy permission mode 名归一化。

源码:jsonl.ts

这是 log-like 格式的优势:局部损坏可局部恢复。但跳过中间 tool message 可能破坏 start/result 配对,UI/adapter 仍需能显示孤儿状态。

消息里可能嵌绝对路径:attachment、planPath、data table、download。简单只转换 header 的 workspace path,迁移后这些引用仍指向旧机器。

JSON stringify 后,代码把当前 session dir 全局替换成:

{{SESSION_PATH}}

读取前再展开到新 session dir。Windows 同时处理 JSON escaped backslash。

源码:makeSessionPathPortable/expandSessionPath

替换发生在序列化字符串层,因此能覆盖深层未知字段;风险是普通文本若恰好包含绝对路径,也会被替换——通常正是想要的可移植行为,但要意识到它不是 schema-aware transform。

SessionPersistenceQueue 提供:

  • 每 session 500ms debounce;
  • 后来的 snapshot 覆盖 pending snapshot;
  • flush(sessionId) 立即耐久;
  • per-session write serialization;
  • flushAll() 关机;
  • cancel() 删除前停止待写;
  • last header signature 供 watcher suppress self-write。

一次 turn 的 tool/text/status 会产生很多小变化。每次重写完整 JSONL 会阻塞 I/O;500ms 合并把高频内存更新变成较低频快照。

以下边界不能等 debounce:

  • user message accepted;
  • branch/transfer 前;
  • session delete/move;
  • server shutdown;
  • sdk id/branch invalidation 等恢复关键状态。

Debounce 是吞吐优化,flush 是语义屏障。

serialize full JSONL
→ write session.jsonl.tmp
→ Windows: unlink old target
→ rename tmp → session.jsonl

crash 发生在写 tmp 时,旧文件仍完整。启动 listing 会清理 orphan .tmp

需要注意:Windows 的 unlink + rename 之间有一个目标不存在窗口,不是严格 POSIX replace 原子性;若对强耐久有更高要求,可用平台原子替换 API、fsync file + dir,或 append log + snapshot。

文件可被 UI watcher/Agent/其他实例改。PersistenceQueue 保存上次自己写出的 header signature:

localSig = 当前内存 header
diskSig = 写前磁盘 header
previousSig = 本队列上次写出的 header

diskSig != previousSig,说明磁盘 metadata 可能被外部修改,于是保留磁盘上的 name/labels/flag/status/permission/unread/read id,同时写入本地新 messages/token。

源码:persistence-queue.ts

这是字段级 optimistic merge,不是通用 conflict resolution。当前白名单外的新 metadata 若忘加入 signature/merge,仍可能被覆盖。

流式 intermediate/status 不一定持久;text_complete 和最终 tool state才是重载所需事实。PersistenceQueue 注释与 SessionManager 共同确保:

  • renderer 临时 streaming message 不落盘;
  • compaction complete 等具有工作流意义的 info 会显式持久化;
  • tool result 过大先外置;
  • hidden system nudge 可持久但不渲染普通 bubble。

分支意味着:新会话模型上下文严格截止于父 session 某条消息。它需要同时建立三份关系:

产品层:child.branchFromMessageId
展示层:复制 cutoff 前 StoredMessages
provider 层:parent sdk session + native turn anchor + fork

只做前两份是“UI 复制”,不能保证 provider 不看见 cutoff 后内容。

sequenceDiagram
participant C as Client
participant SM as SessionManager
participant D as Disk
participant B as Backend
C->>SM: createSession(branchFromMessageId)
SM->>SM: validate parent/provider/cutoff
SM->>D: read parent messages + anchors
SM->>D: create child + copy prefix/files refs
SM->>B: getOrCreateAgent(child)
SM->>B: ensureBranchReady()
alt success
B-->>SM: child sdk session ready
SM->>D: persist child ids
SM-->>C: session_created
else fail
SM->>B: destroy
SM->>D: rollback child
SM-->>C: error
end

严格约束:

  • parent/child backend/provider 必须相同;
  • parent SDK id 与 native anchor 必须存在;
  • Claude 需要 parent sdkCwd;
  • Pi 需要 anchor sidecar/session entries;
  • 新 message path 中的 parent session path 要重映射 child token/path。

原因:

  • provider compaction 删除旧 turn;
  • provider TTL/cleanup;
  • session bundle迁移未带隐藏 SDK transcript;
  • parent cwd 在当前机器不存在;
  • SDK升级不兼容。

处理层级:

  1. preflight 尽早发现;
  2. 若 backend 已建立 child fork但 cutoff anchor 缺失,可用 provider允许的最近上下文并明确提示;
  3. 否则生成 parent prefix summary/seed fresh session;
  4. 原子清全部 branch provider metadata,避免重启无限失败;
  5. 若产品承诺严格分支,则直接失败,不静默降级。

SessionBundle

interface SessionBundle {
version: 1
session: { header: SessionHeader; messages: StoredMessage[] }
files: BundleFile[]
branchInfo?: { sdkSessionId; sdkTurnId; sdkCwd }
}

序列化会收集 session 目录附件、plans、data、downloads 等,跳过:

  • session.jsonl(已结构化进 bundle);
  • session.jsonl.tmp
  • tmp/
  • dot/internal files(由 collector policy);
  • 超过 MAX_BUNDLE_SIZE_BYTES 的 bundle。

Bundle 是 move/fork、backup、跨 server dispatch 的共同格式。

导入一个 bundle 等于写很多攻击者可控路径/内容。正确实现必须:

  • version 与最小 header shape 校验;
  • 每个 BundleFile.path 规范化,拒绝 absolute/../symlink escape;
  • 累计 size 与单文件上限;
  • 新 session ID/目标 workspace 重写;
  • sharedUrl/sharedId 等 server-specific 字段清除;
  • credential 永不随 bundle;
  • provider sdk id 只有同 server/可用 transcript 才保留;
  • 写入 staging 后再原子 commit。

validateBundle() 只做基本结构检查,真正文件 path 安全要由 bundle-file import helper承担。

DispatchMode = 'move' | 'fork'

  • move:目标成功导入后删除/归档源;
  • fork:保留源,目标建新 identity,并尽量携 branchInfo;
  • 同 server 可以复用 provider transcript;
  • 跨 server通常需要 conversation summary,因为 SDK 隐藏存储不在 bundle。

任何 move 都应先确认目标 commit 成功,再删源。跨服务无分布式事务,失败恢复要把操作设计成“可重试 import + 幂等 delete”,而非同时执行。

SessionManager 导出 remote transfer 前要求 session 不在 processing,调用 backend mini completion生成摘要。目标 session 保存:

transferredSessionSummary
transferredSessionSummaryApplied=false

首 turn 隐藏注入后标 applied。原完整 Craft messages仍可导入用于 UI,但 provider continuation 依靠 summary 建新 session。

这避免把源机器 provider transcript 误认为目标可 resume,同时保留用户可见历史。

普通 WebSocket message 会被代理/nginx/Cloudflare 限制。transfer:start/chunk/commit/abort 协议:

start(totalBytes, chunkCount, target channel, largeArgIndex, sha256?)
→ server temp dir + transferId
chunk(index, base64 data) × N
→ commit
→ 检查 owner、完整 indices、size、checksum
→ JSON parse
→ 把 payload 填回 deferred handler 参数
→ 清 temp

源码:handlers/rpc/transfer.ts

安全细节:

  • transfer 绑定创建它的 client id;
  • index 有范围;
  • 5 分钟默认 TTL,每 chunk续期;
  • commit 前检查缺块;
  • size 与 SHA-256;
  • channel 必须预注册为 transferable;
  • cleanup 在执行 handler 前完成,减少异常泄漏。

SessionManager 的 shareToViewer()

  1. 读取当前 session;
  2. 序列化适合公开展示的消息/tool metadata;
  3. source icon 转 base64,Viewer 无本地文件权限;
  4. POST 到 viewer service;
  5. 保存返回的 sharedUrl/sharedId
  6. 持久化并发 session_shared

updateShare() 重新上传同一 id;revokeShare() 删除远端副本并清本地字段;删除 session 时 best-effort revoke,避免孤儿公开副本。

源码:SessionManager.ts

updateShare() 的显式存在可推断:Viewer 是上传快照,而非订阅活跃 session。优点:

  • Viewer 只读、无需连用户 server;
  • 公开内容稳定;
  • 不暴露 workspace RPC/token。

代价:session 继续变化后需要用户更新分享。UI 应清楚显示“分享版本可能旧”。

公开 payload 必须审计:

  • hidden/system/recovery message 是否排除;
  • tool input/result 是否含 secret;
  • 本地绝对路径是否脱敏/tokenize;
  • attachment 是否有明确上传规则;
  • source icon/metadata 是否安全;
  • error details 是否含 credential/header;
  • revoked URL 的缓存/CDN 生命周期。

“本地 transcript 可见”不等于“适合公开”。分享序列化应使用专门 whitelist DTO,不能直接 JSON.stringify ManagedSession。

操作 屏障
普通流式更新 debounce snapshot
用户消息 ack per-session flush
branch announce backend preflight + persist
export session idle + flush
cross-server import bundle validate + chunk checksum + target commit
move 删除源 target success 之后
share remote upload success后存 id/url
revoke/delete remote delete best-effort + local state清理
shutdown flushAll + runtime cleanup

JSONL 让 session 透明可搬运,PersistenceQueue 让它在高频流式场景仍可用;branch 用 provider-native anchor保证语义,bundle/summary 处理跨机器断层,Viewer 则把可公开部分变成显式快照。

下一章看跨 session 的主动编排:Task DAG、Automation event bus 和后台 task如何把“一个会话 Actor”扩展成工作流。