dsh 源码解析
第二段 · 主干 · 第 05 章

会话日志:append-only 事件与 surface 投影

为什么 `messages[]` 只是派生视图,以及 replace 怎么与 append-only 共存

本章基准:dsh 0.1.1-rc.2,commit b150a551b8d465e31e418e1b2eaf5e79bbb7d28e

主要源码:packages/core/session/src/(10 个文件、约 3164 行),以及 packages/session/ 下 15 个插件包(持久化、检查点、投影、标题、遥测)

上一章讲清了循环怎么转。但循环里几乎每一行都在做同一件事:往日志里 append 一条事件。这一章讲那条日志。

先记住 dsh 根 AGENTS.md 里的一条硬规则,它是整个会话层的宪法:

Model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.

这句话是双向的(⟺,不是→)。往右读:任何进入模型请求的东西,都必须能从日志重建。往左读:日志里能重建出来的,就是模型看到的全部——没有第二份"真正的"对话状态藏在某个插件的内存里。

这条规则听起来像洁癖,但它买到的是三件具体的事:resume 后模型看到的历史和崩溃前一模一样fork 出的子会话不需要任何额外状态拷贝压缩可以把 40 条消息换成 1 条摘要而不需要改动任何消费者。这一章就是讲它是怎么被强制执行的。

                    ┌──────────────────────────────────────────┐
   session.append() │   log: SessionEvent[]  (append-only)     │
   ───────────────▶ │   seq 连续、data 深冻结、只增不改          │
                    └───────┬────────────────────┬─────────────┘
                            │                    │
              surfaceOp 标记 │                    │ session/event 事件
                            ▼                    ▼
                  ┌──────────────────┐   ┌─────────────────────────┐
                  │ surface(有序视图)│   │ 持久化 / 投影 / 标题 /   │
                  │  nodes: seq[]    │   │ 遥测(全是插件,全是订阅)│
                  └────────┬─────────┘   └─────────────────────────┘
                           │ deriveEventMessage 逐节点

                    Message[] ──▶ 下一次模型请求

全章图示:assets/ch05-session-log.svg

会话日志:从 append 到 surface 到 Message[]


1. 为什么是事件溯源,而不是一个 messages 数组

绝大多数 Agent 实现里,会话状态就是 messages: Message[]。要压缩就切数组,要 fork 就 slice(),要持久化就 JSON.stringify。简单,够用,直到你想做下面任何一件事:

想做的事 messages[] 做会怎样
回放模型看到的确切请求 做不到:system prompt、tool schema、采样参数不在数组里
展示 token 级的流式过程 做不到:chunk 被合并进最终 message 就丢了
压缩后还能给用户看原始对话 做不到:被压缩的消息已经从数组里删掉了
崩溃后恢复到"合法的 provider transcript" 难:数组可能停在"有 tool_call 没 tool_result"的非法状态
审计"这条内容是谁塞进去的" 做不到:数组里只有 role 和 content

dsh 的选择是把这些全部合成一个东西:一条 append-only 的 SessionEvent 日志messages[] 退化成它的一个派生视图

SessionEventMappackages/core/session/src/types.ts:236)是这条日志的全部词汇表,核心 16 个成员:

export interface SessionEventMap {
  'turn/start': { turn: number }
  'turn/end': { turn: number; reason: TurnEndReason }
  'step/start': { turn: number; step: number }
  'step/end': { turn: number; step: number }
  'user/message': UserMessage
  'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
  'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
  'tool/result': { turn: number; step: number; message: ToolResultMessage; error?: {…}; meta?: JsonValue }
  'todo/write': { todos: TodoItem[] }
  'request/header': { header: EpochHeader; reason: RequestHeaderReason }
  'request/context': RequestContext
  'session/end-seed': Record<string, never>
  // … 以及插件通过 declaration merging 加进来的:agent/inbox/spliced、compaction/*、plan/* …
}

