会话日志:append-only 事件与 surface 投影
为什么 `messages[]` 只是派生视图,以及 replace 怎么与 append-only 共存
本章基准:dsh
0.1.1-rc.2,commitb150a551b8d465e31e418e1b2eaf5e79bbb7d28e主要源码:
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
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[] 退化成它的一个派生视图。
SessionEventMap(packages/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/* …
}
注意这张表里三类东西被放在了一起,而它们的命运完全不同:
- 会派生成消息的:
user/message、assistant/message、tool/result—— 只有这三个。 - 结构边界:
turn/*、step/*、session/end-seed—— 定义时间结构,不进模型。 - log-only 记录:
assistant/chunk、request/header、todo/write、request/context—— 为了回放、UI、审计而存在,不进模型历史。
第 1 类和第 2、3 类的分界线,就是下一节的 surface。
三个字段撑起整个协议
SessionEvent(types.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/start 加 surfaceOp 是编译错误,不是运行时错误。同时 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_VERSION(types.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/message,surfaceOp 是 { 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:321 的 planSurfaceEvent 里):
一、区间必须当前存在。 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 抠掉之后其余字段必须完全相等——callId、isError、meta 一个都不能变。为什么?因为 tool_call 和 tool_result 的配对是 provider 协议要求的。允许 pruner 顺手改 callId,就等于允许它生产一个 provider 会拒绝的 transcript。在做决定的那个操作里强制约束,这是 packages/AGENTS.md 的规则之一,这里是个教科书式的应用。
一个反直觉的推论:surface 不能用来渲染 UI
surface.ts:51 的 isAppendSurfaceEvent() 的 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-through。user/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(新节点)。但只要发生过一次 replace,replaceGeneration 就变了,整个缓存全部丢弃重建。这是故意的: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.prototype(hasIntrinsicConstructor 用 Function.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 并降级为 warn(invokeContainedSessionObservers,index.ts:382)。
JSDoc 里那句话是这个设计的全部理由:
Once the event enters the log, the append is committed: observer failures are logged and contained per listener
事件已经是历史事实了。遥测插件炸了不能让它变得"没发生过",更不能让第二个监听器(比如持久化)看不到它。
关于 time: 用 Date.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:565)seq 就是数组下标。这条契约(注释里叫 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。收益是所有引用都能退化成整数下标:sourceEventSeqs、surfaceOp.start/end、readFrom(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 里记上 parentSession 和 seedLength。就完了。没有状态迁移,没有深拷贝父会话的任何运行时对象——因为根本没有别的状态。
唯一的语义校验是(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_FOUND、SESSION_NOT_LIVE、SESSION_ALREADY_EXISTS、INVALID_BOUNDARY、OPEN_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/createdlistener 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 冲突。prepare 和 enter 是公开的跨包原语,调用者可以在两者之间干任意事情(包括另一次 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/eventand flush onsession/flush/ dispose.
(index.ts:789)核心的 session 包完全不知道磁盘存在。持久化是一个标准的 Capability Seam(第 8 章会系统讲这个三角):
| 角色 | 包 |
|---|---|
| Service Definition | dsh-session-persistence(ctx.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/call 没 tool/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 都组合同一个 PersistenceCoordinator(packages/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)。
以及 pendingCalls 在 step/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 work:
apply对不相关的事件必须返回同一个 state 引用,drive 用Object.is判断要不要往下游推。不相关的事件成本就是一次函数调用。 - whole-value event rule:一个携带状态的日志事件必须带完整的变更后状态,不能只带增量。这让每次转移都平凡地便宜,每个服出去的值都自描述(消费者按 last-wins 处理)。
todo/write存整张 todo 列表而不是"新增一项",就是这条规则。 - synchronous unit discipline:
init/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 |
SurfaceOp:append 与 replace |
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 五条规则 |