第四部:事件账本——Session是append-only日志

Session不是一个可变状态对象,而是一个只追加的事件日志。从append校验到surface投影,从JSONL原子写到崩溃修复,从fork到compaction——所有操作都是日志追加。

一本发光的账本浮在深蓝海水中,每一页是一个事件信封,seq 编号连续;append-only 的钢笔在追加新条目,投影灯光从账本投向多个屏幕——模型历史、UI 与持久化。
展开阅读路线与实验入口

你以为 Session 是一个聊天状态对象——里面存着 messages 数组、当前上下文、token计数。你调用 session.addMessage() 修改它,调用 session.updateContext() 覆盖它。错。

在 DeepSeek Harness 里,Session 是一本 append-only 账本。你永远不修改已写入的条目,只能在末尾追加新事件。模型看到的历史、UI渲染的对话、磁盘上的持久化文件——全部是同一份日志的不同投影。

这是整个系统最反直觉的设计决策,也是可靠性的根基。

这一部解决什么

读完这六章,你能回答六个关键问题:

  • 为什么 append() 是唯一入口,为什么要递归做 lossless-JSON 校验加深拷贝
  • 每个事件信封必须携带哪些字段,未知事件为什么默认拒绝恢复
  • Surface 投影是什么,为什么模型看到的历史和原始日志不是一回事
  • 同一份日志如何长出多个投影:header、context、goal、runtime context
  • JSONL 持久化怎么做原子写,崩溃时如何确定性修复未闭合的 turn
  • Resume、Fork、Compaction 这三种”上下文改写术”为什么仍然是日志追加

你可能在别的 Agent 框架里见过 message 数组直接 push/pop/splice。Harness 不这么做。Compaction 不是删旧消息,是追加一个带 surfaceOp: replace 的新事件。Fork 不是深拷贝状态,是从源日志的连续前缀创建子账本。Resume 不是反序列化一个对象,是把磁盘上的事件读进来,从最后一个完整边界继续追加。

Session 是 append-only event log,Event 信封带因果链,Surface 投影派生模型可见历史,JSONL 原子写和 write-behind 保证 crash recovery,resume/fork/compaction 在事件溯源层操作

所有证据来自固定 commit 的源码。实验命令只读不写,用 $DSH_SOURCE_DIR 指向官方 checkout。

本节源码依据(1 处)

从最核心的 append 机制开始。你会发现,一个看似简单的”往数组里push一个对象”,背后有七层防御。

深入浅出 DeepSeek Harness 第四部:事件账本——Session是append-only日志 第 22 章

会话里没有状态,只有一本只追加的流水账

DeepSeek Harness 的 Session 核心就是一个 private 的、只追加不修改的事件数组:append() 依次做 JSON 快照、重入防护、深冻结、关系校验、表面投影校验,然后才 push 并同步派发观察者。快照隔离让历史读取永不被后续 append 污染。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

现在是一个会话进行到一半的普通时刻:模型刚吐出一段 assistant 消息,工具正要执行,用户随时可能再敲一句话。你以为这一刻 Session 内部是一个”可以读写的状态对象”,存着当前目标、存着消息列表、存着工具进度,像个普通的服务端 session 那样,哪个字段变了就改哪个。其实不是。这一刻 Session 的内部根本没有”状态”,只有一本正在往后写的流水账:一个 private 的事件数组,新发生的一切(用户说的话、assistant 的每一段、工具的每次调用与结果)都是往后追加一条记录,旧记录一个字节都不动。你看到的”当前状态”,全都是从这本流水账现算出来的投影。

上一章 第 21 章 讲的是工具产出怎么被搬运出上下文。本章往更底层走一步:这些被搬运、被截断、被重放的东西,在 Harness 里到底是以什么形态存着的。答案就是这个 append-only 账本本身。

session-append-only 机制图:中央是一个 private readonly log 事件数组,seq 从 0 单调递增;左侧是唯一入口 append() 的七层守卫流水线——组 surfaceMetadata、snapshotJsonValue 深拷贝、重入检查、deepFreeze、validateSessionEventData、surfaceManager.validateNext,最后才 log.push;push 之后同步派发 session/event 观察者并把旧 eventsSnapshot 置 undefined;右侧是快照隔离:全量 snapshotEvents 走 O(1) 冻结缓存,分片读每次新 freeze,后续 append 永不污染已发出的快照;底部是两条静态工厂路径 create(snapshot 拷贝)与 fromRestore(借用冻结事件),构造 seed 时复用 append 的同一套 seq 连续与 JSON 可序列化校验。

账本本体:一个拿不到引用的 private 数组

