一切皆插件:DeepSeek Harness 源码精读 3 / 14

第 3 章

会话日志:Model-visible ⟺ logged

源码核对基于 deepseek-ai/deepseek-harness commit `b150a551b8`(2026-08-21),tag `course-anchor-20260823`对比语料 anthropics/claude-code commit `45bdfa96`(2026-08-23),tag `v2.1.241`本章字数:约 9800 字(不含代码与图)

场景还原

假设你接手了一个审计任务:一个 subagent 在崩溃前最后一次模型请求里,到底看到了什么。你打开会话存储,眼前有两种候选:一份序列化的消息数组快照,或者一条事件追加日志。

先看快照的问题。它只告诉你「那一刻的状态」,不告诉你状态怎么来的。注入的上下文、工具输出的原文、一次被拒绝的空 turn,在快照里要么只剩结果,要么什么都没有。更麻烦的是恢复:崩溃发生时最后一次快照可能根本没写盘,你恢复出一个残缺的历史,而且没有任何信号告诉你它残缺了。审计靠快照,等于默认「快照写盘的那一刻,恰好覆盖了你关心的那一刻」。

dsh 的答案换了一个方向:模型请求的历史从来不从快照读,从追加日志投影。投影的意思是,任何时候拿日志的前缀跑一遍纯函数,就能得到当时模型看到的完整消息列表。仓库红线 Model-visible ⟺ logged 把这条钉死:模型看到的一切必须能从日志重建,同时日志里还存着模型看不到的东西,比如逐 token 的原始 chunk、turn 边界、log-only 记录。前者保证可审计,后者保证可回放,而这两样来自同一条数据流。

这一章的阅读路径是:事件词典长什么样,追加是怎么落盘的,投影是怎么算的,chunk 保真怎么压缩,旧版读新日志时靠什么不翻车,最后拿 claude-code 的 transcript 机制对比「日志是副产品还是数据源」这个根本分歧。

逐行精读

事件词典:模型可见内容的一览表

一切从 SessionEventMap 开始。它是这份日志的全部事件类型,也就是「模型可见内容」的完整清单。dsh 的约定是:模型请求里出现的每一种输入,都必须有一个事件类型承接它。下面是完整定义:

packages/core/session/src/types.ts236:337
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/resultmeta 字段是工具私有的展示载荷,核心不认识它,但 Session.append 会在源头用 lossless JSON 校验拦住不可序列化的值。第五,todo/writerequest/headerrequest/contextsession/end-seed 注释里反复出现一个词:log-only。它们不产生模型消息,只服务状态重建(标题、请求头折叠、seed 边界)。

事件类型有两个派生集合,一个管追加,一个管投影:

packages/core/session/src/types.ts339:340
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

packages/goal/goal/src/domain.ts61:68
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 的联合类型:

packages/core/session/src/types.ts408:440
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 的话,同样的疏忽会「静默恢复一个被掏空的会话」。sourceEventSeqssurfaceOp 是条件字段,只有三类事件能携带:user/messageassistant/messagetool/result。这三类就是 SurfaceEventType,模型视野的入口。注释最后一句点明编译器的角色:非 surface 事件不许带 surface 元数据,这是 Session.append() 调用点被编译器强制保证的。

追加路径:同步、校验、冻结

append 是日志唯一的写入口。它做的事可以浓缩成五步:校验载荷是 lossless JSON、校验请求头兼容、按 log.length 打 seq、深冻结、推进日志并同步通知监听器:

packages/core/session/src/index.ts604:655
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 检查点批量落盘。

投影规则:哪类事件变成模型消息

日志是事实,模型历史是投影。投影的原子规则是这个函数:

packages/core/session/src/surface.ts83:114
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,有一个运行时守卫:

packages/core/session/src/surface.ts35:38
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 上,并做了增量缓存:

packages/core/session/src/index.ts708:747
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 的模型历史,从追加到投影经过了哪几步:

sequenceDiagram participant L as Loop participant S as Session participant F as Surface fold participant P as Provider L->>S: append turn/start L->>S: append user/message with surfaceOp append L->>S: append step/start L->>S: append assistant/chunk L->>S: append assistant/chunk L->>S: append assistant/message S->>F: deriveMessages folds new surface nodes F-->>L: user message plus assistant message L->>P: build model request from derived history Note over L,P: every token the model sees came from the log

chunk 保真:先逐字节留档,再压缩

模型请求里的消息来自投影,但投影丢信息:chunk 的逐 token 边界、时间戳、块序号都不在派生历史里。assistant/chunk 事件补上这一层,它进日志时逐字节留档。这里有一个规模问题:一个流式响应动辄几百个 delta chunk,每行事件若原样存盘,JSON 外壳比载荷大得多。chunk-rows.ts 的模块头注释给了一个实测数字:大约 56 倍。压缩方案是把连续同类的 delta chunk 打包成一行存储记录:

packages/core/session/src/chunk-rows.ts64:77
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。编码器只认白名单里的精确形状,认不出的原样存,这就是「失去压缩,不失去数据」:

packages/core/session/src/chunk-rows.ts192:221
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:

packages/core/session/src/types.ts33:56
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 里讲得更直白,预发布姿态这一段:

AGENTS.md7:7
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 版本号,第二道查事件词汇:

packages/session/session-persistence/src/coordinator.ts1046:1066
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,旧版日志提示当前构建没有升级路径,用户据此知道问题在版本,不在日志本身:

packages/session/session-persistence/src/coordinator.ts77:81
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}

SessionFormatUnsupportedErrorSessionPersistenceCorruptionError 是两个不同的错误类型:格式不支持的日志什么都没坏,只是这个版本读不了,所以错误还带原始日志文件路径,用户还能自己打开看文本。

派生场景:fork 就是一次种子投影

事件溯源最直接的受益者是 fork。ctx.sessions.fork(source, boundary) 把源会话的事件前缀原样拷给子会话:

packages/core/session/src/index.ts1081:1095
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 里记了 parentSessionseedLength,父子的血缘关系落在持久化元数据里。构造函数接受 seed 时逐条验证:每个事件必须能通过 lossless JSON 快照、信封合法、且 seq 必须从 0 连续,与 append 的约束完全一致。所以 fork 出来的会话天然满足「日志可重建」的全部前提。

设计决策分析

为什么是事件溯源:状态和日志在结构上无法分叉

这一条决策的一手推理记在 2026-06-11 的 Agent Note 里。先看 Decision 段:

.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md13:17
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 段只有一条候选,一句话否掉:

.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md21:21
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。下面的状态图回答一个问题:一条事件从写入到被消费,经过哪些状态,又在哪一步被淘汰:

stateDiagram-v2 [*] --> 校验: append 入口 校验 --> 追加: 序列化与不变量检查通过 校验 --> 拒绝: 载荷不可序列化 拒绝 --> [*] 追加 --> 持久化: session/event 同步通知 追加 --> 投影: surfaceOp 节点登记 追加 --> 回放: chunk 逐字节留档 持久化 --> 落盘: turn 结束的 flush 检查点 落盘 --> 重建: resume 或 fork 播种 投影 --> 阴影: compaction replace 阴影 --> [*] 重建 --> [*]

校验是写前关口:snapshotJsonValue 的 lossless JSON 检查拦在入日志之前,invariant companion 再逐事件看日志内部关系。追加同步完成,同一时刻三件事发生:持久化插件收到 session/event 通知进入 write-behind 缓冲、带 surfaceOp 标记的事件登记成 surface 节点、chunk 事件逐字节留档。投影与回放是两种读法,事件自己不知道会被谁读;只有持久化路径决定它能否成为 --resume 或 fork 的种子。阴影是唯一被淘汰的路径:compaction 的 replace 把被覆盖的节点从投影里删掉,日志本体不可修改,事件本身永远留在日志里。

Model-visible ⟺ logged:仓库级的双向约定

AGENTS.md 第 108 行把这条约定写成仓库红线:

AGENTS.md108: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 一节把它展开成机制:

docs/architecture.md94:96
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/calltool/result 按 callId 配对、核心执行事件必须 turn 闭合。架构文档说「a runtime invariant asserts it」,落到代码就是这个结构校验:模型请求的输入全部来自投影,投影来自日志,而日志内部关系被 invariant 看着,三层都不断线。

required-on-read:逃生门朝哪边开

版本机制里最反直觉的选择是 ignorable 的默认值。Note 把这个决定写成「默认 required,忘记标记是可见的过度拒绝;默认 ignorable,同样的疏忽是静默恢复被掏空的会话」:

.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md19:19
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 段头两句:

.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md13:15
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/startturn/end,找到的是 turn/start 说明前缀以未闭合的 turn 结尾,直接抛 SessionForkError。理由在错误语义里:在一个半截 turn 上 fork,子会话继承了一段没有结局的对话,回放时 turn 括号对不上。

如果 append 的载荷里混进了不可序列化的值,会发生什么?

追加在写日志之前被拒绝。落点是 packages/core/session/src/index.ts 第 614 至 617 行:snapshotJsonValue 对载荷做 lossless JSON 快照,返回 undefined 就抛错。这个检查不是摆设:tool/resultmeta 是工具私有载荷,工具想塞个 undefined 或函数进去,错误在这里就被拦住,不会等到持久化后端写盘时才爆。

横向对比

对比组一:claude-code 的 transcript,日志是副产品还是数据源

先交代检索情况。claude-code 的公开仓库在 v2.1.241(commit 45bdfa96)已不包含主产品源码,packages/ 等目录不存在。我用关键词 transcriptjsonlreplayresume 检索全仓,非 CHANGELOG 文件零命中实现代码。transcript 机制的行为证据来自两个地方:仓库自带的插件脚本,和 CHANGELOG 里 115 处相关修复记录。

先看消费方视角。plugins/ralph-wiggum 是个仓库内自带的插件,它的 stop hook 要读「最近一条 assistant 消息」,做法是从 hook 输入里拿路径、用 grep 和 jq 解析:

plugins/ralph-wiggum/hooks/stop-hook.sh57:67
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 067fi
plugins/ralph-wiggum/hooks/stop-hook.sh69:95
69# 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 消息文件,字段像 rolecontent 是产品内部约定,脚本对格式做防御式探测,注释里连「This is unusual and may indicate a Claude Code internal issue」都写上了。消费方不知道格式长什么样,只能猜,猜不中就停。CHANGELOG 里的修复记录印证了这份约定的脆弱面,选三条:

CHANGELOG.md3049:3049
3049- Fixed transcript chain breaks on `--resume` that could lose conversation history when async transcript writes fail silently
CHANGELOG.md3431:3431
3431- Fixed `--resume` silently truncating recent conversation history due to a race between memory-extraction writes and the main transcript
CHANGELOG.md591:591
591- 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 行)。两个视角读的是同一条日志,产物完全不同:

flowchart TB subgraph LOG[append-only log] E1[turn/start] --> E2[user/message] --> E3[assistant/chunk] --> E4[assistant/chunk] --> E5[assistant/message] --> E6[tool/result] end subgraph VIEW1[surface projection] V1[user message] --> V2[assistant message] --> V3[tool result] end subgraph VIEW2[raw stream replay] W1[chunk by chunk with seq and time] end LOG -->|deriveEventMessage fold| VIEW1 LOG -->|expand rows verbatim| VIEW2

这张图回答一个问题:模型历史与回放流为什么可以来自同一条日志。投影视角把六行事件折叠成三句消息,chunk 被合并、边界被跳过;回放视角把 chunk 逐条展开,连打包行都还原成原样。两个视角各付各的代价:投影视角为了增量缓存维护了 surface 与 replaceGeneration;回放视角为了压缩维护了白名单打包与解码校验。它们的共同前提是日志本身既不倾向投影也不倾向回放,只承诺逐字节可重建。这也解释了为什么 assistant/chunk 的注释敢只写一句「token-level replay fidelity」:保真不靠投影路径,靠存储路径。

互动演示设计

