13. 持久化、分支、迁移与分享
这一章把四个看似独立的功能放在一起:保存、分支、跨服务器迁移、公开分享。它们其实都依赖同一问题——如何从一个活跃 session 中提取稳定、可搬运、不会泄漏运行时资源的表示。
13.1 JSONL 为什么是主格式
Section titled “13.1 JSONL 为什么是主格式”Session 文件:
line 1 SessionHeader(列表所需 metadata + 预计算摘要)line 2+ StoredMessage(每行一条)createSessionHeader 预计算:
messageCount;lastMessageRole;preview;tokenUsage;lastFinalMessageId。
所以 session list 读取固定 8KB 首行即可,见 readSessionHeader。
13.2 容错读取
Section titled “13.2 容错读取”readSessionJsonl():
- 首行必须能解析,否则 session 无法确定身份;
- 消息行逐条 resilient parse;
- crash 留下的截断/坏消息行被跳过,而不是丢掉整个 transcript;
- legacy 无
sdkCwd时回退到旧workingDirectory; - portable path 展开;
- legacy permission mode 名归一化。
源码:jsonl.ts。
这是 log-like 格式的优势:局部损坏可局部恢复。但跳过中间 tool message 可能破坏 start/result 配对,UI/adapter 仍需能显示孤儿状态。
13.3 Session path token
Section titled “13.3 Session path token”消息里可能嵌绝对路径: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。
13.4 PersistenceQueue
Section titled “13.4 PersistenceQueue”- 每 session 500ms debounce;
- 后来的 snapshot 覆盖 pending snapshot;
flush(sessionId)立即耐久;- per-session write serialization;
flushAll()关机;cancel()删除前停止待写;- last header signature 供 watcher suppress self-write。
为什么 debounce
Section titled “为什么 debounce”一次 turn 的 tool/text/status 会产生很多小变化。每次重写完整 JSONL 会阻塞 I/O;500ms 合并把高频内存更新变成较低频快照。
为什么仍要 flush
Section titled “为什么仍要 flush”以下边界不能等 debounce:
- user message accepted;
- branch/transfer 前;
- session delete/move;
- server shutdown;
- sdk id/branch invalidation 等恢复关键状态。
Debounce 是吞吐优化,flush 是语义屏障。
13.5 原子写
Section titled “13.5 原子写”serialize full JSONL→ write session.jsonl.tmp→ Windows: unlink old target→ rename tmp → session.jsonlcrash 发生在写 tmp 时,旧文件仍完整。启动 listing 会清理 orphan .tmp。
需要注意:Windows 的 unlink + rename 之间有一个目标不存在窗口,不是严格 POSIX replace 原子性;若对强耐久有更高要求,可用平台原子替换 API、fsync file + dir,或 append log + snapshot。
13.6 外部 metadata 合并
Section titled “13.6 外部 metadata 合并”文件可被 UI watcher/Agent/其他实例改。PersistenceQueue 保存上次自己写出的 header signature:
localSig = 当前内存 headerdiskSig = 写前磁盘 headerpreviousSig = 本队列上次写出的 header若 diskSig != previousSig,说明磁盘 metadata 可能被外部修改,于是保留磁盘上的 name/labels/flag/status/permission/unread/read id,同时写入本地新 messages/token。
这是字段级 optimistic merge,不是通用 conflict resolution。当前白名单外的新 metadata 若忘加入 signature/merge,仍可能被覆盖。
13.7 中间消息与最终 transcript
Section titled “13.7 中间消息与最终 transcript”流式 intermediate/status 不一定持久;text_complete 和最终 tool state才是重载所需事实。PersistenceQueue 注释与 SessionManager 共同确保:
- renderer 临时 streaming message 不落盘;
- compaction complete 等具有工作流意义的 info 会显式持久化;
- tool result 过大先外置;
- hidden system nudge 可持久但不渲染普通 bubble。
13.8 Branch 的语义
Section titled “13.8 Branch 的语义”分支意味着:新会话模型上下文严格截止于父 session 某条消息。它需要同时建立三份关系:
产品层:child.branchFromMessageId展示层:复制 cutoff 前 StoredMessagesprovider 层:parent sdk session + native turn anchor + fork只做前两份是“UI 复制”,不能保证 provider 不看见 cutoff 后内容。
13.9 Branch 创建时序
Section titled “13.9 Branch 创建时序”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。
13.10 锚点失效
Section titled “13.10 锚点失效”原因:
- provider compaction 删除旧 turn;
- provider TTL/cleanup;
- session bundle迁移未带隐藏 SDK transcript;
- parent cwd 在当前机器不存在;
- SDK升级不兼容。
处理层级:
- preflight 尽早发现;
- 若 backend 已建立 child fork但 cutoff anchor 缺失,可用 provider允许的最近上下文并明确提示;
- 否则生成 parent prefix summary/seed fresh session;
- 原子清全部 branch provider metadata,避免重启无限失败;
- 若产品承诺严格分支,则直接失败,不静默降级。
13.11 SessionBundle
Section titled “13.11 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 的共同格式。
13.12 Import 的安全要求
Section titled “13.12 Import 的安全要求”导入一个 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承担。
13.13 Move 与 Fork
Section titled “13.13 Move 与 Fork”DispatchMode = 'move' | 'fork':
move:目标成功导入后删除/归档源;fork:保留源,目标建新 identity,并尽量携 branchInfo;- 同 server 可以复用 provider transcript;
- 跨 server通常需要 conversation summary,因为 SDK 隐藏存储不在 bundle。
任何 move 都应先确认目标 commit 成功,再删源。跨服务无分布式事务,失败恢复要把操作设计成“可重试 import + 幂等 delete”,而非同时执行。
13.14 跨服务器 transfer summary
Section titled “13.14 跨服务器 transfer summary”SessionManager 导出 remote transfer 前要求 session 不在 processing,调用 backend mini completion生成摘要。目标 session 保存:
transferredSessionSummarytransferredSessionSummaryApplied=false首 turn 隐藏注入后标 applied。原完整 Craft messages仍可导入用于 UI,但 provider continuation 依靠 summary 建新 session。
这避免把源机器 provider transcript 误认为目标可 resume,同时保留用户可见历史。
13.15 大 RPC 的 chunked transfer
Section titled “13.15 大 RPC 的 chunked transfer”普通 WebSocket message 会被代理/nginx/Cloudflare 限制。transfer:start/chunk/commit/abort 协议:
start(totalBytes, chunkCount, target channel, largeArgIndex, sha256?)→ server temp dir + transferIdchunk(index, base64 data) × N→ commit→ 检查 owner、完整 indices、size、checksum→ JSON parse→ 把 payload 填回 deferred handler 参数→ 清 temp安全细节:
- transfer 绑定创建它的 client id;
- index 有范围;
- 5 分钟默认 TTL,每 chunk续期;
- commit 前检查缺块;
- size 与 SHA-256;
- channel 必须预注册为 transferable;
- cleanup 在执行 handler 前完成,减少异常泄漏。
13.16 Public Viewer 分享
Section titled “13.16 Public Viewer 分享”SessionManager 的 shareToViewer():
- 读取当前 session;
- 序列化适合公开展示的消息/tool metadata;
- source icon 转 base64,Viewer 无本地文件权限;
- POST 到 viewer service;
- 保存返回的
sharedUrl/sharedId; - 持久化并发
session_shared。
updateShare() 重新上传同一 id;revokeShare() 删除远端副本并清本地字段;删除 session 时 best-effort revoke,避免孤儿公开副本。
13.17 分享不是 live sync
Section titled “13.17 分享不是 live sync”从 updateShare() 的显式存在可推断:Viewer 是上传快照,而非订阅活跃 session。优点:
- Viewer 只读、无需连用户 server;
- 公开内容稳定;
- 不暴露 workspace RPC/token。
代价:session 继续变化后需要用户更新分享。UI 应清楚显示“分享版本可能旧”。
13.18 分享的隐私边界
Section titled “13.18 分享的隐私边界”公开 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。
13.19 耐久性语义总结
Section titled “13.19 耐久性语义总结”| 操作 | 屏障 |
|---|---|
| 普通流式更新 | 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 |
13.20 本章小结
Section titled “13.20 本章小结”JSONL 让 session 透明可搬运,PersistenceQueue 让它在高频流式场景仍可用;branch 用 provider-native anchor保证语义,bundle/summary 处理跨机器断层,Viewer 则把可公开部分变成显式快照。
下一章看跨 session 的主动编排:Task DAG、Automation event bus 和后台 task如何把“一个会话 Actor”扩展成工作流。