跳转至

16. 存储、索引与迁移:哪些数据才是事实

本章代码固定主分支 da00f7f5358f2949383b35c14f548bc20187d80c。发布实验 0.2.0-rc.2 的 Session writer 也已是 V4;不能因上游某份发布状态文档仍记录 V3/alpha.1,就把 rc.2 写出的 V4 说成“尚未发布”。版本差异与证据汇总见 26. 版本对照。

16.1 先分清四种数据

数据 主要实现 权威性 丢失或不兼容时
Session header / 事件日志 session-persistence-jsonl,原始 JSONL 或 Zstd 对话与重建事实 不允许用搜索索引取代;迁移保留旧 generation
插件业务 domain storage-domain,JSON / SQLite 等 backend 插件拥有的记录事实 按 domain/backend 版本与 schema 处理
会话搜索索引 session-query-sqlite 从会话导出的派生数据 可识别的索引可重建,不能删除会话事实
浏览器 snapshot store client-store,可选 localStorage UI 状态/草稿缓存 不保证服务端持久性,不能替代 Agent 历史

“项目用 SQLite”不能推出“所有 Session 在 SQLite 中”。也不能用同一个版本号解释三份介质:当前 Session 逻辑 V4、业务 SQLite 物理 schema 1、搜索 SQLite 派生 schema 8 各自独立。Session 版本 业务 SQLite schema 搜索 SQLite schema

flowchart TB Agent[Agent / 业务插件] --> Session[Session append:语义事件] Session --> Journal[JSONL / Zstd 权威日志] Journal --> Replay[重建 / 纯投影] Replay --> Search[可重建 SQLite 搜索索引] Agent --> Domain[插件 domain:记录与 global] Domain --> KV[业务 KV backend:JSON / SQLite] UI[浏览器 UI] --> Store[client snapshot store] Store --> Local[可选 localStorage] Search --> UI Replay --> UI

自己的 Agent 至少需要先回答:某项数据是“发生过什么”、独立业务记录,还是展示缓存。只有确定事实归属,才能设计崩溃恢复和删除策略。

16.2 Session 格式:header、事件、surface 与物理行

Session header 是不可变元数据,不是第一条聊天消息。它含逻辑 version、id、创建时间和可选 cwd、父会话等;持久化 seam 还记录精确 inheritedEventCount,用于区分 fork 继承的前缀。不要通过数第一条用户消息猜继承切点。header 与版本语义 持久化 handle 契约

事件包含 type/seq/time/data,seq 在同一 Session 单调递增。会影响模型上下文的事件还带 surface 意图:追加或者替换一段已有 surface 节点。替换不是删除历史,它让当前模型可见序列指向新的节点,并记录来源。assistant message 自带 provider stream,与 system/user/tool 的来源引用规则不同。事件和 surface 定义

不知道的新 event type 不能默认跳过。只有 ignorable: true 的纯信息事件才允许未知 reader 安全忽略;未知 required 事件必须拒绝重建。否则可能“JSON 能解析”,但恢复出没有关键约束/错误语义的 Agent。添加自定义日志事件时,若它影响恢复或权限,不能为了兼容把它标成 ignorable。事件兼容规则

逻辑事件与物理 JSONL 行也不是一个永远直接 JSON.stringify 的关系。V4 codec 复用既有 row framing,验证 V4 的 native tool-role、来源、developer/system、fork 等准入规则后编码/解码。物理编码可以压缩引用范围和流片段,reader 必须通过对应 codec 恢复逻辑事件,不应自己逐行 parse 后略过 framing 校验。V4 codec

当前 raw 文件名由格式版本决定:V0 是 session.jsonl,V1+ 是 session.vN.jsonl;压缩后再加 encoding suffix。文件名大小写、前导零、临时名字不符合 canonical generation 规则。版本必须按数值选择,不能把 v10 当成字典序排在 v9 前后随意判定。generation 文件名

16.3 JSONL backend:从 create 到 durable artifact

JSONL config 的 root 必填,插件本身不默认用 process.cwd,防止工作目录改变后把日志散落到不同地方。profile 负责提供实际 home/session 路径。默认 compression 是 zstd,也可选择 none;同一根目录的物理编码需保持一致,不应把两个编码混杂后让 reader 靠猜文件名恢复。JSONL 配置与 encoding