演示形态:模拟器。一句话结论:模型看到的每一句话都是日志投影出来的,日志里没有的内容模型永远看不到。

舞台比喻:屏幕左侧是一根纵向时间线,叫「追加日志」,一条事件一张卡片,卡片上印着 seq、时间戳、类型名和 payload 摘要;右侧是一块对话面板,叫「模型视野」,模型真正看到的 user、assistant、tool result 三条消息在这里排队。中间一个折叠器图标表示投影函数。底部是逻辑轨迹面板,随动画步进高亮。观众把它理解成「账本和报表」:账本记每一笔原始流水,报表是随时可以从账本重算的,没有人手工去改报表。

分步与字幕:

  1. 空会话初始化,时间线顶部落下一张 header 卡片,印着 version: 0。字幕:「一个会话从 header 开始,格式版本钉在 0,没有兼容承诺」。
  2. turn/startuser/message 两张卡片依次落下,后者亮绿,折叠器把后者投成右侧面板的一条用户气泡。字幕:「user/message 是 surface 事件,直接投影成模型视野里的用户消息」。
  3. 六张 assistant/chunk 灰色卡片快速滚入,右侧没有反应;随后 assistant/message 落下,右侧出现助手气泡,六张 chunk 卡片各自画出一条细线指向它。字幕:「chunk 是 token 级保真记录,逐字节留在日志里;投影时它们合并成一个 assistant 消息,合并动作记在 sourceEventSeqs 里」。
  4. tool/calltool/result 落下,右侧出现工具结果卡片。字幕:「tool/result 是第三种 surface 事件,模型视野继续增长,工具返回的原文一字不改」。
  5. 观众把时间轴播放头拖回 seq 5。右侧面板瞬间变成当时的样子,只保留前两条消息。字幕:「任何时候回放,投影都只是对日志前缀的一次折叠,没有第二份状态需要同步」。
  6. 点击「注入未知事件」按钮,时间线尾部落下一张紫色卡片,类型名没有出现在已知清单里,也没有 ignorable 标记。读取器弹出红色横幅:「refusing to interpret the log」。字幕:「遇到不认识又没标记 ignorable 的事件,读取器拒绝重建,它宁可大声失败也不静默猜」。再点一次开关给这张卡片打上 ignorable: true,横幅消失,读取继续。字幕:「显式标记 ignorable,这条记录可以安全跳过,因为它不影响其余日志的解读」。

读者可操作:拖动时间轴播放头回放任意位置;开关「注入未知事件」和它的 ignorable 标记;切换右侧面板的「surface view」与「raw stream view」两个标签,看同一段日志的两种读法。

逻辑轨迹面板(伪代码行 → 白话 → 真实行号):

text
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 自己那条红线:你的日志有没有第二个数据源意义上的消费者,没有,三分之一套版本机制就够用。

思考题

  1. 动手题:写一个不超过 30 行的投影器,复刻 deriveEventMessage 的三条规则。手造一个 8 行的 JSONL 事件日志,字段照 SessionEvent 的结构:typeseqtimedata,data 里放 turnstepmessage(含 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 行,列出你的实现漏掉了它的哪条注释纪律。
  2. 给三个改动场景判断要不要升 SESSION_FORMAT_VERSION:新增一个事件类型 plan/mode;给所有事件的信封加一个必填字段;SurfaceOp 新增第三个变体。依据 packages/core/session/src/types.ts 第 40 至 56 行的注释和 2026-08-10-session-log-version-mechanism.md 的判定标准,逐个给结论和理由。
  3. ignorable 的默认值翻过来想:如果默认是 true(未知事件自动跳过),写作者忘了标记时会发生什么?对比默认 required 时的失败模式,解释为什么 dsh 选了「过度拒绝」。依据 packages/core/session/src/types.ts 第 417 至 426 行。
  4. packages/core/session/src/chunk-rows.ts 第 72 至 77 行,MIN_RUN = 3 为什么是格式常量而不是配置项?如果把它改成可配置,对存量日志的解码会有什么影响?提示:注意注释里「both layouts decode identically」这句话。