打开 Session 类,它最核心的成员不是某个”当前消息列表”,而是:

private readonly log: SessionEvent[] = []

注意三个词叠加起来的含义:private 意味着类外部根本拿不到这个数组的引用;readonly 意味着连类内部都不能把整个数组换掉;数组元素是 SessionEvent。整个类没有任何 update、delete、splice 方法——它不提供任何一条”修改历史”的路径。想改一条已经写进去的事件?做不到。唯一的动作是 append:在末尾加一条新记录。

这不是技术限制,是账本语义。它带来的直接后果有两个:

第一,纠错只能靠追加,不能靠改写。发现上一条工具结果错了,正确做法是再追加一条带 surfaceOp: 'replace' 的更正事件,由投影层在算”当前表面”时把旧节点替换掉——历史本身仍然完整保留。这也是为什么上一章 spill、compaction、fork 这些机制都能安全地”重放”一本账:账一旦写定,就永远是它当时的样子。

第二,编号是追加的天然副产品。每条新事件的序号就是 this.log.length:

const event = deepFreeze({
  type,
  seq: SessionSeq(this.log.length),
  time: Date.now(),
  data: dataSnapshot,
  ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
})

seq 不需要全局计数器,不需要分布式协调——它就是这条记录落进数组时的下标。这天然保证了 seq 从 0 开始、严格单调连续、永不重复。这套不变量是后面所有恢复、投影、迁移机制的地基:只要”seq = log.length”成立,给定任意前缀就能无损重放。

账本性质代码体现带来的保证
私有private readonly log外部无法篡改历史
只追加无 update/delete/splice历史一旦写定永不改变
自动编号seq = SessionSeq(this.log.length)seq 从 0 单调连续
深冻结deepFreeze({...})读到的 event 不可被任何人改
同步时间time: Date.now()每条记录有落盘时刻

为什么 Harness 宁可”永远不改历史”,也不给一个方便的 update 接口? 因为这本账同时是”崩溃恢复的数据源”和”多 Agent 重放的依据”。一旦允许就地修改,你就永远无法区分”这条记录是当时真的发生的”还是”后来被人改过的”——恢复出来的会话就不再是真实发生过的那个会话。append-only 把”历史真实性”从一个需要额外机制维护的承诺,变成了一个由类型系统和方法集直接强制的物理事实。

本节源码依据(4 处)

append() 的七层守卫:顺序为什么不能乱

append() 看起来只是”往数组里 push 一下”,但它在 push 之前排了七层守卫,每一层的顺序都经过推敲。按代码顺序读:

第 1 层,组装表面元数据。 从可选的 SurfaceIntent 里抽出 surfaceOp 与 sourceEventSeqs,拼成一个小对象。这一步先做,是因为它要和 data 一起被快照。

第 2 层,data 深快照。 snapshotJsonValue(data) 把调用方传进来的 data 做一次无损 JSON 快照。这层极其关键:调用方完全可能传一个还在手里继续改的对象。如果账本直接引用这个对象,调用方事后一改,历史记录就被偷偷改写了。所以 append 在这里强制拷一份。拷不出来就抛错:

const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
  throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}

什么样的 data 拷不出来?BigInt、函数、symbol、undefined、负零、非有限数、循环引用、稀疏数组,以及 Map/Set/Date/类实例这类”异类对象”。注释写明这是”one iterative pass reads, validates, and copies each nested value once”——一次迭代同时完成校验和拷贝,一个有状态的 getter 没法对校验方出示一个值、对存储方出示另一个值。表面元数据走完全一样的快照流程。

第 3 层,重入防护。

const entry = attachments.get(this)
if (entry?.appending) {
  throw new Error('session append cannot reenter while another append is being published')
}

发布边界是一个临界区:一旦进入”正在 append”状态,就不许再嵌套 append。这防止了观察者回调里又触发 append、导致发布顺序和预期不一致。

第 4 层,深冻结事件。 把组装好的 {type, seq, time, data, surfaceMetadata} 整个 deepFreeze。从此这条记录谁都改不了——哪怕是 Session 自己。

第 5 层,事件数据校验。 validateSessionEventData(event, ...) 按事件类型校验其业务一致性(比如 request-header 的空字段规则、工具错误的一致性规则)。

第 6 层,表面投影校验。 this.surfaceManager.validateNext(event) 校验这条事件作为下一条表面变更是否合法(替换区间、引用完备性等——这是下一章和第 24 章的主题)。

第 7 层,才是 push 与派发。 先收集 session/event 回调,然后 log.push,把旧快照置空,再同步派发观察者,最后在 finally 里复位 appending 标记。