create() 在进程内登记新 Session 并声明写所有权,但不会马上制造磁盘文件。第一次 append 或显式 flush 空会话才 materialize。这样用户没有真正产生/保存活动时不会留下空文件;也意味着创建 handle 后直接崩溃,未 materialize 的会话不能被当成已持久化。create 路径 空会话 flush

open(id, 'read') 不抢写锁,允许冷读取;历史 generation 可在内存转换成当前逻辑视图,保持原文件不变。open(id, 'write') 先获得进程内 claim,再取得内核写锁,读取/验证现有 generation,必要时发布迁移,再把 handle 返回。返回时 cursor 已与已有事件前缀对齐。open 完整路径

stateDiagram-v2 [*] --> Pending: create Pending --> Materialized: 首批 append 或空 flush Materialized --> Writing: 连续 append Writing --> Materialized: 批次持久完成,推进 cursor Writing --> Retryable: 写失败,保留队列 / 回滚 Retryable --> Writing: 明确 flush / 再次写入 Materialized --> Closed: drain + release Pending --> Closed: close Closed --> [*]

这个图刻意把“在进程内已有 Session”与“磁盘已存在日志”分开。不能看到 UI 中新会话出现,就承诺掉电后肯定能恢复。

16.4 写入链、live buffer 与 flush

JsonlSessionHandle.append() 在入队前验证并深拷贝 batch,保证排队期间调用方改对象不会改变最终落盘内容。写操作串到同一链上,要求 seq 与当前 cursor 连续;只有持久化成功才推进 cursor。append 与写入主体

来自 live Session 的事件由 routing installer 送入 buffer,按有界短窗口批处理;与显式 append 使用同一持久写入链。drain 失败会把取出的 batch 放回队列前面并暂停后台自动 drain,显式 flush 会再次尝试并明确失败,不是静默丢弃。业务代码因此不能把一次同步 session.append 的发布当成文件已经 fsync。SDK 的 messageId 收据、Session 通知和 idle 同样属于逻辑提交/活动状态,不是文件系统耐久确认;本课程的 rc.2 实验在 runtime 关闭并确认退出后检查 V4 文件,不在第一次 idle 时假定文件已经落盘。live buffer/drain

持久化 seam 的关键承诺是 flush:在当前 handle 上形成耐久屏障,空 Session 也可由它 materialize;服务级 flush 处理活动 writer。close 不接受中途取消,先排空 live buffer 和 in-flight 写入,释放 kernel lease/in-process claim;drain 和 release 同时失败会组合报告。handle 契约 close 与缓冲实现

Zstd 写入是独立 frame 组成的日志,header frame 必须恰好一条 header 行;批次形成后续 frames。独立 frame 使 list/stat 可以读少量 header,使崩溃 reader 能界定有效前缀。但压缩本身不赋予副作用事务,也不能承诺所有损坏都自动修复。header frame 校验

16.5 独占 writer:内核锁,而非过期租约

POSIX 使用 session.lock 上的 non-blocking flock;Windows 使用由路径派生的 named semaphore。写 handle 生命周期都持有它,竞争会变成 SessionAlreadyOwnedError。进程死亡后内核释放锁;活着但卡住的进程仍持锁,没有 TTL 抢占。这是避免卡住 writer 复活后与新 writer 同时追加日志。写锁完整实现

POSIX flock 锁住 inode,不是抽象路径。取得锁后还核对该 inode 是否仍在原路径;如果有人 unlink/recreate lock 文件,排他假设会被破坏。因此不要在维护脚本中“清理看起来旧的 session.lock”。正常 release 不删除它。旧文件存在不证明锁仍被占用;应查询/处理持锁进程,而不是删文件。

新 Session 到第一次实际写时才获取磁盘 lease,现有日志 write-open 时就获取。read handle 不触碰这把锁。浏览器 worker 的原生锁入口是单进程 stub,其保证来自进程内 claim,不应被解释为浏览器 worker 可以协调服务器多进程写入。lease 的平台边界

16.6 崩溃尾部与失败回滚

写入失败时 cursor 没推进,下一次会重试同一 batch;如果把部分写出的字节留在原文件里,重试可能造成重复 seq。因此 appendLines() 记住原 size,写并 fsync;失败先关 handle,truncate 回旧 size 并 sync,再重新抛出原失败,回滚也失败则组合错误。追加回滚