注意这张表里三类东西被放在了一起,而它们的命运完全不同:

  1. 会派生成消息的user/messageassistant/messagetool/result —— 只有这三个。
  2. 结构边界turn/*step/*session/end-seed —— 定义时间结构,不进模型。
  3. log-only 记录assistant/chunkrequest/headertodo/writerequest/context —— 为了回放、UI、审计而存在,不进模型历史。

第 1 类和第 2、3 类的分界线,就是下一节的 surface。

三个字段撑起整个协议

SessionEventtypes.ts:408)的信封只有四个必填字段,但它是个真正的判别联合(discriminated union over type),不是 { type: string; data: unknown }

export type SessionEvent<T extends SessionEventType = SessionEventType> = {
  [K in SessionEventType]: {
    type: K
    seq: number      // 会话内单调,且必须从 0 连续
    time: number     // Unix epoch ms
    data: SessionEventMap[K]
    ignorable?: true
  } & (K extends SurfaceEventType ? { sourceEventSeqs?: number[]; surfaceOp?: SurfaceOp } : object)
}[T]

这个映射类型做了一件很值钱的事:surfaceOp / sourceEventSeqs 只在三个 surface 事件类型上存在。给 turn/startsurfaceOp 是编译错误,不是运行时错误。同时 switch (event.type) 能直接收窄 event.data,不需要任何 cast。

ignorable 值得单独说,它是版本兼容的全部机制。默认(字段缺席)意味着"必需":一个读者遇到不认识的 type 且没有这个标记,必须拒绝重建整个会话,而不能悄悄跳过。JSDoc 里给了理由(types.ts:417):

an unrecognized required event may change how the rest of the log is interpreted

举个例子就明白了:如果某个新版本加了 compaction/replace 之类会改写 surface 的事件,旧版本读者跳过它,它读出来的历史就是"包含所有被压缩掉的原文"——parse 成功了,语义完全错了。所以默认是拒绝,写者只在纯信息性、丢了也不影响重建的记录上主动标 ignorable: true。这个默认方向选得很讲究:忘记标记会导致过度拒绝(不方便),漏了检查会导致悄悄读错(灾难)。

SESSION_FORMAT_VERSIONtypes.ts:56)目前钉在 0,且注释里明确了 bump 的判据——由写者决定,不由读者决定

bump exactly when an older runtime could no longer handle a new log with full semantic correctness ("parses without error" is not correctness)

加一个普通事件类型不 bump(ignorable 覆盖词汇表增长);改信封、改核心事件语义、改 surface 机制才 bump。


2. surface:模型可见历史的唯一来源

这是本章最核心的机制,也是 dsh 相对其他 harness 最有辨识度的一处设计。

问题是这样的:日志是 append-only,永不改写。但压缩必须能让 40 条老消息从模型历史里消失。两个要求直接冲突。

常见解法是"逻辑删除":给事件加个 deleted: true,或者维护一个 hiddenSeqs 集合。dsh 的解法不一样——让新事件声明自己替换了哪个区间

export type SurfaceOp =
  | 'append'
  | { op: 'replace'; start: number; end: number }

types.ts:376)每一个会派生成消息的事件,在 append 时必须声明它怎么进入 surface:追加到尾部,还是替换掉 [start, end] 这个闭区间的现有 surface 节点。压缩插件写一条摘要 user/messagesurfaceOp{ op: 'replace', start: 12, end: 51 }——原来那 40 个节点从 surface 上消失了,但它们的日志事件一个字节都没动

于是 surface 就是一个 number[]:模型可见顺序下的事件 seq 列表。fold 逻辑只有两行(surface.ts:362):

if (plan?.kind === 'append') {
  state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
  state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
  state.replaceGeneration += 1
}

三个把这套机制钉死的校验

replace 是个危险的操作——它能让任意历史从模型眼前消失。所以 append() 在事件进日志之前跑三道校验(全在 surface.ts:321planSurfaceEvent 里):

一、区间必须当前存在。 replacementRange()surface.ts:246)用 nodes.indexOf() 找 start/end。注意是在当前 surface 里找,不是在日志里找:一个已经被别人替换掉的节点不能再被替换第二次,indexOf 会返回 -1 直接报错。start 在 end 之后也报错。

二、必须完整交代被遮蔽的节点。 assertProvenance()surface.ts:211):

const missing = shadowedSeqs.filter(seq => !sources.has(seq))
if (missing.length > 0) {
  throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
}

sourceEventSeqs 必须包含每一个被这次替换遮蔽掉的 surface 节点。这不是审计装饰——它把"我替换了什么"变成了日志里的一等事实。任何后来的读者(UI、遥测、下一次压缩)都能从日志本身恢复出"这条摘要吃掉了哪 40 条消息",不需要重跑压缩算法。同一个函数还顺手禁掉了三件事:重复的 source、指向自己或未来的 source(source >= event.seq)、以及除 assistant/message 外的空 sourceEventSeqs

三、工具结果只能改内容,不能改身份。 assertToolResultRewrite()surface.ts:287)是个很有意思的特例。工具结果的 pruner(第 10 章)需要把一个巨大的文件读取结果换成 [输出已省略],这也走 replace。但它被限死了:

if (shadowedSeqs.length !== 1) throw new Error('tool/result surface replacement must rewrite exactly one current node')
// … 把双方的 content[0].content 都置 null 后深比较
if (!isDeepEqualJson(originalRest, replacementRest)) {
  throw new Error('tool/result surface replacement may change only content')
}

只能替换恰好一个节点,目标必须也是 tool/result,并且把 content 抠掉之后其余字段必须完全相等——callIdisErrormeta 一个都不能变。为什么?因为 tool_calltool_result 的配对是 provider 协议要求的。允许 pruner 顺手改 callId,就等于允许它生产一个 provider 会拒绝的 transcript。在做决定的那个操作里强制约束,这是 packages/AGENTS.md 的规则之一,这里是个教科书式的应用。

一个反直觉的推论:surface 不能用来渲染 UI

surface.ts:51isAppendSurfaceEvent() 的 JSDoc 把这点说得很清楚:

The model-visible surface deliberately shadows replaced ranges, so it is the wrong source for a human transcript — a landed replacement would erase conversation the user already saw.

压缩一旦落地,surface 上那 40 条消息就没了。但用户已经看过它们,UI 上不能凭空消失。所以人类可见的对话记录用的是另一个过滤器:所有 surfaceOp === 'append' 的事件(append-origin),不管后来有没有被遮蔽。

同一条日志,两个投影,两套规则:

消费者 过滤规则 压缩后的表现
模型请求 当前 surface 节点 40 条 → 1 条摘要
人类 transcript surfaceOp === 'append' 40 条原文都还在,摘要作为额外一条

这就是为什么 deriveMessages() 不能直接拿来做 UI,也是为什么会有 packages/session/session-projection 那一整套投影机制。

deriveMessages():投影,带缓存,但缓存会整块作废

真正把 surface 变成 Message[] 的规则只有一个纯函数,deriveEventMessage()surface.ts:83),13 行:

switch (event.type) {
  case 'user/message': return event.data
  case 'assistant/message': {
    if (event.data.message.content.length === 0) return null
    return event.data.message
  }
  case 'tool/result': return event.data.message
  default: return null
}

三点值得停一下。

第一,verbatim pass-throughuser/message 直接返回 event.data,不加任何包装。注释里明确禁止在这里补 <context> 之类的框:框由生产者自己烘进 content(比如 agent-instructions 插件自己写 <system-reminder>)。理由就是宪法那条——如果投影会加东西,那日志里的内容就不等于模型看到的内容了。

第二,空内容的 assistant message 返回 null。一个撞到 max-tokens 的 step 可能只产出了 usage 而没有任何内容,这个事件必须存在(usage 要记账),但不能往 transcript 里塞一条空的 assistant 消息。

第三,这个函数是 export纯函数Session.deriveMessages() 把它 fold 在活的 surface 上,而外部重建器(持久化、SDK、快照测试)把同一个函数 fold 在某个日志前缀的 surface 上——于是"某次请求当时到底看到了什么"可以被精确重算,而不是近似重现。

deriveMessages()index.ts:726)本身是带缓存的增量投影,但缓存作废的方式很有意思:

const generation = surface.replaceGeneration
if (generation !== this.derivedGeneration) {
  this.derived = []
  this.derivedNodes = 0
  this.derivedGeneration = generation
}
for (const seq of nodes.slice(this.derivedNodes)) { … }

普通 append 是 O(新节点)。但只要发生过一次 replacereplaceGeneration 就变了,整个缓存全部丢弃重建。这是故意的:replace 会在中间做 splice,增量修补的逻辑复杂度远高于重算,而压缩本身是低频操作。在低频路径上选简单,在高频路径上选快


3. append():唯一的写入口,以及它的六道关卡

Session.append()index.ts:604)是整条日志唯一的写入点。它只有 50 行,但每一行都在挡一种具体的错误。按执行顺序:

const dataSnapshot = snapshotJsonValue(data)                       // ① 无损 JSON 校验 + 脱离
if (dataSnapshot === undefined) throw new Error(`… non-JSON-serializable data`)
assertSupportedRequestHeader(type, dataSnapshot, …)                 // ② 拒绝已废弃的旧词汇
const entry = attachments.get(this)
if (entry?.appending) throw new Error('session append cannot reenter …')  // ③ 禁止重入
const event = deepFreeze({ type, seq: this.log.length, time: Date.now(), data: dataSnapshot, … })
this.surfaceManager.validateNext(event)                             // ④ surface 转移校验
if (entry !== undefined) entry.appending = true
try {
  let callbacks
  if (entry !== undefined) callbacks = collectSessionCallbacks(…)    // ⑤ 先把监听器快照下来
  this.log.push(event)                                              // ⑥ 提交(不可逆)
  this.eventsSnapshot = undefined
  if (callbacks !== undefined) invokeContainedSessionObservers(…)    // ⑦ 再逐个通知
  return event
} finally { … }

① 无损 JSON。 snapshotJsonValue()json.ts)一次递归完成"校验 + 拷贝"。为什么必须一次?append() 自己的 JSDoc(index.ts:596)说得很直接:

One recursive pass reads, validates, and copies each nested value once, so a stateful getter cannot supply one value to validation and another to storage.

isJsonValue(data)structuredClone(data) 是两次读取,一个带副作用的 getter 就能让"校验通过的值"和"存下来的值"不是同一个东西。这条日志是持久化的真相来源,这种缝隙不能有。

被拒绝的东西列得很全:BigInt、function、symbol、undefined、负零、非有限数、循环引用、稀疏数组、以及 Map/Set/Date/class 实例。json.ts 甚至去检查了原型是不是本 realm 的 intrinsic Object.prototype / Array.prototypehasIntrinsicConstructorFunction.prototype.toString 比对 [native code])——防的是伪造原型的对象混过 Array.isArray 检查。这个严格程度在别处会显得偏执,在"必须字节级 round-trip"的边界上是刚好。

这一关的位置也是设计:在 append 处失败,而不是在后面某次 backend flush 时失败。工具塞了个不可序列化的 meta,你在写它的那一行就看到报错,而不是十分钟后在持久化线程里看到。

③ 禁止重入。 一个 session/event 监听器在被通知时又调 append(),seq 分配就乱了。这里直接拒绝。

④ 在推进日志之前校验 surface。 validateNext()surface.ts:421)把候选事件的转移规划好但不提交_pendingPlan),失败就抛,日志和 surface 都没被碰。等这个事件真正进了 log,_processDelta() 会认出这个 pending plan 并直接复用,不重算。构造函数里的 seed 校验走的也是同一条路径(index.ts:531),所以seed 和 live append 满足完全相同的不变量——一个 fork 出来的会话不可能构造出任何 backend 存不下的日志。

⑤⑥⑦ 先快照监听器,再提交,再通知。 这三步的顺序解决了一个很细的问题:Cordis 的 dispatch('emit', …) 会做内部校验、可能抛错。如果先 push 再解析监听器,解析失败时事件已经进日志了,回不去。所以先把回调列表拿到手(可能抛,此时日志还干净),push(此时提交,不可逆),然后逐个调用——每个监听器的异常和 rejection 都被单独 catch 并降级为 warninvokeContainedSessionObserversindex.ts:382)。

JSDoc 里那句话是这个设计的全部理由:

Once the event enters the log, the append is committed: observer failures are logged and contained per listener

事件已经是历史事实了。遥测插件炸了不能让它变得"没发生过",更不能让第二个监听器(比如持久化)看不到它。

关于 timeDate.now(),而不是单调时钟。所以两个连续事件的 time 可以倒退(NTP 校准)。chunk-rows.ts 存储 chunk 的时间差时明确处理了这点:a gap may be negative when the wall clock stepped backwards

深冻结:不是防御性拷贝,是消灭改写的可能

deepFreeze 在两处出现:事件进日志时冻结整个事件,deriveMessages() 返回的 Message 对象也是冻结的。但注意它不做第二次深拷贝

Their content reuses the already frozen durable event data, so the cache needs no second deep clone and consumers still cannot mutate the log.

index.ts:721)一份数据,一次冻结,被日志、派生历史、模型请求、UI 推送共享。这是"append-only + 不可变"组合出来的红利:不可变的东西不需要防御性拷贝。

events getter(index.ts:559)还做了一层:返回的是冻结的快照数组,且缓存到下次 append。所以调用者手里那个数组永远不会自己变长——这条性质对任何做异步遍历的消费者都很关键。


4. seq 连续性:一个看起来很小的契约

get seq(): number {
  return this.log.length
}

index.ts:565seq 就是数组下标。这条契约(注释里叫 the seq = log.length contiguity contract)被三处独立地强制:

  • append()seq: this.log.length
  • 构造函数:if (snapshot.seq !== index) throw …seed must be contiguous from 0
  • 持久化 backend:append 的首个 seq 必须等于存储的 next-seq,load 拒绝中间的 seq 空洞。

代价是日志里不能有洞。哪怕是几百条 token 级的 chunk 事件,也必须一条不少地占着 seq。收益是所有引用都能退化成整数下标:sourceEventSeqssurfaceOp.start/endreadFrom(fromSeq)、SQLite 的 WHERE seq >= ?——全都不需要任何 id 映射表。assertToolResultRewrite 里那句 events[originalSeq - baseSeq] 就是这条契约的直接兑现。

顺带解决了另一个问题:幂等追加。backend 重试一个批次时,"首个 seq 必须等于存储的 next-seq" 这一条就足以把重复写变成一次显式失败,不需要额外的去重逻辑。


5. 三个"边界":seed、firstLiveSeq、seedLength

resume 和 fork 是同一个机制(塞一个 seed 进构造函数),但它们需要区分开的边界不止一个。这里有三个概念,很容易混,dsh 的 JSDoc 花了 20 行专门解释(index.ts:450):

名字 是什么 存哪
header.seedLength 持久的 fork 血缘边界:从父会话继承了多少条 header(持久化)
session.firstLiveSeq 本进程内第一条自己写的事件的 seq 内存,不持久化
session/end-seed 事件 firstLiveSeq 在日志里的投影 日志

为什么需要三个?看一个 resume:一个会话被 fork 出来时 seedLength = 12,之后自己跑了 30 条,进程重启,resume 把全部 42 条作为 seed 塞进构造函数。此时:

  • header.seedLength 还是 12(血缘事实,不该因为重启而变);
  • firstLiveSeq 是 42(本次进程的构造事实);
  • 日志末尾会多一条 session/end-seed

firstLiveSeq 存在的理由是构造函数的 seed 不发 session/event。任何"用重放日志来代替订阅"的消费者(比如遥测的 adoption 路径)必须知道从哪开始才不会重复处理。

session/end-seed 存在的理由更微妙——它是给读存储的人用的。举个具体场景:一个压缩插件写了 compaction/start 却在写 compaction/end 之前崩了。下次打开这个会话,读者看到一个没配对的 open marker,它怎么知道这是"上一个生命周期留下的残骸"还是"当前正在进行中的压缩"?答案在 types.ts:314

an unmatched opening marker before this event belongs to an ended lifecycle, whatever ended it

在最后一个 session/end-seed 之前的未配对 marker,一定属于已经结束的生命周期。

而且这个事件不会重复写index.ts:545):

if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
  this.append('session/end-seed', {})
}

如果 seed 已经以它结尾就跳过。理由是"冷会话在第一次触碰时会被 resume",如果每次打开都追加一条,光是反复打开一个会话就能把日志撑起来。代价是这个事件不一定在 firstLiveSeq 这个位置——所以规则是"找最后一个",而不是"找 firstLiveSeq 处的那个"。JSDoc 还专门警告:这不是别人还活着的信号,跨进程的写者互斥需要日志之外的机制。

fork:唯一的限制是不能切在 turn 中间

SessionStore.fork()index.ts:1081)做的事情朴素得有点意外:算出一个前缀,create() 一个新会话把它当 seed,meta 里记上 parentSessionseedLength。就完了。没有状态迁移,没有深拷贝父会话的任何运行时对象——因为根本没有别的状态

唯一的语义校验是(index.ts:1128):

const lastTurnBoundary = events.slice(0, boundary + 1)
  .findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastTurnBoundary?.type === 'turn/start') {
  throw new SessionForkError(`… ends inside open turn …`, 'OPEN_TURN')
}

往回找最后一个 turn 边界,如果它是 turn/start,说明切点在一个未关闭的 turn 内部——拒绝。因为那个前缀可能包含"有 tool_call 没有 tool_result"的悬空调用,provider 会直接拒收这样的 transcript。

SessionForkErrorCode 五个码(index.ts:771)都是显式的:SESSION_NOT_FOUNDSESSION_NOT_LIVESESSION_ALREADY_EXISTSINVALID_BOUNDARYOPEN_TURN。注意 SESSION_NOT_LIVE——传一个 Session 对象进来,但它不是 store 里那个活的实例(比如一个 detached 的旧对象),会被单独拒绝,而不是被当成 not found。


6. 生命周期:prepare / enter / announce 的三段拆分

SessionStore 有一个 create(),也有拆开的 prepare() + enter() + announce()。第四章讲 AgentLoop 时已经见过这个模式,这里看它在 session 侧的实现,会发现两个不同的问题被同一个拆分解决了。

create()index.ts:830)是便利封装:

create(id?, options?): Session {
  const session = this.prepare(id, options)
  this.ctx.effect(function* (this: SessionStore) {
    yield this.enter(session)     // 先 yield detach
    this.announce(session)        // 再宣告
  }.bind(this), 'sessions.create()')
  return session
}

问题一:宣告失败要能回滚。 注释解释了 yield 顺序:

Yield the detach BEFORE announcing so a throwing session/created listener rolls the attach back … instead of leaking the store entry and its publication hooks.

generator effect 在抛错时会把已经 yield 的 disposer 全部执行(第 2 章讲过)。所以先 yield detach、再 announce,一个抛错的创建监听器就会让 attach 干净地回滚。

问题二:有些调用者必须自己拥有这个生命周期。 prepare() 的 JSDoc 点名了 AgentLoop:

a caller that owns a composite ctx.effect (the agent factory) folds the session lifecycle into that ONE effect so a fiber unload tears the session + agent down as a single ORDERED chain rather than as racing sibling effects — which would remove the publication hooks before the driver's closing events commit, dropping them.

这是个真实的 bug 形状:如果 session 和 agent 是两个并列的 effect,fiber 卸载时它们的 disposer 并发跑,session 的发布钩子可能在 driver 写完最后那几条 turn/end 之前就被摘掉——那几条事件会静默丢失。折成一个 effect 就有了确定的顺序。

enter()index.ts:913)还有一个容易被忽略的细节:它重新检查一次 id 冲突prepareenter 是公开的跨包原语,调用者可以在两者之间干任意事情(包括另一次 create)。如果不重查,一个过期的 prepared session 会覆盖掉活的 store entry,然后它的 detach disposer 会在稍后把真正那个 session 删掉。注释直接标明了这是"权威碰撞边界"。

还有一处 announcing / appending / detachRequested 三个布尔配合的延迟 detach:一个 session/created 监听器可能立刻调用 detach,此时如果真的摘掉钩子,同一次同步派发里的后续监听器就看不到它了。所以先记 detachRequested,等派发展开完再执行。这类"同步派发期间的自杀请求"是事件驱动系统的经典陷阱,dsh 在 session 和 agent 两侧用了同一套写法。


7. 持久化:一个 seam,两个 backend,一个共享协调器

SessionStore 的注释里写着:

Persistence is intentionally not implemented here — persistence plugins subscribe to session/event and flush on session/flush / dispose.

index.ts:789)核心的 session 包完全不知道磁盘存在。持久化是一个标准的 Capability Seam(第 8 章会系统讲这个三角):

角色
Service Definition dsh-session-persistencectx.sessionPersistence,抽象 Service)
Provider dsh-session-persistence-jsonl / dsh-session-persistence-sqlite
Consumer dsh-session-checkpoint-policy、resume 路径、--continue、Web 历史页

被持久化的单元就是 SessionEvent 本身,没有并行的"持久化消息"类型。不可重放的元数据(版本、cwd、血缘、seed 边界)走 SessionHeader 单独存。

三条 backend 必须遵守的不变量

README 里列的四条,前三条尤其值得记:

一、append-only;崩溃的 turn 是被关闭,不是被截断。 这是 repair.ts 的全部内容。一个进程在 turn 中间被杀,日志停在"有 tool/calltool/result"。interruptedTurnClosers()repair.ts:27)扫一遍日志,算出未配对的调用,然后合成一串收尾事件:每个悬空调用一条错误 tool/result,然后 step/end?、然后 turn/end { kind: 'interrupted' }

合成结果的文本本身是设计过的,两种情况给模型的指示完全不同(repair.ts:103):

text: started
  ? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
  : 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.'

区别在 callSeq 是否存在:日志里有 tool/call 说明工具可能已经跑了TOOL_OUTCOME_UNKNOWN),所以给模型的指示是"按工具语义判断,只读或幂等的才能重试,有副作用的先验证外部状态或者问用户"。没有 tool/call 说明它连派发都没到(TOOL_NOT_STARTED),直接重试就行。

这是"model-facing contract 从模型视角写"的一个好例子:没有任何 harness 内部词汇(coordinator、torn tail、backend),只有模型做决定需要的信息。

合成事件的 seq 从 last.seq + 1 继续,time 复用最后一条真实事件的时间——保证确定性,也不发明一个"未来"的时间点。

二、seq 连续。 中间的空洞或解析错误直接拒绝(SessionPersistenceCorruptionError);只有从未写完的尾部碎片(torn tail)才被丢弃。这个区分很重要:尾部半行是崩溃的正常产物,中间的洞是数据损坏。

三、格式版本和未知事件的拒绝方向。 不支持的 version 或本 build 不认识的必需事件类型 → SessionFormatUnsupportedError,而且报错要说明拒绝的方向(是日志太新还是太旧),并且在 backend 保留单文件 artifact 时给出原始日志路径。

写协调器:JSONL 和 SQLite 共享的那一半

两个 backend 都组合同一个 PersistenceCoordinatorpackages/session/session-persistence/src/coordinator.ts,1362 行),只实现一个很小的 PersistenceBackend<TornMarker> 存储钩子接口。协调器拥有:per-id 状态、串行化、每个活会话一个有界写控制器、惰性物化、崩溃尾修复、会话收养、静默销毁。

批处理窗口的语义值得抄:

The first pending event starts a fixed batching window; later events join without resetting its deadline.

第一条事件开一个固定窗口,后来的事件加入但不重置截止时间。所以 writeBatchMaxDelayMs 界定的是一个有上界的等待,而不是一个可以被持续的事件流无限推后的 debounce。写入期间进来的事件形成新的一批。

tornMarker 是完全不透明的:协调器只测 !== undefined 并把它原样传回 commitRepair,从不看它的值——JSONL 用的是要截断到的字节偏移,SQLite 用的是要删起的 seq。注释里还提醒了一个陷阱:marker 可能是 falsy 的(偏移 0),所以判空必须用 !== undefined 而不是真值测试。

崩溃修复只对冷会话做。活会话的 load() 会先快照内存里的权威日志、等它落盘、balanced 才返回;一个 turn 还开着的活会话直接拒绝,而不是给它合成中断收尾——那会把一个正在跑的 turn 说成中断了。

chunk 打包:56 倍的压缩,靠的是"存储行不是事件"

assistant/chunk 是日志里数量最多的事件——token 级的 delta,一次响应几百条。JSON 信封({"type":"assistant/chunk","seq":…,"time":…,"data":{"turn":…,"step":…,"chunk":{…}}})比 payload 本身大得多,chunk-rows.ts 开头量化了这个比例:在一次真实的 DeepSeek 会话上测得约 56 倍

packChunkRuns()chunk-rows.ts:192)把连续的同 block delta 打成一个存储行(text-chunks / reasoning-chunks / tool-call-chunks),decodeStorageRecord() 精确还原成原始事件。做法上有三个决定:

存储行不是会话事件。 它们不进 Session.events,没有 SessionEventMap 条目,并且故意用不带斜杠的裸 tag(text-chunks 而不是 chunk/text),这样读者不可能把它和事件词汇表搞混。

编码器白名单精确形状,不认识的原样存。 未知字段或未来的 chunk 变体只会损失压缩,不会损失数据

解码器先校验后展开,遇到畸形的行 tag 直接 fail loud,而不是静默丢掉一整个 run。

时间戳存成 gap 数组(dt: number[],长度比成员数少一),文本存成 texts: string[]不 join——因为 token 边界本身是数据,回放时要按原样一条条重现。


8. 语义检查点:await 加在哪里,才叫"落盘了"

Session.append() 是同步的,热路径从不阻塞在 I/O 上。那什么时候真正落盘?答案是一个独立的插件:dsh-session-checkpoint-policy(约 60 行的核心逻辑)。

它挂三个点:

export const inject = ['llm', 'sessionPersistence', 'sessions', 'tools']

一、模型请求之前。 afterCheckpoint() 包装 llm/stream

return (async function* (): AsyncIterable<StreamChunk> {
  await ctx.sessions.flush(session)
  yield* next()
})()

注意这是个 generator,flush第一次要 chunk 的时候才执行——把 stream 的构造和它的第一次拉取分开了。语义是:在 adapter 真正发出请求之前,这次请求所依赖的完整日志前缀必须是持久的

二、顶层工具调用之前。 记录了 tool/call 之后、跑工具体之前 flush。

三、session/flush 是共享的静默屏障。 它会 drain 掉在它执行期间新进来的事件。

选这三个点的理由是它们是有副作用的边界:模型请求会花钱、会产生 provider 侧状态;工具执行会改文件、会发网络请求。在它们之前落盘,意味着崩溃恢复时你至少知道"我发出过这个请求 / 我启动过这个工具"。而且是 fail-closed:检查点失败就不调用下游 adapter 或工具体。

ctx.sessions.flush()唯一的 flush 入口index.ts:1022)。理由写在 JSDoc 里:store 拥有 carrier,所以调用者不能自己 ctx.parallel('session/flush', …)——一个拥有者、一个拼写,scoped-dispatch 的不变量才能钉住它。实现上用 Promise.allSettled所有监听器结束,然后抛第一个失败:不能因为第一个 backend 失败就让第二个 backend 的写入悬在半空。


9. 运行时不变量:日志的语法检查器

packages/AGENTS.md 要求每个包都有一个 ./invariant companion。session 包的(invariant.ts,250 行)是全仓库里最实在的一个:它在每一条事件被接受时做一遍关系检查。

它维护的 per-session 状态很小(invariant.ts:23):

interface SessionTrace {
  lastSeq: number
  openTurn: number | null
  openStep: number | null
  nextTurn: number
  nextStep: number
  pendingCalls: Set<CallId>
}

然后每种事件都有对应的断言(invariant.ts:71 起):

  • turn/start:不能在另一个 turn 还开着的时候开;turn 必须等于 nextTurn(不能跳号);
  • turn/end:必须匹配当前 open turn;并且不能在 step 还开着的时候关
  • step/start:turn 必须匹配、上一个 step 必须已关、编号必须等于 nextStep
  • assistant/chunk / assistant/message / tool/*:必须命名当前打开的那个 turn/step(requireOpenStep)。

以及 pendingCallsstep/end 时被 clear、在 turn/* 边界被 clear——这样一个 turn 的悬空调用不会泄漏到下一个 turn 的修复逻辑里。

关键在于这些不是单元测试里的断言,而是运行时的关系检查,且检查的是"权威事件流之间的关系",不是"某个 service 存不存在"。这正是根 AGENTS.md 那条规则要的东西:

Runtime invariants assert owned relationships. Check authoritative event streams or mutable data, not service or method presence …

值得注意的是 validateEvent() 的写法:它不直接改 trace,而是返回一个 SessionTraceTransition(延迟的变更描述)。和 surface 的 validateNext 一个思路——校验失败时状态不能被部分污染。


10. 十五个插件:日志之上的整个生态

packages/session/ 下面有 15 个包,全部只做一件事:订阅 session/event,然后干自己的活。它们互相不知道对方存在。

干什么
session-persistence Service Definition + 写协调器
session-persistence-jsonl 一会话一文件,可选 zstd
session-persistence-sqlite 单库多会话,支持 seek 读
session-checkpoint-policy 语义检查点(上一节)
session-projection 投影注册表:framework drives, domain computes
session-projection-cache 投影状态的持久化缓存
session-stats 统计
session-telemetry / -otel 遥测 + OpenTelemetry 导出
session-title + 4 个 provider 会话标题(首个 prompt / 全部 prompt / LLM 生成…)

session-projection 的设计原则很值得单独看一眼,它是"日志之上做增量计算"的通用答案:

  • framework drives, domain computes:注册表只订阅一次 session/event,每条事件过一遍所有单元的 apply。域插件自己不持有任何订阅
  • same-reference means no workapply 对不相关的事件必须返回同一个 state 引用,drive 用 Object.is 判断要不要往下游推。不相关的事件成本就是一次函数调用。
  • whole-value event rule:一个携带状态的日志事件必须带完整的变更后状态,不能只带增量。这让每次转移都平凡地便宜,每个服出去的值都自描述(消费者按 last-wins 处理)。todo/write 存整张 todo 列表而不是"新增一项",就是这条规则。
  • synchronous unit disciplineinit / apply / view 必须同步,因为 carrier 要在同一个 tick 里读 snapshot() 和它的页面切片——这才是 asOfSeq 能构成"一个一致切面"的原因。
  • stateVersion 是缓存失效锚:持久化的投影缓存存 (sessionId, key, ver, seq, val),用 stateSchema 校验后才用;改了状态字段或 fold 语义就 bump。

这五条加起来,就是让"任意多个域插件在同一条日志上做增量计算"这件事变得可控。


11. 设计代价

这一章讲的东西不便宜,如实列一下。

日志比 messages 数组大一到两个数量级。 一次响应几百条 chunk 事件,全部要占 seq、要落盘。chunk-rows 的打包把它压回去大半,但代价是多了一套"存储行"词汇表、一个编码器、一个解码器,加上"不认识就原样存"的兜底路径。

seq = log.length 不能有洞。 收益是所有引用退化成整数,代价是任何"跳过某类事件不存"的优化都不可能——你不能只存 surface 事件。

surface 的 replace 是个强大到危险的操作。 它能让任意历史从模型眼前消失,所以周围堆了三道校验、一个 generation 计数器、以及一条"UI 不能用 surface"的规则。这些复杂度全部是 replace 的成本。

append 的每一次写都跑一次递归的无损 JSON 校验。 对 chunk 这种高频事件,这是热路径上的实际开销。选它是因为"持久化边界不能有校验/存储不一致的缝隙"这条要求权重更高。

三个 seed 边界(seedLength / firstLiveSeq / session/end-seed)确实容易混。 dsh 用 20 行 JSDoc 来解释它们的区别,这本身就是一个信号:这块概念的必要复杂度已经接近可理解的上限。

prepare / enter / announce 拆开之后,公开 API 多了两个可以被误用的原语。 enter() 里那次重复的 id 检查就是为了兜住这种误用——注释明确说了 create 和 agent factory 都是背靠背调用所以不会触发,但公开 API 不能假设调用者也这样。


12. 可迁移的经验

这一章的机制里,有几条不依赖 Cordis、不依赖 dsh,任何 Agent 系统都能拿走。

1. 把 messages[] 降级为派生视图。 只要你想做压缩、fork、精确回放、或者"给模型看 A 但给人看 B",事件日志加投影就是那个正确的形状。反过来说,如果这四件事你都不做,messages[] 完全够用——不要为了架构而架构。

2. 让"删除"表现为"新事件声明替换"。 这一条是本章的核心洞见。append-only 和"历史要能被改写"看起来矛盾,{ op: 'replace', start, end } + 必须完整交代被遮蔽节点,就同时满足了两边。而且它顺手让"这条摘要吃掉了哪些消息"变成了日志里的一等事实。

3. 同一份数据可以有多个投影,规则不同是正常的。 模型看当前 surface,人看 append-origin。不要试图用一个视图满足两个消费者——那会导致其中一个被迫接受错误的语义。

4. 校验和拷贝要一次完成。 snapshotJsonValue 那句"a stateful getter cannot supply one value to validation and another to storage"值得贴在任何持久化边界上。

5. 在最早能失败的地方失败。 不可序列化的数据在 append() 那一行报错,不是在十分钟后的 flush 里。seed 走和 live append 完全相同的校验,所以 fork 不可能构造出存不下的日志。

6. 提交之后的通知失败不能回滚已提交的事实。 先快照监听器(可能抛,此时还能回滚)→ 提交 → 逐个通知且逐个隔离异常。这个顺序在任何"事件已发生 + 有多个订阅者"的系统里都适用。

7. 崩溃恢复要修成"合法状态",不要修成"干净状态"。 截断掉半个 turn 是简单的,但你会丢掉可能很大的真实工作。合成收尾事件保住了内容,而且合成的文本可以按"知道多少"分级给模型不同的指示。

8. 增量计算的注册表:框架 drive,域 compute,同引用即无操作。 三条规则就能支撑任意多个域插件在同一条流上做增量投影,且互不知情。


13. 本章源码位置

位置 内容
packages/core/session/src/types.ts:56 SESSION_FORMAT_VERSION = 0 与 bump 判据
packages/core/session/src/types.ts:61 SessionHeader:不进日志的存储元数据
packages/core/session/src/types.ts:155 TurnEndReasonMap:六种结束原因(含 interrupted
packages/core/session/src/types.ts:236 SessionEventMap:日志的全部词汇表
packages/core/session/src/types.ts:336 session/end-seed:seed 边界的持久投影
packages/core/session/src/types.ts:347 SurfaceEventType:只有三个
packages/core/session/src/types.ts:376 SurfaceOpappendreplace
packages/core/session/src/types.ts:408 SessionEvent 信封与 ignorable 的默认拒绝
packages/core/session/src/surface.ts:83 deriveEventMessage():唯一的逐节点投影规则
packages/core/session/src/surface.ts:51 isAppendSurfaceEvent():人类 transcript 的过滤器
packages/core/session/src/surface.ts:211 assertProvenance():必须交代每个被遮蔽的节点
packages/core/session/src/surface.ts:246 replacementRange():在当前 surface 里定位
packages/core/session/src/surface.ts:287 assertToolResultRewrite():只能改 content
packages/core/session/src/surface.ts:321 planSurfaceEvent():先规划后提交
packages/core/session/src/surface.ts:387 foldSurface():整条日志的纯 fold
packages/core/session/src/surface.ts:398 SurfaceManager:增量视图 + append 边界校验
packages/core/session/src/index.ts:425 Session:不是 Service,是普通类
packages/core/session/src/index.ts:472 firstLiveSeq 与三个 seed 边界的 20 行解释
packages/core/session/src/index.ts:499 构造函数:seed 走和 append 相同的不变量
packages/core/session/src/index.ts:559 events getter:冻结快照 + 缓存到下次 append
packages/core/session/src/index.ts:604 append():唯一写入口的六道关卡
packages/core/session/src/index.ts:670 requestHeader():增量折叠,冻结后暴露
packages/core/session/src/index.ts:726 deriveMessages():缓存 + generation 整块作废
packages/core/session/src/index.ts:830 create():yield detach 再 announce
packages/core/session/src/index.ts:863 prepare():为什么 AgentLoop 不能用 create
packages/core/session/src/index.ts:913 enter():权威碰撞边界 + 延迟 detach
packages/core/session/src/index.ts:1022 flush():唯一的 flush 入口,allSettled 后抛首个失败
packages/core/session/src/index.ts:1081 fork():算前缀 + create
packages/core/session/src/index.ts:1128 fork 的唯一语义校验:不能切在开着的 turn 里
packages/core/session/src/json.ts 无损 JSON:一次递归、realm intrinsic 原型检查
packages/core/session/src/repair.ts:27 interruptedTurnClosers():把崩溃修成合法 transcript
packages/core/session/src/repair.ts:103 两档修复文本:TOOL_OUTCOME_UNKNOWN / TOOL_NOT_STARTED
packages/core/session/src/chunk-rows.ts:192 packChunkRuns():56× 信封开销的应对
packages/core/session/src/chunk-rows.ts:339 decodeStorageRecord():畸形行 fail loud
packages/core/session/src/invariant.ts:23 SessionTrace:turn/step/call 的运行时语法检查
packages/core/session/src/invariant.ts:55 validateEvent():返回 transition,不直接改状态
packages/session/session-persistence/README.md seam 定义、三条 backend 不变量、协调器契约
packages/session/session-persistence/src/coordinator.ts 1362 行的共享写协调器
packages/session/session-checkpoint-policy/src/index.ts 三个语义检查点,fail-closed
packages/session/session-projection/README.md framework drives / domain computes 五条规则