第 3 章
会话日志:Model-visible ⟺ logged
场景还原
假设你接手了一个审计任务:一个 subagent 在崩溃前最后一次模型请求里,到底看到了什么。你打开会话存储,眼前有两种候选:一份序列化的消息数组快照,或者一条事件追加日志。
先看快照的问题。它只告诉你「那一刻的状态」,不告诉你状态怎么来的。注入的上下文、工具输出的原文、一次被拒绝的空 turn,在快照里要么只剩结果,要么什么都没有。更麻烦的是恢复:崩溃发生时最后一次快照可能根本没写盘,你恢复出一个残缺的历史,而且没有任何信号告诉你它残缺了。审计靠快照,等于默认「快照写盘的那一刻,恰好覆盖了你关心的那一刻」。
dsh 的答案换了一个方向:模型请求的历史从来不从快照读,从追加日志投影。投影的意思是,任何时候拿日志的前缀跑一遍纯函数,就能得到当时模型看到的完整消息列表。仓库红线 Model-visible ⟺ logged 把这条钉死:模型看到的一切必须能从日志重建,同时日志里还存着模型看不到的东西,比如逐 token 的原始 chunk、turn 边界、log-only 记录。前者保证可审计,后者保证可回放,而这两样来自同一条数据流。
这一章的阅读路径是:事件词典长什么样,追加是怎么落盘的,投影是怎么算的,chunk 保真怎么压缩,旧版读新日志时靠什么不翻车,最后拿 claude-code 的 transcript 机制对比「日志是副产品还是数据源」这个根本分歧。
逐行精读
事件词典:模型可见内容的一览表
一切从 SessionEventMap 开始。它是这份日志的全部事件类型,也就是「模型可见内容」的完整清单。dsh 的约定是:模型请求里出现的每一种输入,都必须有一个事件类型承接它。下面是完整定义:
236export interface SessionEventMap {237 /**238 * Opens turn `turn` before the loop claims queued input or runs pre-step.239 * Rejection, empty input, cancellation, or failure may close it with no240 * step; otherwise the following identified `user/message` event or batch241 * records the messages entering the step.242 */243 'turn/start': { turn: number }244 /**245 * Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn246 * with no entered step has no `step/start` or `step/end`. The loop does not await a247 * flush at turn boundaries: `dsh-session-checkpoint-policy` owns the248 * per-request durability checkpoint, and consumers that read storage after249 * `whenIdle()` flush themselves. Success commits the turn; rejection is250 * reported live and does not prevent later work.251 */252 'turn/end': { turn: number; reason: TurnEndReason }253 /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */254 'step/start': { turn: number; step: number }255 /** Closes step `step` of turn `turn`. */256 'step/end': { turn: number; step: number }257 /**258 * A user-role message on the model-visible surface: a direct human prompt259 * (the queued message claimed for this turn), a synthetic `agent.inject()`260 * context (file-change notices, subdir AGENTS.md, skill content, cron261 * notifications, …), or an entered goal continuation round. All three262 * project their `content` verbatim; `source` tells them apart.263 */264 'user/message': UserMessage265 /** Raw stream chunk — token-level replay fidelity. */266 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }267 /**268 * Assembled assistant message for one step (derived history uses this).269 * Carries the step's `usage` when the adapter reported token accounting, so270 * the model output and its accounting travel together (there is no separate271 * usage record). `usage` is absent when the adapter reported none. A turn272 * cancelled mid-stream finalizes its delivered text/reasoning prefix as this273 * event with `interrupted: true`; undispatched tool calls are absent. The274 * marker distinguishes that prefix without re-deriving interruption from turn275 * boundaries. An aborted turn with no such event streamed no visible content.276 */277 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }278 /**279 * The model requested one tool invocation: `name` with the raw `arguments`280 * JSON string exactly as the model produced it (unparsed). `callId` pairs the281 * call with its `tool/result`.282 */283 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }284 /**285 * A completed tool call's model-facing result, optional internal failure286 * identity, and optional tool-private `meta` presentation payload. `meta` is287 * opaque to the core (the producing tool owns its shape and reads it back in288 * `presentResult`) but MUST be JSON-serializable: `Session.append`289 * runtime-validates all event data with `isJsonValue`, so a non-serializable290 * `meta` is rejected at the source, and the durable log reproduces the291 * identical card on replay. Absent292 * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time293 * contextual diff here).294 */295 'tool/result': {296 turn: number297 step: number298 message: ToolResultMessage299 error?: { name: string; code: string }300 meta?: JsonValue301 }302 /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */303 'todo/write': { todos: TodoItem[] }304 /**305 * Full header for the next request, appended inside its step before dispatch.306 * It is log-only; the latest snapshot reconstructs the request header.307 */308 'request/header': { header: EpochHeader; reason: RequestHeaderReason }309 /**310 * Route metadata for the next request, logged only when the route or capacity311 * changes. It does not participate in request reconstruction or header equality.312 */313 'request/context': RequestContext314 /**315 * Marks the end of a constructor seed. Events before it have smaller seq316 * values and came from the seed (resume, fork, or replay); this lifecycle317 * produced none of them. This log-only event is the durable projection of318 * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`319 * carry the meaning.320 *321 * Locate the LAST one in stored history. A seed already ending in one is not322 * re-marked, so reopening an untouched session does not grow its log per323 * pickup and the event need not be at the current `firstLiveSeq`.324 *325 * `Session`'s constructor is the only legitimate writer. The invariant326 * companion deliberately constrains nothing here, so a plugin appending one327 * would silently classify every live bracket before it as seed history.328 *329 * An owner of a standalone open/close bracket (`compaction/start` …330 * `compaction/end`) reads it because seed history and live work are otherwise331 * byte-identical: an unmatched opening marker before this event belongs to332 * an ended lifecycle, whatever ended it. NOT a liveness signal about other333 * writers — a concurrently live session holds its own boundary elsewhere,334 * so tolerating concurrent writers needs a signal beyond the log.335 */336 'session/end-seed': Record<string, never>337}几个要点。第一,turn/*、step/* 负责圈定范围:step 是一次模型调用加它触发的工具执行,turn 是零个或多个 step,turn/start 的注释写明拒绝、空输入、取消都可能让一个 turn 没有 step 就关闭。第二,user/message 的注释列了三种来源:用户直接输入、agent.inject() 注入的合成上下文、goal 延续轮,全部原样投影,靠 source 区分。第三,assistant/message 是派生历史的权威来源,而 assistant/chunk 注释只有一句话:原始流块,token 级回放保真。第四,tool/result 的 meta 字段是工具私有的展示载荷,核心不认识它,但 Session.append 会在源头用 lossless JSON 校验拦住不可序列化的值。第五,todo/write、request/header、request/context、session/end-seed 注释里反复出现一个词:log-only。它们不产生模型消息,只服务状态重建(标题、请求头折叠、seed 边界)。
事件类型有两个派生集合,一个管追加,一个管投影:
339/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */340export type SessionEventType = keyof SessionEventMap注意注释里的「plugin-merged extensions included」。SessionEventMap 是 interface,插件通过 declaration merging 往里面加成员,加完 SessionEventType 自动扩大。下面看一个真实扩展,goal 插件挂的 goal/change:
61declare module '@deepseek-ai/dsh-session/types' {62 interface SessionEventMap {63 /**64 * Complete post-mutation goal state or clear tombstone.65 */66 'goal/change': GoalChangeMeta67 }68}插件不改 dsh-session 的任何源码,只在自己的包里声明一次模块合并,事件词典就多了 goal/change。这个机制后面还会再出现两次:一次是「已知事件清单」的生成,一次是版本号的判定标准。
事件信封:seq、time、ignorable、surface
每个事件的实际结构是 SessionEvent,一个按 type 判别并收窄 data 的联合类型:
408export type SessionEvent<T extends SessionEventType = SessionEventType> = {409 [K in SessionEventType]: {410 type: K411 /** Monotonic sequence number within the session. */412 seq: number413 /** Unix epoch milliseconds. */414 time: number415 data: SessionEventMap[K]416 /**417 * Marks an event a reader may safely skip when it does not recognize418 * `type`. Absent means required: a reader meeting an unrecognized type419 * without this marker MUST refuse to reconstruct the session instead of420 * silently dropping the event, because an unrecognized required event may421 * change how the rest of the log is interpreted. A writer sets `true` only422 * on purely informational records whose loss cannot affect reconstruction;423 * defaulting to required means a forgotten marker over-refuses (an424 * inconvenience) rather than silently resuming a gutted session.425 */426 ignorable?: true427 } & (K extends SurfaceEventType ? {428 /**429 * Seq numbers of earlier events that this event cites as sources430 * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,431 * or the surface nodes shadowed by a compaction replace node). An432 * `assistant/message` may carry a present empty array for a known empty433 * provider stream; when the field is absent, the event does not record which434 * earlier events produced the message.435 */436 sourceEventSeqs?: number[]437 /** How this event entered the surface; absent for non-surface events. */438 surfaceOp?: SurfaceOp439 } : object)440}[T]四个字段的职责:seq 从 0 开始连续编号,是日志的物理位置;time 是写入时刻;data 是类型化的载荷。ignorable 是逃生门,注释把两种失败模式讲得很清楚:默认 required,忘记标只会「过度拒绝」一个本来能恢复的会话;默认 ignorable 的话,同样的疏忽会「静默恢复一个被掏空的会话」。sourceEventSeqs 和 surfaceOp 是条件字段,只有三类事件能携带:user/message、assistant/message、tool/result。这三类就是 SurfaceEventType,模型视野的入口。注释最后一句点明编译器的角色:非 surface 事件不许带 surface 元数据,这是 Session.append() 调用点被编译器强制保证的。
追加路径:同步、校验、冻结
append 是日志唯一的写入口。它做的事可以浓缩成五步:校验载荷是 lossless JSON、校验请求头兼容、按 log.length 打 seq、深冻结、推进日志并同步通知监听器:
604 append<T extends SessionEventType>(605 type: T,606 data: SessionEventMap[T],⋯ ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []· ): SessionEvent<T> {· const surfaceOpts: SurfaceIntent | undefined = opts[0]· const surfaceMetadata = {⋯ ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },⋯ ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },· }· const dataSnapshot = snapshotJsonValue(data)· if (dataSnapshot === undefined) {· throw new Error(`session event "${type}" carries non-JSON-serializable data`)· }· assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`)· const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)· if (surfaceMetadataSnapshot === undefined) {· throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)· }· const entry = attachments.get(this)· if (entry?.appending) {· throw new Error('session append cannot reenter while another append is being published')· }· const event = deepFreeze({· type,· seq: this.log.length,· time: Date.now(),· data: dataSnapshot,⋯ ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),633 } as unknown as SessionEvent<T>)634 this.surfaceManager.validateNext(event as SessionEvent)635636 if (entry !== undefined) entry.appending = true637 try {638 let callbacks: SessionCallback[] | undefined639 const callbackArgs: unknown[] = [this, event]640 if (entry !== undefined) {641 callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])642 }643 this.log.push(event as SessionEvent)644 this.eventsSnapshot = undefined645 if (callbacks !== undefined && entry !== undefined) {646 invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)647 }648 return event649 } finally {650 if (entry !== undefined) {651 entry.appending = false652 if (entry.detachRequested && !entry.announcing) entry.detach()653 }654 }655 }第 614 至 617 行是源头校验:snapshotJsonValue 对载荷做 lossless JSON 快照,不可序列化直接抛错。第 627 至 633 行构造事件本体,seq: this.log.length 让 seq 永远等于数组下标,这就是「seq 连续」这一不变量的物理来源。第 634 行在事件进日志之前先交给 surfaceManager.validateNext 做折叠预检,失败则日志不变。第 643 行才是真正的 log.push,随后同步派发 session/event 通知。整个函数同步执行,热路径不碰 I/O,持久化是另外的插件在 session/flush 检查点批量落盘。
投影规则:哪类事件变成模型消息
日志是事实,模型历史是投影。投影的原子规则是这个函数:
83export function deriveEventMessage(event: SessionEvent): Message | null {84 // Intentionally non-exhaustive: only message-producing events derive85 // history; turn/step boundaries, chunks, usage, and errors are trace/replay86 // data.87 switch (event.type) {88 // Ordinary prompts and injected context project in user role: the event's89 // model-facing content stays verbatim. Do NOT re-add per-type framing90 // (e.g. `<context>`) here: framing is caller-owned — a producer bakes it91 // into `content`, as agent-instructions does with `<system-reminder>` — or,92 // if reintroduced, must be driven by the event `meta` map and a dedicated93 // renderer, keeping this projection a verbatim pass-through. See the94 // deferred design note in95 // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md96 case 'user/message': {97 return event.data98 }99 case 'assistant/message': {100 // Skip an empty-content assistant/message: it exists only to host a101 // max-tokens step's usage and must not inject a content-less assistant102 // turn into the provider transcript.103 if (event.data.message.content.length === 0) return null104 return event.data.message105 }106 case 'tool/result': {107 return event.data.message108 }109 default:110 // A non-surface event (boundary, chunk, log-only record) projects to111 // no message. Merge-extensible union: no assertNever here.112 return null113 }114}规则只有四条,前面注释就写了「故意不穷尽」:user/message 原样投影成 user 角色;assistant/message 投影成 assistant 消息,但空 content 的返回 null,因为那种事件只为了承载 max-tokens step 的 usage;tool/result 投影成工具结果;其余一切返回 null。chunk 不投影,turn 边界不投影,log-only 记录不投影。注释里还钉了一条纪律:不要在这里给 user/message 加 <context> 之类的包装,framing 是生产方的责任,投影是逐字的直通。
哪类事件有资格上 surface,有一个运行时守卫:
35export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {36 if (!SURFACE_EVENT_TYPES.has(event.type)) return false37 return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined38}类型守卫检查两件事:类型在三个 surface 类型里,且 surfaceOp 标记在场。surfaceOp 只有两个值:'append' 表示追加到模型视野尾部,{ op: 'replace', start, end } 表示替换一段已有节点,后者是 compaction 用的。所以「模型视野」本身也是一份可折叠的数据结构:节点是 seq 数组,replace 会删掉被阴影覆盖的节点。
派生缓存:投影只算新节点
deriveMessages 把投影规则折到整个 surface 上,并做了增量缓存:
708 /**709 * Derive the LLM message history by walking the ordered sequences of710 * message-producing events maintained by `surfaceOp` markers. The711 * surface is the single source of derived history: every message-producing712 * append records its `surfaceOp`, so a raw event with no marker (a chunk, a713 * turn boundary) is correctly absent, and a compaction `replace` deletes the714 * shadowed nodes from the derivation. The projection rules are715 * {@link deriveEventMessage}, folded per node.716 *717 * CACHED: each surface node is projected exactly once, when first seen — a718 * call costs O(new nodes), and a surface rewrite (a `replace`;719 * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is720 * a fresh snapshot per call (later appends never grow an array a caller721 * already holds); the `Message` objects in it are SHARED and **deep-frozen**.722 * Their content reuses the already frozen durable event data, so the cache723 * needs no second deep clone and consumers still cannot mutate the log.724 * @returns a fresh array of the shared, frozen derived history.725 */726 deriveMessages(): Message[] {727 const surface = this.surface728 const nodes = surface.nodes729 const generation = surface.replaceGeneration730 if (generation !== this.derivedGeneration) {731 this.derived = []732 this.derivedNodes = 0733 this.derivedGeneration = generation734 }735 for (const seq of nodes.slice(this.derivedNodes)) {736 // Surface sequences are built from this.log — seq is always a valid737 // index by construction. The non-null assertion expresses that invariant.738 // oxlint-disable-next-line typescript/no-non-null-assertion739 const msg = this.deriveEventMessage(this.log[seq]!)740 // A surface node is one of the five message-producing types, but an741 // empty-content assistant/message (a max-tokens step that hosts only742 // usage) derives to null and must not enter the transcript.743 if (msg) this.derived.push(msg)744 }745 this.derivedNodes = nodes.length746 return [...this.derived]747 }注释把三件事讲死了:每次调用成本是 O(新节点),因为每个 surface 节点只投影一次;replace 通过 replaceGeneration 计数让缓存整体失效重建;返回的数组是新鲜快照,但里面的 Message 对象是共享且深冻结的,复用事件数据本身的冻结副本,消费者改不动日志。第 741 至 743 行再次处理空 assistant/message:投影成 null 就不进 transcript。
下面的时序图回答一个问题:一次 step 的模型历史,从追加到投影经过了哪几步:
chunk 保真:先逐字节留档,再压缩
模型请求里的消息来自投影,但投影丢信息:chunk 的逐 token 边界、时间戳、块序号都不在派生历史里。assistant/chunk 事件补上这一层,它进日志时逐字节留档。这里有一个规模问题:一个流式响应动辄几百个 delta chunk,每行事件若原样存盘,JSON 外壳比载荷大得多。chunk-rows.ts 的模块头注释给了一个实测数字:大约 56 倍。压缩方案是把连续同类的 delta chunk 打包成一行存储记录:
64export type ChunkRow =65 | { type: 'text-chunks'; seq0: number; time0: number; data: TextRunData }66 | { type: 'reasoning-chunks'; seq0: number; time0: number; data: TextRunData }67 | { type: 'tool-call-chunks'; seq0: number; time0: number; data: ToolCallRunData }6869/** One durable log line's JSON value: a session event verbatim, or a packed chunk row. */70export type StorageRecord = SessionEvent | ChunkRow7172/**73 * Minimum members before a run packs. Below it a row's envelope rivals the74 * event lines it replaces. A format constant, not a tunable: both layouts75 * decode identically, so changing it never invalidates stored logs.76 */77const MIN_RUN = 3打包是格式常量的性质,注释里写明了:两种布局解码结果相同,改 MIN_RUN 不会让存量日志失效,所以它不需要做成配置。三个 row 类型各自保存 seq0/time0 锚点加 dt 时间差数组,成员 k 重建为 seq seq0 + k。编码器只认白名单里的精确形状,认不出的原样存,这就是「失去压缩,不失去数据」:
192export function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[] {193 const out: StorageRecord[] = []194 let kind: DeltaKind | undefined195 let run: DeltaEvent[] = []196 const flush = (): void => {197 if (kind !== undefined && run.length >= MIN_RUN) out.push(buildRow(kind, run))198 else out.push(...run)199 kind = undefined200 run = []201 }202 for (const event of events) {203 const k = classify(event)204 if (k === undefined) {205 flush()206 out.push(event)207 continue208 }209 const delta = event as DeltaEvent210 const last = run[run.length - 1]211 if (k === kind && last !== undefined && continues(last, delta, k)) {212 run.push(delta)213 continue214 }215 flush()216 kind = k217 run = [delta]218 }219 flush()220 return out221}classify 对每个事件做结构白名单检查:外壳精确是 {type, seq, time, data}、seq 和 time 是安全整数、chunk 类型在三个 delta 变体里。continues 检查连续性:seq 递增 1、时间差在安全范围内、同 turn 同 step 同块、tool-call 的 call id 与 name 完全一致。任何一条不满足,这个 run 就结算,不满足的事件原样单行存储。解码侧对称:decodeStorageRecord 先看行标签,带 -chunks 后缀的走校验加展开,校验不过直接抛 malformed 错误,拒绝整段 run,不会静默丢数据。
读取与版本:旧版读新日志,直接拒读
日志要能跨版本读,这是版本机制的出发点。SESSION_FORMAT_VERSION 是唯一的版本号,现在钉在 0:
33/**34 * The on-disk session format version, stamped into every newly-written {@link SessionHeader}35 * and enforced by every persistence backend on load. The single source of truth for the36 * version — write sites and the load-time check all read it.37 * While the harness is unreleased it is pinned at `0`: no compatibility is38 * implied, incompatible logs are rejected, and no migration is provided.39 *40 * The version is a single monotonic integer with no major/minor split. Whether41 * a bump is needed is decided by what the WRITER emits, never by what a newer42 * reader can accept: bump exactly when an older runtime could no longer handle43 * a new log with full semantic correctness ("parses without error" is not44 * correctness — silently skipping content that shapes reconstruction is a45 * wrong read). Only structural changes reach that bar: the header shape, the46 * {@link SessionEvent} envelope, core event semantics, or the surface47 * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).48 * Adding an ordinary event type does not bump — the per-event49 * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When50 * in doubt, bump: a near-identity upgrade step is almost free, a missed bump51 * makes older runtimes read new logs wrong silently. The full mechanism52 * (upgrade-step chain, in-memory view conversion, migrate-on-continue) is53 * recorded in the session-log-version-mechanism Agent Note54 * (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`).55 */56export const SESSION_FORMAT_VERSION = 0注释本身就是一篇小文档,把判定标准写在常量旁边:「解析不报错」不算正确,静默跳过塑造重建的内容才是错误读。只有结构性变化够格升版本:header 形状、事件信封、核心事件语义、surface 机制。加一个普通事件类型不升版本,交给 ignorable 兜住。钉在 0 的理由在仓库级 AGENTS.md 里讲得更直白,预发布姿态这一段:
7**Remove this section at the first tagged release.** With no external consumers, prefer the correct foundation over compatibility shims: rename or repackage freely and update every reference together. Backends reject old on-disk formats. SQLite uses monotonic `SCHEMA_VERSION`; `dsh-session` keeps `SESSION_FORMAT_VERSION` at `0` with no compatibility promise.「没有兼容承诺」是刻意为之:没有外部消费者,正确的地基优先于兼容垫片。
读取侧有两道闸,都在持久化协调器里。第一道查 header 版本号,第二道查事件词汇:
1046 private assertVersion(meta: SessionHeader): void {1047 if (meta.version === SESSION_FORMAT_VERSION) return1048 throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version))1049 }10501051 /**1052 * Refuse a log containing an event type this build does not know, unless the1053 * writer marked the event ignorable: an unrecognized required event may1054 * change how the rest of the log must be interpreted, so silently skipping1055 * it would reconstruct a wrong session (the envelope contract on1056 * `SessionEvent.ignorable`). Runs on NORMALIZED events — after1057 * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes1058 * this build still reads and rejected the ones it does not, so those keep1059 * their specific diagnostics.1060 */1061 private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {1062 for (const event of events) {1063 if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue1064 throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)1065 }1066 }assertEventsSupported 用一份仓库内生成的事件清单做白名单,KNOWN_SESSION_EVENT_TYPES 由生成脚本从所有 SessionEventMap 合并处收集,verify-persistence-catalog 门保证它不过期。白名单之外的事件,要么带 ignorable: true 被跳过,要么整个日志被拒绝。assertVersion 的拒绝文案区分方向:新版日志提示用户升级 harness,旧版日志提示当前构建没有升级路径,用户据此知道问题在版本,不在日志本身:
77export function sessionFormatVersionRefusal(id: string, version: number): string {78 return version > SESSION_FORMAT_VERSION79 ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`80 : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`81}SessionFormatUnsupportedError 与 SessionPersistenceCorruptionError 是两个不同的错误类型:格式不支持的日志什么都没坏,只是这个版本读不了,所以错误还带原始日志文件路径,用户还能自己打开看文本。
派生场景:fork 就是一次种子投影
事件溯源最直接的受益者是 fork。ctx.sessions.fork(source, boundary) 把源会话的事件前缀原样拷给子会话:
1081 fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session {1082 if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {1083 throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')1084 }1085 const liveSource = this._resolveForkSource(source)1086 const seed = this._forkSeed(liveSource, boundary)1087 return this.create(childSessionId, {1088 seed,1089 meta: {⋯ ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},1091 parentSession: liveSource.id,1092 seedLength: seed.length,1093 },1094 })1095 }子会话的 header 里记了 parentSession 和 seedLength,父子的血缘关系落在持久化元数据里。构造函数接受 seed 时逐条验证:每个事件必须能通过 lossless JSON 快照、信封合法、且 seq 必须从 0 连续,与 append 的约束完全一致。所以 fork 出来的会话天然满足「日志可重建」的全部前提。
设计决策分析
为什么是事件溯源:状态和日志在结构上无法分叉
这一条决策的一手推理记在 2026-06-11 的 Agent Note 里。先看 Decision 段:
13A `Session` is an append-only log of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`); raw stream chunks are logged for token-level replay fidelity while the assembled `assistant/message` event is authoritative for derivation. Replay/fork = seed a new session with an existing log.⋯ // ... 同步追加与 session/flush 检查点的说明 ...·Appends are synchronous (the hot path never blocks on I/O); `session/event` is a sync notification; persistence plugins buffer write-behind and drain at the awaited `session/flush` checkpoint fired at every turn end.⋯ // ... 追加顺序契约的说明 ...17Ordering contract: the loop claims inbox messages before `agent/pre-step`, opens `step/start` only after an enter decision, then appends the returned `user/message` batch before request derivation. Provider output is assembled and appended as `assistant/message` before tool dispatch, so the durable log records the exact message the tools follow. Regression tests pin that ordering.三个要点:日志是唯一事实来源,消息历史是派生的;chunk 原样入日志保回放保真,而派生历史以 assistant/message 为准;回放和 fork 就是拿既有日志播种新会话。Note 的 Alternatives 段只有一条候选,一句话否掉:
21**A mutable message array with events fired as notifications** — simpler, but state and log can diverge; with event-sourcing the log IS the state, so divergence is structurally impossible.可变消息数组加事件通知,更简单,但状态和日志会分叉;事件溯源里日志就是状态,分叉在结构上不可能。这是选择这一形态的核心理由。代价在 Consequences 段写明了:派生成本随日志长度增长,compaction 是预期的缓解手段,日志本身不可修改。
三条决策合起来就是一条事件的生命周期:校验在源头、写入同步、消费在派生、淘汰靠 compaction。下面的状态图回答一个问题:一条事件从写入到被消费,经过哪些状态,又在哪一步被淘汰:
校验是写前关口:snapshotJsonValue 的 lossless JSON 检查拦在入日志之前,invariant companion 再逐事件看日志内部关系。追加同步完成,同一时刻三件事发生:持久化插件收到 session/event 通知进入 write-behind 缓冲、带 surfaceOp 标记的事件登记成 surface 节点、chunk 事件逐字节留档。投影与回放是两种读法,事件自己不知道会被谁读;只有持久化路径决定它能否成为 --resume 或 fork 的种子。阴影是唯一被淘汰的路径:compaction 的 replace 把被覆盖的节点从投影里删掉,日志本体不可修改,事件本身永远留在日志里。
Model-visible ⟺ logged:仓库级的双向约定
AGENTS.md 第 108 行把这条约定写成仓库红线:
108- **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.架构文档的 Session log 一节把它展开成机制:
94The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence all derive from this stream.9596**Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log.注意最后一句话的次序:新增模型可见输入,先扩展 SessionEventMap,再从日志渲染。类型层面,Session.append 的签名本身就执行这个约束,surface 事件必须带 SurfaceIntent,非 surface 事件不许带。运行时层面,dsh-session 的 invariant companion(packages/core/session/src/invariant.ts)逐事件校验日志内部关系:seq 严格递增(第 60 至 62 行)、turn/step 括号配对、tool/call 与 tool/result 按 callId 配对、核心执行事件必须 turn 闭合。架构文档说「a runtime invariant asserts it」,落到代码就是这个结构校验:模型请求的输入全部来自投影,投影来自日志,而日志内部关系被 invariant 看着,三层都不断线。
required-on-read:逃生门朝哪边开
版本机制里最反直觉的选择是 ignorable 的默认值。Note 把这个决定写成「默认 required,忘记标记是可见的过度拒绝;默认 ignorable,同样的疏忽是静默恢复被掏空的会话」:
19**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).选型的逻辑是失败模式的不对称:写作者忘了标记,代价是日志被拒、用户升级或手工处理,属于可见的麻烦;反过来,读者默认放过未知事件,代价是静默重建出错,属于不可见的安全事故。同一个疏忽,朝向决定了它是一次事故还是一次烦扰。Note 还给了结构上的底气:模型可见内容只走三类 surface 事件加两个 header/context 折叠,危险的未知恰好是那些「改变其余日志解读方式」的非 surface 事件。
版本号本身的选择同样有一个独立推理:为什么是单整数而不是 major/minor,为什么升不升版本由写作者决定。Note 的 Decision 段头两句:
13**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent.⋯ // ... 升版判定的说明 ...15**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.「能不能自动升级」是升级步自身的属性,两段式编号会在设计时提前承诺一个当时并不知道的答案。所以 dsh 用单整数加按步存在的 upgrader 表达,这与 SQLite 后端的 SCHEMA_VERSION 一致。拿不准就升:近似恒等的升级步几乎免费,漏升则让旧读者静默读错。
不这样做会出什么事
把三条决策各自的反面推演一遍。第一,如果消息历史是可变数组而非派生投影,那么「模型看到什么」就取决于数组最后一次被改写成什么,与日志无关。崩溃恢复时,持久化插件存的是日志,数组得从日志重建,重建逻辑若与写路径的维护逻辑不一致,两者早晚分叉,而分叉没有任何信号。第二,如果未知事件默认放过(ignorable 默认 true),新插件加的事件在旧版运行时被静默跳过,session/end-seed 这类改变边界解读的事件一旦被跳过,resume 会从错误的位置继续,审计看到的会是「看起来正常但少了关键一步」的历史。第三,如果版本号不做方向感知拒绝,旧版读新版日志会报「损坏」之类误导性错误,用户可能直接删掉日志,把该升级解决的问题变成丢数据。
边界条件剖析
如果旧版运行时遇到一个不认识的事件类型,会发生什么?
分两种情况。事件类型在白名单之外且没带 ignorable: true:整个日志被拒绝,落点是 packages/session/session-persistence/src/coordinator.ts 第 1061 至 1066 行,assertEventsSupported 逐事件比对 KNOWN_SESSION_EVENT_TYPES,未知且不可忽略就抛 SessionFormatUnsupportedError,错误信息点名事件类型和 seq。如果日志的 header 版本号更大,连事件都不需要解码就拒绝,落点是同文件第 1046 至 1049 行 assertVersion 加第 77 至 81 行的方向感知文案,并且 JSONL 后端在解码任何事件行之前先拒外来版本,避免把未来格式误报成损坏。
如果 assistant/message 的 content 是空的,投影会怎样?
返回 null,消息不进派生历史。落点是 packages/core/session/src/surface.ts 第 103 行 if (event.data.message.content.length === 0) return null,以及 packages/core/session/src/index.ts 第 741 至 743 行投影时跳过 null。这种事件只存在于 max-tokens 上限命中、step 只有 usage 没有正文的场景,它仍然要入日志,因为 usage 记录要随事件一起持久化,但往模型 transcript 里塞一个空 assistant 消息会污染下一次请求。
如果 fork 边界落在开着的 turn 中间,会发生什么?
拒绝 fork,报 OPEN_TURN。落点是 packages/core/session/src/index.ts 第 1128 至 1136 行:_forkSeed 在边界前缀里找最后一个 turn/start 或 turn/end,找到的是 turn/start 说明前缀以未闭合的 turn 结尾,直接抛 SessionForkError。理由在错误语义里:在一个半截 turn 上 fork,子会话继承了一段没有结局的对话,回放时 turn 括号对不上。
如果 append 的载荷里混进了不可序列化的值,会发生什么?
追加在写日志之前被拒绝。落点是 packages/core/session/src/index.ts 第 614 至 617 行:snapshotJsonValue 对载荷做 lossless JSON 快照,返回 undefined 就抛错。这个检查不是摆设:tool/result 的 meta 是工具私有载荷,工具想塞个 undefined 或函数进去,错误在这里就被拦住,不会等到持久化后端写盘时才爆。
横向对比
对比组一:claude-code 的 transcript,日志是副产品还是数据源
先交代检索情况。claude-code 的公开仓库在 v2.1.241(commit 45bdfa96)已不包含主产品源码,packages/ 等目录不存在。我用关键词 transcript、jsonl、replay、resume 检索全仓,非 CHANGELOG 文件零命中实现代码。transcript 机制的行为证据来自两个地方:仓库自带的插件脚本,和 CHANGELOG 里 115 处相关修复记录。
先看消费方视角。plugins/ralph-wiggum 是个仓库内自带的插件,它的 stop hook 要读「最近一条 assistant 消息」,做法是从 hook 输入里拿路径、用 grep 和 jq 解析:
57# Get transcript path from hook input58TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path')5960if [[ ! -f "$TRANSCRIPT_PATH" ]]; then61 echo "⚠️ Ralph loop: Transcript file not found" >&262 echo " Expected: $TRANSCRIPT_PATH" >&263 echo " This is unusual and may indicate a Claude Code internal issue." >&264 echo " Ralph loop is stopping." >&265 rm "$RALPH_STATE_FILE"66 exit 067fi69# Read last assistant message from transcript (JSONL format - one JSON per line)70# First check if there are any assistant messages71if ! grep -q '"role":"assistant"' "$TRANSCRIPT_PATH"; then72 echo "⚠️ Ralph loop: No assistant messages found in transcript" >&273 echo " Transcript: $TRANSCRIPT_PATH" >&274 echo " This is unusual and may indicate a transcript format issue" >&275 echo " Ralph loop is stopping." >&276 rm "$RALPH_STATE_FILE"77 exit 078fi7980# Extract last assistant message with explicit error handling81LAST_LINE=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -1)82if [[ -z "$LAST_LINE" ]]; then83 echo "⚠️ Ralph loop: Failed to extract last assistant message" >&284 echo " Ralph loop is stopping." >&285 rm "$RALPH_STATE_FILE"86 exit 087fi8889# Parse JSON with error handling90LAST_OUTPUT=$(echo "$LAST_LINE" | jq -r '91 .message.content |92 map(select(.type == "text")) |93 map(.text) |94 join("\n")95' 2>&1)这页代码暴露了 transcript 的性质:它是按行写的 JSONL 消息文件,字段像 role、content 是产品内部约定,脚本对格式做防御式探测,注释里连「This is unusual and may indicate a Claude Code internal issue」都写上了。消费方不知道格式长什么样,只能猜,猜不中就停。CHANGELOG 里的修复记录印证了这份约定的脆弱面,选三条:
3049- Fixed transcript chain breaks on `--resume` that could lose conversation history when async transcript writes fail silently3431- Fixed `--resume` silently truncating recent conversation history due to a race between memory-extraction writes and the main transcript591- Added warnings when transcript writes are failing (e.g. disk full) or when session saving is off due to an inherited environment variable, instead of losing transcripts silently这三条记录的时间跨度说明这是反复踩坑的领域:异步写失败会让 resume 断链、内存抽取和主 transcript 竞争会静默截断、写失败早期连告警都没有。有趣的是它们的方向:都在「修复 transcript 作为副产品时损坏了主功能」这件事上,没有一条在做「让 transcript 成为其他数据的来源」。这正是对比的核心,同一个问题的两种答案:
dsh 把日志当数据源。模型请求的历史、fork、resume、telemetry、持久化、UI 全部派生自同一条事件流,格式有版本整数、读取有方向感知拒绝、词汇增长有 ignorable 逃生门,消费方拿到的永远是经过校验的投影。代价是这层机制本身不便宜:类型化事件词典、surface 折叠、版本机制、invariant 校验,都是要写的代码。
claude-code 把 transcript 当副产品。产品主功能不依赖它,--resume 和 UI 展示才读它,所以格式可以保持内部约定,不需要版本整数和投影函数。代价是副产品不承担数据源职责时,没人对它的完整性负责:损坏、截断、竞争在 CHANGELOG 里反复出现,外部消费方(比如仓库里的插件)只能靠 grep 加 jq 加一堆防御分支来读。这两个代价各自成立,因为两边的产品边界不同:claude-code 的 transcript 只服务自家客户端,坏了修在产品里;dsh 的日志要服务插件树里任意组合的生产者和消费者,完整性必须是结构保证的。
需要说明的是,以上对 claude-code transcript 机制的描述基于其公开仓库中的插件消费方代码与 CHANGELOG 修复记录,主产品实现不在公开源码范围内,两侧机制细节的对照以各自公开材料为界。
对比组二:同一份日志的两种读法,surface 投影与原始 chunk 流
dsh 内部有一组现成的双视角对照。模型历史是 deriveEventMessage 折叠 surface 的结果(packages/core/session/src/surface.ts 第 83 至 114 行),而 token 级回放是 packChunkRuns 打包的 chunk 流(packages/core/session/src/chunk-rows.ts 第 192 至 221 行)。两个视角读的是同一条日志,产物完全不同:
这张图回答一个问题:模型历史与回放流为什么可以来自同一条日志。投影视角把六行事件折叠成三句消息,chunk 被合并、边界被跳过;回放视角把 chunk 逐条展开,连打包行都还原成原样。两个视角各付各的代价:投影视角为了增量缓存维护了 surface 与 replaceGeneration;回放视角为了压缩维护了白名单打包与解码校验。它们的共同前提是日志本身既不倾向投影也不倾向回放,只承诺逐字节可重建。这也解释了为什么 assistant/chunk 的注释敢只写一句「token-level replay fidelity」:保真不靠投影路径,靠存储路径。
互动演示设计
演示形态:模拟器。一句话结论:模型看到的每一句话都是日志投影出来的,日志里没有的内容模型永远看不到。
舞台比喻:屏幕左侧是一根纵向时间线,叫「追加日志」,一条事件一张卡片,卡片上印着 seq、时间戳、类型名和 payload 摘要;右侧是一块对话面板,叫「模型视野」,模型真正看到的 user、assistant、tool result 三条消息在这里排队。中间一个折叠器图标表示投影函数。底部是逻辑轨迹面板,随动画步进高亮。观众把它理解成「账本和报表」:账本记每一笔原始流水,报表是随时可以从账本重算的,没有人手工去改报表。
分步与字幕:
- 空会话初始化,时间线顶部落下一张 header 卡片,印着
version: 0。字幕:「一个会话从 header 开始,格式版本钉在 0,没有兼容承诺」。 turn/start、user/message两张卡片依次落下,后者亮绿,折叠器把后者投成右侧面板的一条用户气泡。字幕:「user/message 是 surface 事件,直接投影成模型视野里的用户消息」。- 六张
assistant/chunk灰色卡片快速滚入,右侧没有反应;随后assistant/message落下,右侧出现助手气泡,六张 chunk 卡片各自画出一条细线指向它。字幕:「chunk 是 token 级保真记录,逐字节留在日志里;投影时它们合并成一个 assistant 消息,合并动作记在 sourceEventSeqs 里」。 tool/call与tool/result落下,右侧出现工具结果卡片。字幕:「tool/result 是第三种 surface 事件,模型视野继续增长,工具返回的原文一字不改」。- 观众把时间轴播放头拖回 seq 5。右侧面板瞬间变成当时的样子,只保留前两条消息。字幕:「任何时候回放,投影都只是对日志前缀的一次折叠,没有第二份状态需要同步」。
- 点击「注入未知事件」按钮,时间线尾部落下一张紫色卡片,类型名没有出现在已知清单里,也没有 ignorable 标记。读取器弹出红色横幅:「refusing to interpret the log」。字幕:「遇到不认识又没标记 ignorable 的事件,读取器拒绝重建,它宁可大声失败也不静默猜」。再点一次开关给这张卡片打上
ignorable: true,横幅消失,读取继续。字幕:「显式标记 ignorable,这条记录可以安全跳过,因为它不影响其余日志的解读」。
读者可操作:拖动时间轴播放头回放任意位置;开关「注入未知事件」和它的 ignorable 标记;切换右侧面板的「surface view」与「raw stream view」两个标签,看同一段日志的两种读法。
逻辑轨迹面板(伪代码行 → 白话 → 真实行号):
session.append('assistant/chunk', ...) → 同步校验 lossless JSON 并深冻结 → index.ts:604
surfaceManager.validateNext(event) → 折叠前先验证 surface 转移合法 → surface.ts:421
this.log.push(event) → 事件进入追加日志,seq 即下标 → index.ts:643
deriveEventMessage(event) → 三类 surface 事件投影成 Message → surface.ts:83
deriveMessages() → 增量折叠,只投影未见过的节点 → index.ts:726
assertEventsSupported(meta, events) → 未知类型无 ignorable 则拒绝 → coordinator.ts:1061
sessionFormatVersionRefusal(id, v) → 版本不匹配时按方向给出文案 → coordinator.ts:77可迁移结论
值得抄的第一条:追加日志加纯投影函数,把「事实记录」和「视图计算」分开。这条不依赖 TypeScript,任何语言都能落地。最小形态是一个数组加一个纯函数:写路径只 push 事件,读路径永远 fold(events) -> messages,中间没有第二个可变状态。30 行以内:events: list[dict],def fold(events): 按类型分派,非消息类型跳过。要点是视图永不由写路径维护,谁想加一个视图,写一个投影函数,不碰写路径。
值得抄的第二条:未知记录默认拒绝,逃生门显式声明。解析循环里一行判断就够了:if tag not in KNOWN and not record.get('ignorable'): raise。抄这一条时顺手把默认值想清楚:未知默认拒绝,代价是最坏情况是可见的报错;未知默认放过,代价是最坏情况是静默的错读。选前者,因为系统里大多数失败是开发者疏忽,把疏忽导向可见失败,比导向静默错误划算。
值得抄的第三条是文档即判决:dsh 把「什么情况升版本」的判定标准写在常量旁边的注释里,把「为什么默认 required」写进 Agent Note,把红线和机制互相链接。判定标准这种东西写在设计文档里没人看得到,写在代码注释里才有人路过时读到。抄它的最小成本形态是给每个格式常量配一段 5 行注释,说清楚什么改动需要动它。
过度设计的部分要认清。方向感知的版本拒绝、升级步链、surface replace 机制、chunk 打包压缩,这些为「多个插件扩展词汇、跨版本长期兼容、大体积流式日志」服务。如果你的日志只有一个消费者,比如单机工具的调试回放,那 SESSION_FORMAT_VERSION 一个常量加一条「版本不一致就拒读」的规则就够了,assertEventsSupported 那层白名单都可以省,因为词汇表只有一个生产者。判断标准是 dsh 自己那条红线:你的日志有没有第二个数据源意义上的消费者,没有,三分之一套版本机制就够用。
思考题
- 动手题:写一个不超过 30 行的投影器,复刻
deriveEventMessage的三条规则。手造一个 8 行的 JSONL 事件日志,字段照SessionEvent的结构:type、seq、time、data,data 里放turn、step、message(含content数组)等字段。至少包含:一条user/message、一条 content 为空的assistant/message、一条正常assistant/message、两条assistant/chunk、一条tool/result。用 Python 写projector.py,跑python3 projector.py session.jsonl,验证两个断言:空 content 的 assistant 消息不进输出;chunk 不产生输出。跑通后对照packages/core/session/src/surface.ts第 83 至 114 行,列出你的实现漏掉了它的哪条注释纪律。 - 给三个改动场景判断要不要升
SESSION_FORMAT_VERSION:新增一个事件类型plan/mode;给所有事件的信封加一个必填字段;SurfaceOp新增第三个变体。依据packages/core/session/src/types.ts第 40 至 56 行的注释和2026-08-10-session-log-version-mechanism.md的判定标准,逐个给结论和理由。 - 把
ignorable的默认值翻过来想:如果默认是 true(未知事件自动跳过),写作者忘了标记时会发生什么?对比默认 required 时的失败模式,解释为什么 dsh 选了「过度拒绝」。依据packages/core/session/src/types.ts第 417 至 426 行。 - 读
packages/core/session/src/chunk-rows.ts第 72 至 77 行,MIN_RUN = 3为什么是格式常量而不是配置项?如果把它改成可配置,对存量日志的解码会有什么影响?提示:注意注释里「both layouts decode identically」这句话。