崩溃是另一类情形:reader 按 codec 验证已有前缀,并识别可恢复的尾部。write handle 在第一次新 append 前先 truncate torn bytes,再重写从尾部恢复出的完整事件,最后追加新的连续 batch。每个恢复步骤成功后才清除对应状态,失败可以重试。torn tail 的写修复

这种恢复针对可判定的尾部和合法前缀,不意味着随意修改中间事件、坏 header、未知 required event、未来格式都能修复。维护时应复制原始 generation 与相关附件,记录错误,再让 reader 按自己的协议处理。不得用“删掉坏行继续跑”掩盖恢复语义变化。

16.7 检查点:外部副作用前先保存意图

session-checkpoint-policy 安装三处屏障:有 Session 的模型请求在 downstream adapter 启动前 flush;顶层工具在 tool body 前 flush;下一次 agent/pre-step 前 flush 上一步的响应/工具结果。嵌套工具重用已保存的外层调用检查点。检查点策略

sequenceDiagram participant Driver as Agent loop participant Log as Session / persistence participant Tool as 外部工具 Driver->>Log: 记录 tool/call Driver->>Log: flush alt flush 失败 Log-->>Driver: reject Note over Tool: tool body 不运行 else flush 成功 Log-->>Driver: durable Driver->>Tool: dispatch body Tool-->>Driver: 结果 Driver->>Log: 记录 tool/result Driver->>Log: 下一请求边界 flush end

checkpoint 失败会 fail closed:模型或工具不被 dispatch;工具 flush 后还检查 signal 是否取消。这个顺序让重启后知道“曾决定执行什么”,但不存在跨 JSONL 与外部 API 的原子事务。工具已在第三方完成、结果尚未记录时崩溃,重试可能重复副作用。自己的 Agent 工具要带外部 idempotency key,或者先查状态再决定补偿。

16.8 迁移:读时转换,写时发布新 generation

逻辑迁移以相邻版本边组成唯一完整链:V0→V1→V2→V3→V4。声明必须 adjacent,同来源不能重复,名称唯一,缺边直接拒绝;存储格式比当前更高也拒绝。不能把 V1 reader 会 parse 一些 V4 JSON 当成正确向前兼容。迁移链编译与计划

历史读路径在内存准备当前 artifact,不发布 successor。write-open 已获独占 writer 后,才显式发布新 generation。prepareMigration() 固定源物理身份、转换并校验 header/currentVersion、相关来源,返回可重复调用但共享一次 Promise 的 publish 操作。准备迁移

真正 publish 的关键顺序是:写临时文件并 fsync → 验证临时文件 digest/bytes 与逻辑事件 → 再查 source identity 与关联来源 → 独占创建目标 generation → 同步目录 → 移除临时名字。POSIX hard link 不覆盖已有目标;Windows 用对应 native publish helper。遇到 EEXIST,不盲目认定成功,而是验证现有 winner 的 bytes/digest、文件类型与 canonical entry。独占发布与冲突验证

flowchart LR Old[旧 generation 保留] --> Read[稳定读取 / adjacent 转换] Read --> Prepared[内存 artifact] Prepared -->|只读| View[当前逻辑视图] Prepared -->|写 open| Temp[写临时 current + fsync] Temp --> Verify[核对目标 / 源身份 / 相关来源] Verify --> Publish[独占发布 successor] Publish --> New[新的 current generation] Old -.不覆盖,不删除.-> New

旧 generation 是恢复和审计证据,不会因迁移自动 move/delete/overwrite。备份与升级前仍须保存所有相关数据,因为“旧文件保留”不等于所有新写数据都能让旧 runtime 读取。writer 格式升级取决于旧 runtime 能否语义正确处理新输出,不只取决于 JSON schema 是否多了字段。writer 版本政策

上游 session-format-status.md 的 latestReleasedVersion 记录与 rc.2 tag writer 不一致;本课程以冻结发布源码中 SESSION_FORMAT_VERSION = 4 和实际运行产物为准,明确保留这个文档差异,不推断“V4 从未发布”。状态记录的来源与义务

16.9 业务 domain:耐久后再改变内存

defineDomain() 声明 domain 名称、version、tables 和 schema,可选 global/layout/compatibleVersions/invalidRecords。名字必须符合 backend 标识规则,版本非负整数,global schema 不能接受 null,因为 backend 把 null 用作“从未写入”sentinel。domain 声明