顺序守卫失败时保护的是什么
1组 surfaceMetadata—表面元数据纳入快照
2snapshotJsonValue(data)抛 non-JSON-serializable调用方可变对象不污染账本
3appending 重入检查抛 cannot reenter发布边界不被嵌套打破
4deepFreeze(event)—落盘事件永久不可变
5validateSessionEventData抛类型规则错误业务一致性
6surfaceManager.validateNext抛表面契约错误投影可重放
7push + 派发—真正入账并通知

为什么”校验”和”拷贝”必须在 push 之前,而且合在一次遍历里? 因为账本是 durable source of truth。注释原话:“a bad event fails at the append site rather than later during a backend flush”。如果 push 之后才校验,或者校验和拷贝分两步,就可能出现”校验过的值”和”实际存的值”不一致——一个 getter 在两次读取间变了。把校验和拷贝压在同一次无损遍历里,append 点的失败就是”这条事件根本没进账本”,而不是”进了账本但将来 flush 时才暴露”。

本节源码依据(7 处)

快照隔离:读过的历史,后来改不了

账本是只追加的,但”读取”需要一个稳定的视图。如果调用方拿到的事件数组随下一次 append 就被改写,那么正在读历史做重放的代码就会读到一半变样。Harness 的解法是快照隔离:

snapshotEvents(fromSeq = 0, toSeqExclusive = this.seq): readonly SessionEvent[] {
  if (fromSeq === 0 && toSeqExclusive === this.log.length) {
    this.eventsSnapshot ??= Object.freeze([...this.log])
    return this.eventsSnapshot
  }
  return Object.freeze(this.log.slice(fromSeq, toSeqExclusive))
}

读全量(fromSeq=0、toSeqExclusive=整个 log)走一条快速路:把整个账本 [...this.log] 拷贝一份、Object.freeze,并缓存到 eventsSnapshot。下一次全量读直接返回这份缓存,O(1)。而每次 append 成功后第一件事就是 this.eventsSnapshot = undefined——旧快照作废,下一次全量读才重新拷贝。

分片读(指定 from/to)不走缓存,每次 slice 出一个新的冻结数组。

这套机制保证了两个性质:

  • 稳定:任何一次 snapshotEvents 返回的数组,在它返回之后,永远不变。后续 append 只是让 eventsSnapshot 缓存失效,绝不去改已经发出去的那份。
  • 省:在两次 append 之间会有很多次全量读(投影、序列化、观察者),它们共享同一份冻结拷贝,不重复分配。

注释说得很直白:“A full current snapshot is reused until the next append; every previously returned snapshot remains stable after later appends.”

读路径行为复杂度后续 append 是否影响
全量读(0→末)缓存 Object.freeze([...log])O(1) 复用不影响(旧快照冻结)
分片读(from→to)每次新 slice + freezeO(分片长)不影响
缓存失效append 后 eventsSnapshot=undefined—下次全量读重建

为什么全量读要缓存、分片读却每次新拷? 因为全量读在会话运行中会被反复调用(每次派生消息、每次写盘、每次通知),而分片读往往是一次性的范围查询。缓存全量快照把”热路径”的分配次数压到每个 append 一次;分片读不缓存,是因为它的结果取决于当时的边界,缓存反而要管理失效。这个取舍本质是:稳定优先,复用其次。

本节源码依据(4 处)

两条构造路径:create 与 fromRestore

账本不会凭空长出来——它要么是一个新会话从零 append,要么是从外部(磁盘、fork)把一段已有的事件”接”进来。Session 用两个静态工厂区分这两种来源:

static create(id, seed?, header?, inheritedEventCount?, projections?): Session {
  return new Session(id, seed, header, 'snapshot', inheritedEventCount, projections)
}
static fromRestore(id, seed, header, inheritedEventCount, eventState, projections?): Session {
  return new Session(id, seed, header, eventState, inheritedEventCount, projections)
}

差别在第四个参数 mode:

  • create 用 'snapshot':它信任外部传入的 seed,但会自己做一次无损 JSON 快照和深冻结。注释写明这是给”borrowed replay or fork events”用的——你给它一段事件,它拷贝成自己的、不可变的版本。
  • fromRestore 用 eventState:它借用外部已经独立拥有或已深冻结的 seed,不再拷贝,直接采纳。这是从磁盘恢复时的路径——磁盘解出来的事件本来就是不可变的,再拷一遍纯属浪费。

但无论走哪条路,构造 seed 时都复用了 append 的同一套不变量校验:

for (const [index, source] of seed.entries()) {
  const snapshot = mode === 'snapshot' ? snapshotJsonValue(source) : source
  if (snapshot === undefined) {
    throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
  }
  assertSessionEventEnvelope(snapshot, index)
  if (snapshot.seq !== index) {
    throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
  }
  this.surfaceManager.validateNext(snapshot)
  this.log.push(...)
}

注释点出了核心动机:“Validate the seed to the SAME invariants append enforces, so a replay/fork cannot construct a live log that no persistence backend could store”。也就是说,从磁盘恢复或 fork 重放得到的日志,和运行期直接 append 出来的日志,必须形状完全一致——否则就会出现”内存里一本账、磁盘上另一本账”的分叉。

最关键的一条是 seed must be contiguous from 0:seed 里第 i 条事件的 seq 必须正好等于 i。这把”seq = log.length”不变量从运行期延伸到了恢复期。

工厂mode对 seed 的处理适用场景
create'snapshot'自己快照 + 深冻结fork / replay 借用事件
fromRestoreeventState直接借用,不拷贝从磁盘恢复

两条路径共用:seed 必须无损 JSON 可序列化、信封形状合法、seq 从 0 连续、表面投影可重放。

为什么恢复路径不能跳过校验、直接信磁盘? 因为磁盘文件可能是损坏的、被截断的、或者来自一个更新版本的 Harness。如果构造时不校验,一本非法的账会”看起来正常”地进入运行期,直到某次写盘或重放才炸——那时已经很难定位是哪里坏了。把校验放在构造点,等于让”非法账”在诞生的瞬间就被拒掉。

本节源码依据(6 处)

SessionHeader 与 seed 裁剪点

账本之外,Session 还带着一份不可变元数据 header(id、version、createdAt、cwd、是否 seeded、父会话、委派深度等)。它和账本是分开管理的,并有一条与”fork/恢复”直接相关的规则:seeded 的会话必须有一个明确的继承前缀长度,并且这个裁剪点要有一个 session/end-seed 标记事件。

构造时连着抛了一串校验:

if (this.header.isSeeded && seed === undefined) throw new Error('seeded session requires an explicit constructor seed')
if (this.header.isSeeded && suppliedInheritedEventCount === undefined) throw new Error('seeded session requires an inherited event count')
if (!this.header.isSeeded && inheritedEventCount !== 0) throw new Error('unseeded session inherited event count must be 0')
if (inheritedEventCount > this.log.length) throw new Error('session inherited event count exceeds its event log')

也就是说:一个声称”是从别处 fork 来的”会话,必须同时给出它继承了多长一段前缀,并且这个前缀长度不能超过它实际的账本长度。末尾还会视情况追加一条 session/end-seed 标记,把裁剪点永久钉在账本里。

这套规则是第 26 章崩溃恢复和第 28 章 fork/compaction 的前提:账本自己记得”从哪一条开始是我自己的历史,之前的是继承来的”。恢复时据此区分”我的事件”和”借来的事件”。

本节源码依据(3 处)

小结

  • Session 是一本流水账,不是状态对象。 它的心脏是 private readonly log,只追加、不修改;所谓”当前状态”全是从这本账现算的投影。
  • append() 是唯一入口,七层守卫顺序不可乱。 JSON 快照、重入防护、深冻结、数据校验、表面校验全部在 push 之前完成,失败绝不污染账本。
  • 读到的是落盘快照,不是可变输入。 snapshotJsonValue 把调用方的可变对象拷成不可变副本,事后修改进不了账本。
  • 快照隔离让历史稳定。 全量快照 O(1) 复用、append 即失效,已发出的历史永远不被后续追加改写。
  • 恢复与运行同形状。 create/fromRestore 以同一套 seq 连续与 JSON 可序列化校验接纳 seed,从磁盘恢复的账和运行期 append 的账长得一模一样。

下一章:第 23 章

只读验证

# 验证源码身份(应输出 477b4f420553e8a52c2fbccc464d7561b239c443)
git -C "$DSH_SOURCE_DIR" rev-parse HEAD

# 逐字核对本章引用的关键错误消息与不变量
grep -n "carries non-JSON-serializable" "$DSH_SOURCE_DIR/packages/core/session/src/index.ts"
grep -n "session append cannot reenter" "$DSH_SOURCE_DIR/packages/core/session/src/index.ts"
grep -n "seed must be contiguous from 0" "$DSH_SOURCE_DIR/packages/core/session/src/index.ts"
grep -n "SessionSeq(this.log.length)" "$DSH_SOURCE_DIR/packages/core/session/src/index.ts"