会话即日志:
SessionEvent 与事件溯源。
每一轮对话、每一个 token、每一次工具调用,在这套 harness 里都以同一种形式存在:
追加到会话日志的一条 SessionEvent。读懂这条只增日志,就看懂了 dsh
的记忆系统——模型历史、fork、resume、transcript、遥测,全是它的投影。
- 解释为什么「只增日志是唯一真源」让状态与日志在结构上不可能分歧
- 说清
deriveMessages()的投影规则,以及assistant/chunk与assistant/message的分工 - 列举从同一条事件流派生的能力,并说明
SESSION_FORMAT_VERSION与ignorable的作用 - 用 model-visible ⟺ logged 铁律,判断一种新输入该以什么方式接入
事件溯源心智:日志就是状态
在 dsh 里,一个 Session 不是「带历史的消息对象」,而是一份仅追加(append-only)的、
类型化的 SessionEvent 日志——它是这次 agent 交互的唯一真源。
模型看到的消息历史从不单独存储,而是每次从日志派生;回放,就是用同一组事件重新派生一遍。
这是 packages/core/session(dsh-session)的核心决策,记录在 Agent Note
2026-06-11-event-sourced-sessions 里。
这个设计有一个被明确否决的对照组:「可变消息数组 + 事件仅作通知」。那条路实现更简单, 但内存状态与日志可能悄悄分叉。事件溯源让日志本身就是状态——分歧在结构上不可能发生。 这就是它被称为全仓库中心的原因:改日志 = 改产品的记忆,下游一切(重放、分支、审计、统计)随之改变。
日志有几条不容妥协的硬约定,读代码前先把它们刻进脑子:
- seq 单调且连续:
seq === log.length,不能跳号、不能过滤——连原始的流式 chunk 都占一个序号。 - 事件不可变:进入日志时被深冻结;
append是同步操作,热路径从不阻塞 I/O。 - 无损 JSON:所有
event.data必须可无损序列化为 JSON,Session.append在源头校验并直接拒绝坏事件。 - 先提交后广播:追加提交后通过
session/event同步通知;持久化插件异步缓冲,在session/flush检查点排空。
一份持久化日志(JSONL 后端的逻辑视图)长这样——第一行是 header,之后每行一条事件:
{"type":"session","version":3,"id":"session-…","createdAt":…} ← SessionHeader 行
{"type":"turn/start","seq":0,"time":…,"data":{"turn":1}}
{"type":"step/start","seq":1,…,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":2,…,"data":{…},"surfaceOp":"append"} ← 你的输入
{"type":"request/header","seq":3,…,"data":{"header":{…},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,…,"data":{"chunk":{…}}} ← 原始流分片
{"type":"assistant/chunk","seq":5,…,"data":{"chunk":{…}}}
{"type":"assistant/message","seq":6,…,"surfaceOp":"append","sourceEventSeqs":[4,5]}
{"type":"tool/call","seq":7,…,"data":{"callId":…,"name":"bash","arguments":…}}
{"type":"tool/result","seq":8,…,"surfaceOp":"append"} ← 工具结果
{"type":"step/end","seq":9,…}
{"type":"turn/end","seq":10,…,"data":{"turn":1,"reason":{"kind":"completed"}}}SessionEventMap 可以被插件用 declaration merging(声明合并)扩展——压缩 seam 的
compaction/start / compaction/summary / compaction/end、钩子桥接的
hook/invoked / hook/result 都是这么来的。所以「日志」是一个可扩展的真源,
而不是一张写死的表。生成的 docs/persistence-catalog.md 列举了全部成员。
deriveMessages():从日志投影模型历史
日志里的大多数事件并不直接变成消息。只有三种 surface 事件——user/message、
assistant/message、tool/result——携带 surfaceOp 标记,
声明自己如何进入有序的 surface。deriveMessages() 沿 surface 节点逐条折叠纯函数
deriveEventMessage(),投影出模型看到的 Message[]。它是缓存的
(每个节点只在首次出现时投影一次,surface 被 replace 重写时重建)且冻结的
(调用方拿到共享的深冻结消息,改历史这件事在类型上就写不出来)。
投影规则一共四条,其余事件一律是结构信息:
user/message→ 一条 user 消息,content原样呈现。注入的上下文(agent.inject()的产物:文件变更通知、skill 内容、cron 提醒……)也是它,靠source字段区分来源。assistant/message→ 一条 assistant 消息。内容为空的会被跳过(比如 max-tokens 截断且无输出的步骤),但事件仍留在日志里保存usage、提供方与模型。tool/result→ 一条携带tool-result块的 user 消息。turn/*、step/*、request/*、assistant/chunk等其余事件 → 不产生任何消息。
那 assistant/chunk 为什么存在?因为它保的是另外两样东西:token 级 replay 保真和
UI 流式渲染保真。原始分片原样落日志,派生历史时却被跳过——组装后的
assistant/message 才是权威,并用 sourceEventSeqs 回指生成它的那些 chunk 的 seq。
token 记账走同一条路:读每步的 usage 分片,没有时回退到 assistant/message.usage。
压缩(compaction)是这套投影的另一半:surfaceOp: {op:'replace',start,end} 会遮蔽一段
surface 节点,让派生历史变短,而日志原文一字不动。派生成本随日志长度增长,压缩是预期的缓解手段,
不是改写日志——日志永远只增。
同一条流的派生物
因为一切皆日志的投影,下面这些看似无关的能力,共享同一条事件流:
- fork —
ctx.sessions.fork(source, boundary?)取到指定 seq 为止的前缀做种子,开出一个活跃的子会话;header 里记下parentSession与seedLength。以开放轮次结尾的前缀会被拒绝,而不是静默截断。 - resume — 重启后
ctx.agents.resume({ resumeSessionId })用整段持久日志做构造种子;session/end-seed事件标记「种子历史」与「本次生命周期的实时写入」之间的边界。 - transcript — 面向人类的文本记录是另一个投影,读的是追加来源的事件——surface 会有意遮蔽被
replace概括掉的范围。 - telemetry — 订阅
session/event的消费方;接手一份已有日志的会话时从firstLiveSeq开始,不重复计算种子历史。 - 持久化 — 持久化插件订阅
session/event异步缓冲落盘,session/flush排空。JSONL 后端(每会话一个session.v3.jsonl.zstd)遵守同一份约定:每个事件包括 chunk 都无损保存,seq 保持连续。(SQLite 如今只以session-query-sqlite的身份作为查询索引层存在,不承担持久化。)
版本闸门:SESSION_FORMAT_VERSION 与 ignorable
日志是要落盘的,落盘就有格式问题。dsh-session 的 SESSION_FORMAT_VERSION 现在是
3,且这个格式已随产品正式发布——「哪个版本已发布」的单一真源是权威文档
docs/session-format-status.md。header 里的 version 在创建时盖戳,
兼容则靠相邻迁移:迁移包族 session-format-v0-to-v1、v1-to-v2、
v2-to-v3 一环扣一环,打开旧日志时沿这条链升级为当前代——写打开还会把迁移结果
发布成一个新的 generation 文件。历史 generation 一旦落盘就不可变:不移动、不覆盖、
不删除已提交的 generation,源文件逐字节保留,于是一个会话目录里可能同时躺着
session.jsonl(v0)与 session.v1/v2/v3.jsonl,运行时永远选择数值最高的那一代。
SessionFormatUnsupportedError 只拒绝没有迁移路径可走的版本——比如比当前构建更新的日志
(错误消息提示先升级 harness)——绝不猜着读。
另一条闸门在事件信封上:ignorable: true。读取器遇到不认识的事件类型时默认必须拒绝重建——
因为一条不认识的必需事件可能改变日志其余部分的解读方式;只有丢失也不影响重建的纯信息记录,
写入方才允许标 ignorable: true 让它可跳过。默认必需意味着忘标记的后果只是「过度拒绝」
(一种不便),而不是静默恢复出一个被掏空的会话(一次事故)。这条规则由
KNOWN_SESSION_EVENT_TYPES 清单(gen-persistence-catalog 生成)在加载边界执行。
铁律:model-visible ⟺ logged
这条双向约束是整套设计的承重墙:抵达模型请求的一切,都必须能从日志重建;反过来,模型历史也只从日志渲染。
连请求信封都不例外——request/header 事件把调用配置、渲染后的系统提示词、组装好的工具 schema
一并写进日志,因此每个对话请求都是日志的纯函数。一项运行时不变量持续断言这个关系,
而不是靠 Code Review 时的人肉记忆。
它直接规定了你扩展时的动作顺序。想让模型看到一种新输入(比如一种新的环境通知),正确路径是:
先扩展 SessionEventMap 声明一个 session 事件、把它 append 进日志,再由投影把它渲染进请求。
内建的 agent.inject() 就是这个模式的实例:注入的上下文落到下一次获准的请求里,
成为一条带 source 标记的 user/message——于是 fork、resume、transcript、
遥测全都自动看见了它。
反模式:在请求发出前,临时往 messages 数组里塞一段不入日志的内容。当时模型「看见」了, 但重放、fork、resume、遥测里它都不存在——日志与模型所见就此分叉。 这正是事件溯源要在结构上消灭的事故,也是运行时不变量会抓住的违规。
动手试:解剖一份真实日志
默认配置下,会话持久化在 ~/.dsh/sessions 下(root 来自 base 组合包里的
!!js dshHomePath('sessions')),文件是 Zstandard 压缩的 session.v3.jsonl.zstd,
不能直接按行阅读。为了看清每一条事件,我们用 L2 学过的 patch 技能做一个实验 overlay:
换成未压缩格式,并写入一个独立的 root(一个 root 只属于一种编码,混用会被拒绝)——
新会话由当前构建写出,落盘为 session.v3.jsonl。
- 新建
plain-sessions.yml,按 id 替换 JSONL 后端的整条 config:
# plain-sessions.yml —— 未压缩 + 独立 root 的实验 overlay
- id: session-persistence-jsonl # 定位 base 组合包里的条目
config: # 整条替换它的 config
root: !!js dshHomePath('sessions-plain')
compression: 'none'2. 带着 overlay 跑一次会动用工具的任务:
pnpm dsh --profile headless --patch ./plain-sessions.yml "用 bash 工具看看当前目录有什么文件,再用一句话总结"3. 找到这次会话的日志文件(项目目录 → 会话目录 → transcript):
find ~/.dsh/sessions-plain -name session.v3.jsonl4. 统计事件类型,再各揪一条出来看(把路径换成上一步找到的那个):
grep -o '"type":"[a-z/-]*"' ~/.dsh/sessions-plain/*/*/session.v3.jsonl | sort | uniq -c | sort -rngrep -m1 '"type":"user/message"' ~/.dsh/sessions-plain/*/*/session.v3.jsonl; grep -m1 '"type":"assistant/chunk"' ~/.dsh/sessions-plain/*/*/session.v3.jsonl; grep -m1 '"type":"tool/result"' ~/.dsh/sessions-plain/*/*/session.v3.jsonl在输出里指认三件事:user/message 是你的输入进入模型可见 surface 的那一刻;
assistant/chunk 通常有几十上百条,是 token 级原始流;tool/result 是工具结果的模型面内容。
如果这轮没有 tool/result(模型没调用工具),换一个明确要读文件的任务再来一次。
注意 assistant/chunk 的数量级:它们占掉了大部分 seq。这就是「seq 不能过滤、chunk 也占号」的直观含义——
删掉它们,replay 和 UI 流式回放就都失真了。
课堂练习
消息历史从不单独存储:surface 事件(user/message / assistant/message / tool/result)携带 surfaceOp,deriveMessages() 沿它折叠出历史。A 正是被明确否决的替代方案——可变数组 + 通知会让状态与日志悄悄分叉。
铁律 model-visible ⟺ logged:进模型请求的必须能从日志重建。临时塞数据(A)或 UI 层拼接(D)都让模型「看见」了日志里不存在的东西,重放、fork、resume、遥测全部丢失它,运行时不变量会断言违规。内建的 agent.inject() 就是 C 的实例。
架构文档的原话:fork、恢复、transcript、遥测和持久化都派生自该事件流。E 错:工具结果的模型面内容以 tool/result 事件进入同一条日志(它还要参与模型历史投影),没有平行的私有存储。
B 违反只增约定与 seq 连续性——压缩用 replace 遮蔽 surface,也不动日志原文。D 直接撞上 model-visible ⟺ logged 铁律。A 对应崩溃恢复用 turn/end { kind: 'interrupted' } 配平遗留轮次的设计;C 是持久化约定明文:每个事件包括 chunk 无损保存。
问答为什么 session 日志被称为「一切的源头」?写下答案后点击对照›
参考要点:① 它是唯一真源——消息历史不单独存储,deriveMessages() 从日志派生,状态即日志,分歧在结构上不可能;② fork / resume / transcript / telemetry / 持久化全部从同一条流派生,改日志等于改产品所有下游的记忆与行为;③ 连请求都是日志的纯函数——request/header 把调用配置、系统提示词、工具 schema 都写进日志,重放即可完整重建一次对话。
问答解释 model-visible ⟺ logged 的含义,以及它约束了什么扩展行为。写下答案后点击对照›
参考要点:双向等价——抵达模型请求的一切必须能从日志重建(→),模型历史也只从日志渲染(←),由运行时不变量持续断言。约束:新增模型可见输入必须先扩展 SessionEventMap 增加 session 事件并在投影中渲染它,禁止在请求前临时塞数据;否则重放、fork、resume、遥测都看不到这份内容,日志与模型所见分叉——这正是事件溯源要消灭的事故。agent.inject() 是合规路径的内建实例。
本课小结
现在你知道 dsh 的记忆长什么样了:一条只增的 SessionEvent 日志是唯一真源,
deriveMessages() 沿 surface 投影出模型历史,fork / resume / transcript / 遥测 / 持久化
全是同一条流的派生物,而 model-visible ⟺ logged 铁律保证了这一切不发散。
下一课把模块 B 串起来:跟着一次 turn 从 inbox 到 turn/end 走完完整生命周期,
看这条日志是在哪里、以什么顺序一笔一笔写出来的。