DomainFacility.open:保留名称 → 按 route 找 backend → 要求 kv facet → open descriptor → loadAll → 按表 schema 校验记录 → 构建 Domain。失败时释放 unit 与名称,不留下半注册 domain。backup-and-skip 只在 spec 明确选择且 backend 支持备份 record 时成立;不能把坏权威数据默认当成不存在。open 完整语义

Domain 内每个写排入一条 per-domain 队列,先 await backend durable primitive,再改变内存,再发布 domain/changed。失败不改内存;changed observer 失败不能追溯撤销已提交记录。update(key, fn) 在自己的队列位置读取当前值并写回,可避免同一 domain 中的读改写交错。domain 队列与提交

这里不是全库事务:SQLite unit 每个 primitive 是独立 statement,没有给 domain 的任意多步 put 自动包成跨表交易。先改订单再改余额,要自己设计原子记录、事务能力或可恢复状态机。domain/changed 是提交后通知,不是事务 participant。SQLite primitive

写自己的持久化插件时,可以先保存一个完整 JSON 记录:状态、业务版本、请求 ID、外部副作用 ID、更新时间放在同一记录中。把“值字段可读”“写链有序”“业务动作可恢复”区分开;其中最后一项不能由 storage 插件替你证明。

16.10 两种 SQLite 升级策略不能互换

业务 storage-sqlite 创建 owner-only 文件/目录,使用 STRICT 表保存 unit 名称/版本、global 和各 unit 的 JSON 值;user_version 必须为 0 或本 build 的 1,其他物理 schema 拒绝。unit 已存版本与 descriptor 不同也拒绝,不能指望一个 compatibleVersions 列表自动让该 backend 实现所有迁移。业务介质 schema unit 版本拒绝

搜索 session-query-sqlite 当前 schema 8。它要求 application_id 为自己的标识或真正空 DB,检查 user tables 白名单;识别为自己派生索引且 schema 过期,才 DROP 已知派生表重建。未知应用、有用户表但 application_id=0、混入陌生表均拒绝,避免把业务库当缓存清掉。派生索引防护

查询 reconcile 比较 persisted revision 与 live fingerprint,观察稳定来源,更新持久/临时索引与 generation,在 BEGIN IMMEDIATE/COMMIT 中替换派生数据,失败 ROLLBACK 并报告索引错误。generation 参与游标一致性:来源改变后旧 cursor 不能被当作同一快照的下一页随意拼接。索引 reconciliation

schema 8 不表示 Session V8;搜索库损坏不意味着可以删除 JSONL;业务 SQLite version-mismatch 不意味着可以照搜索库的 DROP 策略操作。这三个错误很相似,恢复权限与数据价值完全不同。

16.11 client store:状态引擎与可选浏览器缓存

client-store 是 React-free snapshot observable:getSnapshot/subscribe/update/set,以 zustand vanilla 和 Immer 实现,UI renderer 才生成 React selector hook。默认同步通知保证受控输入同 tick 回显;显式 raf 模式合并一帧变化,Node 中回退微任务。它不是服务器 Session storage。snapshot 引擎

可选 persist 保存完整 JSON 值到 localStorage,不做对象 spread,以免把字符串草稿变成字母索引对象。重水合 JSON 失败、quota、private-mode、Node 无 localStorage 都不应让 store 本身失效;这意味着 persistence 不保证成功。rehydrate 也没有自动套 domain 的服务器 schema 校验。localStorage 实现

defineStore 的 handle identity 和 scopeKey 决定共享实例/persist key。session scope key 会拼接到 persist 名;多个实例用同 key 会互相污染同一个 entry,唯一性由框架缓存/调用方保证,不由 create() 自动去重。clearPersisted 只清浏览器对应 key,不能宣称已经删除服务端聊天日志。声明 store 与 scope

16.12 给自己的 Agent 设计恢复演练

做一次小而实际的演练:创建任务 → 验证存在 header/artifact → 在工具前检查点处失败 → 验证工具没执行 → 外部动作后、result 落盘前中断 → 验证外部幂等 → 冷读历史不激活模型 → 显式恢复 → 索引重建。每一步记录介质路径、逻辑 seq、外部 requestId 和结果。

这种演练比单纯说“使用 event sourcing,所以可恢复”更有用。课程的离线实验验证了限定范围的行为;压缩 fuzz、Windows native locks、断电文件系统、任意旧格式复杂损坏和真实第三方 API 仍需专项验证,不能由一套 happy-path demo 覆盖。